部分 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 容错补全、字段就绪回调、解析失败回退。

ts 复制代码
interface ParseResult {
  /** 当前已能解析出的部分对象(可能不完整) */
  partial: Record<string, 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 Set<string>();
  private onComplete: ((parsed: Record<string, 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: Record<string, unknown>) => void;
  } = {}) {
    this.onFieldReady = opts.onFieldReady ?? null;
    this.onComplete = opts.onComplete ?? null;
  }

  /**
   * 喂入新 Token 并尝试解析。
   * 为什么每次都重新解析整个缓冲区而不是维护增量 AST:
   * arguments 通常较短(几百字节),重解析成本远低于维护 AST 的复杂度。
   * 真正的增量 AST 只在超长 arguments(>10KB)场景才值得引入。
   */
  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?: Record<string, 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: Record<string, 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: Record<string, 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 标记,完整后用标准解析覆盖,禁止直接触发函数调用。最终在首字节延迟与解析正确性之间取得平衡。这条路在毫秒级流式响应下能跑通,回报是值得的。

相关推荐
OpenApi.cc1 小时前
Mocode 开发文档平台
人工智能·深度学习·目标检测·自然语言处理·语音识别
Promise微笑1 小时前
电力电缆故障分类与精准定位:物理机制、诊断挑战及前沿技术
人工智能·分类·数据挖掘
GrepowTattu1 小时前
智能护膝与外骨骼设备为什么需要异形定制电池?
人工智能·智能穿戴
夜影风1 小时前
我国AI智能体产业发展洞察:为何能在AI应用层实现“换道超车“
大数据·人工智能
是店小二呀1 小时前
NanoPi R4S怎么搭私人云盘?iStoreOS与WebDAV完整教程
数据库·人工智能
阳光是sunny1 小时前
LangGraph 核心概念详解:从编译到可视化
前端·人工智能·后端
枫叶丹41 小时前
Codex Hooks 实战:给 AI 工作流增加确定性门禁
人工智能·chatgpt·agent·codex
大鹏的NLP博客1 小时前
CMAD:基于紧凑表征学习与马氏距离统计判别的工业异常检测架构
人工智能·深度学习
安逸sgr1 小时前
Agent经典面试题:Agent 安全问题有哪些?如何防止工具误调用和 Prompt Injection?
人工智能·ai·agent·智能体