每收到一块就 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 异常 | 字段类型错误、未知字段、终局跨字段错误 | 字段级与终局校验边界清晰 |
| 并发 | 旧请求晚到、新请求覆盖、重复字段事件 | 旧事件被丢弃,新版本不会倒退 |
| 协议噪声 | 代码围栏、前后说明文字 | 明确失败或由服务端规范化,不静默猜测 |
| 重试 | 单字段失败、依赖字段失效、重试超限 | 局部恢复后按策略升级为整请求回退 |
落地可按以下顺序推进:
- 固定一个根对象或根数组 Schema,明确字段路径、字段级规则、全文档规则和提交边界。
- 固定传输契约,至少包含
turnId、attemptId、字段路径和版本;完成字节解码、取消与超时处理。 - 实现增量扫描器,并先用分片边界测试验证“何时可以提交”。
- 接入连接状态机和字段状态机,加入旧请求过滤、原子替换与渲染合并。
- 建立异常矩阵和指标监控。早期版本可以先统一回退到整请求重试。
- 最后加入字段级局部重试,并验证它确实减少了重试 token,而不是只增加了一层状态。
如果现在就开始改代码,先选一份固定 Schema,写清楚每个字段何时算“可以上屏”,再实现扫描器与状态机。不要先从重试接口开始:在提交边界尚不明确时,局部重试只会把错误扩散得更快。
[email protected]
。
我们承诺在核实信息后,将第一时间删除相关内容或断开链接。
























暂无评论内容