tool-calling-streamed-argument-fragmentation · ZH · 2026-11-02

流式返回里的工具参数被切成碎片:在一把 key 上把 arguments 拼回完整对象再执行

流式 tool call 的 arguments 并非一次性到达,而是按 token 切片跨多个 chunk 推送。本文讲解如何按 index 累积片段、只拼接 arguments 字符串并对其他字段做首次赋值或替换、用 finish_reason 与空 choices 的 [DONE] 信号判断调用收完,最后再做 JSON 解析与执行,并说明在一把 key 调多模型的聚合场景下如何统一处理不同厂商的流格式差异。

为什么 arguments 会被切碎

流式响应的本质是服务端边生成边推送。模型在决定调用工具后,会逐 token 输出工具名和参数,而参数通常是 JSON 字符串。于是你在每个 chunk 里拿到的 function.arguments 往往只是 {"city": 或 "Bei 这样的片段,而不是完整对象。

直接在第一个 chunk 上做 JSON.parse 必然失败。正确做法是先累积,等调用结束后再解析。

先分清:哪些字段要拼接,哪些要替换

工具调用在流式协议里通常带一个 index,用来区分同一次响应中的多个并行调用。处理规则可以总结为:

  • 按 index 分组:不同 index 是不同调用,片段绝不跨 index 混拼。
  • id 替换:调用 id 只在首个相关片段出现,后续片段可能为空或缺失,用首次出现的值覆盖即可,不要拼接。
  • type 替换:一般是 function,同样取首次出现的值。
  • function.name 拼接或首次赋值:若服务端把函数名也切片推送,就需要拼接;若只推一次,则首次赋值后忽略后续空值。稳妥写法是「有值就追加,无值就跳过」,因为名字通常很短,追加式写法对两种服务端都成立。
  • function.arguments 拼接:这是唯一必须严格按顺序字符串追加的字段,不能解析、不能 trim、不能去重。

一个常见的坑是把整个 delta 对象做浅合并。字符串字段一旦被 Object.assign 覆盖,前面积累的片段就丢了。应当显式只对 arguments 做 +=。

判断一次调用是否收完

不能靠「收到一个 chunk 里有 name 就算开始、有右括号就算结束」。可靠信号有两类:

  1. finishreason:当某个 chunk 的 choices[0].finishreason 变为 toolcalls(部分服务端用 functioncall),说明模型已生成完所有调用,可以进入收尾流程。
  2. 流结束标记:SSE 中常见的 data: [DONE],或直接收到连接关闭。此时即使 finish_reason 缺失,也应把已累积的调用视为完整。

注意 finish_reason 可能在最后一个带内容的 chunk 上,也可能在一个 choices 为空数组的尾部 chunk 上,取决于服务端实现。因此收尾逻辑应放在「循环结束后」统一执行,而不是依赖某个特定 chunk。

收尾:解析、校验、执行

循环结束后,对每个 index 的累积结果做三件事:

  • 若 arguments 为空字符串,视为无参调用,用 {} 代替。
  • 尝试 JSON.parse。解析失败通常意味着流被截断(网络中断、超时),此时应丢弃该调用并重试或报错,而不是把半截字符串传给工具函数。
  • 校验 name 是否在允许的工具白名单内,参数是否符合工具的 schema,再执行。

工具返回结果后,把 role: tool 的消息连同对应的 toolcallid 追加回上下文,再进行下一轮请求。

一把 key 下的多模型差异

聚合网关的好处是一个 API key 可以调多个模型,但不同厂商的流在细节上并不一致:有的在首个片段给出完整 id,有的把 id 放在最后一个片段;有的在 delta 里用 toolcalls,有的在非流式路径上仍用旧的 functioncall 结构。

工程上的应对是把解析层抽象成状态机:以 index 为键维护累积器,对未知字段做忽略而不是报错,对缺失字段做默认值填充。这样同一套代码就能同时消费 Claude、GPT、DeepSeek、Qwen、GLM、Kimi 的流式工具调用,而不必为每个模型写分支。

调试建议

  • 先把原始 SSE 行打印出来看一遍,确认 index、id、arguments 的实际分布。
  • 用「累积后重新序列化」的方式对比最终 JSON,确认没有丢失或多出字符。
  • 对并行调用造一个多工具测试用例,验证 index 分组是否正确。

流式工具调用本身不复杂,难点全在「别在错误的时机做解析」。把拼接和解析这两步分开,问题就基本消失了。