🏠

NATS AI Agent 协议介绍

Table of Contents

1. 为什么是 NATS?

AI Agent 不是孤立运行的。一个 Agent 需要调用另一个 Agent 的能力——问 题在于它们怎么找到彼此、怎么对话。

以研发体系为例:项目管理 Agent 要安排任务给开发 Agent、 架构师 Agent 和测试 Agent;文档 Agent 需要架构 Agent 提供具体架构细节;测试 Agent 要和开发 Agent 讨论细节。

以联合循环燃气轮机监盘为例:值长 Agent 安排任务给值班员 Agent; 值班员完成任务后回报值长;值长遇到复杂问题向技工 Agent 咨询; 技工 Agent 需要向值班员 Agent 调取历史数据。

最粗暴的方案:每个 Agent 暴露一个 HTTP 端点,注册到中心化的服务发现工具 (Consul、etcd),然后轮询或回调。这套架构的问题:

  • 中心化瓶颈 :注册中心挂了,所有 Agent 互相看不见
  • 请求-响应耦合 :HTTP 是同步的,Agent 需要等响应,天然不适合事件驱动
  • 寻址僵化 :Agent 的 IP 或 DNS 变了,注册信息就失效
  • 无原生 pub/sub :一个 Agent 要广播消息给一群 Agent,得自己实现
  • 安全各自为政 :每个 HTTP 端点都需要独立处理认证和授权, NATS 只需要一套接入方案——基于 subject 的资源鉴权就够了

这套问题不新鲜——微服务领域十年前就遇到过。NATS 给出的答案也很老,但恰好 对 AI Agent 的场景成立:

  • Agent 通过 subject 寻址,不依赖 IP/DNS
  • Agent 通过 queue group 做负载均衡,不依赖网关
  • Agent 通过 pub/sub 广播,不依赖额外的消息队列
  • Agent 通过微服务抽象自我描述,不依赖外部注册中心

换个说法:NATS 不是为 AI Agent 设计的,但 AI Agent 遇到的通信问题, NATS 在微服务时代已经解决了一遍。

2. NATS 微服务:Agent 的底座

要理解 NATS 能为 Agent 做什么,先理解 NATS 微服务这个概念。

2.1. 2.1 从裸 subject 到微服务

如果你用过 NATS,最基本的用法是这样:

// 服务端:订阅 "print" subject
nc.Subscribe("print", func(msg *nats.Msg) {
    // 收到打印请求
    fmt.Printf("打印: %s\n", string(msg.Data))
})

// 客户端:向 "print" 发消息
nc.Publish("print", []byte("请打印这份文档"))

这是 NATS 的核心模式——通过 subject 做服务路由。但它有几个不足:

  1. 没有服务元数据——别人不知道你是谁、能干什么
  2. 没有健康检查——你挂了别人不知道
  3. 没有负载均衡——多实例需要自己实现

NATS 微服务( nats micro )在这之上做了封装:

裸 subject NATS 微服务
只有一个 subject 名 有服务名、版本、描述
无元数据 可通过 metadata 携带信息
无健康检测 内置 ping/health 端点
多实例靠自己 内置 queue group 负载均衡
无 schema 可定义多个端点(endpoint)

2.2. 2.2 Go 示例:远程打印

用一个具体的场景来理解 NATS 微服务。假设家里有一台打印机,没有公网 IP, 你在外面想通过手机打印文件。

场景角色:

  • *服务端(打印机应用)*:家里的打印机服务器,注册为 printer 微服务
  • *客户端(手机应用)*:手机上的打印客户端,发现 printer 服务并发起请求

打印机应用:

package main

import (
    "log"
    "time"
    "github.com/nats-io/nats.go"
    "github.com/nats-io/nats.go/micro"
)

func main() {
    nc, _ := nats.Connect(nats.DefaultURL)
    defer nc.Close()

    // 注册为 NATS 微服务
    srv, err := micro.AddService(nc, micro.Config{
        Name:    "PrinterService",
        Version: "1.0.0",
        // 元数据:服务自我描述
        Metadata: map[string]string{
            "agent":    "printer",
            "location": "home",
            "owner":    "nanjj",
            "protocol": "agent.v1",
        },
    })
    if err != nil {
        log.Fatal(err)
    }

    // 定义端点:print
    srv.AddEndpoint("print", micro.EndpointConfig{
        Subject: "agent.printer.print",
        Handler: micro.HandlerFunc(func(req micro.Request) {
            docName := string(req.Data())
            log.Printf("收到打印请求: %s\n", docName)

            // 模拟打印
            time.Sleep(2 * time.Second)

            req.Respond([]byte("打印完成: " + docName))
        }),
    })

    // 阻塞
    select {}
}

