前端接模型流式输出老拿到半截对象,是不是一开始就不该让它吐一个大对象?

想让模型返回结构化结果,一边接收一边渲染成卡片列表,常见的写法是在提示词里要一个大对象,形如 {"cards":[{...},{...}]}。后端原样转发 token,前端每收到一块就试着 JSON.parse。结果就是,大部分时间拿到的都是没闭合的字符串,解析直接抛错,到头来只能等最后一个 } 到了才渲染,流式输出等于白做。

网上能搜到的办法大多是写个补全函数,把缺的引号和括号填上再解析。能用,但我觉得毛病出在协议上:界面本来就是一张张卡片,数据为什么非要包成一个整体?

换成一行一条

NDJSON 规范很短:每条 JSON 文本后面跟一个 \n,文本内部不允许出现换行,编码用 UTF-8,解析端要同时接受 \n 和 \r\n,媒体类型建议用 application/x-ndjson。放到这个场景里,就是让后端每生成完一张卡片就输出一行:

{"type":"card","id":1,"title":"第一张卡片"}
{"type":"card","id":2,"title":"第二张"}
{"type":"done","count":2}

每行单独就是合法 JSON,前端按换行切开就能逐条 JSON.parse,半截对象的问题自然没了。需要总数、引用列表这类整体信息时,在最后补一行 done 汇总。

如果是让模型直接写这种格式,后端最好自己按行缓冲,解析成功后再用 JSON.stringify 重新输出一遍。模型偶尔会在字段里塞真换行,或者漏掉引号,这一步能把脏数据挡在服务端。

前端怎么读

response.body 是一个 ReadableStream,读出来是字节块。接一个 TextDecoderStream 就能转成字符串流,解码器在块与块之间保留状态,一个汉字的三个字节被拆到两块里也能正确拼回来。剩下要自己处理的有两件事:块的边界跟行对不上,最后一行可能没有换行。

export async function* readNdjson(response) {
  if (!response.ok || !response.body) {
    throw new Error(`HTTP ${response.status}`);
  }
  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();

  let buf = '';
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += value;

    let nl;
    while ((nl = buf.indexOf('\n')) !== -1) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      const obj = parseLine(line);
      if (obj !== undefined) yield obj;
    }
  }
  // 流结束时 buf 里可能还剩最后一行(服务端没补换行)
  const last = parseLine(buf);
  if (last !== undefined) yield last;
}

function parseLine(raw) {
  const line = raw.trim(); // 顺手去掉 \r
  if (!line) return undefined;
  try {
    return JSON.parse(line);
  } catch (err) {
    console.warn('跳过坏行:', line.slice(0, 80));
    return undefined;
  }
}

有几处细节容易漏:

  • buf 里只留最后一个换行之后的内容,这段是还没收全的那一行,等下一块到了再拼。
  • 读到 done 以后要把 buf 再处理一次。规范要求每条后面都带 \n,但服务端漏掉末尾换行的情况并不少,不处理就会丢掉最后一条。
  • 坏行跳过并打日志,一行脏数据不至于让整个流崩掉。规范的意思是遇到无法解析的文本应当报错,空行可以静默忽略,所以跳过坏行属于业务上的取舍,团队里统一好就行。
  • trim() 会顺带去掉行尾的 \r,\r\n 也就兼容了。

渲染和工具调用

const pending = [];
let scheduled = false;

function enqueue(card) {
  pending.push(card);
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(() => {
      scheduled = false;
      for (const c of pending.splice(0)) renderCard(c);
    });
  }
}

async function run(url, signal) {
  const res = await fetch(url, { signal });
  const toolCalls = [];
  let summary = null;

  for await (const msg of readNdjson(res)) {
    if (msg.type === 'card') enqueue(msg);
    else if (msg.type === 'tool_call') toolCalls.push(msg);
    else if (msg.type === 'done') summary = msg;
  }

  if (!summary) throw new Error('流提前断了,没等到 done');
  for (const call of toolCalls) await runTool(call);
  return summary;
}

卡片先进队列,用 requestAnimationFrame 合并成每帧最多一次 DOM 更新,后端一秒推几十行也不会每来一行就重排。tool_call 只收集不执行,等 done 那行到了才统一跑;收不到 done 就说明流半路断了,这时宁可报错,也别拿着不完整的结果去调接口、写数据库。用户点停止时调用 AbortController 的 abort(),读取会以异常结束,走同一个错误分支。

两段代码在 Node 里也能跑通,只是那边没有 requestAnimationFrame,测试时拿 setTimeout 顶一下就行。用一个每次只发 7 字节的本地服务测过,中文被切断、坏行、末尾缺换行这几种情况都能正常处理。

拆不开的情况

这套做法适合结果天然能拆成多条的场景,比如列表、卡片、搜索结果、多步执行日志。如果界面就是一个嵌套很深的大对象,又要求字段边生成边显示,拆行帮不上忙,还是得上增量 JSON 解析器。另外模型偶尔不按格式输出,服务端那层校验省不掉。

你们接流式的结构化输出时,是改协议,还是在前端硬补括号?有没有碰到怎么都拆不成多行的数据形状?