流式 JSON 边接收边渲染,前端用状态机减少整段重跑

每收到一块就 JSON.parse,失败后继续吞异常,不属于增量解析:它既无法消费尚未闭合的 JSON,也容易让“答案闪现后消失”的故障反复出现。可靠路径是持续解码文本、扫描完整字段、执行字段级 Schema 校验、原子提交给 UI,最后再校验整份文档。掘金热榜文章《模型吐出半截JSON,前端如何边收边渲染?2026年AI应用必须闯过的工程关》也讨论了这个问题。

“边接收边渲染”也不等于把半截字符串直接放进消息气泡。连接状态与字段状态必须分开管理:连接负责区分接收、完成、失败和取消,字段负责区分待解析、已解析、已校验、已提交与无效。UI 默认只消费已提交字段,这样即使后端只提供原始 JSON 字节流,前端也能逐步提取完整内容,而不会让旧请求、失败重试和半成品互相覆盖。

为什么逐块调用 JSON.parse 仍然无法渲染?

典型错误实现如下:

buffer += chunk;

try {
  const value = JSON.parse(buffer);
  render(value);
} catch {
  // 流尚未结束,继续等待
}

问题在于,JSON.parse 接受的是一份完整 JSON 文档,而流式缓冲区在绝大多数时刻都只是文档前缀。异常被吞掉以后,解析器并没有获得任何可渲染字段,只是停止了报错。

有些实现会在失败前先乐观渲染原始字符串,等流结束再统一 JSON.parse。一旦末尾括号错误、说明文字混入或 Schema 不通过,界面就会清空气泡并从头重试,用户看到的便是答案闪现、消失,然后重新出现。

从排障结果看,一套流式结构化输出至少要满足三个条件:消费侧不接触半成品;失败能够定位到尽可能小的范围;额外开销可以度量。资料将这三项分别归纳为提交边界、局部失败和可测指标。因此,问题不只是“写一个流式请求函数”,而是要把解析、校验、展示和恢复拆成不同层次。

流式数据如何到达浏览器,分片边界能否作为 JSON 边界?

SSE、fetch + ReadableStream 和 WebSocket 都可以承载流式数据,但适用重点不同。

方式 适合场景 主要边界
SSE 服务端向浏览器单向推送文本事件 事件协议不等于 JSON 字段协议;仍需处理事件拼装、去重与重连
fetch + ReadableStream AI 对话、结构化输出、需要请求体和取消能力 前端负责字节解码、协议解析和错误恢复
WebSocket 客户端与服务端需要持续双向控制 对单向生成链路通常增加了不必要的连接与状态复杂度

HTTP 分块边界、SSE 的 data 事件边界和模型 token 边界,都不能默认当作 JSON 字段边界。分片可能切在键名、字符串引号、反斜杠、Unicode 转义、嵌套容器乃至一个数字中间;反过来,一个分片也可能同时包含多个完整字段。流式数据被切碎后无法直接交给 JSON.parse,正是增量解析要解决的问题。

字节解码还需要单独处理。UTF-8 字符可能横跨两个网络分片,不能每个分片都单独创建新的 TextDecoder:

const decoder = new TextDecoder();
const reader = response.body!.getReader();

try {
  while (true) {
    const { done, value } = await reader.read();

    if (done) break;

    const text = decoder.decode(value, { stream: true });

    for (const candidate of scanner.feed(text)) {
      handleCandidate(candidate);
    }
  }

  const tail = decoder.decode();
  if (tail) {
    for (const candidate of scanner.feed(tail)) {
      handleCandidate(candidate);
    }
  }

  return scanner.finish();
} finally {
  reader.releaseLock();
}

取消和超时都应落到 AbortController。业务上还要区分总超时与空闲超时:前者限制整个生成过程,后者限制两个有效分片之间的等待时间。读取循环应保持串行,避免为每个分片启动无法控制的异步任务;昂贵的 Schema 校验和 UI 更新可以进入有序队列,否则解析速度一旦落后于网络速度,任务就会持续积压。

需要在浏览器中解析未闭合的 JSON 吗?

