
流式返回的工具调用,参数不是一次到齐的。它会按网络分片切成好几段,每一段都是一个不完整的 JSON 片段。写处理逻辑时按片解析,得到的是连续的语法错误。
下面这套做法用桩数据驱动,数据形态照着常见流式接口的返回写的。模型本身的行为没有实测,处理方法可以照搬。
先把碎片原样打出来
动手写累积器之前,先看清流里到底发了什么。三行原文如下:
== 0. 桩数据:三行流式返回的原文(模型本身的行为没有实测)==
data: {"id":"chatcmpl-stub","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"cit"}}]},"finish_reason":null}]}
data: {"id":"chatcmpl-stub","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"name":"get_weather","arguments":"y\":\"深"}}]},"finish_reason":null}]}
data: [DONE]
第一片带着 id 和函数名,arguments 只有五个字符;第二片没有 id,也没有函数名,只有 arguments。这个分布是后面全部逻辑的依据。
== 1. 这段参数在流里被切成几片 ==
参数原文:{"city":"深圳","days":3}(长度 22)
第 1 片 arguments:"{\"cit"(长度 5)
第 2 片 arguments:"y\":\"深"(长度 5)
第 3 片 arguments:"圳\",\"d"(长度 5)
第 4 片 arguments:"ays\":"(长度 5)
第 5 片 arguments:"3}"(长度 2)
分片长度合计 22,与原文长度相等:true
五片,长度 5、5、5、5、2,合计 22,正好是完整参数的长度。分片边界落在字符中间,不受 JSON 结构影响,遇到多字节的汉字也一样切。
按片解析会失败成什么样
先看错误的样子,比直接给正确写法更容易记住要防的是什么:
== 2. 反例一:每收到一片就 JSON.parse ==
"{\"cit" → SyntaxError: Unterminated string in JSON at position 5 (line 1 column 6)
"y\":\"深" → SyntaxError: Unexpected token 'y', "y":"深" is not valid JSON
5 片里解析成功 0 片,失败 5 片
五片里成功 0 片。错误信息有两种:第一片报 Unterminated string,因为引号还没闭合;第二片报 Unexpected token,因为片段本身不是合法的 JSON 起始。这两种错误在日志里会让人误以为模型吐了坏数据,实际只是把分片当成了完整消息。
累积器按 index 建
每个工具调用在流里有一个 index,第一片带着它,后面的分片沿用同一个值。累积器就是一张以 index 为键的表,值放 id、函数名和拼起来的参数串。
const pending = new Map();
function feedToolCall(tc) {
const idx = tc.index ?? 0;
const slot = pending.get(idx) ?? { id: '', name: '', args: '' };
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;
pending.set(idx, slot);
}
三处判断是有必要的:id 只在第一片出现,函数名只在第一片出现,arguments 每一片都要拼。写成直接赋值会在第二片把前面攒的内容抹掉。
收尾时解析一次
流结束的标志是收到 finish_reason 为 tool_calls 的那一片,或者收到 [DONE]。这时候把表里的每一条取出来,各自解析一次:
function finishToolCalls() {
return [...pending.entries()].map(([index, slot]) => ({
index,
id: slot.id,
name: slot.name,
args: JSON.parse(slot.args),
}));
}
解析放在这里,错误信息才有意义:抛出来的就是这条调用的完整参数有问题,而不是「某一片不完整」。
检查点:三条必须成立的断言
== 4. 正确写法:按 index 各攒一份,收尾时各解析一次 ==
index 0 的 get_weather:攒到 22 个字符,解析结果 {"city":"深圳","days":3}
index 1 的 get_time:攒到 22 个字符,解析结果 {"city":"杭州","days":1}
== 5. 检查点:三个必须成立的断言 ==
① 攒出来的串与原文逐字相同:true
② 每片都在原文里连着:true
③ 只有第一片带 id 与函数名:后续分片里 function.name 为空:true
第一条断言是攒出来的串与原文逐字相同,中间没有多一个逗号或者少一个引号。第二条是每一片都能在原文里找到连续位置,用来验证分片顺序没有被网络乱序。第三条是后续分片里函数名为空,这条决定了累积器不能用覆盖式赋值。
两个容易忽略的情况
并行调用时会有多个 index 交替到达,跟单条调用一样按 index 归位即可。不归位直接拼接会得到两个 JSON 首尾相接:
== 3. 反例二:不按 index 归位,把所有片直接拼起来 ==
两个工具调用的分片交替到达,直接首尾相接得到:{"city":"深圳","days":3}{"city":"杭州","days":1}
解析失败:Unexpected non-whitespace character after JSON at position 22 (line 1 column 23)
第二个情况是空参数。一个没有入参的工具,arguments 可能是空串,也可能整段分片都不出现。这时候解析空串会抛错,要在解析前判断一次,把空串当成空对象。
什么时候这套逻辑可以省掉
不用流式的接口一次返回完整 JSON,直接解析就行,多一套累积器只是多一个出错的地方。需要展示打字机效果、或者单次响应可能很大想让首字节早一点到的场景,才值得写这段代码。
我的取舍是:累积器只做拼接,不做任何校验;校验放到收尾解析之后统一做。理由是分片边界看不出语义,在拼接阶段判断状态容易把正常的跨片结构当成错误。