模型吐到一半的 JSON 为什么不崩、不卡、不抖?流式 Function Call 的三层修复

11 阅读9分钟

模型吐到一半的 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 还没到完整的 truelastTokenEnd 不前进,于是尾巴就是 tru。R3 触发,丢弃尾巴,在值位置补 null,结果 {"value": null}

半截数字。输入 {"count": 12.,模型正写 12.5 但只到小数点。消费字面量时识别出 12,遇到 . 进入小数部分但小数位为 0,返回 12。尾巴是 12.,数字修复函数判断 12. 不完整,修成 12,结果 {"count": 12}

为什么这里宁可丢也不补全成 true12.5?因为模型在哪个字符被切断是任意的——可能在 ttrtrutrue。如果按"最长关键字前缀"去补全,结果就依赖切断位置,违反 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。最后提取工具名(优先 nametool)和参数。

流式场景有个细节: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 才真正做到边收边渲染、不崩不卡不抖。