手机应用:

package main

import (
    "fmt"
    "log"
    "time"

    "github.com/nats-io/nats.go"
)

func main() {
    nc, _ := nats.Connect(nats.DefaultURL)
    defer nc.Close()

    // 通过 subject 直接调用打印服务
    msg, err := nc.Request("agent.printer.print",
        []byte("resume.pdf"), 5*time.Second)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("打印机回复: %s\n", string(msg.Data))
}

这个例子展示了几个关键点:

  1. Agent 的自我描述 :通过 metadata 声明自己是 printer、在哪里、支持什么协议
  2. subject 即地址agent.printer.print 是一个结构化 subject,客户端 不需要知道 IP
  3. 请求-响应语义 :NATS 的 Request 模式天然支持超时和回复

2.3. 2.3 NATS 微服务带来的好处

对 Agent 来说,NATS 微服务提供了几个层面的能力:

需求 NATS 微服务的解决方案
Agent 如何被发现 metadata 描述自身能力
多实例如何负载 queue group 自动分发
Agent 挂了怎么办 内置 ping 健康检测
如何定义接口 endpoint 声明式定义
版本兼容 服务版本号 + 多版本共存

3. Subject 层级结构:Agent 的寻址空间

NATS 的 subject 不是扁平的。点号分隔的层级结构 + 通配符,让 Agent 的 寻址变得灵活。

3.1. 3.1 层级设计

在 Agent 场景下,subject 层级可以这样设计:

agent.<action>.<agent_name>.<domain>

几个例子:

Subject 含义
agent.print.printer.home 家里的打印机执行打印
agent.weather.bot.public 公共天气机器人查天气
agent.mail.fermi.dscli dscli 的 Fermi 人物收邮件
agent.ping.* 对所有 Agent 发 ping

3.2. 3.2 通配符的威力

NATS 的两个通配符让 Agent 发现变得极其灵活:

  • * :匹配一级
  • > :匹配后续所有级
// 发现所有 printer
nc.Subscribe("agent.print.*.*", func(msg *nats.Msg) {
    // 匹配 agent.print.printer.home、agent.print.printer.office 等
})

// 发现某个 Agent 的所有端点
nc.Subscribe("agent.>.fermi.dscli", func(msg *nats.Msg) {
    // 匹配 agent.mail.fermi.dscli、agent.ping.fermi.dscli 等
})

// 监听所有 Agent 的 ping
nc.Subscribe("agent.ping.>", func(msg *nats.Msg) {
    fmt.Printf("收到 ping: %s\n", string(msg.Data))
})

3.3. 3.3 与 DNS 类比

DNS 把域名转成 IP,NATS subject 把 Agent 名转成通信地址。

DNS NATS Subject
printer.home agent.print.printer.home
*.home agent.*.*.home
CNAME 别名 用不同的 subject 绑定同一服务

区别在于:DNS 返回 IP 然后你自己连,NATS 直接传递消息。Agent 不需要知道 对端在哪里——NATS 总线帮你搞定。

4. NATS Agent Protocol:约定行为

NATS 微服务提供了底座,subject 层级提供了寻址。但 Agent 之间还需要协议 来约定——请求长什么样、响应怎么返回、错误怎么表示。

4.1. 4.1 协议分层

我们把 Agent 之间的通信协议分为三层:

职责 NATS 中的实现
传输层 消息路由、可靠性 NATS core(至少一次语义)
寻址层 Agent 发现、端点定位 subject 层级 + 通配符
应用层 请求/响应格式、错误码 Agent Protocol 定义

NATS 负责底下两层,应用层由 Agent Protocol 来约定。

4.2. 4.2 协议核心设计

Agent Protocol 的核心约定很简单:

1. 每个 Agent 注册为一个 NATS 微服务
2. 每个 Agent 的 metadata 包含 protocol_version
3. 请求和响应用 JSON 编码
4. 响应中包含 status、data、error 三个字段

消息格式:

