我最近和几个做Agent平台的朋友聊天,发现大家最后都卡在同一个地方:模型越调越聪明,工具越接越多,可Agent之间的通信还是靠“字符串拼接大法”。LLM项目做到一定规模后,真正决定系统能跑多稳的,往往不是模型本身,而是那套看不见的应用层通信格式——它定义了Agent A怎么把自己的意图、状态、工具调用结果交给Agent B,彼此之间怎么纠错、怎么扩容、怎么审计。
这算是一篇踩坑笔记,给你讲讲我们在LLM/Agent场景里设计应用层通信格式时踩过的坑、定过的规范,以及可以直接拿走的模板。适合正在做多Agent编排、Agent框架或者内部工具调用的工程师,也适合想从“只有一个Agent玩具”跨到“多个Agent可靠协作”的团队参考。
1. 为什么Agent之间需要一份“应用层通信格式”
1.1 LLM和Agent并不住在同一个进程里
做传统后端开发时,两个模块要协作,直接方法调用就好了,参数类型编译器都帮你检查。可在LLM/Agent场景里,模型是一个无状态的HTTP接口,Agent是一个有状态的业务服务,二者在大部分项目里不在同一个进程,甚至不在同一个机房。只要跨了进程边界,就必须把内存里的对象序列化成字节流,再在另一侧反序列化回对象。用什么序列化结构、字段怎么命名、错误怎么表达,就全部取决于你选定的通信格式。
我见过不少项目一开始图省事,把工具调用拼成自然语言文本丢给下一个Agent,比如“请帮我调用query_weather,城市是北京”。短期能跑通,但很快就出现怪问题:Agent B把参数名猜成“cityName”,或是把温度值当成了城市名。原因很简单:文本消息的语义边界太模糊,模型一发挥就偏。反过来,如果使用一段明确的JSON,加上schema约束,模型被逼着按照既定结构输出,解析成本和理解歧义都会成倍下降。
更隐蔽的一点是,就算所有Agent都在同一个可执行文件里,只要中间隔了一个LLM的生成过程,你就没法保证过来的一定是干净数据。大模型输出天然是概率性的,同一句话在不同温度下可能给出完全不同格式。所以“通信格式”不是可有可无的约定,而是替整个系统兜底的那道防线。
1.2 自然语言只适合“人读”,不适合“程序读”
对AI Agent来说,自然语言是面向用户的体验层,面向程序、工具和其他Agent的交接层必须结构化。自然语言表达有大量隐式的省略、指代和歧义,例如“它”“那边”“按上次来”,程序要正确理解需要额外一轮共指消解;而结构化消息像表单一样把每个槽位填好,程序拿到就能直接执行。
举个例子,你告诉Agent“给老王办公室发个提醒,明天下午3点”,这句话里至少包含动作(发提醒)、收件人(老王?老王的办公室?)、时间(明天下午3点)、地点(办公室?)等不确定性。而如果走结构化格式,消息里会出现明确的action: create_reminder、recipient: {name: "老王", scope: "office"}、schedule: {date: "2025-06-02", time: "15:00"}。模型要做的是把用户话语映射到这些字段,而不是让下游Agent再去猜。
也不是说自然语言就该完全消失。恰恰相反,在Agent面向外部输出最终结论时,自然语言仍然是必须的;只是内部协作尽量少用。我们团队的原则是:面向人的输出用自然语言,面向程序的通信用结构化格式,面向Agent之间流转的记忆也尽量结构化。这条原则执行下来,系统出问题时的排查难度下降了一个数量级。
1.3 通信格式决定了可观测性、测试与信任边界
再往上一层,通信格式还决定了Agent系统能长多大。没有统一消息格式时,A发出了什么、B收到了什么,只能靠人工翻日志;有了统一格式后,每条链路都能落到一个校验器里,你想加监控、加样本回流、加安全审计,都在同一个地方做。
这也是为什么我认为应用层通信格式应该被当成“一等公民”,而不是顺手write一个JSON就完。你现在省下的设计时间,未来会在排查、测试、扩容时加倍还回来。我们团队把消息schema作为仓库里的独立版本管理项,任何字段调整都要过评审,效果非常显著。
2. 拆解应用层通信格式要管住的四件事
2.1 消息信封:先有天,再有地
不管内部用什么协议,消息的外层最好有一个统一的“信封”:版本号、消息ID、链路ID、会话ID、时间戳、发送方、接收方。这套概念和HTTP Header有点像,但它出现在应用层消息体里,原因主要有三个。
第一是路由。多Agent场景里,消息不一定是点对点,可能要通过编排器转发,或者广播给多个Agent。没有发送方/接收方字段,编排器就只能猜。第二是追踪。一次用户请求通常会拆成“规划-执行-审查”多步的Agent链,链路ID能把这些步骤串成一条完整trace,出了问题直接看trace,不用人肉拼接日志。第三是幂等。在线系统一定会重试,消息ID就是天然的幂等键。
实战中我推荐最小信封长这样,注意每个字段都不是摆设,后续所有工具调用、错误、事件都会被包在这个信封里。
{ "version": "1.2", "message_id": "msg_01J", "trace_id": "trace_8f3", "session_id": "sess_91a", "workflow_id": "flow_001", "from": "planner_agent", "to": "executor_agent", "role": "assistant", "timestamp": "2025-06-01T10:00:00Z", "type": "tool_call_request", "payload": {} }你可能会问,role这些字段不是模型API本身不是有吗?是的,但那是LLM Provider消息里的user/assistant/system,而Agent之间通信还要表达“这是谁发起的调用”“这条消息是请求还是结果”“当前处于哪个工作流步骤”,这些业务角色、消息语义和消息分片,是模型接口不会替你表达的。应用层通信格式必须自己承担。
2.2 工具调用:把“意图”变成机器可执行的行动
目前LLM生态里最成熟的结构化意图表达,是OpenAI Function Calling、Anthropic Tool Use那一套数据结构。一条工具调用通常包含:id(工具调用唯一标识)、type(function)、name(函数名)、arguments(参数字符串)。下面是一个典型的输出:
{ "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "query_weather", "arguments": "{\"city\": \"北京\", \"date\": \"2025-06-01\"}" } } ] }这里最容易踩坑的是arguments居然是个字符串,而不是JSON对象。很多框架在设计时为了兼容模型输出,故意把它序列化成字符串。结果下游程序拿到后还得再 parse 一次,稍微有个非法转义就崩。这个设计有历史原因:早期模型对复杂嵌套JSON对象支持不好,把参数当作普通字符串去生成,准确率高很多。但如果你的下游是自己控制的Agent,我建议在内部流转时直接改成结构化对象,并给模型明确schema,让模型输出对象而不是字符串;如果用的是外部模型返回格式,则在适配器层统一转换。
另一个要点是“意图”与“执行”分离。模型输出 tool_call 只是意图,实际工具是否执行、结果如何返回,需要在格式里允许同一ID被后续消息引用。比如执行完成后,回一条type: "tool_call_result",带上tool_call_id: "call_abc123"、status: "success"。这样可以把模型生成的“话”和工具执行的“事”分表隔离,审计时能看到谁在什么时候真正调了什么。
2.3 上下文、记忆与状态:别让Agent失忆
多Agent协作里常见的第二个问题是:Agent B只拿到当前这一步的输入,没有上一步的记忆,导致同一个项目前后矛盾。应用层通信格式里,必须有专门承载上下文和记忆的字段。
可以设计一个统一的context对象,包含system_instructions、working_memory、artifacts三块。system_instructions是本次任务的固定约束;working_memory是任务过程中产生的关键结论、中间状态、决策记录;artifacts是已经生成的文档、代码、图片等信息。这样设计有几个好处:上下文大小可控,不会把整个历史对话塞进去;每个Agent只读取自己关心的部分;后续可以在不改变主消息结构的情况下,单独升级记忆模块。
当然要注意,上下文不能无限增长,LLM的上下文窗口永远是稀缺资源。实践中我们会对working_memory做摘要化:每完成一个子任务,由当前Agent产出一条结构化摘要,合并到context中;原始细节则落到外部队列或向量库。通信格式里只传摘要和引用ID(比如artifact_ref: "doc_123"),下次需要再按ID拉取。这既保证消息简短,又保证信息不丢。
2.4 错误、重试与终止:让失败也变成“结构化信息”
很多人设计通信格式时只画“成功路径”,一遇到失败就乱了阵脚。其实对Agent系统来说,失败是常态:工具可能超时、模型可能格式错误、另一个Agent可能宕机。通信格式必须为失败留出明确位置。
我采用过的最简单方案:每个消息外层可以带status字段,可选"pending" | "success" | "error" | "cancelled";错误时在payload里放置error对象,包含code、message、retryable、details。retryable尤其重要,它告诉调用方这个错误能不能重试。比如上下文超限就是不可重试的,需要换摘要重来;工具超时则是可重试的。
错误码不需要照搬HTTP状态码,最好按业务语义定义。我们维护了一套内部错误码表,大概几十个,足够覆盖99%场景。常见的有:invalid_tool_call、tool_execution_error、tool_not_found、context_window_exceeded、rate_limited、timeout、permission_denied。有了这套错误码,上游Agent才能做出“重试、换工具、降级、询问用户”的决策,而不是看到一个笼统的失败就放弃。
3. 主流通信格式选型:JSON、JSON-RPC、MCP还是NDJSON
3.1 纯JSON + JSON Schema:最普及,但需要自律
大部分团队的第一选择一定是纯JSON。原因很直接:LLM Provider返回的就是JSON,模型对JSON的生成能力最强;JSON Schema又提供了校验、文档、代码生成的基础。缺点也同样明显:纯JSON只是数据表示,不约束交互语义。请求和响应长什么样、错误怎么表示、重试规则,全靠团队自己约定,标准版本更是空白。
所以我的建议是,如果项目规模小、Agent数量少、工具调用不超过几十个,不要为了用新协议而用新协议。你先定义好一套信封+Schema,把版本、ID、错误、状态这四个要素补齐,就已经比90%的临时拼接方案强。到了需要跨团队、跨系统协作时,再考虑下面的标准协议。
3.2 JSON-RPC 2.0:轻量标准,请求响应天然对应
JSON-RPC 2.0之所以适合Agent场景,是因为它极其简单又有标准错误结构。一条请求是{"jsonrpc":"2.0","method":"tools.call","params":{...},"id":"1"},响应是{"jsonrpc":"2.0","result":{...},"id":"1"}或{"jsonrpc":"2.0","error":{"code":-32000,"message":"..."},"id":"1"}。
它天然解决了消息ID与响应关联的问题,比我们前面自己设计的信封轻很多。缺点是它没有事件推送、没有流式传输语义,不太适合Agent在运行过程中实时上报进度。如果你需要流式能力,可以在JSON-RPC外层叠加NDJSON或SSE。很多真实Agent框架就是这么干的:底层用JSON-RPC定义方法调用,传输层用NDJSON按行传输。
3.3 MCP:把工具、资源、提示词统一成“AI应用的USB-C”
近几年很热的 MCP(Model Context Protocol)本质上就是为LLM与外部工具、数据源通信设计的应用层协议。它基于JSON-RPC 2.0,定义了tools、resources、prompts三类原语,目标是让模型不用为每个外部系统写一套私有协议。
我对MCP的态度是:它适合做“Agent接入外部生态”的统一入口,但不等于它解决了所有Agent间通信问题。因为MCP的定位是模型客户端与服务器之间的接口,而不是两个对等Agent之间的业务协作协议。业务消息里仍然需要你的信封、上下文、业务错误码。可以把它理解为通信格式生态里的一层,而不是全部。
选择MCP时要特别注意协议版本和工具数量。工具很多时,MCP的list_tools和模型侧的工具选择会变成性能瓶颈,通常需要在应用层做工具缓存和过滤。这也是网上很多Agent框架虽然支持MCP,但绝不在关键链路上每次都全量拉工具的原因。
3.4 NDJSON与SSE:流式输出和进度事件的首选
Agent执行一个复杂任务往往需要几十秒甚至几分钟,用户不可能干等。此时通信格式需要考虑“边做边报进度”。NDJSON(Newline Delimited JSON)解决的就是这个问题:每一行是一个独立JSON对象,用换行符分隔,下游可以逐行解析,不用等完整响应体。SSE(Server-Sent Events)则是一种单向服务器推送协议,浏览器和服务器都可以消费。
实践中,我们的消息分发层会把长时间运行的Agent事件流用NDJSON输出,事件类型包括agent_start、tool_call_started、tool_call_result、status_update、agent_end等。这样前端可以实时渲染Agent的思考过程,后端也可以把这些事件灌入日志系统做回放。
| 格式 | 使用场景 | 优点 | 缺点 |
|---|---|---|---|
| 纯JSON | 请求-响应式内部通信 | 生态好、模型友好 | 无标准信封、无流式语义 |
| JSON-RPC 2.0 | 工具方法调用 | 标准错误、ID关联 | 不支持事件与流式 |
| MCP | 模型接入工具与资源 | 统一生态、三方接入方便 | 语义偏模型-服务器,不是业务协议 |
| NDJSON/SSE | 流式事件、实时进度 | 逐行消费、断流恢复方便 | 不适合强结构化二元交互 |
3.5 二进制序列化:内部高吞吐时再考虑
也有团队想用 MessagePack 或 Protobuf 来提升性能。我个人在LLM/Agent场景里很少推荐,因为流量大头在模型API的文本交互上,内部序列化的耗时占比很低,却牺牲了日志可读性和调试便利性。除非你真的在做低延迟、高并发的Agent网关,成千上万的内部消息要中转聚合,那可以只对“结果数据”做二进制编码,而对“业务信封”保留JSON。
4. 实操:可直接抄作业的通信格式模板
4.1 信封字段与版本策略
这一节我们给出一个通用模板,你把type和payload替换成自己的业务就好。版本号放第一层,且只在大版本不兼容时递增;小版本升级体现在extensions里。我们约定:未知的extensions字段必须被忽略,未知的version必须被拒绝。这样做的好处是,老节点读到新消息不会直接崩溃,新老版本可以平滑过渡。
{ "version": "1.2", "message_id": "msg_003", "trace_id": "trace_88f", "session_id": "session_12", "workflow_id": "wf_09", "from": "planner", "to": "executor", "role": "assistant", "type": "tool_call_request", "timestamp": "2025-06-01T10:00:00Z", "extensions": { "priority": "high" }, "payload": { "tool_call_id": "call_abc123", "tool_name": "query_weather", "arguments": { "city": "北京", "date": "2025-06-01" } } }4.2 一次完整的工具调用往返
为了让你看得更清,我们模拟一段完整交互。先是Planner发出调用请求,如上一条;Executor收到后执行工具,返回结果:
{ "version": "1.2", "message_id": "msg_004", "trace_id": "trace_88f", "session_id": "session_12", "workflow_id": "wf_09", "from": "executor", "to": "planner", "role": "tool", "type": "tool_call_result", "timestamp": "2025-06-01T10:00:05Z", "payload": { "tool_call_id": "call_abc123", "status": "success", "output": {"temperature": 28, "humidity": 60} } }注意tool_call_id必须和请求里的完全一致,这是关联链路的钥匙。如果你只依赖消息ID也能关联,但工具调用ID更贴近模型侧的语义,后续拿这个结果回填给模型时,模型能直接把它当作函数结果继续推理。响应里不要带上所有平台级元数据,比如集群名、鉴权token,这些要么放到HTTP Header,要么放到extensions,并从日志脱敏。
4.3 多Agent协作的消息流转示例
假设一个内容生成项目,包含规划Agent、写作Agent、审查Agent。规划Agent拆好任务后,向写作Agent发一条任务消息;写作Agent完成初稿后,向审查Agent发一条审查请求并附上context中的artifacts;审查Agent发现问题,返回一条type: "agent_message"的反馈,写作Agent据此修改。
设计时建议多一个phase字段,也可以放payload,标记当前处于工作流的哪个阶段。这样编排器可以按阶段做并发控制:比如同一时间只允许一个写作任务运行,避免多个Agent同时修改同一个文档。阶段字段还方便回滚:一旦发现某一步出错,直接把工作流状态恢复到上个阶段的检查点重跑。
{ "version": "1.2", "message_id": "msg_010", "trace_id": "trace_99a", "workflow_id": "wf_09", "from": "reviewer", "to": "writer", "type": "agent_message", "phase": "revision", "payload": { "content": "第二段论点不清晰,请补充数据", "reason": "missing_evidence", "artifact_ref": "doc_001" } }这种消息的优点是审查结果本身也被结构化,reason字段可以被程序进一步分类和统计,而不只是给人看的一句话。
4.4 扩展性与向后兼容
任何通信格式都会面临演进。我建议三条铁律:一是永远不要直接修改已有字段的含义;二是新增字段只允许加在extensions或 payload 里,禁止修改已发布字段的类型;三是所有解析器统一在入口做“unknown field ignored”处理,不要把未知字段直接抛异常。
如果你的项目需要对接多个模型供应商,最好在协议适配层做一次“统一消息格式规范”。模型A返回的tool_calls和模型B返回的结构可能不一样,但内部统一后,下游Agent只认我们的规范,不认具体厂商格式。我们曾经在两天内从OpenAI换到另一个本地模型,就因为适配层做了统一格式,整体改动量小到可以接受。
5. 踩坑实录:调试通信格式时的典型问题
5.1 模型输出的JSON总是坏掉
这个问题几乎每个团队都会遇到。一种是模型把arguments里的字符串当成普通文本导致引号错乱;一种是模型在JSON外额外输出了“思考过程”;还有一种是在流式模式下,消息被截断了。
我的处理顺序是:优先在模型层规避,给模型设置response_format: {"type": "json_object"},并在工具定义里写明strict: true,部分Provider支持;然后在应用层加一个容错解析器,先尝试JSON.parse,失败后做简单修复(去掉首尾说明文本、修正缺失引号);最后才将解析失败的消息连同原始输出一起回流,用于后续微调或prompt优化。不要让容错解析器写太多正则,太脆,只处理最常见的几种情况就好。
5.2 工具参数和Schema对不上
模型经常会平白多传一个参数,或者把日期类型传成字符串。你自己定的通信格式里,每个工具的input_schema必须可校验,避免运行时才崩。我们用JSON Schema做一层校验,不兼容时返回结构化错误给模型,让模型自己去修正;同时编写工具时要习惯容错:可缺省参数都给默认值,能自动转换的类型先转换。
有一个容易被忽略的点:模型见过的schema如果太长,反而更容易出错。工具定义尽量精简,参数尽量少于8个,嵌套深度尽量不超过两层。这不是教条,是模型对复杂结构生成本来就弱,你的通信格式再正确,模型不配合也是白搭。
5.3 并发、重试与幂等:在线Agent系统的大考
应用层通信格式设计的成败,最终体现在抗并网上。多Agent并发时,可能出现同一个任务被两个Agent重复执行、或者同一消息被重发。所以每条消息的message_id要在消费端做去重;每个工具要有幂等键,比如“发送邮件”类工具必须支持传入request_id,便于重试时不产生重复邮件。
重试策略我总结为:先看错误码。如果是rate_limited或timeout,指数退避加随机抖动重试;如果是invalid_tool_call,不是重试能解决的,应该回到模型层重新生成;如果是permission_denied,就不要重试,直接转人工。会话状态建议加版本号或updated_at,冲突时用乐观锁,避免两个Agent“各改一半”。
5.4 安全:工具调用是新的提权入口
最后必须说安全。Agent通信格式传输的是“可执行的意图”,这比普通文本数据风险更高:只要有人能伪造或篡改一条tool_call_request,就能让系统调用任意工具。所以我们做了几个基本动作:一是所有Agent间消息走内部mTLS,并校验from字段,防止伪造来源;二是敏感工具(发消息、删数据、支付)在消息里必须带require_confirmation: true,由上层人工确认后再执行;三是外部来源文本不能直接进入工具参数,必须经过安全过滤。
另外要特别小心 prompt injection:当Agent从网页、邮件或用户输入中读取内容时,这些内容可能夹带“忽略之前的指令”“调用某某工具”等恶意指令。在通信格式层面能做的,是把“外部内容”和“系统指令”用不同字段隔离,解析器对外部内容打上source: "external"标记,限制它参与系统级指令的上下文。做不到百分百防,但至少能降低被突破的概率。
这个项目做下来,我从实际项目里得到的最大教训是:应用层通信格式这件事,往小了说是几个JSON字段,往大了说是Agent系统的接口契约。如果你刚开始做Agent,别急着上复杂的协议和框架,先把自己团队内部的信封、错误、版本、幂等四件套固定下来。等真的需要跨系统协作,再向JSON-RPC、MCP演进。比起换更强模型,把消息格式搞扎实,往往能带来更稳定的整体体验。最后再分享一个小技巧:在测试环境里故意破坏几条消息(缺字段、错类型、版本不匹配),看看你的Agent能不能优雅降级,这比写一百行单元测试更能暴露协议设计问题。