
流式接口把一次工具调用切成十几片下发,拼接逻辑写错的表现往往是「工具参数莫名其妙」,而不是报错。下面七个问题和答案都来自本机脚本 tools/verify-stream-tool-calls.mjs,用写死的桩模型分片驱动解析代码,分片序列固定,方便逐片核对。
为什么按 id 归类会拼错
因为 id 只在第一次出现的那一片里有。后面所有分片只带 index 和参数片段,用 d.id ?? ... 去取键的写法,从第二片起拿到的是 undefined,于是所有后续片段被叠到同一个键上。实测里两个调用的九片被归成三个键,两个正常调用的参数长度都是 0,剩下的 41 个字符全在第三个键上,拼出来的字符串把两个城市的参数混成了一段。
按 index 归类为什么就对了
index 是分片自带的坐标,从第一片到最后一片都在,用它当键就不需要依赖「哪片先到」。按 index 归类的实测结果是两个键,id 和函数名在首片写入一次,后续片只追加参数。同一份分片序列,两种写法给出的键数从 3 变 2,键数不对就已经说明拼错了,这点可以作为单元测试的断言。
参数能不能边收边 parse
不能。第一片非空参数是 {"ci,parse 到第 5 个字符就抛 Unterminated string in JSON。JSON 是自定界语法,任意前缀都可能不合法,所以只能攒完再解析。要更早发现畸形参数,可以数括号配对深度,但判合法性仍然得等最后一片。
arguments 是空串和缺字段有什么区别
对拼接逻辑没有区别,两者都该按空串处理,实测里三种情况(空串、缺 arguments、整个 function 缺)用 ?? "" 兜住之后结果一致。真正的坑是兜底值的写法:用 || 而不是 ?? 时,空串被当成假值替换成默认值,输出变成 {} 而不是空字符串,接下来 JSON.parse 就会把空参数当成空对象,调用被静默地改成「无参数」语义。
并行调用交错下发时怎么区分
只认 index。桩模型把两个调用的分片完全交错下发,同一 index 的片段之间夹着另一路的数据,按到达顺序拼接会立刻出错。分片本身不保证同一次调用连续到达,这一点在协议层就没有承诺,所以解析代码不能依赖顺序,只能依赖坐标。
回填历史时有哪些硬约束
发起工具调用的那条 assistant 消息必须带 tool_calls 数组,紧随其后的工具结果消息要带对应的 tool_call_id 指回去。实测里合规的历史校验返回空数组,把 assistant 的 tool_calls 去掉之后立刻报两条「前面找不到带 tool_calls 的 assistant 消息」,故意写一个不存在的 id 则报「不在前面声明的 id 列表里」。另有一条本脚本的校验没覆盖:多个 tool 消息的先后顺序与 tool_calls 的声明顺序不一致时,这里查不出来,服务端是否严格校验顺序也没实测。
拼接结果怎么验收
两个断言就够:键数等于 stderr 之外看到的 index 个数,以及每条参数能过 JSON.parse。实测里两个调用分别解出 {"city":"深圳"} 与 {"city":"上海","unit":"\"u\""},第二个参数里那个带转义引号的值是刻意切的,用来验证分片不会破坏转义序列。
node v26.8.1
=== 桩模型下发的 9 个分片 ===
#0 index=0 id=call_a1 name=get_weather arguments=""
#1 index=1 id=call_b2 name=get_weather arguments=""
#2 index=0 id=(无) name=(无) arguments="{\"ci"
#3 index=1 id=(无) name=(无) arguments="{\"ci"
#4 index=0 id=(无) name=(无) arguments="ty\":\"深"
#5 index=1 id=(无) name=(无) arguments="ty\":\"上"
#6 index=0 id=(无) name=(无) arguments="圳\"}"
#7 index=1 id=(无) name=(无) arguments="海\",\"unit\":\"\\\""
#8 index=1 id=(无) name=(无) arguments="u\\\"\"}"
=== 拼法一:按 id 归类 ===
Map 里出现 3 个键: ["call_a1","call_b2",null]
call_a1 -> name="get_weather" args=""
call_b2 -> name="get_weather" args=""
undefined -> name="" args="{\"ci{\"city\":\"深ty\":\"上圳\"}海\",\"unit\":\"\\\"u\\\"\"}"
两个调用被并成 3 个,参数长度分别是 0 / 0 / 41
=== 拼法二:按 index 归类 ===
Map 里出现 2 个键: [0,1]
index 0 -> id="call_a1" name="get_weather" args="{\"city\":\"深圳\"}"
index 1 -> id="call_b2" name="get_weather" args="{\"city\":\"上海\",\"unit\":\"\\\"u\\\"\"}"
=== 拼接结果能不能过 JSON.parse ===
index 0: get_weather({"city":"深圳"})
index 1: get_weather({"city":"上海","unit":"\"u\""})
=== 边收边 parse 会怎样 ===
index 0 一共收到 3 片非空参数(首片 arguments 是空串,已跳过),第 1 片时 parse 就抛了
抛的那次 buffer 内容: "{\"ci"
报错: Unterminated string in JSON at position 4 (line 1 column 5)
=== 空字符串与缺字段 ===
arguments 是空串 -> "x"
arguments 缺字段 -> "x"
整个 function 缺 -> "x"
用 || 而不是 ?? 的时候,空串会被当成假值: "{} vs "
=== 回填对话历史时的约束(OpenAI 兼容接口的常见校验) ===
assistant 消息: {"role":"assistant","content":null,"tool_calls":[{"id":"call_a1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"深圳\"}"}},{"id":"call_b2","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"上海\",\"unit\":\"\\\"u\\\"\"}"}}]}
紧跟的 tool 消息: [{"role":"tool","tool_call_id":"call_a1","content":"{\"temp\":28}"},{"role":"tool","tool_call_id":"call_b2","content":"{\"temp\":29}"}]
合规历史校验: []
少了 tool_calls 的 assistant: ["第 1 条 tool 消息前面找不到带 tool_calls 的 assistant 消息","第 2 条 tool 消息前面找不到带 tool_calls 的 assistant 消息"]
tool_call_id 对不上: ["第 1 条 tool_call_id=call_zzz 不在前面 assistant 声明的 id 列表里"]
两个 tool 消息的先后顺序与 tool_calls 顺序不一致时,校验查不出来(本脚本的校验不管顺序)
落地写法
维护一个以 index 为键的表,每片到了就按 index 取桶,id 与函数名只在有值时覆盖,参数一律追加,等到流结束再统一 parse。这套逻辑与模型无关,被驱动的是一份写死的分片序列,真实模型会怎么切、会不会出现 index 乱序或重复的 id,本文都没实测,遇到异常分片时以日志里的原始分片为准。我的做法是把这个拼接函数单独抽出来加两条断言,键数与可解析性,谁改这段代码都会被这两条拦住。