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 做服务路由。但它有几个不足:
- 没有服务元数据——别人不知道你是谁、能干什么
- 没有健康检查——你挂了别人不知道
- 没有负载均衡——多实例需要自己实现
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))
}
这个例子展示了几个关键点:
- Agent 的自我描述 :通过 metadata 声明自己是 printer、在哪里、支持什么协议
- subject 即地址 :
agent.printer.print是一个结构化 subject,客户端 不需要知道 IP - 请求-响应语义 :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/sqlite 、
modernc.org/ccgo ),已向开源社区提交并被采纳多个 MR;Fermi 专注于
文档写作与翻译。每个角色的本地 SQLite 库里积累的是调试历史、设计决策、
局部最优解——这些是通用模型一次 prompt 给不出来的。专业化的代价是
协作成本,但邮件系统恰好解决了这个问题。
邮件是异步的。A 给 B 发信,不期望 B 马上回复。B 收到后存在本地 收件箱,忙完手头的事再读,读完后决定是否回复、何时回复。这个模式 让每个人物可以保持深度工作,不被即时消息打断;同时又能在需要时 互通有无。
NATS 在这里的角色:只传输,保证送到。不保证是否回复,不保证回复 时间。
4.3.1. 场景:Fermi 给 Curie 发邮件问技术问题
Fermi 遇到一个 dscli 内部架构问题,想请教 Curie。流程完全异步:
- Curie 上线时注册为 NATS 微服务,订阅
agent.mail.curie.dscli - Fermi 向
agent.mail.curie.dscliPublish 一封 JSON 邮件, 然后继续自己的工作——不等回复 - NATS 将邮件路由到 Curie 实例,Curie 存入本地收件箱
- Curie 有空时读到 Fermi 的邮件,研究清楚后
向
agent.mail.fermi.dscliPublish 回复 - 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 场景,恰好够用:
- 寻址 :subject 替代 DNS/IP,Agent 移动后不需要更新地址
- 发现 :通配符订阅让 Agent 可以动态发现同类
- 通信 :pub/sub + request/reply 覆盖了广播和点对点两种模式
- 规模 :queue group 让多实例水平扩展无压力
- 安全 :一套接入方案覆盖所有 Agent 的鉴权
这不是一个"面向 AI Agent 的新协议"——这是一个老协议的新用途。NATS 的 微服务抽象在诞生时服务的是普通微服务,但现在拆掉"微服务"这个标签,换成 "AI Agent",一切依然成立。
旧物新生。这就够了。
Footnotes:
dscli( https://github.com/dscli/dscli )是一个基于命令行的 AI
助手框架,内置 32 个 AI 人物和邮件通信系统。