tool-calling-streaming-deltas · ZH · 2026-10-08

流式里的工具调用:在 SSE 响应中拼接 tool_call 增量片段

流式工具调用中,function arguments 会按 token 切成多个 delta 片段陆续下发。本文说明这些片段如何分片、为何不能边收边 JSON.parse,并给出一个正确的缓冲拼接写法与常见坑。

为什么工具调用的参数是流式的

在支持工具调用(function calling)的接口里,模型通常先输出要调用哪个函数、再逐个 token 生成这个函数的入参。

如果开启流式(stream: true),服务端出于延迟考虑不会等整段参数生成完再一次性发给你,而是沿着 SSE 一条条把增量推过来。于是你收到的不是一份完整 JSON,而是一串碎片:

  • 每一片可能只有几个字符,比如 {"ci、ty":"北、京"};
  • 碎片不一定按字节对齐,也不保证 UTF-8 字符完整;
  • 哪个片段属于哪个 tool call,靠的是 index 或 id 来区分。

一个典型的 SSE 增量长什么样

不同厂商字段名略有差异,但结构高度一致。常见形态是每个 chunk 里带一个 delta.tool_calls 数组,元素大致包含:

  • index:这是第几个 tool call(一次可能并行调多个);
  • id:工具调用的唯一标识,通常只在第一个片段里出现;
  • function.name:函数名,同样通常只在第一个片段出现;
  • function.arguments:参数 JSON 的一个片段字符串,后续片段持续追加。

概念上,你收到的序列像这样(伪代码,非真实响应):

delta.tool_calls[0].function.name      = "get_weather"
delta.tool_calls[0].function.arguments = '{"ci'
delta.tool_calls[0].function.arguments = 'ty":"北'
delta.tool_calls[0].function.arguments = '京"}'

最后一条通常还会带一个 finishreason,比如 toolcalls,告诉你参数已经发完。

为什么边收边 parse 会失败

最直觉的写法是:每收到一片 arguments 就 JSON.parse 一次,想尽早拿到参数。这在流式工具调用里几乎必然报错,原因很直接:

  • 片段是任意切的,'{"ci' 单独拿出来不是合法 JSON;
  • 一个多字节字符可能被切在两片之间,直接解码还可能得到损坏字符;
  • 参数本身是渐进构造的,只有全部片段拼完才是完整对象。

所以对参数片段做逐片解析,报的是语法错误,而不是数据错误——它不是「格式不对」,而是「还没拼完」。

正确做法:先缓冲,再整体解析

核心思路只有一句话:按 tool call 分组,把 arguments 当字符串累加,等到流结束(或该调用结束)再统一解析一次。

1. 用一个 map 按 index 聚合

const buffer = new Map(); // index -> { id, name, args }

for await (const chunk of stream) {
  const tcs = chunk.choices?.[0]?.delta?.tool_calls;
  if (!tcs) continue;

  for (const tc of tcs) {
    const key = tc.index ?? 0;
    if (!buffer.has(key)) {
      buffer.set(key, { id: null, name: null, args: '' });
    }
    const slot = buffer.get(key);

    if (tc.id) slot.id = tc.id;                  // 只在首片出现
    if (tc.function?.name) slot.name = tc.function.name;
    if (tc.function?.arguments) slot.args += tc.function.arguments; // 关键:累加
  }
}

注意两个点:

  • id 和 name 用「存在才覆盖」,不要无条件赋值,否则会被后续的空值清掉;
  • arguments 用 += 而不是 =,这正是拼接发生的地方。

2. 流结束后再解析

const calls = [];
for (const slot of buffer.values()) {
  calls.push({
    id: slot.id,
    name: slot.name,
    arguments: slot.args ? JSON.parse(slot.args) : {},
  });
}

此时 slot.args 已经是完整 JSON 字符串,一次 parse 即可。

常见坑

  • 多工具并行时混用同一个变量:一次响应可能同时发起多个 tool call,必须按 index 分开累加,否则参数会互相串台。
  • 把 id/name 当成每片都有:多数实现只在首片给,后续为空,覆盖逻辑要写对。
  • JSON 解析放在循环里:性能差且必错,解析只应在拼接完成后做一次。
  • 空参数:无参函数的 arguments 可能为空字符串,解析前要判空,回退成 {}。
  • 增量里夹了非参数事件:SSE 流中可能有心跳、usage 统计等其它事件,处理时要先判断有没有 tool_calls 再进逻辑。

一个可选的校验层

拼完、parse 完之后,建议再做一层轻校验,而不是直接信任结果:

  • 检查 name 是否在你的工具白名单里;
  • 用参数 schema 校验字段类型;
  • 对缺失的必填参数,选择补齐默认值或把错误回传给模型让它重试。

这样即便某个片段拼接过程中出问题,也能在真正执行工具之前拦下来。

小结

流式工具调用的参数本质是「分片下发的字符串」。处理原则就三步:按 index 分组、+= 累加、结束后整体 JSON.parse。理解这一点,边收边解析的报错自然就消失了。