// 请求
{
    "action": "print",
    "params": {
        "document": "resume.pdf",
        "copies": 2
    },
    "request_id": "req-001"
}

// 成功响应
{
    "status": "ok",
    "data": {
        "job_id": "job-123",
        "message": "打印完成"
    },
    "request_id": "req-001"
}

// 错误响应
{
    "status": "error",
    "error": {
        "code": "NO_PAPER",
        "message": "打印机缺纸"
    },
    "request_id": "req-001"
}

4.3. 4.3 实例:dscli1 邮件系统

4.2 节的请求-响应格式适合同步 RPC(如 2.2 节的远程打印)。邮件系统 是异步通信——发件人不等回复,收件人忙完再读——因此协议格式有所不同。 但底层的传输、寻址、发现机制完全一致。

先回答一个问题:为什么 dscli 需要 32 个 AI 人物,而不是一个万能角色?

因为真正的领域知识只能用时间堆。Bohr 专攻 dscli 研发与智能体构造, 张衡死磕 cgo-free 数据库方案( modernc.org/sqlitemodernc.org/ccgo ),已向开源社区提交并被采纳多个 MR;Fermi 专注于 文档写作与翻译。每个角色的本地 SQLite 库里积累的是调试历史、设计决策、 局部最优解——这些是通用模型一次 prompt 给不出来的。专业化的代价是 协作成本,但邮件系统恰好解决了这个问题。

邮件是异步的。A 给 B 发信,不期望 B 马上回复。B 收到后存在本地 收件箱,忙完手头的事再读,读完后决定是否回复、何时回复。这个模式 让每个人物可以保持深度工作,不被即时消息打断;同时又能在需要时 互通有无。

NATS 在这里的角色:只传输,保证送到。不保证是否回复,不保证回复 时间。

4.3.1. 场景:Fermi 给 Curie 发邮件问技术问题

Fermi 遇到一个 dscli 内部架构问题,想请教 Curie。流程完全异步:

  1. Curie 上线时注册为 NATS 微服务,订阅 agent.mail.curie.dscli
  2. Fermi 向 agent.mail.curie.dscli Publish 一封 JSON 邮件, 然后继续自己的工作——不等回复
  3. NATS 将邮件路由到 Curie 实例,Curie 存入本地收件箱
  4. Curie 有空时读到 Fermi 的邮件,研究清楚后 向 agent.mail.fermi.dscli Publish 回复
  5. Fermi 下次执行 readmail 时看到 Curie 的回答

注意第 2 步和第 4 步都是 Publish,不是 Request。每一封邮件独立传输, 回复是一封新邮件,不是对原请求的 HTTP 式响应。

4.3.2. 4.3.1 Agent 注册与收件

每个 dscli 人物启动时做两件事:注册微服务(让别人发现你),订阅收件 subject(接收邮件)。

package main

import (
    "encoding/json"
    "log"

    "github.com/nats-io/nats.go"
    "github.com/nats-io/nats.go/micro"
)

// Curie 注册自己的邮件服务
func registerMailAgent(nc *nats.Conn) {
    // 注册微服务——元数据暴露给其他 Agent 发现
    srv, err := micro.AddService(nc, micro.Config{
        Name:    "CurieMail",
        Version: "1.0.0",
        Metadata: map[string]string{
            "agent":     "curie",
            "persona":   "Curie",
            "expertise": "dscli 研发与智能体构造",
            "protocol":  "agent.v1",
        },
    })
    if err != nil {
        log.Fatal(err)
    }
    _ = srv  // 元数据已注册,其他 Agent 可查询

    // 订阅收件 subject——队列组:多实例负载均衡
    nc.QueueSubscribe("agent.mail.curie.dscli",
        "mail-handlers", handleMail)

    log.Println("Curie 邮件服务就绪")
}

// handleMail 收到邮件后存入本地收件箱
func handleMail(msg *nats.Msg) {
    var mail struct {
        From, To, Subject, Body, MessageID string
    }
    json.Unmarshal(msg.Data, &mail)

    saveToInbox(mail)
    log.Printf("收到 %s 的邮件: %s", mail.From, mail.Subject)
    // 不回复——回答是另一封邮件,由 Curie 准备好后异步发出
}

