1. 为什么应用层通信格式是Agent系统里最容易被低估的一环
做Agent开发的人,十有八九把精力砸在提示词工程、工具调用、记忆管理、编排框架选型上,很少有人一开始就认真琢磨一件事:两个LLM或者两个Agent之间,消息到底长什么样。我见过太多项目,单Agent跑得好好的,一旦上多Agent协作,立刻出现"消息丢了""参数传错了""对方理解偏了""循环调用停不下来"这类问题。排查半天,最后发现根子不在模型能力,而在通信格式设计得太随意。
所谓应用层通信格式,指的是Agent与Agent、Agent与LLM、Agent与外部工具之间,在应用层约定的消息结构。它不关心底层是HTTP还是WebSocket,也不关心是本地进程调用还是跨网络传输,它关心的是:一条消息里有哪些字段、每个字段什么含义、什么情况下该填什么、接收方怎么解析、解析失败怎么办。你可以把它理解成Agent世界的"普通话"——大家约定好怎么说,才能互相听懂。
这件事为什么重要?因为LLM本质上是无状态的。它不像传统服务那样有明确的函数签名和类型系统,你给它一段文本,它给你一段文本,中间全靠约定。一旦多个Agent要协作,比如一个负责规划、一个负责检索、一个负责执行、一个负责校验,它们之间传递的信息如果格式不统一,就会出现"规划Agent说'去查一下用户订单',执行Agent却不知道要查哪个用户、查什么字段、返回什么格式"这种经典事故。
这篇文章适合三类人看:一是正在做多Agent协作系统、被消息传递问题折磨的开发者;二是准备从单Agent往多Agent架构演进、想提前避坑的工程师;三是想理解Agent底层运作机制、不满足于只会调框架的学习者。我会从实际项目出发,把通信格式的设计逻辑、字段取舍、踩坑经验、验证方法讲透,尽量让你看完就能动手改自己的项目。
提示:本文讨论的是应用层协议设计,不涉及任何网络底层或特殊通道技术,所有内容均基于常规的进程间通信与接口调用实践。
2. 拆解一条Agent消息:从"三个点"到完整信封
2.1 用"我是谁、我在找什么、我能提供什么"理解消息三要素
热词里有一句话特别精辟:LLM的token三个点——key我是谁、query我在找什么、value我能提供什么。这其实是从注意力机制借来的类比,但放到Agent通信格式设计上,同样成立。一条合格的Agent消息,本质上也要回答三个问题:
- 我是谁(Identity):发送方的身份标识。是哪个Agent、哪个角色、哪个实例。没有这个,接收方无法判断该用什么策略响应。
- 我在找什么(Intent):这条消息的意图。是请求、是响应、是通知、是错误上报,还是心跳。意图决定了接收方的处理分支。
- 我能提供什么(Payload):消息携带的实际内容。可能是查询参数、可能是执行结果、可能是待处理的数据块。
很多人设计消息格式时,只关注Payload,把身份和意图塞进文本里让模型自己猜。结果就是模型经常"猜错",把通知当成请求处理,或者把A的消息当成B的。显式地把这三要素拆成独立字段,是通信格式设计的第一原则。
2.2 一个最小可用的消息信封结构
我一般推荐用**信封(Envelope)+ 载荷(Payload)**的两层结构。信封放元信息,载荷放业务数据。这样解析逻辑和业务逻辑分离,改起来互不影响。下面是一个我用过很多项目的JSON结构示例:
{ "msg_id": "a1b2c3d4-0001", "trace_id": "trace-20240115-xyz", "sender": { "agent_id": "planner-01", "role": "planner", "version": "1.2.0" }, "receiver": { "agent_id": "retriever-03", "role": "retriever" }, "intent": "request", "action": "search_knowledge", "timestamp": 1705312800123, "ttl_ms": 30000, "payload": { "query": "用户订单退款政策", "top_k": 5, "filters": {"lang": "zh", "source": "faq"} }, "context": { "session_id": "sess-8899", "parent_msg_id": null, "retry_count": 0 } }这个结构里,每个字段都有明确用途。msg_id用于去重和追踪,trace_id用于串联一次完整任务链路,sender和receiver解决"我是谁"和"发给谁",intent和action解决"我在找什么",payload解决"我能提供什么",context解决"这条消息在哪个上下文里"。
2.3 为什么字段命名比字段数量更重要
我踩过最大的坑,是早期用type这个字段同时表示"消息类型"和"业务动作"。结果规划Agent发type: "search",检索Agent以为是消息类型,直接进了默认分支,把请求当成了未知消息丢弃。后来拆成intent(消息语义:request/response/notify/error)和action(业务动作:search_knowledge/execute_code/validate_result)两个字段,问题立刻消失。
字段命名的核心原则是一个字段只表达一个维度。不要用type这种万能词,它会让不同开发者产生不同理解。宁可字段多一点,也不要语义重叠。多出来的字段解析成本几乎为零,但语义歧义带来的调试成本可能是几小时甚至几天。
3. 意图分类与动作路由:让消息找对门
3.1 四种基础意图及其处理分支
不管你的Agent系统多复杂,消息意图基本可以归为四类,我称之为RRNA模型:
| 意图 | 英文 | 语义 | 接收方典型处理 |
|---|---|---|---|
| 请求 | request | 我需要你做某事 | 执行动作,返回response |
| 响应 | response | 这是你要的结果 | 关联原请求,继续流程 |
| 通知 | notify | 告诉你一件事,不需回复 | 记录或触发副作用 |
| 异常 | error | 出错了,需要处理 | 重试、降级或上报 |
这四类覆盖了绝大多数场景。有人会问,那"心跳"和"取消"算什么?心跳可以归为notify,取消可以归为request的一个特殊action。不要为每个边缘场景新增意图类型,否则意图枚举会膨胀到无法维护。
3.2 action字段的设计:动词+名词的稳定约定
action字段我强烈建议用动词_名词的格式,比如search_knowledge、execute_code、validate_result、summarize_text。这样做有三个好处:一是可读性强,看名字就知道干什么;二是便于路由,接收方可以用前缀匹配做粗分类;三是便于版本管理,新增动作不影响已有动作。
路由逻辑通常长这样:
def route_message(msg): intent = msg["intent"] action = msg.get("action") if intent == "request": handler = ACTION_REGISTRY.get(action) if not handler: return build_error(msg, "unknown_action", f"未注册的动作: {action}") return handler(msg) elif intent == "response": return correlate_response(msg) elif intent == "notify": return handle_notification(msg) elif intent == "error": return handle_error(msg) else: return build_error(msg, "unknown_intent", f"未知意图: {intent}")注意这里对未知action和未知intent都返回了error,而不是静默丢弃。这是通信格式设计的一条铁律:宁可报错,不要沉默。静默丢弃是分布式系统里最难排查的问题之一。
3.3 请求-响应关联:parent_msg_id的正确用法
多Agent系统里,响应消息必须能关联到原请求。我见过有人用"时间戳最近"来匹配,这在并发场景下必然出错。正确做法是在响应消息的context.parent_msg_id里填原请求的msg_id。
这样做的额外好处是,你可以用trace_id把一次完整任务的所有消息串起来,出问题时直接按trace_id捞日志,整条链路一目了然。我在一个客服Agent项目里,就是靠这个机制把"用户投诉响应慢"的问题定位到某个检索Agent的TTL设置过短导致频繁重试。
注意:
parent_msg_id只在response和error消息里填,request和notify里应为null。填错会导致关联逻辑混乱。
4. 载荷设计:结构化与自然语言的边界在哪
4.1 什么时候用结构化字段,什么时候用自然语言
这是通信格式设计里最纠结的问题。LLM擅长处理自然语言,但自然语言不适合做精确路由和校验。我的经验法则是:
- 路由和校验相关的信息,必须结构化。比如action、参数名、参数类型、超时时间。
- 需要LLM理解和生成的语义内容,可以用自然语言。比如query文本、总结内容、推理过程。
- 两者混合时,结构化字段放信封,自然语言放payload的特定字段。
举个例子,检索请求里,top_k、filters这些必须结构化,因为要精确执行;而query本身是自然语言,因为要交给向量检索或LLM理解。不要试图把top_k塞进query文本里让模型解析,那是给自己找麻烦。
4.2 payload的schema校验:别让脏数据流进下游
Agent系统里,payload的schema校验经常被忽略。大家觉得"都是自己人发的消息,不会有问题"。但实际项目中,模型生成的参数经常出现类型错误、字段缺失、值越界。比如top_k本该是整数,模型给了个字符串"5";filters本该是对象,模型给了个字符串。
我的做法是在每个action的handler入口做一次schema校验,用类似JSON Schema或Pydantic的机制:
from pydantic import BaseModel, Field, ValidationError class SearchPayload(BaseModel): query: str = Field(..., min_length=1, max_length=2000) top_k: int = Field(default=5, ge=1, le=50) filters: dict = Field(default_factory=dict) def handle_search(msg): try: payload = SearchPayload(**msg["payload"]) except ValidationError as e: return build_error(msg, "invalid_payload", str(e)) # 继续处理校验失败时返回error消息,让上游决定是重试还是修正。这样脏数据不会流进下游,问题定位也快。
4.3 大载荷的处理:引用传递还是值传递
当payload很大时,比如一个几MB的文档、一张图片、一段长文本,直接塞进消息里会导致消息体积膨胀,影响传输效率和日志存储。这时候有两种策略:
- 值传递:payload直接包含完整数据。适合小数据,简单直接。
- 引用传递:payload只包含一个引用ID或URL,接收方按需拉取。适合大数据,但增加了一次往返。
我的经验是,超过64KB的载荷就考虑引用传递。具体阈值可以根据你的传输层和日志策略调整。引用传递时,payload里放{"ref_type": "blob", "ref_id": "blob-123", "size": 1048576, "mime": "text/plain"}这样的结构,接收方用ref_id去对象存储或缓存里取。
引用传递的坑在于生命周期管理。如果发送方发完消息就把blob删了,接收方去取时已经没了。所以要么约定blob的TTL大于消息TTL,要么用引用计数。我在一个项目里就因为这个问题丢过数据,后来统一规定blob TTL至少是消息TTL的3倍。
5. 多Agent协作中的通信陷阱与排查链路
5.1 循环调用:A等B、B等A的死锁
多Agent系统最经典的坑是循环等待。规划Agent发请求给执行Agent,执行Agent发现需要更多信息,又发请求给规划Agent,规划Agent此时正在等执行Agent的响应,无法处理新请求,双方死锁。
这个问题的根因是同步阻塞式通信加上缺少调用链检测。解决方案有两个层面:
- 格式层面:在context里加
call_chain字段,记录这条消息经过的所有agent_id。接收方检查自己是否已在链中,如果在,说明出现循环,直接返回error。 - 架构层面:把同步调用改成异步消息队列,每个Agent独立消费自己的队列,不阻塞等待。
我一般两个都做。格式层面的检测是兜底,架构层面的异步是治本。
"context": { "call_chain": ["planner-01", "executor-02", "planner-01"], "session_id": "sess-8899" }接收方看到call_chain里已有自己,立刻返回error: circular_call_detected。
5.2 消息丢失与重复:幂等性设计
消息队列可能丢消息,也可能重复投递。Agent系统必须假设这两种情况都会发生。应对方法是幂等性设计:每条消息带唯一msg_id,接收方维护一个已处理msg_id的集合(可以用Redis或本地LRU缓存),处理前先查重。
这里有个细节:已处理集合的TTL要大于消息的最大可能重投递时间。如果TTL太短,重复消息会被当成新消息处理;如果太长,集合会无限膨胀。我一般设TTL为24小时,配合LRU淘汰,兼顾覆盖和内存。
5.3 一次真实的排查链路:响应超时到底卡在哪
说个真实案例。一个多Agent项目,用户反馈"有时候要等很久才回复"。日志显示规划Agent发出请求后,平均2秒收到响应,但P99达到30秒。排查过程如下:
第一步,按trace_id捞完整链路日志,发现30秒的请求里,检索Agent的处理时间正常(200ms),但规划Agent收到响应后,又发了一次相同的请求。
第二步,检查消息内容,发现第二次请求的context.retry_count是1,说明触发了重试。但检索Agent明明已经返回了结果,为什么规划Agent没收到?
第三步,检查响应消息的parent_msg_id,发现它指向的是第一次请求的msg_id,但规划Agent的重试逻辑用的是新的msg_id去匹配,导致匹配不上,以为超时,又发了一次。
根因找到了:重试时没有复用原msg_id,导致响应无法关联。修复方案是重试时保持msg_id不变,只增加retry_count。改完之后P99降到3秒以内。
这个案例的教训是:msg_id的语义要明确。它标识的是"一次逻辑请求",不是"一次物理发送"。重试是同一个逻辑请求的多次物理发送,msg_id应该不变。
6. 版本演进与兼容:格式改了怎么不炸
6.1 向后兼容的三条规则
Agent系统是演进的,通信格式一定会改。改格式时如果不注意兼容,会导致新旧Agent无法通信。我总结三条规则:
- 只增字段,不删字段,不改字段语义。新增字段时给默认值,老Agent忽略它,新Agent使用它。
- 字段类型不变。如果
top_k原来是整数,不要改成字符串,宁可新增一个top_k_str字段。 - 枚举值只增不减。intent和action的枚举可以新增,但不要删除或重命名已有的,否则老Agent发来的消息会变成未知类型。
6.2 版本号放在哪:信封还是载荷
版本号我建议放在信封的sender.version里,而不是单独一个协议版本字段。原因是:协议版本往往和Agent实现版本绑定,分开维护容易不一致。接收方根据sender.version决定用哪套解析逻辑,简单直接。
如果确实需要独立的协议版本,可以在信封加protocol_version字段,但要在文档里明确它和sender.version的关系。我见过项目里两个版本号打架,排查了半天才发现是协议版本没更新。
6.3 灰度切换:双写双读的过渡策略
大版本不兼容时,需要灰度切换。策略是双写双读:发送方同时按新旧格式发两份,接收方同时支持解析新旧格式,根据sender.version路由。等所有Agent都升级完,再下线旧格式。
这个过渡期可能持续几周甚至几个月,所以格式设计时就要预留扩展空间。比如payload里留一个ext字段放实验性内容,信封里留meta字段放非核心元信息。这样小改动不用动主结构。
7. 实测有效的调试与验证手段
7.1 消息日志:记什么、怎么记、存多久
调试通信问题,日志是命根子。我的日志策略是:
- 全量记录信封:每条消息的完整信封都记,包括msg_id、trace_id、sender、receiver、intent、action、timestamp。
- 载荷按需记录:小载荷全记,大载荷记摘要(长度、hash、前200字符)。
- 按trace_id索引:日志系统支持按trace_id快速检索,这是排查链路问题的关键。
- 保留7天:热数据7天,冷数据归档30天。太短不够排查,太长存储成本高。
7.2 用LLM as Judge做消息质量抽检
这是个进阶技巧。你可以用一个独立的LLM作为"裁判",定期抽检消息样本,判断消息是否符合格式规范、语义是否清晰、是否存在歧义。比如让它判断"这条request消息的payload是否足以让接收方完成任务"。
具体做法是准备一个抽检脚本,每天随机抽100条消息,喂给裁判LLM,输出结构化评分。评分低的样本人工复核,发现格式设计问题。这个方法帮我发现过好几个字段语义模糊的问题,比纯人工review效率高得多。
7.3 压测:并发下的消息乱序与丢失
上线前必须做并发压测。重点观察三个指标:
| 指标 | 含义 | 健康阈值 |
|---|---|---|
| 消息丢失率 | 发出但未收到的比例 | < 0.01% |
| 消息重复率 | 收到多次的比例 | 允许,但需幂等 |
| 乱序率 | 响应早于请求到达的比例 | 允许,但需关联 |
压测时故意制造网络抖动、Agent重启、队列积压等场景,验证系统的鲁棒性。我在一个项目里压测发现,当并发超过500时,消息乱序率飙升,原因是用了多个消费者但没做顺序保证。后来改成按session_id分区消费,问题解决。
提示:压测环境要尽量贴近生产,包括消息大小分布、Agent处理耗时分布、网络延迟分布。用均匀分布压测会掩盖很多问题。
8. 我在多个Agent项目里沉淀下来的几条硬经验
第一条,通信格式要先于业务逻辑设计。很多人是先写业务,遇到通信问题再补格式,结果格式被业务需求拉扯得七零八落。正确顺序是先定格式骨架,再往里填业务。
第二条,信封字段宁多勿少。多一个字段的解析成本几乎为零,但少一个字段可能导致无法追踪、无法去重、无法关联。我现在的标准信封有12个字段,用下来觉得刚好。
第三条,所有消息都要能被独立理解。不要设计那种"必须结合上一条消息才能理解"的格式。每条消息自带完整上下文(通过context字段),这样重放、调试、审计都方便。
第四条,错误消息要和正常消息一样认真设计。错误码、错误描述、可选的修复建议,都要结构化。我见过项目里错误消息就是一句"出错了",排查时完全不知道哪错了。
第五条,格式文档要跟着代码走。用代码生成文档,或者用schema文件作为唯一真相源。手写文档一定会和实现脱节,这是铁律。
最后分享一个我常用的小技巧:在开发阶段,给每条消息加一个debug字段,放一些临时的调试信息,比如"这条消息由哪个函数生成""经过了哪些中间件"。上线前把debug字段关掉。这个字段在排查诡异问题时能救命,而且不影响正式格式。
这套通信格式的设计思路,我在检索增强、任务编排、多轮对话等不同场景都用过,核心逻辑是通用的。你可以根据自己的业务特点调整字段,但"信封+载荷""意图+动作""幂等+关联"这几个骨架,建议保留。真正跑起来之后你会发现,前期在格式上多花的两天,能省下后期排查问题的一周。