流式里的工具调用:在 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。理解这一点,边收边解析的报错自然就消失了。