关键点: agent.mail.curie.dscli 这个 subject 编码了三层信息—— agent (我是 Agent)、 mail (提供邮件能力)、 curie.dscli (dscli 域的 Curie 人物)。任何知道这个 subject 的 Agent 都可以 给它发邮件,不需要知道 IP 或部署位置。

注意 handleMail 没有调用 req.Respond()——它只存邮件,不回复。回答是 另一封邮件,由 Curie 在准备好后异步发出。

4.3.3. 4.3.2 发件:按 subject 寻址

// Fermi 发邮件给 Curie——Publish,不等回复
func sendMail(nc *nats.Conn, to, subject, body string) error {
    msg := map[string]string{
        "from":       "fermi",
        "to":         to,
        "subject":    subject,
        "body":       body,
        "message_id": fmt.Sprintf("msg-%d", time.Now().UnixNano()),
    }
    data, _ := json.Marshal(msg)

    // Publish —— 发完就返回,继续做自己的事
    return nc.Publish(
        fmt.Sprintf("agent.mail.%s.dscli", to),
        data)
}

核心区别:这里用的是 Publish 而不是 Request。Fermi 把邮件发出去就 返回了,不等 Curie 的回答。Curie 什么时候回复、想不想回复,完全由 Curie 自己决定。如果 Curie 正在忙,邮件安静地躺在收件箱里——不会 超时,不会出错。

每个邮件带一个 message_id 作为唯一标识。回复时用 in_reply_to 引用原邮件 ID,实现线程追踪。这些约定由 Agent Protocol 的上层 定义,NATS 只负责把邮件送到正确的 subject。

4.3.4. 4.3.3 路由:NATS 总线自动交付

NATS 负责把邮件从发件人送到收件人。不需要中心化的邮件服务器,不需要 DNS 解析,不需要端口映射。

1. Fermi 调用 nc.Publish("agent.mail.curie.dscli", data)
2. NATS 根据 subject 将消息投递给匹配的订阅者
3. Curie 的 QueueSubscribe handler 被调用,邮件存入收件箱
4. Curie 有空时读邮件,研究后向 agent.mail.fermi.dscli 发回复
5. Fermi 下次 readmail 时看到回复

QueueSubscribe 提供了负载均衡:如果 Curie 有多个实例,同一封邮件 只会被其中一个实例处理。这是扩展性的保障,但也意味着每个实例的 收件箱是独立的——更成熟的方案可以用共享存储,简单场景下每人一个本地 收件箱也够用。

4.3.5. 4.3.4 dscli 的收发流程

dscli 的 mail 工具集在这种架构下工作:

  • sendmail → 向目标 Agent 的 subject Publish 邮件(异步,不等回复)
  • readmail → 读本地收件箱(邮件已存在本地 SQLite 库中)
  • listmail → 查询本地收件箱列表
  • mail_search → 全文检索本地邮件库
  • replymail → 创建回复邮件,Publish 到原发件人的 subject

底层通信走 NATS 的 Publish/Subscribe,上层存储和管理保持简单。收件 就是一次 Publish 加一次本地写入;发件就是一次 Publish。没有轮询、 没有长连接、没有回调。

这就是 NATS 对 Agent 的价值:它解决的是 Agent 之间"怎么找到、怎么对话" 的问题,而上层协议(消息格式、回复约定)由 Agent Protocol 定义。各司其 职。

5. 总结:为什么是 NATS + Agent Protocol?

NATS 微服务 + Agent Protocol 的组合不是一个新发明——它在微服务领域已 经沉淀了十年。但对 AI Agent 场景,恰好够用:

  1. 寻址 :subject 替代 DNS/IP,Agent 移动后不需要更新地址
  2. 发现 :通配符订阅让 Agent 可以动态发现同类
  3. 通信 :pub/sub + request/reply 覆盖了广播和点对点两种模式
  4. 规模 :queue group 让多实例水平扩展无压力
  5. 安全 :一套接入方案覆盖所有 Agent 的鉴权

这不是一个"面向 AI Agent 的新协议"——这是一个老协议的新用途。NATS 的 微服务抽象在诞生时服务的是普通微服务,但现在拆掉"微服务"这个标签,换成 "AI Agent",一切依然成立。

旧物新生。这就够了。

Footnotes:

1

dscli( https://github.com/dscli/dscli )是一个基于命令行的 AI 助手框架,内置 32 个 AI 人物和邮件通信系统。