1. 为什么应用层通信格式是 Agent 落地的隐形地基
做过 Agent 项目的人大概都有过这种体验:单 Agent 跑 demo 的时候丝滑得不行,一旦上多 Agent 协作,或者把 LLM 接进已有的业务系统,各种诡异问题就冒出来了——消息丢了、上下文串了、工具调用参数对不上、流式输出拼不回去。排查半天,最后发现根子不在模型,而在通信格式没设计好。
我前后参与过几个 Agent 平台的建设,从最早的"两个 Agent 用 JSON 硬怼"到后来做统一的通信协议层,踩的坑足够写一本小册子。这篇就围绕LLM/Agent 之间的应用层通信格式这个主题,把我在实际项目里沉淀下来的设计思路、字段约定、踩坑经验完整拆一遍。不管你是刚接触 Agent 开发,还是已经在做多 Agent 编排,只要涉及"两个智能体之间怎么说话"这件事,这里的内容都能直接拿去参考。
先把范围界定清楚。这里说的"应用层通信格式",指的是 Agent 与 Agent、Agent 与 LLM、Agent 与工具/外部系统之间,在应用层约定的消息结构。它不关心底层是 HTTP、WebSocket 还是消息队列,关心的是消息长什么样、字段怎么定义、语义怎么表达、状态怎么流转。这一层设计得好不好,直接决定了你的 Agent 系统是可维护的工程,还是一坨改一处崩三处的意大利面。
我见过太多团队一上来就急着接模型、调 prompt,通信格式随手用个{"role": "user", "content": "..."}就开干。等到要加多轮工具调用、要支持多 Agent 广播、要做可观测性的时候,才发现原来的结构根本撑不住,只能推倒重来。所以我的建议很直接:在写第一行 Agent 业务代码之前,先把通信格式定下来。这件事的投入产出比高得离谱。
2. 通信格式的整体设计思路与选型考量
2.1 从"能跑"到"能扩展"的分水岭
最朴素的 Agent 通信就是 OpenAI 那套 Chat Completions 格式:一个 messages 数组,每个元素有 role 和 content。单轮对话、简单工具调用,这套结构确实够用。但它有几个天生的短板,一旦系统复杂起来就会暴露。
第一个短板是角色语义太窄。role 只有 system、user、assistant、tool 四种,多 Agent 场景下你根本分不清这条消息是哪个 Agent 发的、发给谁的。第二个短板是缺少元数据位。消息 ID、时间戳、追踪 ID、优先级这些工程上必需的东西,标准格式里没有位置放,只能塞进 content 里当字符串,解析起来极其恶心。第三个短板是状态表达缺失。一条消息是"思考中"、"等待工具返回"还是"已完成",格式本身不表达,全靠业务层自己维护状态机。
所以我的设计原则是:在兼容主流 LLM 接口格式的基础上,做一层信封(Envelope)封装。信封负责工程元数据和路由,内层 payload 保持和模型接口兼容。这样既能直接喂给模型,又能承载多 Agent 协作需要的额外信息。
2.2 信封 + 载荷的分层结构
具体来说,我把一条 Agent 消息拆成三层:
- 传输层信封(Envelope):消息 ID、会话 ID、发送方、接收方、时间戳、追踪链路、优先级、过期时间。这一层和业务无关,纯工程。
- 语义层头(Header):消息类型(请求/响应/事件/心跳)、意图标签、期望的响应格式、超时设置、重试策略。
- 业务载荷(Payload):真正的对话内容、工具调用、结构化数据。这一层尽量贴近模型原生格式。
为什么要这么分?因为这三层的变更频率完全不同。传输层几乎不变,语义层偶尔加字段,业务载荷天天在改。分层之后,改业务不会动到路由逻辑,加路由字段也不会污染模型输入。这是典型的关注点分离,在通信格式设计里同样适用。
2.3 为什么不用现成的 RPC 框架直接套
有朋友会问:gRPC、Thrift 这些成熟的 RPC 框架不香吗,为什么要自己设计格式?
我的回答是:RPC 框架解决的是"怎么调用",Agent 通信要解决的是"怎么协作"。RPC 的请求-响应模型是同步的、强类型的、一次性的,而 Agent 之间的交互是异步的、流式的、可能多轮的、可能一对多的。你当然可以用 gRPC 做底层传输,但上层还是得定义一套 Agent 语义的格式。而且 LLM 的输出天然是自然语言和半结构化 JSON 混合,强类型 schema 反而会成为负担。
我的实践是:底层传输用成熟方案(HTTP/WebSocket/gRPC 都行),应用层格式自己定义。两者解耦,各司其职。
3. 核心字段设计与语义约定详解
3.1 消息信封的必备字段
先上一份我在多个项目里验证过的信封字段定义,用 JSON 表示:
{ "msg_id": "01HX2K...", "session_id": "sess_abc123", "trace_id": "trace_xyz789", "parent_msg_id": "01HX2J...", "from": {"agent_id": "planner", "instance": "planner-01"}, "to": {"agent_id": "executor", "instance": "executor-03"}, "timestamp": 1718000000123, "ttl_ms": 30000, "priority": 5, "header": { "type": "request", "intent": "tool_call", "expect_reply": true, "reply_format": "json_schema", "timeout_ms": 15000 }, "payload": { } }逐个说下关键字段的设计意图。
msg_id用 ULID 而不是 UUID,是因为 ULID 自带时间戳前缀,天然有序,做日志排序和分页特别方便,而且字符串长度比 UUID 短。session_id标识一次完整的会话,多 Agent 协作时所有相关消息共享同一个 session。trace_id和parent_msg_id是给可观测性用的,有了这两个字段,你可以把一次复杂的多 Agent 调用还原成一棵完整的调用树,排查问题时直接看树就行。
from和to用对象而不是字符串,是为了给未来留扩展位。现在只有 agent_id 和 instance,将来要加租户、区域、版本号,直接往对象里塞就行,不用改格式。
ttl_ms和priority这两个字段很多人会忽略,但在生产环境里极其重要。Agent 之间的调用可能因为模型慢、工具卡而堆积,没有 TTL 的消息会一直占着资源。priority 则用于在队列积压时做分级处理,比如用户直接触发的请求优先级高于后台批处理任务。
3.2 语义层 header 的类型体系
header.type 我定义了五种基本类型,覆盖绝大多数场景:
| 类型 | 用途 | 是否需要回复 |
|---|---|---|
| request | 请求对方执行某个动作 | 是 |
| response | 对 request 的响应 | 否 |
| event | 单向事件通知,如状态变更 | 否 |
| stream | 流式数据分片 | 否(有结束标记) |
| heartbeat | 心跳保活 | 否 |
这里有个容易踩的坑:很多人把流式输出当成多次 response。这是错的。流式应该用独立的 stream 类型,每条分片带一个seq序号和is_final标记,接收方按 seq 拼接。如果混用 response,接收方无法区分"这是一次完整响应"还是"这是响应的一部分",逻辑会乱。
intent字段是给路由和策略用的。比如 intent 是tool_call的消息,路由层可以优先调度到有对应工具权限的 Agent;intent 是final_answer的,直接返回给用户。这个字段让路由逻辑和业务逻辑解耦,非常实用。
3.3 payload 的三种形态
payload 我按内容形态分三种,用payload.kind区分:
- text:纯文本,最常见,直接对应模型的 content。
- structured:结构化数据,比如工具调用的参数、Agent 之间的状态同步。
- multimodal:多模态内容,图片、音频等,用引用或 base64 承载。
text 形态下,payload 结构就是{"kind": "text", "content": "..."},简单直接。structured 形态下,我强烈建议带一个schema_ref字段,指向一个 schema 定义(可以是 URL,也可以是注册表里的 ID),这样接收方能校验数据合法性,而不是拿到一坨不知道结构的 JSON 硬解析。
提示:payload 里千万不要放二进制大对象。图片、文件这类内容,统一走对象存储,payload 里只放引用 URL 和校验哈希。我见过把 base64 图片塞进消息体的,一条消息几 MB,消息队列直接被打爆。
4. 多 Agent 协作场景下的通信模式实现
4.1 请求-响应模式的完整实现
这是最基础的模式,但要做好也不简单。核心难点在于异步响应如何正确路由回发起方。
我的做法是:发起方发送 request 时,在 header 里带上reply_to字段(可以是队列名、回调 URL 或 Agent 地址),接收方处理完后,用同一个session_id和parent_msg_id指向原请求,发回 response。发起方根据 parent_msg_id 匹配等待中的请求。
这里有个细节:超时处理。发起方发出请求后,启动一个定时器,超时后不再等待,但要发一条event类型的取消消息给接收方,让对方停止处理。否则接收方可能还在傻傻地跑一个已经没人要的任务,白白消耗模型 token。
# 发起方伪代码 pending = {} def send_request(to_agent, payload, timeout_ms=15000): msg = build_envelope( to=to_agent, header={"type": "request", "expect_reply": True, "timeout_ms": timeout_ms}, payload=payload ) pending[msg["msg_id"]] = {"event": threading.Event(), "result": None} transport.send(msg) if not pending[msg["msg_id"]]["event"].wait(timeout_ms / 1000): # 超时,发取消事件 transport.send(build_cancel(msg["msg_id"], to_agent)) raise TimeoutError(f"agent {to_agent} timeout") return pending.pop(msg["msg_id"])["result"]4.2 广播与订阅模式的落地
多 Agent 系统里经常需要"一个 Agent 发消息,多个 Agent 都关心"。比如规划 Agent 产出了任务分解,执行 Agent、监控 Agent、日志 Agent 都需要知道。
广播模式的关键是订阅关系要独立于消息格式。我的做法是:消息格式里只写to是broadcast或者一个 topic 名,具体的订阅关系由消息中间件维护。这样 Agent 之间不需要互相知道对方存在,解耦彻底。
但广播有个陷阱:消息风暴。如果每个 Agent 收到广播后又广播,很容易指数级放大。我的经验是给广播消息加一个hop_count字段,每转发一次减一,减到零就丢弃。同时限制广播的 topic 数量,不要什么都广播。
4.3 流式输出的分片与重组
LLM 的流式输出是刚需,但跨 Agent 传递流式数据需要额外设计。我的方案是:
- 每条 stream 消息带
stream_id、seq、is_final三个字段。 - 接收方按 stream_id 分组,按 seq 排序,收到 is_final 后拼接完整内容。
- 如果中间丢包(seq 不连续),接收方主动发一条 request 请求重传缺失分片。
{ "header": {"type": "stream"}, "payload": { "kind": "text", "stream_id": "st_001", "seq": 3, "is_final": false, "content": "正在分析" } }注意:流式分片不要按固定字节数切,要按语义边界切(比如按 token 或按句子)。按字节切会把一个 UTF-8 字符切成两半,接收方拼出来是乱码。这个坑我在早期项目里踩过,排查了一下午。
5. 实操过程与关键环节的完整实现
5.1 从零搭建一套通信格式的步骤
假设你现在要在一个新项目里落地这套格式,我按实际顺序给你捋一遍。
第一步,定义 schema 并注册。用 JSON Schema 或 Protobuf 把信封、header、payload 的结构写清楚,放到一个共享的 schema 仓库里。所有 Agent 都从这个仓库拉取 schema 做校验。这一步看似繁琐,但能省掉后面无数的"字段对不上"问题。
第二步,实现序列化/反序列化层。我推荐 JSON 作为默认格式,因为可读性好、调试方便、和 LLM 生态天然兼容。性能敏感的场景可以上 MessagePack 或 Protobuf,但要注意 LLM 输出的 JSON 可能不规范(比如带 markdown 代码块标记),需要做容错解析。
第三步,实现传输适配层。把 HTTP、WebSocket、消息队列的差异封装起来,上层只看到统一的 send/receive 接口。这样将来换传输方案,业务代码不用动。
第四步,实现路由与匹配。根据 to 字段和 session_id 把消息投递到正确的 Agent 实例。这里要注意 Agent 实例可能动态扩缩容,路由表要能实时更新。
第五步,接入可观测性。每条消息的收发都打点,记录 msg_id、trace_id、耗时、大小。用 trace_id 把一次完整调用串起来,出问题时能快速定位。
5.2 参数选择的计算过程
通信格式里几个关键参数不能拍脑袋定,得算。
TTL 怎么定?取决于下游 Agent 的最长处理时间。假设你的 Agent 调用一次 LLM 平均 3 秒,最坏情况重试 3 次,加上工具调用 5 秒,那么 TTL 至少是 3×3+5=14 秒,留一倍余量取 30 秒比较稳妥。TTL 太短会误杀正常请求,太长会拖垮资源。
消息体大小上限怎么定?假设你的消息队列单条上限是 1MB,那么 payload 里的文本内容要控制在 800KB 以内(留出信封和编码开销)。按中文 UTF-8 每字 3 字节算,大约 26 万字。超过这个长度就该考虑分片或走对象存储了。
并发连接数怎么估?假设有 N 个 Agent 实例,每个实例平均同时处理 M 个会话,那么需要的连接数大约是 N×M。做容量规划时按峰值再乘 1.5 倍。
5.3 一次完整的多 Agent 调用实录
我拿一个实际场景走一遍:用户问"帮我查下北京明天的天气,如果下雨就提醒我带伞"。
规划 Agent 收到用户请求,解析出两个子任务:查天气、条件提醒。它发两条 request 给执行 Agent,session_id 相同,parent_msg_id 都指向用户请求。
执行 Agent 收到查天气的请求,调用天气工具,拿到结果后发 response 回规划 Agent。同时它把"查天气"这个子任务的状态用 event 广播给监控 Agent。
规划 Agent 收到天气结果,判断是否下雨,如果下雨,再发一条 request 给提醒 Agent。提醒 Agent 生成提醒文案,发 response 回规划 Agent。
规划 Agent 汇总所有结果,生成最终回答,发 response 给用户接口。
整个过程涉及 6 条消息,全部共享同一个 trace_id。出问题时,按 trace_id 一查,整条链路清清楚楚。这就是通信格式设计到位带来的可观测性红利。
6. 常见问题与排查技巧实录
6.1 消息乱序与重复的处理
分布式环境下,消息乱序和重复是常态,不是异常。我的处理原则是:格式层面提供幂等和排序的依据,业务层面做去重和重排。
幂等靠 msg_id,接收方维护一个最近处理过的 msg_id 集合(用布隆过滤器省内存),重复的直接丢弃。排序靠 seq 或 timestamp,但要注意不同机器的时钟可能不一致,所以排序优先用逻辑序号而不是物理时间。
6.2 上下文串台的排查
多 Agent 并发时,最恶心的 bug 是 A 会话的消息跑到了 B 会话里。根因通常是 session_id 没正确传递,或者 Agent 实例复用了全局状态。
排查方法:在每条消息的日志里强制打印 session_id,出问题时 grep 一下,看是哪一步 session_id 变了。预防方法:Agent 处理消息时,把 session_id 作为上下文的一部分显式传递,不要依赖全局变量或线程本地存储。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 消息丢失 | TTL 过期、队列满、路由错误 | 查 TTL 配置、队列深度、路由表 |
| 响应超时 | 下游慢、死锁、重试风暴 | 查下游耗时分布、调用链、重试次数 |
| 内容乱码 | 编码不一致、分片切错 | 统一 UTF-8、检查分片边界 |
| 上下文串台 | session_id 丢失、状态复用 | 检查 session 传递、实例隔离 |
| 消息重复 | 重试未幂等、至少一次投递 | 加 msg_id 去重 |
| 流式拼接错乱 | seq 缺失、is_final 未标记 | 检查分片字段完整性 |
6.4 几个我踩过的坑
坑一:把 trace_id 当成 session_id 用。这俩不是一回事。trace_id 标识一次调用链路,session_id 标识一次会话。一次会话可能包含多次调用,一个调用也可能跨多个会话(比如 Agent 之间转发)。混用会导致追踪和会话管理都乱套。
坑二:payload 里塞了不该塞的东西。有人把整个对话历史塞进每条消息的 payload,消息体越来越大,最后几 MB 一条。正确做法是 payload 只放增量,历史由会话存储统一管理,消息里只带引用。
坑三:忽略消息大小对模型的影响。通信格式里的字段名、嵌套层级都会变成 token 消耗。如果你的 Agent 之间传递的消息要喂给模型,格式越精简越好。我见过用超长字段名的,光字段名就吃掉几百 token,纯浪费。
坑四:没有版本号。通信格式一定会演进,没有版本号就没法平滑升级。我的做法是在信封里加schema_version字段,接收方根据版本号选择对应的解析逻辑,新旧版本共存一段时间再下线旧的。
7. 格式演进与向后兼容的实战策略
通信格式不是定完就一劳永逸的,业务在变,格式必然要演进。关键是演进过程不能让现有系统崩掉。
我的策略是三条:加字段不删字段、新老版本并行、灰度切换。
加字段永远比删字段安全。新增字段时给默认值,老版本接收方忽略不认识的字段即可。要删字段时,先标记为 deprecated,观察一段时间确认没人用了再删。
版本升级用schema_version控制。接收方同时支持 N 和 N+1 两个版本,发送方逐步切到新版本。等所有发送方都切完,再下线旧版本支持。
灰度切换时,先在一个非核心的 Agent 上试新格式,观察一段时间没问题再推广。我一般会保留一个"格式开关",出问题能一键回滚到旧格式。
这套策略听起来保守,但在生产环境里,稳定压倒一切。通信格式是系统的地基,地基出问题,上面全塌。宁可演进慢一点,也不要冒进。
8. 我在实际项目中的几点体会
做了这么多 Agent 项目,关于通信格式,我最深的体会是:它看起来是技术细节,实际上是架构决策。格式定得好,Agent 之间职责清晰、边界明确、可观测性强;定得差,所有 Agent 搅在一起,改一处崩三处。
另一个体会是不要过度设计。我早期做过一个特别复杂的格式,支持各种花哨的语义,结果团队没人愿意用,最后还是回归到简单版本。格式的价值在于被使用,不在于功能多。从最小可用开始,按需演进,比一上来就搞大而全靠谱得多。
最后分享一个小技巧:把通信格式的 schema 当成文档来写。每个字段都写清楚用途、取值范围、示例。新人接手时,看 schema 就能理解系统怎么运转,比看代码快得多。这件事花不了多少时间,但收益是长期的。
如果你正在做 Agent 项目,建议现在就去看看你的消息格式,问自己三个问题:多 Agent 场景下能分清谁发的吗?出问题能追踪到完整链路吗?加个新字段需要改几处代码?如果答案让你犹豫,那这套格式可能该重构了。