ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent通信别靠字符串拼接:LLM应用层通信格式设计与实战

Agent通信别靠字符串拼接:LLM应用层通信格式设计与实战 我最近和几个做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工具调用唯一标识、typefunction、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还是NDJSON3.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”近几年很热的 MCPModel 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执行一个复杂任务往往需要几十秒甚至几分钟用户不可能干等。此时通信格式需要考虑“边做边报进度”。NDJSONNewline Delimited JSON解决的就是这个问题每一行是一个独立JSON对象用换行符分隔下游可以逐行解析不用等完整响应体。SSEServer-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能不能优雅降级这比写一百行单元测试更能暴露协议设计问题。
返回列表