把工具结果注回 agent 循环:六个后端上格式化在哪一步断掉
本文围绕手写 agent 循环中工具结果回灌格式不一致的问题,解释各后端对 tool-result 消息载荷的期望差异,并给出一个在调用聚合站前统一归一化、在解析响应后再适配的六后端通用中间层实现思路。
问题从哪来
手写 agent 循环时,工具调用结果需要作为一条消息回灌给模型。不同后端对这条消息的字段命名、角色名、内容结构和配对约束期望不同,格式一旦错位,通常不会报「格式错误」,而是模型开始忽略工具结果、复述旧参数,或返回空内容。
典型断点集中在四步:
- 把工具返回值序列化成字符串时的包装方式
- tool-result 消息的角色与关联 ID 字段
- 多段内容(文本加图片或结构化数据)的数组形态
- 同一轮多个并行工具调用的顺序与配对
各后端的期望差异
聚合站把请求转发给不同后端,各后端对 tool-result 载荷的期望并不一致。常见差异维度:
- 关联字段:有的要求 tool-result 消息带
toolcallid,有的要求tooluseid,有的用id或call_id。 - 角色命名:
tool、function、user内嵌结果块,各不相同。 - 内容结构:有的只接受字符串,有的接受
{type, text}数组,有的把结果放在content里、有的放在output或result里。 - 并行调用配对:同一轮返回多个 tool call 时,有的要求每个结果独立一条消息并严格按 call 顺序排列,有的允许合并成一条数组。
- 错误与空结果:工具返回错误时,有的需要
is_error标记,有的只接受普通文本,空字符串可能被当作缺失。
这些差异不是「谁对谁错」,而是各后端的消息协议不同。手写循环直接拼一种固定格式,换后端就会断。
归一化中间层的位置
把归一化放在「业务循环」和「HTTP 请求」之间,而不是散落在工具实现里。中间层只做两件事:
- 出站归一化:把内部统一的工具结果结构,转换成目标后端期望的载荷。
- 入站适配:把后端响应里的工具调用解析回内部统一结构。
内部统一结构建议只保留最小字段:
call_id:工具调用的唯一标识name:工具名arguments:解析后的参数对象result:序列化后的结果文本is_error:布尔标记
六后端通用的实现思路
中间层按「能力声明 + 适配函数」组织,而不是为每个后端写一整套循环。
- 能力声明表:为每个后端记录它期望的关联字段名、角色名、内容形态、是否支持并行结果合并、错误标记字段。
- 统一序列化:工具返回值先统一转成字符串(JSON 用稳定键序),再交给适配函数包装,避免各工具自己拼格式。
- 配对校验:回灌前检查每个 tool call 是否都有对应结果、
call_id是否一一匹配、顺序是否符合后端要求,不匹配就补齐或重排。 - 错误归一:工具异常统一映射为
is_error,再按后端能力降级为文本前缀或专用字段。 - 响应解析:从响应里提取工具调用时,同时兼容
toolcalls、content中的tooluse块等形态,统一写入内部结构。
伪代码层面,循环只依赖内部结构:
result = call_model(messages)
for call in parse_tool_calls(result):
output = run_tool(call.name, call.arguments)
messages.append(normalize_tool_result(call, output))
messages = adapt_outbound(messages, backend)
关键是 adapt_outbound 是纯函数,输入内部消息列表,输出目标后端载荷。这样新增后端只需补一张能力声明和少量分支,不用改循环。
调试建议
- 把归一化前后的载荷都打日志,定位是在哪一步字段名或结构对不上。
- 先用单工具、单轮调用验证,再开并行调用。
- 工具结果保持纯文本优先,结构化数据序列化成字符串,减少后端对数组形态的分歧。
- 空结果显式写成占位文本,避免被当作缺失。
小结
格式化断掉通常不在工具本身,而在回灌消息的协议差异。把归一化收敛到一个中间层、用能力声明描述各后端期望、在出站前做配对校验,就能让同一个手写 agent 循环稳定跑在多个后端上。