streaming-tool-calls-partial-json · ZH · 2026-10-24

流式工具调用在一把 key 上:各后端的 arguments 分片怎么拼回完整 JSON

聚合站用一把 Key 调多个模型时,流式工具调用的最大坑在于不同后端把 tool_call 的 arguments 切成 chunk 的方式不一致。本文梳理 Claude、GPT、DeepSeek、Qwen、GLM、Kimi 在流式分片上的常见差异,给出「按 index/id 聚合、收齐再解析」的统一策略,并说明在聚合网关层如何做保守兜底(缓冲区上限、半截 JSON 处理、严格模式降级),让工具调用在切换模型时不中断。

为什么流式工具调用容易出问题

非流式请求里,tool_calls[].function.arguments 是一个完整的 JSON 字符串,JSON.parse 一次就完事。流式请求里,这个字符串会被切成若干片(delta),逐个塞进 SSE chunk。问题在于:切片规则没有跨厂商统一标准。

同一段参数,有的后端按 token 边界切,有的按字符切,有的在开头补一个空片,有的把 id、name 只放在第一片。客户端如果假设「每个 chunk 都是合法 JSON」,就一定会在某个后端上崩。

在聚合站场景下这一点被放大:你手上只有一把 API Key,同一个 SDK、同一段解析代码要同时面对六种后端。所以解析层必须做得比「对接单一厂商」更保守。

六个后端的分片差异,归成三类

不必背每个厂商的细节,把差异归纳成三类就够写代码了。

1. 分片粒度与边界

  • 有的后端按 token 切,片与片之间可能落在 JSON 字符串内部,甚至切在多字节 UTF-8 字符中间(取决于上游序列化方式)。
  • 有的后端倾向于在较自然的边界切,单片的字符串更完整。
  • 还有的会在第一个分片里给出 arguments: "" 或很小的前缀,之后再逐片追加。

结论:不要对单片长度或内容做任何假设。字符串拼接是唯一安全的操作。

2. 元信息出现的位置

tool_call 的 index、id、function.name 出现位置不一致:

  • 通常第一个分片带 index 和 id,name 也在第一片。
  • 后续分片只带 index 和 arguments 的增量,id/name 缺省。
  • 个别后端会在每个分片重复 index,但 id 只在首片出现。

结论:首片负责建立「这是哪个工具调用」的身份,后续分片只做追加。聚合时要按 index 归并,而不是按到达顺序新建对象。

3. 结束信号的位置

  • 有的后端在最后一个参数分片之后,再发一个 finishreason: "toolcalls" 的 chunk。
  • 有的把 finish_reason 放在同一个 chunk 里。
  • 有的在参数流结束后先发一个空 delta,再发结束标记。

结论:不要用「收到 finish_reason」当作参数已完整的充分条件,要同时跟踪「该 index 是否已收到结束」。

统一策略:按 index 聚合,收齐再解析

核心思路很简单:流式只做搬运和累积,解析放到最后一次性做。

数据结构

维护一个按 index 索引的 map:

calls: Map<index, {
  id: string | null,
  name: string | null,
  argsBuffer: string,   // 纯字符串累加
  done: boolean
}>

每收到一个 chunk:

  1. 遍历 delta.tool_calls(可能为空数组,也可能不存在)。
  2. 对每一项取 index;若 map 中不存在则创建。
  3. id、name 只在当前值为 null 时写入(首片优先,后续不覆盖)。
  4. arguments 无条件 argsBuffer += fragment。不要 trim,不要尝试中途 parse。
  5. 若该 chunk 的 finishreason 为 toolcalls,把对应 index 标记 done(或在流结束时统一标记)。

收尾与解析

流结束后:

  1. 按 index 升序遍历。
  2. 对每个 argsBuffer 做一次 JSON.parse。
  3. 空字符串按 {} 处理(无参工具很常见)。
  4. 解析成功则组装成标准的 tool_calls 数组回给业务层。

这一段的关键纪律是:在流进行中绝不 parse。半截 JSON 必然抛错,而错误处理一旦触发重试,流已经消费掉的部分就丢了。

聚合站上的严格模式兜底

上面的策略在单厂商下够用。经过聚合网关时,还有几件事要考虑。

缓冲区上限

参数理论上可以很长(比如让模型生成一个大 JSON)。给 argsBuffer 设一个上限,超过就截断并报错,而不是无限吃内存。这个上限按你的业务能接受的参数规模定,不要照抄别人的数字。

半截 JSON 的处理

如果流在参数没收齐就断了(网络中断、上游超时),argsBuffer 是半截 JSON。此时:

  • 不要尝试用正则「修补」补全引号或括号,那会产出错误但看起来合法的参数。
  • 直接把该次工具调用标记为失败,让上层决定是重试整个请求还是降级。

严格模式与非严格模式

建议在客户端分两档:

  • 严格模式:必须拿齐 id、name、完整可解析的 argsBuffer,否则视为失败。适合会真正执行副作用的工具(写文件、发请求)。
  • 宽松模式:argsBuffer 解析失败时,把原始字符串交给上层人工/二次模型处理,或直接丢弃该工具调用、继续对话。适合只读型、可重试的场景。

聚合站无法改变上游的分片行为,但可以在网关侧做统一归一:把各后端的 delta 转成同一种聚合形态再下发。这样客户端只需实现一套收集逻辑,切模型时不用改代码。这也是「一把 Key 调多个模型」在工程上真正省事的地方。

切换模型时的注意点

  • 不同后端对「无参数工具调用」的表达不同:有的给 "",有的给 "{}",有的直接不发 arguments 分片。统一按 {} 兜底。
  • 并行工具调用(一次返回多个 tool_call)依赖 index 区分;如果某个后端不保证 index 连续,仍按出现的 index 建 map,不要用数组下标硬对。
  • name 缺失时不要猜,视为该调用无效。

小结

流式工具调用的解析难点不在 JSON 本身,而在分片契约的不统一。把它当成一个纯粹的字符串累积问题:按 index 建桶、首片定身份、中途只追加、结束再解析、失败不修补。再加上缓冲区上限和严格/宽松两档兜底,同一套代码就能在聚合站的一把 Key 上稳定跑通多个后端。