本篇是每日一篇的 AI 工程方法笔记,只讲可以直接动手改代码的部分,不做新闻转载。
今天这个点很具体:让模型输出结构化 JSON,同时又要边生成边渲染。Markdown 流式好办,逐字追加就行;JSON 不好办,因为它在传输过程中的任意一刻都可能不是合法 JSON,而 JSON.parse 只认合法 JSON。多数前端事故不是因为模型输出错了,而是解析层在中间态抛异常,然后界面要么整块空白、要么反复闪、要么把半截数字当真值用了。
下面四种崩法和两段代码,都是我在 Node v20.13.1 上实际跑过的,中间态解析结果我按真实输出写。
一、先认全这四种崩法
- 语法崩 :流式片段是
{"title":"A,字符串没闭合,JSON.parse直接抛,你的 catch 分支往往写成「忽略这一帧」,于是界面从头到尾什么都不显示,直到最后一帧才啪地一下全出来。这比不流式还差。 - 数量崩 :数组元素数量未知。
{"tags":[这一刻你不知道后面有几个元素,v-for拿到空数组就渲染成空态,等元素陆续到达又变成列表。中间如果渲染了「暂无数据」,用户就看见一次假空态。 - 默认值崩 :字段名先到、值后到。
{"num":时该字段是 undefined,你的模板写了{``{ num || 0 }},界面先显示 0,值到达后跳到 7。用户会以为数字变了,其实是根本没数据时被默认值顶上了。 - 回跳崩 :模型会在生成过程中自我修正,同一路径的值先出现
7后变成70,甚至把前面已经渲染完成的字段整段改写。如果你的 store 是「收到就写入并触发请求」,那一次改写就会发一次请求,流式十几帧能打出十几次请求。
第 4 种最贵,因为它看起来完全正常。
二、结构层:回退到最近的「安全切点」,再补全括号
不要试图实时 parse 整个字符串。可行做法是两步:从尾部往前找一个安全切点(, : { [ } ] " 这些字符处),切掉后面的残缺部分,然后按未闭合的容器补上 } ]。补括号前必须先扫一遍字符串状态,否则会把字符串里的 { 当成容器。
javascript
// 计算未闭合容器;引号内的括号与转义引号都不算
function closers(s) {
const stack = [];
let inStr = false, esc = false;
for (const c of s) {
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
} else if (c === '"') inStr = true;
else if (c === '{' || c === '[') stack.push(c);
else if (c === '}' || c === ']') stack.pop();
}
return { danglingString: inStr, tail: stack.reverse().map(o => (o === '{' ? '}' : ']')).join('') };
}
// 从尾部最多回退 40 个切点,第一个能 parse 的就返回
function partialParse(raw) {
const src = raw.replace(/^\s*```(json)?\s*/, '').replace(/\s*```\s*$/, '');
const cuts = [];
for (let i = src.length; i > 0 && cuts.length < 40; i--) {
const c = src[i - 1];
if (c === ',' || c === ':' || c === '{' || c === '[' || c === '}' || c === ']' || c === '"') cuts.push(i);
}
cuts.push(0);
for (const i of cuts) {
const body = src.slice(0, i).replace(/[,:]\s*$/, '');
const { danglingString, tail } = closers(body);
if (danglingString) continue; // 引号不配对,继续往前退
try { return { ok: true, value: JSON.parse(body + tail) }; }
catch (e) { /* 换下一个切点 */ }
}
return { ok: false, value: null };
}
实测(输入流式片段 → 解析结果):
{"tit→{}{"title":"A→{}(值没闭合,整个字段丢弃,不是显示半截){"title":"A","tags":[→{"title":"A","tags":[]}{"title":"A","tags":["f→{"title":"A","tags":[]}{"title":"A","tags":["fe"],"num":→{"title":"A","tags":["fe"]}{"title":"A","tags":["fe"],"num":7}→ 完整对象{"title":"A","tags":["fe"],"num":7,"nested":{"x":[1,2,→{"...","nested":{"x":[1,2]}}- 5000 字符长字符串 + 未闭合数组的用例:解析成功,耗时约 1ms
关键行为是第二条:值没闭合时,这个字段就当不存在。这正是你要的语义------半截的数字、半截的 ID 一律不进界面、不进 store。至于空数组,它是「结构上确定存在、数量还没到」,所以渲染骨架而不是空态,这两件事必须区分开。
三、渲染层:把「结构」和「正在敲的这段字符串」分开处理
上一节牺牲了流式感:字符串没闭合就看不见。要边打字边显示,就再补一个函数,专门取出「当前正在输入的这段字符串」,并判断它是键、数组元素还是值------判断依据是容器类型栈加上前面有没有冒号。
javascript
function danglingText(raw) {
const stack = []; // 只放 '{' 与 '['
let inStr = false, esc = false, start = -1, ctx = '';
for (let i = 0; i < raw.length; i++) {
const c = raw[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') {
inStr = true; start = i;
const top = stack[stack.length - 1];
if (top === '[') ctx = 'item';
else if (top === '{') ctx = /:\s*$/.test(raw.slice(0, i)) ? 'value' : 'key';
else ctx = 'root';
continue;
}
if (c === '{' || c === '[') stack.push(c);
else if (c === '}' || c === ']') stack.pop();
}
if (!inStr) return null;
return { ctx, text: raw.slice(start + 1) };
}
实测:{"tit → {ctx:"key", text:"tit"};{"title":"A → {ctx:"value", text:"A"};{"title":"A","tags":["f → {ctx:"item", text:"f"};{"title":"A","tags":["fe"],"n → {ctx:"key", text:"n"};完整 JSON → null。
于是渲染策略就清楚了:partialParse 给出可以放心渲染的结构,danglingText 给出当前那一路正在打字的文本,把它单独挂到对应节点上,并且按 ctx 分色 ------key 说明这个字段名还没定,界面上应该显示一个「字段名待定」的骨架态而不是把 tit 当标题展示;value / item 才是内容打字机。Vue2 里这两个函数适合合成一个 mixin:structure 是 computed,dangling 是每帧赋值的数据源,模板里用 v-if="structure.num !== undefined" 而不是 structure.num || 0,就把前面的默认值崩掉了。
四、提交层:只有流结束后的值才允许进业务逻辑
这一节是防第 4 种崩的,也是三层里最容易被省掉的。规则只有一条:流式中间态只用于展示,不用于决策。
- 金额、数量、ID、时间戳这类字段,不要在
onMessage里写 store、不要触发 watch 请求。等done帧之后一次性提交。 - 如果业务必须提前发请求(比如边生成边预览),给请求带一个 generation 序号,回包时序号不是最新就丢弃,否则前一次半截值拉回的结果会覆盖后一次的正确结果。
- 「一键停止 / 重新生成」要同时做三件事:断开流、回滚到上一次已提交的结构、清掉 dangling 文本。只断流不回滚,界面上会留下一个永远打不完的光标。
- schema 校验放在提交层做,不要放在每帧做。每帧校验的失败率极高,因为你在校验半截 JSON。
想验证自己做得对不对,埋这四个点
- 首个有意义字段出现的时间 (
firstMeaningfulFrame):如果它接近流的结束时间,说明你的流式只是假流式,用户体验和一次性返回没区别; - 每帧解析失败率与平均回退字符数:回退量持续偏大,说明模型在输出非 JSON 的包裹文本(前缀说明、代码块围栏),该修 prompt 而不是修解析器;
- 提交前发生的字段回跳次数(同一路径值被改写 ≥2 次的路径数):这是模型自我修正的频率,也是你「中间态进 store」会付出多少代价的直接指标;
- 结束后 schema 校验失败率:只有这一项能当作质量指标对外报。前三项是用来调自己的界面的。
第 1 个指标最反直觉:很多人做完流式才发现首屏时间和之前一模一样,原因通常是自己把渲染绑定在 ok=true 且完整对象上,而不是绑定在渐进结构上。
边界说明
本篇是技术方法整理,不涉及具体厂商口径与宣传数字。文中两段代码是可直接用的最小实现,但有意省略了三件事:未处理 \u 形式的 unicode 转义序列跨帧到达的情况、未处理 BOM 与模型输出的自然语言前缀(partialParse 只脱了代码块围栏)、以及未在超大数据流上做切片优化(回退尝试硬上限 40 次,够用但会丢掉超长未闭合字符串的尾部展示)。如果你的场景是长文本加深层嵌套,用现成的增量式 JSON 流解析器更合适,但仍建议保留上面这套「结构 / 打字 / 提交」的分层判断,否则换库也照样会崩在第 3、4 种情况上。另外不要用 eval、new Function 或宽松 JSON 解析(JSON5)去兜住半截输出------那是把解析问题换成注入问题。
💡 小结
流式 JSON 的正确做法不是「想办法把半截 JSON 解析成完整 JSON」,而是承认它每帧都不完整:结构层只渲染已闭合的部分并把未闭合字段当作不存在,渲染层单独显示正在打字的那一段,提交层只在流结束后让值进业务逻辑。四种崩法里,语法崩和默认值崩是显示问题,数量崩是状态设计问题,回跳崩是并发问题------它们要用三种不同的手段来治,这就是要分三层的原因。
标签:#AI #大模型 #前端开发 #agent #工程实践