不一定。后端可控时,服务端按字段发送完整事件,通常比让每个浏览器重复实现增量扫描更稳妥。

服务端契约 前端工作 优点 风险
原始 JSON 字节流或 JSON 增量 字符扫描、字段提取、局部校验 兼容已有模型接口,协议简单 前端复杂度高,必须正确处理所有边界
字段级完整事件 按事件校验并提交 提交边界清楚,前端状态简单 后端仍需解析和验证模型输出

字段级事件可以采用如下契约:

event: field
data: {"attemptId":"a-17","path":"/title","version":1,"value":"订单摘要"}

event: field
data: {"attemptId":"a-17","path":"/total","version":1,"value":128}

event: done
data: {"attemptId":"a-17"}

这里的 path 指向 JSON Pointer,version 用于处理重试和重复事件,attemptId 用来识别具体传输尝试。服务端只有在字段已经完整并通过必要校验后,才发送 field 事件。前端收到事件后仍可执行自己的 Schema 校验,流结束后仍应执行全文档校验。

如果服务端只能发送原始增量,事件可以退化为:

event: delta
data: {"attemptId":"a-17","delta":"{\"title\":\""}

外层事件是完整 JSON,但 delta 中的目标 JSON 仍然只到了一部分,前端依旧需要增量扫描。

我的取舍是:后端可控时优先提供字段级事件;必须兼容原始字节流时,再在前端实现字符级扫描。字段级事件也不能替代终局校验,因为跨字段约束通常要等整份结果到齐后才能判断。

如何从未闭合的 JSON 中提取完整字段?

增量扫描器需要维护词法状态,而不是简单计算花括号数量。至少要跟踪:

  • 当前是否位于字符串中;
  • 字符串内是否存在尚未处理的转义字符;
  • 对象与数组的嵌套层级;
  • 对象成员是否已经读到键值分隔符;
  • 数字或字面量是否已经遇到确定的后续分隔符。

