部分 JSON 的增量解析Function Calling 流式输出的前端执行策略一、流式 Function Calling 的首字节延迟为什么不能等完整 JSON大模型 Function Calling 的 arguments 字段是流式返回的。模型一边生成一边吐 Token前端收到的可能先是{city: 上几十毫秒后才补全为{city: 上海, days: 3}。如果前端等完整 JSON 再解析首字节延迟会被放大几百毫秒用户感觉模型变慢了。这事我见过太多团队栽进去——Function Calling 上线后首字节延迟从 200 毫秒涨到 800 毫秒业务方以为是模型变慢其实是前端在等完整 JSON。更糟的是某些工具调用参数很长模型生成 2 秒才完整前端干等 2 秒才显示「正在调用查天气」用户体验直接崩盘。正确的做法是增量解析。每收到一个 Token立刻尝试解析当前缓冲区提前暴露已完整的字段。模型刚吐出{city: 上海时前端就能渲染「即将调用查天气城市上海」让用户看到进展。等完整 JSON 后再执行真正的函数调用。但增量解析的核心难点是部分 JSON 无法直接JSON.parse。{city: 上这种字符串未闭合标准解析器会抛错。前端需要一套容错策略补全未闭合的引号与括号、移除尾部逗号、对解析失败做回退。同时还要维护一个增量 AST让已完整的字段能提前被消费。二、部分 JSON 的容错与增量 AST流式解析的底层机制部分 JSON 的核心问题是结构不完整。常见三种中断态字符串未闭合{city: 上、对象未闭合{city: 上海,、数组未闭合{tags: [a, b。每种都需要不同的补全策略。字符串未闭合时需要在缓冲区末尾补一个。但要注意转义如果末尾是反斜杠补会被当成转义引号需要先移除反斜杠或补两个字符。对象未闭合时需要移除末尾的逗号如果有再补}。数组同理补]。容错的本质是猜测模型还没生成完的内容。猜对了能提前暴露字段猜错了会展示错误值。所以增量解析必须设计成「乐观展示 完整后校验」流式阶段展示的值带 pending 标记完整后用标准JSON.parse覆盖。增量 AST 是另一层。每收到 Token更新当前解析状态机在「对象内」期待键或值在「字符串内」累积字符在「数组内」期待元素。状态机能提前暴露已完整的字段无需等整个对象闭合。某对话产品接入增量解析后Function Calling 的首字节延迟从 800 毫秒降到 180 毫秒。综上流式 JSON 增量解析以「乐观展示 完整后校验」为核心逐 Token 更新解析状态机字段就绪即触发回调让 UI 提前渲染完整对象闭合后再用标准 JSON.parse 覆盖兼顾低延迟与正确性。三、生产级流式 JSON 增量解析器实现下面给出一个可复用的增量解析器。它支持部分 JSON 容错补全、字段就绪回调、解析失败回退。interface ParseResult { /** 当前已能解析出的部分对象可能不完整 */ partial: Recordstring, unknown; /** 是否已收到完整 JSON */ complete: boolean; /** 本次新增的就绪字段名列表 */ newFields: string[]; } type FieldReadyCallback (field: string, value: unknown) void; /** * 流式 JSON 增量解析器。 * 为什么不直接用 JSON.parse 容错 * JSON.parse 每次都要重解析整个缓冲区且无法区分「本次新增了哪些字段」。 * 增量解析能精准触发字段就绪回调避免重复渲染。 */ export class StreamingJsonParser { private buffer ; private knownFields new Setstring(); private onComplete: ((parsed: Recordstring, unknown) void) | null null; private onFieldReady: FieldReadyCallback | null null; private aborted false; // 防止异常流撑爆内存64KB 覆盖 99% 的 Function Calling 场景 private maxBufferBytes 64 * 1024; constructor(opts: { onFieldReady?: FieldReadyCallback; onComplete?: (parsed: Recordstring, unknown) void; } {}) { this.onFieldReady opts.onFieldReady ?? null; this.onComplete opts.onComplete ?? null; } /** * 喂入新 Token 并尝试解析。 * 为什么每次都重新解析整个缓冲区而不是维护增量 AST * arguments 通常较短几百字节重解析成本远低于维护 AST 的复杂度。 * 真正的增量 AST 只在超长 arguments10KB场景才值得引入。 */ feed(token: string): ParseResult { if (this.aborted) { return { partial: {}, complete: false, newFields: [] }; } this.buffer token; // 缓冲区溢出保护异常流可能无限输出 if (this.buffer.length this.maxBufferBytes) { console.warn(流式 JSON 缓冲区溢出中止解析); this.aborted true; return { partial: {}, complete: false, newFields: [] }; } // 第一尝试直接解析完整 JSON 时命中 const direct this.tryParse(this.buffer); if (direct.ok) { const newFields this.detectNewFields(direct.value!); this.emitFields(direct.value!, newFields); this.onComplete?.(direct.value!); return { partial: direct.value!, complete: true, newFields }; } // 第二尝试容错补全后解析 const patched this.patchPartialJson(this.buffer); const patchedResult this.tryParse(patched); if (patchedResult.ok) { const newFields this.detectNewFields(patchedResult.value!); this.emitFields(patchedResult.value!, newFields); return { partial: patchedResult.value!, complete: false, newFields }; } // 解析失败保持上一次的 partial等更多 Token return { partial: {}, complete: false, newFields: [] }; } /** * 容错补全根据缓冲区末尾状态补全缺失的闭合符号。 * 为什么不直接拼接 ]} * 不同中断态需要不同补全盲目拼接会让 JSON.parse 报错而非返回部分结果。 */ private patchPartialJson(input: string): string { let s input.trimEnd(); if (s ) return {}; // 移除尾部不完整的逗号或冒号 if (s.endsWith(,)) s s.slice(0, -1); if (s.endsWith(:)) s s.slice(0, -1) :null; // 检查是否在字符串内部未闭合的字符串 const inString this.isInUnclosedString(s); if (inString) { // 末尾是奇数个反斜杠时补引号前要先补反斜杠抵消转义 const trailingBackslashes this.countTrailingBackslashes(s); if (trailingBackslashes % 2 1) { s \\; } s ; } // 统计未闭合的括号层级 const { braces, brackets } this.countUnclosed(s); s ].repeat(brackets); s }.repeat(braces); return s; } /** * 判断缓冲区是否处于未闭合字符串状态。 * 为什么用字符遍历而不是正则 * 嵌套转义如 \\\\下正则容易误判遍历更准确可控。 */ private isInUnclosedString(s: string): boolean { let inStr false; let escape false; for (let i 0; i s.length; i) { const ch s[i]; if (escape) { escape false; continue; } if (ch \\) { escape true; continue; } if (ch ) inStr !inStr; } return inStr; } private countTrailingBackslashes(s: string): number { let count 0; for (let i s.length - 1; i 0; i--) { if (s[i] \\) count; else break; } return count; } /** * 统计未闭合的 {} 与 []。 * 为什么简单计数不够 * 字符串内的括号不应计入必须跳过字符串内部。 */ private countUnclosed(s: string): { braces: number; brackets: number } { let braces 0, brackets 0; let inStr false, escape false; for (let i 0; i s.length; i) { const ch s[i]; if (escape) { escape false; continue; } if (ch \\) { escape true; continue; } if (ch ) { inStr !inStr; continue; } if (inStr) continue; if (ch {) braces; else if (ch }) braces--; else if (ch [) brackets; else if (ch ]) brackets--; } // 负值表示多余闭合符号按 0 处理容错 return { braces: Math.max(0, braces), brackets: Math.max(0, brackets) }; } private tryParse(s: string): { ok: boolean; value?: Recordstring, unknown } { try { const v JSON.parse(s); // 仅接受对象类型避免裸字符串/数字被误判 if (v typeof v object !Array.isArray(v)) { return { ok: true, value: v }; } return { ok: false }; } catch { return { ok: false }; } } private detectNewFields(obj: Recordstring, unknown): string[] { const news: string[] []; for (const k of Object.keys(obj)) { if (!this.knownFields.has(k)) { this.knownFields.add(k); news.push(k); } } return news; } private emitFields(obj: Recordstring, unknown, fields: string[]) { for (const f of fields) { this.onFieldReady?.(f, obj[f]); } } /** 中断解析用于用户切换会话或离开页面 */ abort() { this.aborted true; } /** 重置以复用实例 */ reset() { this.buffer ; this.knownFields.clear(); this.aborted false; } }关键点在于三处。其一双重尝试策略先直接解析命中完整 JSON失败后再容错补全兼顾性能与容错。其二字符串闭合判断用字符遍历而非正则能正确处理转义嵌套。其三缓冲区溢出保护防止异常流撑爆内存。某对话产品接入后Function Calling 首字节延迟稳定在 200 毫秒内用户感知「模型变快了」。四、增量解析的代价容错误判、内存累积、状态复杂度与适用边界增量解析不是没有代价。第一道代价是容错误判。补全策略本质是猜测猜错时会展示错误的字段值。例如模型生成{city: 上海, weather:时容错补全可能插入null前端展示「天气null」。这就是为什么必须用「pending 标记 完整后覆盖」策略不能把流式阶段的值当最终结果。某团队曾直接用流式解析结果触发函数调用结果补全的null被当成真实参数工具调用失败。第二道代价是内存累积。缓冲区随 Token 增长超长 arguments如生成代码、长文本会持续占用内存。必须设上限并在溢出时降级到「等完整」模式。64KB 是经验值覆盖 99% 的 Function Calling 场景。第三道代价是状态复杂度。容错逻辑分支多测试用例必须覆盖字符串中断、对象中断、数组中断、转义嵌套、嵌套对象、空数组、Unicode 字符等。任一场景漏测都会在线上偶发崩溃。适用边界Function Calling 频繁、参数较短10KB、对首字节延迟敏感的产品收益最高。一次性长文本生成、参数超大的场景等完整再解析反而更稳。五、总结流式 Function Calling 的工程核心是把部分 JSON 增量解析为可消费的字段提前暴露调用意图。落地建议第一双重尝试策略先直接解析命中完整 JSON失败后再容错补全。第二字符串闭合判断用字符遍历正确处理转义嵌套。第三缓冲区设上限溢出时降级到等待完整模式。第四流式阶段的值带 pending 标记完整后用标准解析覆盖禁止直接触发函数调用。最终在首字节延迟与解析正确性之间取得平衡。这条路在毫秒级流式响应下能跑通回报是值得的。
部分 JSON 的增量解析:Function Calling 流式输出的前端执行策略
部分 JSON 的增量解析Function Calling 流式输出的前端执行策略一、流式 Function Calling 的首字节延迟为什么不能等完整 JSON大模型 Function Calling 的 arguments 字段是流式返回的。模型一边生成一边吐 Token前端收到的可能先是{city: 上几十毫秒后才补全为{city: 上海, days: 3}。如果前端等完整 JSON 再解析首字节延迟会被放大几百毫秒用户感觉模型变慢了。这事我见过太多团队栽进去——Function Calling 上线后首字节延迟从 200 毫秒涨到 800 毫秒业务方以为是模型变慢其实是前端在等完整 JSON。更糟的是某些工具调用参数很长模型生成 2 秒才完整前端干等 2 秒才显示「正在调用查天气」用户体验直接崩盘。正确的做法是增量解析。每收到一个 Token立刻尝试解析当前缓冲区提前暴露已完整的字段。模型刚吐出{city: 上海时前端就能渲染「即将调用查天气城市上海」让用户看到进展。等完整 JSON 后再执行真正的函数调用。但增量解析的核心难点是部分 JSON 无法直接JSON.parse。{city: 上这种字符串未闭合标准解析器会抛错。前端需要一套容错策略补全未闭合的引号与括号、移除尾部逗号、对解析失败做回退。同时还要维护一个增量 AST让已完整的字段能提前被消费。二、部分 JSON 的容错与增量 AST流式解析的底层机制部分 JSON 的核心问题是结构不完整。常见三种中断态字符串未闭合{city: 上、对象未闭合{city: 上海,、数组未闭合{tags: [a, b。每种都需要不同的补全策略。字符串未闭合时需要在缓冲区末尾补一个。但要注意转义如果末尾是反斜杠补会被当成转义引号需要先移除反斜杠或补两个字符。对象未闭合时需要移除末尾的逗号如果有再补}。数组同理补]。容错的本质是猜测模型还没生成完的内容。猜对了能提前暴露字段猜错了会展示错误值。所以增量解析必须设计成「乐观展示 完整后校验」流式阶段展示的值带 pending 标记完整后用标准JSON.parse覆盖。增量 AST 是另一层。每收到 Token更新当前解析状态机在「对象内」期待键或值在「字符串内」累积字符在「数组内」期待元素。状态机能提前暴露已完整的字段无需等整个对象闭合。某对话产品接入增量解析后Function Calling 的首字节延迟从 800 毫秒降到 180 毫秒。综上流式 JSON 增量解析以「乐观展示 完整后校验」为核心逐 Token 更新解析状态机字段就绪即触发回调让 UI 提前渲染完整对象闭合后再用标准 JSON.parse 覆盖兼顾低延迟与正确性。三、生产级流式 JSON 增量解析器实现下面给出一个可复用的增量解析器。它支持部分 JSON 容错补全、字段就绪回调、解析失败回退。interface ParseResult { /** 当前已能解析出的部分对象可能不完整 */ partial: Recordstring, unknown; /** 是否已收到完整 JSON */ complete: boolean; /** 本次新增的就绪字段名列表 */ newFields: string[]; } type FieldReadyCallback (field: string, value: unknown) void; /** * 流式 JSON 增量解析器。 * 为什么不直接用 JSON.parse 容错 * JSON.parse 每次都要重解析整个缓冲区且无法区分「本次新增了哪些字段」。 * 增量解析能精准触发字段就绪回调避免重复渲染。 */ export class StreamingJsonParser { private buffer ; private knownFields new Setstring(); private onComplete: ((parsed: Recordstring, unknown) void) | null null; private onFieldReady: FieldReadyCallback | null null; private aborted false; // 防止异常流撑爆内存64KB 覆盖 99% 的 Function Calling 场景 private maxBufferBytes 64 * 1024; constructor(opts: { onFieldReady?: FieldReadyCallback; onComplete?: (parsed: Recordstring, unknown) void; } {}) { this.onFieldReady opts.onFieldReady ?? null; this.onComplete opts.onComplete ?? null; } /** * 喂入新 Token 并尝试解析。 * 为什么每次都重新解析整个缓冲区而不是维护增量 AST * arguments 通常较短几百字节重解析成本远低于维护 AST 的复杂度。 * 真正的增量 AST 只在超长 arguments10KB场景才值得引入。 */ feed(token: string): ParseResult { if (this.aborted) { return { partial: {}, complete: false, newFields: [] }; } this.buffer token; // 缓冲区溢出保护异常流可能无限输出 if (this.buffer.length this.maxBufferBytes) { console.warn(流式 JSON 缓冲区溢出中止解析); this.aborted true; return { partial: {}, complete: false, newFields: [] }; } // 第一尝试直接解析完整 JSON 时命中 const direct this.tryParse(this.buffer); if (direct.ok) { const newFields this.detectNewFields(direct.value!); this.emitFields(direct.value!, newFields); this.onComplete?.(direct.value!); return { partial: direct.value!, complete: true, newFields }; } // 第二尝试容错补全后解析 const patched this.patchPartialJson(this.buffer); const patchedResult this.tryParse(patched); if (patchedResult.ok) { const newFields this.detectNewFields(patchedResult.value!); this.emitFields(patchedResult.value!, newFields); return { partial: patchedResult.value!, complete: false, newFields }; } // 解析失败保持上一次的 partial等更多 Token return { partial: {}, complete: false, newFields: [] }; } /** * 容错补全根据缓冲区末尾状态补全缺失的闭合符号。 * 为什么不直接拼接 ]} * 不同中断态需要不同补全盲目拼接会让 JSON.parse 报错而非返回部分结果。 */ private patchPartialJson(input: string): string { let s input.trimEnd(); if (s ) return {}; // 移除尾部不完整的逗号或冒号 if (s.endsWith(,)) s s.slice(0, -1); if (s.endsWith(:)) s s.slice(0, -1) :null; // 检查是否在字符串内部未闭合的字符串 const inString this.isInUnclosedString(s); if (inString) { // 末尾是奇数个反斜杠时补引号前要先补反斜杠抵消转义 const trailingBackslashes this.countTrailingBackslashes(s); if (trailingBackslashes % 2 1) { s \\; } s ; } // 统计未闭合的括号层级 const { braces, brackets } this.countUnclosed(s); s ].repeat(brackets); s }.repeat(braces); return s; } /** * 判断缓冲区是否处于未闭合字符串状态。 * 为什么用字符遍历而不是正则 * 嵌套转义如 \\\\下正则容易误判遍历更准确可控。 */ private isInUnclosedString(s: string): boolean { let inStr false; let escape false; for (let i 0; i s.length; i) { const ch s[i]; if (escape) { escape false; continue; } if (ch \\) { escape true; continue; } if (ch ) inStr !inStr; } return inStr; } private countTrailingBackslashes(s: string): number { let count 0; for (let i s.length - 1; i 0; i--) { if (s[i] \\) count; else break; } return count; } /** * 统计未闭合的 {} 与 []。 * 为什么简单计数不够 * 字符串内的括号不应计入必须跳过字符串内部。 */ private countUnclosed(s: string): { braces: number; brackets: number } { let braces 0, brackets 0; let inStr false, escape false; for (let i 0; i s.length; i) { const ch s[i]; if (escape) { escape false; continue; } if (ch \\) { escape true; continue; } if (ch ) { inStr !inStr; continue; } if (inStr) continue; if (ch {) braces; else if (ch }) braces--; else if (ch [) brackets; else if (ch ]) brackets--; } // 负值表示多余闭合符号按 0 处理容错 return { braces: Math.max(0, braces), brackets: Math.max(0, brackets) }; } private tryParse(s: string): { ok: boolean; value?: Recordstring, unknown } { try { const v JSON.parse(s); // 仅接受对象类型避免裸字符串/数字被误判 if (v typeof v object !Array.isArray(v)) { return { ok: true, value: v }; } return { ok: false }; } catch { return { ok: false }; } } private detectNewFields(obj: Recordstring, unknown): string[] { const news: string[] []; for (const k of Object.keys(obj)) { if (!this.knownFields.has(k)) { this.knownFields.add(k); news.push(k); } } return news; } private emitFields(obj: Recordstring, unknown, fields: string[]) { for (const f of fields) { this.onFieldReady?.(f, obj[f]); } } /** 中断解析用于用户切换会话或离开页面 */ abort() { this.aborted true; } /** 重置以复用实例 */ reset() { this.buffer ; this.knownFields.clear(); this.aborted false; } }关键点在于三处。其一双重尝试策略先直接解析命中完整 JSON失败后再容错补全兼顾性能与容错。其二字符串闭合判断用字符遍历而非正则能正确处理转义嵌套。其三缓冲区溢出保护防止异常流撑爆内存。某对话产品接入后Function Calling 首字节延迟稳定在 200 毫秒内用户感知「模型变快了」。四、增量解析的代价容错误判、内存累积、状态复杂度与适用边界增量解析不是没有代价。第一道代价是容错误判。补全策略本质是猜测猜错时会展示错误的字段值。例如模型生成{city: 上海, weather:时容错补全可能插入null前端展示「天气null」。这就是为什么必须用「pending 标记 完整后覆盖」策略不能把流式阶段的值当最终结果。某团队曾直接用流式解析结果触发函数调用结果补全的null被当成真实参数工具调用失败。第二道代价是内存累积。缓冲区随 Token 增长超长 arguments如生成代码、长文本会持续占用内存。必须设上限并在溢出时降级到「等完整」模式。64KB 是经验值覆盖 99% 的 Function Calling 场景。第三道代价是状态复杂度。容错逻辑分支多测试用例必须覆盖字符串中断、对象中断、数组中断、转义嵌套、嵌套对象、空数组、Unicode 字符等。任一场景漏测都会在线上偶发崩溃。适用边界Function Calling 频繁、参数较短10KB、对首字节延迟敏感的产品收益最高。一次性长文本生成、参数超大的场景等完整再解析反而更稳。五、总结流式 Function Calling 的工程核心是把部分 JSON 增量解析为可消费的字段提前暴露调用意图。落地建议第一双重尝试策略先直接解析命中完整 JSON失败后再容错补全。第二字符串闭合判断用字符遍历正确处理转义嵌套。第三缓冲区设上限溢出时降级到等待完整模式。第四流式阶段的值带 pending 标记完整后用标准解析覆盖禁止直接触发函数调用。最终在首字节延迟与解析正确性之间取得平衡。这条路在毫秒级流式响应下能跑通回报是值得的。