【AI方法笔记】2026-11-02:模型流式吐 JSON 时,前端会遇到四种崩法

本篇是每日一篇的 AI 工程方法笔记,只讲可以直接动手改代码的部分,不做新闻转载。

今天这个点很具体:让模型输出结构化 JSON,同时又要边生成边渲染。Markdown 流式好办,逐字追加就行;JSON 不好办,因为它在传输过程中的任意一刻都可能不是合法 JSON,而 JSON.parse 只认合法 JSON。多数前端事故不是因为模型输出错了,而是解析层在中间态抛异常,然后界面要么整块空白、要么反复闪、要么把半截数字当真值用了。

下面四种崩法和两段代码,都是我在 Node v20.13.1 上实际跑过的,中间态解析结果我按真实输出写。

一、先认全这四种崩法

  1. 语法崩 :流式片段是 {"title":"A,字符串没闭合,JSON.parse 直接抛,你的 catch 分支往往写成「忽略这一帧」,于是界面从头到尾什么都不显示,直到最后一帧才啪地一下全出来。这比不流式还差。
  2. 数量崩 :数组元素数量未知。{"tags":[ 这一刻你不知道后面有几个元素,v-for 拿到空数组就渲染成空态,等元素陆续到达又变成列表。中间如果渲染了「暂无数据」,用户就看见一次假空态。
  3. 默认值崩 :字段名先到、值后到。{"num": 时该字段是 undefined,你的模板写了 {``{ num || 0 }},界面先显示 0,值到达后跳到 7。用户会以为数字变了,其实是根本没数据时被默认值顶上了。
  4. 回跳崩 :模型会在生成过程中自我修正,同一路径的值先出现 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。

想验证自己做得对不对,埋这四个点

  1. 首个有意义字段出现的时间 (firstMeaningfulFrame):如果它接近流的结束时间,说明你的流式只是假流式,用户体验和一次性返回没区别;
  2. 每帧解析失败率与平均回退字符数:回退量持续偏大,说明模型在输出非 JSON 的包裹文本(前缀说明、代码块围栏),该修 prompt 而不是修解析器;
  3. 提交前发生的字段回跳次数(同一路径值被改写 ≥2 次的路径数):这是模型自我修正的频率,也是你「中间态进 store」会付出多少代价的直接指标;
  4. 结束后 schema 校验失败率:只有这一项能当作质量指标对外报。前三项是用来调自己的界面的。

第 1 个指标最反直觉:很多人做完流式才发现首屏时间和之前一模一样,原因通常是自己把渲染绑定在 ok=true 且完整对象上,而不是绑定在渐进结构上。

边界说明

本篇是技术方法整理,不涉及具体厂商口径与宣传数字。文中两段代码是可直接用的最小实现,但有意省略了三件事:未处理 \u 形式的 unicode 转义序列跨帧到达的情况、未处理 BOM 与模型输出的自然语言前缀(partialParse 只脱了代码块围栏)、以及未在超大数据流上做切片优化(回退尝试硬上限 40 次,够用但会丢掉超长未闭合字符串的尾部展示)。如果你的场景是长文本加深层嵌套,用现成的增量式 JSON 流解析器更合适,但仍建议保留上面这套「结构 / 打字 / 提交」的分层判断,否则换库也照样会崩在第 3、4 种情况上。另外不要用 eval、new Function 或宽松 JSON 解析(JSON5)去兜住半截输出------那是把解析问题换成注入问题。

💡 小结

流式 JSON 的正确做法不是「想办法把半截 JSON 解析成完整 JSON」,而是承认它每帧都不完整:结构层只渲染已闭合的部分并把未闭合字段当作不存在,渲染层单独显示正在打字的那一段,提交层只在流结束后让值进业务逻辑。四种崩法里,语法崩和默认值崩是显示问题,数量崩是状态设计问题,回跳崩是并发问题------它们要用三种不同的手段来治,这就是要分三层的原因。

标签:#AI #大模型 #前端开发 #agent #工程实践

相关推荐
集芯微电科技有限公司39 分钟前
40V/24A高频率增强型GaN HEMT功率开关管/高频DC转换/电动工具/GaN充电器 方案首选
人工智能·科技·单片机·嵌入式硬件·深度学习·神经网络·生成对抗网络
CC数分40 分钟前
统计学基础|先把问题问对:数据分析的六大类型
大数据·人工智能·算法
Elastic 中国社区官方博客41 分钟前
基于 Elasticsearch 与 Jina AI 的全模态智能搜索实践:技术全解析
大数据·人工智能·elasticsearch·搜索引擎·ai·信息可视化·jina
asdzx6742 分钟前
C# 操作 PDF 元数据:从属性写入到字段提取的完整流程
前端·pdf·c#
xiaoyunxuexibiji44 分钟前
国资国企采购穿透式监管建设方案:运营体系、部署模式与智能应用
人工智能
RobinDevNotes1 小时前
Memvid 把 Agent 长期记忆塞进一个文件
人工智能·rust·openresty
吹什么轩1 小时前
constexpr 完全指南:从 const 的两种含义,到编译期计算的四次演进
java·前端·javascript
Solange__1 小时前
图片转3D导出GLB、FBX还是OBJ?按5类任务比较材质、动画与可编辑性
人工智能·3d·ai·自然语言处理·编辑器·blender·材质
RPAdaren1 小时前
财务机器人要怎么选?落地财务机器人时的常见问题有哪些?
大数据·人工智能·数据挖掘