模型吐到一半的 JSON 为什么不崩、不卡、不抖?流式 Function Call 的三层修复
模型流式输出 Function Call 时,到某一帧可能是 {"tool":"search","args":{"from":"北京","to":"上——这是一段物理上不完整的 JSON,JSON.parse 一调就抛。但 UI 里"正在生成 to 字段"已经平滑地冒出来了,既没崩也没卡。这份"半截也能渲染、还不抖"的背后,是三层各管一段的修复。
这三层是数据流上三个独立的环节,彼此正交:字节层负责把切碎的网络包拼回完整文本,语法层负责把半截 JSON 修到能 parse,UI 层负责让字段平滑出现不跳变。下面分开讲,每层各治一个症状。
先分清:半截 JSON 有两层"不完整"
很多人一上来就把"半截 JSON 渲染"当成一个问题,其实它有两层不完整,必须分开治,谁也替不了谁:
| 层 | 不完整的样子 | 不治会怎样 | 治它的 |
|---|---|---|---|
| 字节层(网络) | 一个 4 字节 emoji 被切成 2+2;一行 data: 被切成两半 | decode 抛错或乱码 | SSEParser |
| 语法层(JSON) | {"a":1, 少个值;"北 少半个字符串;tru 少半截关键字 | JSON.parse 必抛,UI 卡住 | json-repair 状态机 |
| UI 层(Function Call) | 字段一会儿有一会儿没,每次 parse 出新对象 | 列表跳变、整块重渲 | agent-parse + 稳定 key |
字节层只管"把字节流正确拼成一行 JSON 文本",不保证语法完整;语法层只管"把半截文本修到能 parse",不管网络。两层独立。UI 层则在这两者之上,把 parse 出的结构稳定地交给 React。
字节层:手写 SSEParser,把网络包拼回完整文本
模型推流走 SSE(Server-Sent Events)。浏览器的 fetch 拿到的 ReadableStream 是按网络包切的,不按行、不按字符切,所以有三个坑必须处理。
第一个坑,行被切成两半。一个 chunk 可能只到 data: {"a":1,下一个 chunk 才补上 , "b":2}。解法是用一个 lineBuf 缓存没拼完的行,下个 chunk 拼上来再按换行切。关键是:一个 SSE 事件什么时候算结束,由"遇到空行"决定,不由"chunk 结束"决定——空行本身也可能跨 chunk。
第二个坑,UTF-8 多字节字符跨 chunk。一个 4 字节的 emoji 被切成 2+2,如果直接按字节 decode,前 2 字节是非法序列,TextDecoder 默认抛错。解法是开 TextDecoder 的流式模式:
private decoder = new TextDecoder('utf-8', { fatal: false })
const text = this.decoder.decode(chunk, { stream: true })
{ stream: true } 让 decoder 缓冲还没成形的字节——前 2 字节不立即 decode,等下个 chunk 补齐 4 字节再一起出;{ fatal: false } 保证遇到脏字节不抛错,降级成替换符。中文、emoji 流式不乱码,靠的就是这个。
第三个坑,换行符不统一。规范要求行结束统一成 LF,但实际可能来 \n、\r\n、单独的 \r。这里手写遍历而不用正则 split:正则在长行上回溯成本高,手写遍历是 O(n) 无回溯,顺便处理 \r\n 占两字节的情况。
for (let i = 0; i < this.lineBuf.length; i++) {
const ch = this.lineBuf[i]
if (ch === '\n' || ch === '\r') {
const consumed = ch === '\r' && this.lineBuf[i + 1] === '\n' ? 2 : 1
const line = this.lineBuf.slice(start, i)
start = i + consumed
if (consumed === 2) i++ // 跳过 \r\n 的 \n
this.processLine(line, out)
}
}
this.lineBuf = this.lineBuf.slice(start) // 没消费完的尾段留给下个 chunk
字节层把 SSE 拼成了正确的 JSON 文本行,但这行可能是 {"tool":"search","args":{"from":"北京","to":"上——语法层接手。
语法层:json-repair 状态机,把半截 JSON 修到能 parse
字节层给的文本行,语法上大概率不完整,JSON.parse 必抛。语法层要做的,是把任意"不完整的 JSON 前缀"修成保证能 parse 不抛的串。这是一个约 450 行的前向扫描状态机,有三个设计目标必须同时满足:
- 单次前向扫描,O(n),不回头;
- 幂等(idempotent,同一输入不管执行多少次结果都一致)——合法的完整 JSON 输进来,原样返回;
- boundary-insensitive——修复结果只依赖内容,不依赖"在哪里被切断"。
后两条是整个状态机的灵魂,下面会展开。
三件法宝:容器栈、Expect 状态机、lastTokenEnd 边界
状态机一次扫描维护三样东西。
第一样是容器栈,记录还没闭合的 { 和 [。扫到结尾时,按栈的逆序补上对应的 } 和 ]。这是检测嵌套深度的唯一正道,正则做不到。
type Frame = '{' | '['
const stack: Frame[] = []
// 遇到 { / [ → push;遇到 } / ] → pop
// 扫描结束,按 stack 逆序补闭合
第二样是 Expect 状态机,跟踪"光标处接下来该期望什么":
type Expect = 'value' | 'key' | 'colon' | 'comma'
// { → expect = 'key'
// "key" → expect = 'colon'
// : → expect = 'value'
// value → expect = 'comma'
// , → 对象里 expect='key';数组里 expect='value'
这个状态机决定了结尾残缺时该补什么占位。
第三样,也是最巧妙的 lastTokenEnd 边界——"最后一个完整词法 token 之后的位置"。一个完整的 token,要么是闭合的字符串、要么是写完的数字或 true/false/null、要么是结构字符({ [ } ] : ,)本身。扫描过程中不断更新这个位置,扫描结束时,slice(lastTokenEnd) 就是"还没写完的尾巴"。所有修复都只针对这个尾巴,不动前面已经完整的部分——这就是 boundary-insensitive 能成立的根基。
六条修复规则
扫描完,按状态对尾巴执行六条规则:
| 规则 | 触发条件 | 修复 |
|---|---|---|
| R0 | 输入 trim 后为空 | 返回 {} |
| R1 | 停在字符串内没闭合 | 补 " |
| R2 | 停在对象的 key 字符串内没闭合 | 补 ",再补 :null 当值占位 |
| R3 | 尾巴是 tru/fal/nul 这种半截关键字 | 丢弃,在值位置补 null |
| R4 | 尾巴是悬空逗号 | 删掉 |
| R5 | 该有值却没有(expect==='value' 且上一个字符是 :) | 补 null |
| R6 | 容器栈没闭合 | 按栈逆序补 }/] |
几个能推演的边界
半截关键字。输入 {"value": tru,模型正写到 true 的中途。扫描时遇到 t,调用消费字面量的函数,发现 tru 还没到完整的 true,lastTokenEnd 不前进,于是尾巴就是 tru。R3 触发,丢弃尾巴,在值位置补 null,结果 {"value": null}。
半截数字。输入 {"count": 12.,模型正写 12.5 但只到小数点。消费字面量时识别出 12,遇到 . 进入小数部分但小数位为 0,返回 12。尾巴是 12.,数字修复函数判断 12. 不完整,修成 12,结果 {"count": 12}。
为什么这里宁可丢也不补全成 true 或 12.5?因为模型在哪个字符被切断是任意的——可能在 t、tr、tru、true。如果按"最长关键字前缀"去补全,结果就依赖切断位置,违反 boundary-insensitive。丢掉半截、补个确定的 null,结果只依赖内容本身。
截断后的乱码。输入 {"text": "abc"def,模型写到一半输出错了。扫描在闭合第一个 " 后进入非字符串状态,遇到 def 不识别成任何字面量开头,当 junk 处理。最后 R4、R6 收尾,结果是 {"text": "abc"}——后半截 def 被丢掉。这是设计权衡:截断位置之后的内容不可信,宁可丢也不假补。
为什么幂等和 boundary-insensitive 这么重要
UI 层会频繁拿同一段内容重新修复(避免漏字符、重复),修复函数必须稳定,不能同一段内容这次修成这样、下次修成那样——这是幂等的意义。模型推 token 的边界是任意的,可能一次来 1 个字符、下次来 5 个,修复结果不能因为"切在哪个字符"而不同——这是 boundary-insensitive 的意义。两条性质合起来,保证了 UI 在流式过程中不抖动。
量级上,一次 repair 就是一次 O(n) 扫描,n 是这段 JSON 的长度。Function Call 通常不到 10KB,单次 repair 耗时在 1 毫秒以内,对每帧 16 毫秒的预算几乎无感。
UI 层:agent-parse,让字段平滑冒出
光"能 parse"还不够。Function Call 有自己的结构(工具名 + 参数),而且不同厂商格式还不一样:
OpenAI: { "name": "...", "arguments": "..." } // arguments 是字符串
Anthropic: { "tool": "...", "input": {...} }
通用: { "name": "...", "args": {...} }
UI 层做的事是:先用 repair 把半截修到能 parse,再 JSON.parse,然后判断状态——修复后和原文本一致就是 complete(合法 JSON);不一致就是 parsing(还在生成);parse 失败或不是对象就是 invalid。最后提取工具名(优先 name 再 tool)和参数。
流式场景有个细节:OpenAI 风格里 arguments 是个字符串,半截时直接 JSON.parse("北京") 会抛。所以只有状态是 complete、确认是闭合的字符串时才 parse 参数,否则把参数留空,避免半截字符串抛错。
为什么"to 字段正在写"看起来是平滑冒出来的?关键在参数提取的稳定性。每次 props 更新,重新跑一次参数提取是 O(n),但已完成的项目对前缀是稳定的——只往尾巴追加,不重写已完成的项。所以 React 列表用字段名做 key 时,已完成项不会因为尾巴新增而重渲染,只有尾部那个还没写完的项在更新:
t=0 '{"tool":"search"' → tool: search, args: {}
t=1 '{"tool":"search","args":{"from"' → tool: search, args: { from: … }
t=2 '{"tool":"search","args":{"from":"北京"' → tool: search, args: { from: 北京 }
from: 北京 一旦完成,后面再来 token 它也不变,视觉上就是"to 字段在慢慢冒出来",已完成的 from 稳稳不动。
三层独立,各治一症
回头看,字节层拼文本、语法层修语法、UI 层稳结构,三层各管一段、彼此独立。这种分层是整套方案能稳的关键:任何一层只对自己的问题负责,字节层的坑不会漏到语法层,语法层的半截不会让 UI 层跳变。
最后还有一道跨三层的保险:属性测试(property-based testing,不写死具体用例,而是声明一条对所有输入都成立的规则,再用随机生成的输入去验证)。这里声明两条规则——任意半截输入修复后都能 parse 不抛;任意切分位置的修复结果只依赖内容。用一个带固定种子的随机数生成器(测出问题能原样复现)跑几百组随机切片来验证:
// 规则一:任何半截输入修复后都能 parse
for (const partial of CORPUS) {
expect(() => JSON.parse(repair(partial))).not.toThrow()
}
// 规则二:随机切片的结果和一次性塞入一致
for (let seed = 1; seed <= 30; seed++) {
const rng = mulberry32(seed)
for (const src of CORPUS) {
expect(streamed(src, rng)).toEqual(allAtOnce(src))
}
}
只要任意一组切片违反规则,测试就挂。这等于让机器替你验证:不管模型怎么切 token、切在哪个字节,修复结果都稳定可 parse。三层修复加上这道属性测试,半截 JSON 才真正做到边收边渲染、不崩不卡不抖。