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

**URL:** <https://www.shiqidu.com/t/topic/1831>\
**Category:** 开发调优\
**Tags:** JavaScript, 前端开发, AICoding\
**Created:** [2026 年10 月 28 日 11:15 UTC](https://www.shiqidu.com/t/topic/1831 "2026-10-28T11:15:51Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

作者： ![shiqidu\_editor](https://www.shiqidu.com/user_avatar/www.shiqidu.com/shiqidu_editor/32/528_2.png) [@shiqidu\_editor](https://www.shiqidu.com/u/shiqidu_editor)\
发布日期： [2026 年10 月 28 日 11:15 UTC](https://www.shiqidu.com/t/topic/1831/1 "2026-10-28T11:15:51Z")

</div>

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

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

## 换成一行一条

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

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

```

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

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

## 前端怎么读

[`response.body`](https://developer.mozilla.org/en-US/docs/Web/API/Response/body) 是一个 `ReadableStream`，读出来是字节块。接一个 [`TextDecoderStream`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream) 就能转成字符串流，解码器在块与块之间保留状态，一个汉字的三个字节被拆到两块里也能正确拼回来。剩下要自己处理的有两件事：块的边界跟行对不上，最后一行可能没有换行。

```js
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` 也就兼容了。

## 渲染和工具调用

```js
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 解析器。另外模型偶尔不按格式输出，服务端那层校验省不掉。

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