字符串中的 {、[、引号和括号不能改变容器层级;\\ 与 \" 也必须作为一个整体处理。Unicode 转义即使被拆成多个分片,也不能被误判为字符串结束。

下面的 TypeScript 骨架只展示顶层对象和顶层数组的提交边界。scanValueEnd 负责字符串、容器与原始值的词法扫描:

type Phase =
  | "root"
  | "key-first"
  | "key"
  | "colon"
  | "value"
  | "value-first"
  | "after-value"
  | "done"
  | "failed";

type Candidate = {
  path: string;
  raw: string;
  value: unknown;
};

class TopLevelScanner {
  private text = "";
  private pos = 0;
  private phase: Phase = "root";
  private root: "object" | "array" | null = null;
  private currentKey = "";
  private arrayIndex = 0;
  private error: string | null = null;

  feed(chunk: string): Candidate[] {
    if (this.phase === "failed") return [];

    this.text += chunk;
    const events: Candidate[] = [];
    let i = this.pos;

    try {
      while (true) {
        if (this.phase === "done") break;

        if (this.phase === "root") {
          i = skipWhitespace(this.text, i);
          if (i >= this.text.length) break;

          if (this.text[i] === "{") {
            this.root = "object";
            this.phase = "key-first";
          } else if (this.text[i] === "[") {
            this.root = "array";
            this.phase = "value-first";
          } else {
            throw new Error("JSON root must be an object or array");
          }

          i++;
          continue;
        }

        if (
          this.root === "object" &&
          (this.phase === "key-first" || this.phase === "key")
        ) {
          i = skipWhitespace(this.text, i);
          if (i >= this.text.length) break;

          if (this.phase === "key-first" && this.text[i] === "}") {
            this.phase = "done";
            i++;
            continue;
          }

          if (this.text[i] !== '"') {
            throw new Error("object key must be a string");
          }

          const end = scanStringEnd(this.text, i);
          if (end === null) break;

          this.currentKey = JSON.parse(this.text.slice(i, end));
          i = end;
          this.phase = "colon";
          continue;
        }

        if (this.root === "object" && this.phase === "colon") {
          i = skipWhitespace(this.text, i);
          if (i >= this.text.length) break;
          if (this.text[i] !== ":") throw new Error("missing colon");

          i++;
          this.phase = "value";
          continue;
        }

        if (this.phase === "value" || this.phase === "value-first") {
          i = skipWhitespace(this.text, i);
          if (i >= this.text.length) break;

          if (
            this.root === "array" &&
            this.phase === "value-first" &&
            this.text[i] === "]"
          ) {
            this.phase = "done";
            i++;
            continue;
          }

          const end = scanValueEnd(this.text, i);
          if (end === "incomplete") break;
          if (end === "invalid") throw new Error("invalid JSON value");

          const raw = this.text.slice(i, end);
          const value = JSON.parse(raw);
          const path =
            this.root === "object"
              ? pointer(this.currentKey)
              : `/${this.arrayIndex}`;

          events.push({ path, raw, value });

          if (this.root === "array") this.arrayIndex++;
          i = end;
          this.phase = "after-value";
          continue;
        }

        if (this.phase === "after-value") {
          i = skipWhitespace(this.text, i);
          if (i >= this.text.length) break;

          if (this.text[i] === ",") {
            i++;
            this.phase = this.root === "object" ? "key" : "value";
            continue;
          }

          if (this.root === "object" && this.text[i] === "}") {
            this.phase = "done";
            i++;
            continue;
          }

          if (this.root === "array" && this.text[i] === "]") {
            this.phase = "done";
            i++;
            continue;
          }

          throw new Error("unexpected token after value");
        }
      }
    } catch (error) {
      this.phase = "failed";
      this.error =
        error instanceof Error ? error.message : String(error);
    }

    this.pos = i;
    return events;
  }

  finish():
    | { ok: true; value: unknown }
    | { ok: false; error: string } {
    if (this.error) return { ok: false, error: this.error };
    if (this.phase !== "done") {
      return { ok: false, error: "JSON is incomplete" };
    }

    try {
      return { ok: true, value: JSON.parse(this.text) };
    } catch (error) {
      return {
        ok: false,
        error: error instanceof Error ? error.message : String(error),
      };
    }
  }
}

function skipWhitespace(text: string, start: number): number {
  let i = start;
  while (i < text.length && /\s/.test(text[i])) i++;
  return i;
}

function pointer(key: string): string {
  return `/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`;
}

function scanStringEnd(text: string, start: number): number | null {
  let escaped = false;

  for (let i = start + 1; i < text.length; i++) {
    if (escaped) {
      escaped = false;
    } else if (text[i] === "\\") {
      escaped = true;
    } else if (text[i] === '"') {
      return i + 1;
    }
  }

  return null;
}

function scanValueEnd(
  text: string,
  start: number
): number | "incomplete" | "invalid" {
  const first = text[start];

  if (first === '"') {
    const end = scanStringEnd(text, start);
    return end === null ? "incomplete" : end;
  }

  if (first === "{" || first === "[") {
    const stack: string[] = [first];
    let inString = false;
    let escaped = false;

    for (let i = start + 1; i < text.length; i++) {
      const ch = text[i];

      if (inString) {
        if (escaped) escaped = false;
        else if (ch === "\\") escaped = true;
        else if (ch === '"') inString = false;
        continue;
      }

      if (ch === '"') inString = true;
      else if (ch === "{" || ch === "[") stack.push(ch);
      else if (ch === "}" || ch === "]") {
        const expected = ch === "}" ? "{" : "[";
        if (stack[stack.length - 1] !== expected) return "invalid";

        stack.pop();
        if (stack.length === 0) return i + 1;
      }
    }

    return "incomplete";
  }

  if ("-0123456789tfn".includes(first)) {
    let i = start + 1;
    while (i < text.length && !/[\s,\]}]/.test(text[i])) i++;
    return i === text.length ? "incomplete" : i;
  }

  return "invalid";
}

这段骨架有三项限制:它只提取顶层字段;容器内部是否满足完整 JSON 语法,要通过字段级 JSON.parse 再次确认;生产实现还应持久化词法游标,避免长字符串被反复扫描。实际项目更适合使用经过验证的增量解析器,并为分片边界建立模糊测试。

顶层数组可以作为元素流提交,例如 /0、/1 分别代表已经完成的数组元素。若希望对象内部的商品也逐条展示,则需要扫描到更深层级,或者让服务端直接发送 /items/0 这样的字段事件。提交粒度必须与 Schema 和 UI 需求一致,不能把尚未闭合的数组当成完整字段。

代码围栏和额外说明文字也不应靠正则猜测处理。模型输出如果可能是 ```json 包裹的文本,前后端应使用明确的内容类型或服务端规范化;JSON 前后的自然语言说明则应由服务端剥离。否则“寻找第一个 {”很容易误把说明文字中的括号当成根节点。

为什么连接状态和字段状态需要分开管理?

连接状态描述的是整次请求,字段状态描述的是某个字段,两者不能混在一个布尔值里。

连接状态可表示为:

idle → connecting → streaming → finalizing → completed
                 ↘ failed / cancelled
                 ↘ retrying → connecting

字段状态可表示为:

pending → parsed → validated → committed
                  ↘ invalid

parsed 表示语法完整,validated 表示通过字段级 Schema,committed 表示已经正式交给 UI 和下游。流传输结束并不等于 completed:还要等待全文档解析与终局校验结束。

字段事件进入状态机时,至少要检查请求标识和版本:

type FieldState =
  | "pending"
  | "parsed"
  | "validated"
  | "committed"
  | "invalid";

type FieldRecord = {
  status: FieldState;
  value?: unknown;       // 最近一次已提交值
  candidate?: unknown;   // 当前候选值
  version: number;
  error?: string;
};

function acceptCandidate(
  candidate: Candidate,
  attemptId: string,
  version: number,
  fieldSchemas: Record<string, { safeParse(value: unknown): unknown }>
): void {
  if (attemptId !== activeAttemptId) return;

  const current = fields.get(candidate.path);
  if (current && version <= current.version) return;

  const schema = fieldSchemas[candidate.path];
  if (!schema) {
    fields.set(candidate.path, {
      status: "invalid",
      value: current?.value,
      version,
      error: "No schema for field",
    });
    return;
  }

  const result = schema.safeParse(candidate.value);
  if (!result.success) {
    fields.set(candidate.path, {
      status: "invalid",
      value: current?.value,
      version,
      error: "Field schema validation failed",
    });
    return;
  }

  fields.set(candidate.path, {
    status: "committed",
    value: result.data,
    candidate: result.data,
    version,
  });

  scheduleRender();
}

请求标识最好分成两层:turnId 表示一次业务回答,attemptId 表示该回答的一次传输尝试。开始新请求时递增活动尝试标识并取消旧连接;旧请求即使晚到片段,也会因为 attemptId 不匹配而被丢弃。新尝试的字段版本应继续递增,避免重试结果被旧值覆盖。

React 不必按每个高频分片更新。解析和提交可以逐字段执行,渲染则可以通过 requestAnimationFrame 或微任务合并,把同一帧内的多次字段变化合并成一次提交。校验任务也必须保持顺序,否则较旧字段可能晚于较新字段写入。

UI 可以选择两种策略:

策略 展示内容 适合场景 代价
严格提交 只显示 committed 字段 交易、订单、企业数据 首次展示要等待字段校验
临时预览 将未提交内容放在独立的预览层 创作类、低风险摘要 可能撤回,不能直接用于导出、计费或业务动作

临时预览可以改善等待感,但不应改变正式消费链路。即使预览已经上屏,未通过校验的内容也不能进入最终答案。

字段完整后,为什么仍不能直接提交?

“完整”只解决语法边界,没有解决数据形状和业务约束。模型侧应尽量通过 JSON Schema 或结构化输出约束字段形状,前端再使用 Zod 或等价工具校验收到的完整字段。

例如,根对象包含标题、商品数组和总价,可以分别定义字段 Schema:

import { z } from "zod";

const ItemSchema = z.object({
  sku: z.string().min(1),
  name: z.string().min(1),
  price: z.number().nonnegative(),
});

const FieldSchemas = {
  "/title": z.string().min(1),
  "/items": z.array(ItemSchema),
  "/total": z.number().nonnegative(),
};

const WholeSchema = z
  .object({
    title: z.string().min(1),
    items: z.array(ItemSchema),
    total: z.number().nonnegative(),
  })
  .strict();

字段级校验解决的是“这个字段能否独立消费”。流结束后还要执行:

const finished = scanner.finish();

if (!finished.ok) {
  // 进入 failed,保留已经提交的字段
  throw new Error(finished.error);
}

const finalResult = WholeSchema.safeParse(finished.value);

if (!finalResult.success) {
  // 终局校验失败,不能把连接标记为 completed
  throw new Error("Final document validation failed");
}

未知字段的处理规则应当明确:拒绝或允许,不能因为 Schema 默认行为不同,让未定义数据静默进入结果。字段 Schema 与全文档 Schema 也要保持同一版本,否则流中途升级规则会产生前后不一致。

Schema 通过不等于业务正确。例如 total 是非负数字,只能证明它是数字;商品金额是否算错、SKU 是否真实存在、地址与配送方式是否匹配,仍属于业务语义校验。如果某个字段必须等待关联字段才能确定,就应把提交条件写入状态机,而不是提前把值当成最终结果。

坏括号、截断、断流、超时和取消分别怎么处理?

异常处理的关键是区分“继续等待”“恢复同一传输”“重新生成字段”和“整请求回退”。这些动作不能统一写成 retry()。

场景 检测方式 状态处理 恢复策略
完整字段出现坏括号或语法错误 字段级 JSON.parse 失败;根结构错误由扫描器发现 当前字段 invalid,保留已提交字段 能定位路径时局部重试;根结构损坏时整请求回退
JSON 在中途截断 finish() 发现根节点尚未闭合 连接进入 failed,未完成字段保持 pending 后端支持续写时请求后缀,否则重新请求
网络中断 reader.read() 抛错或 SSE 连接异常 保留已提交字段,标记传输失败 先尝试恢复同一 attemptId;无法恢复时再重新生成
请求超时 定时器触发 AbortController 区分空闲超时和总超时 按业务预算选择字段或整请求重试
用户取消 用户主动触发 AbortController 进入 cancelled,不自动重试 保留或隐藏已提交结果由产品决定
Schema 不合法 字段级或终局 safeParse 失败 字段进入 invalid 局部重试优先,失败后再升级
重复提交、旧片段晚到 attemptId、版本号不匹配 丢弃旧事件,不改变当前状态 不需要重试
代码围栏或说明文字混入 根节点不是预期对象或数组 进入协议错误状态 服务端规范化后重试,不能静默猜测

网络重连与模型重新生成是两回事。SSE 重连如果能继续同一条流,应当复用原 attemptId,并根据事件 ID 去重;重新调用模型则应生成新的 attemptId。如果服务端没有断点续传、状态查询或续写接口,前端无法证明自己拿到了缺失部分,此时只能重新请求。

已经提交的字段默认应保留,但“保留”不等于“整条回答已经成功”。如果坏括号破坏了根结构,旧字段和后续重试结果可能无法组成同一份自洽文档。界面可以保留它们用于查看,状态上仍应保持失败或待修复。

局部重试真的比重开请求更划算吗?

局部重试不是前端拿到一个错误字段后,再单独调用一次模型生成同名字段。它需要后端提供明确的恢复契约,并保留生成该字段所需的上下文。

可用的接口形态类似:

POST /ai/turns/{turnId}/retry-fields
Content-Type: application/json

{
  "attemptId": "a-17",
  "paths": ["/items"],
  "reason": "schema"
}

后端收到请求后,需要确定重试范围。假设 items 发生变化,而 subtotal 和 total 依赖它,那么重试范围可能不止 /items。后端应依据字段依赖关系计算需要重算的最小闭包,并将新结果作为新的字段版本返回。

依赖关系不能由前端凭字段名猜测。例如:

items ──→ subtotal ──→ total
address ─────────────→ shippingFee ──→ total

如果模型重新生成了 items,是否需要重算 subtotal,取决于两者是否由同一次生成过程产生,以及业务是否要求严格一致。后端掌握生成上下文,应由它返回实际受影响路径。

局部重试的成本也不是天然更低。它仍然可能触发上下文预填充、约束解码和重复 token,只是避免重新生成无关字段。因此是否划算,要通过重试 token 占比和实际业务成本判断,而不是只看“重试次数少了一次”。

以下情况应直接升级为整请求回退:

  • 根 JSON 结构已经损坏,无法可靠定位字段;
  • 后端没有字段级重试接口;
  • 错误来自跨字段语义,而不是单个字段;
  • 局部重试达到配置上限;
  • 依赖范围持续扩大,局部恢复已经没有意义;
  • 同一个字段反复生成仍无法通过校验。

每次局部重试都应有幂等标识和最大次数。前端不能在失败事件上无限循环;后端也要限制单次请求的字段范围和重试预算。

状态机本身只能减少整段清空、整段重渲染和错误结果覆盖。真正减少模型整段重跑,仍然依赖后端提供字段级生成与依赖计算能力。

如何验收这套实现,并确定落地顺序?

验收基线应是“等待完整 JSON 后一次性解析”。重点不是宣称固定提升比例,而是记录同一任务在两种方案下的可测差异。

指标 口径
TTFV,首片有效时间 从发起请求到首个字段通过校验、提交并实际绘制的时间,不等同于首字节时间
终局校验通过率 成功完成全文档解析与 Schema 校验的流数量,占终局流数量的比例
重试 token 占比 局部或整请求重试产生的 token,占本次任务全部生成 token 的比例
撤回率 已经展示但随后被判定无效或被新版本替换的内容比例
解析积压与陈旧事件数 观察校验队列长度、渲染合并次数以及被请求标识过滤掉的片段数量

测试矩阵至少要覆盖以下情况:

类别 测试输入 预期结果
分片边界 切在键名、引号、反斜杠、Unicode 转义、UTF-8 字节或嵌套结构中 不提前提交,不误判字符串结束
多字段分片 一个分片包含多个字段 按顺序产生多个候选字段
根结构 根对象、根数组、嵌套对象与数组 顶层字段或数组元素按约定提交
语法错误 坏括号、错误逗号、非法转义 错误字段不被提交,已提交内容不被无故清空
截断 字符串、数组或根对象中途结束 终局识别为不完整,不误报成功
传输异常 网络中断、空闲超时、总超时、用户取消 状态与恢复动作符合异常矩阵
Schema 异常 字段类型错误、未知字段、终局跨字段错误 字段级与终局校验边界清晰
并发 旧请求晚到、新请求覆盖、重复字段事件 旧事件被丢弃,新版本不会倒退
协议噪声 代码围栏、前后说明文字 明确失败或由服务端规范化,不静默猜测
重试 单字段失败、依赖字段失效、重试超限 局部恢复后按策略升级为整请求回退

落地可按以下顺序推进:

  1. 固定一个根对象或根数组 Schema,明确字段路径、字段级规则、全文档规则和提交边界。
  2. 固定传输契约,至少包含 turnId、attemptId、字段路径和版本;完成字节解码、取消与超时处理。
  3. 实现增量扫描器,并先用分片边界测试验证“何时可以提交”。
  4. 接入连接状态机和字段状态机,加入旧请求过滤、原子替换与渲染合并。
  5. 建立异常矩阵和指标监控。早期版本可以先统一回退到整请求重试。
  6. 最后加入字段级局部重试,并验证它确实减少了重试 token,而不是只增加了一层状态。

如果现在就开始改代码,先选一份固定 Schema,写清楚每个字段何时算“可以上屏”,再实现扫描器与状态机。不要先从重试接口开始:在提交边界尚不明确时,局部重试只会把错误扩散得更快。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容