我让大模型以流式方式返回一段 JSON,想着边收到边解析更快,结果每次拿到的都是残缺的半截 JSON 解析直接报错,而且流到一半模型出错时前面已经发给用户的内容根本收不回来:一次误把流式增量输出当成完整结果来用的深度复盘
那个"JSON 解析时灵时不灵、还偶尔把半截脏数据展示给了用户"的 bug,让我重新理解了 LLM 的流式输出。我做了个功能:让大模型返回一段结构化的 JSON(比如一个包含多个字段的分析结果),为了"更快",我用了流式(streaming)方式——模型一边生成、一边把 token 一段段地推给我,我想着"边收到边解析,体验更流畅"。于是我在每收到一段流式数据时,就 JSON.parse 一下累积的内容。结果一运行,问题百出:大部分时候 JSON.parse 直接报错(Unexpected end of JSON input)——因为我拿到的是 {"name": "张 这样残缺的半截 JSON;偶尔还更糟:流传到一半,模型那边出错了/网络断了,可我前面已经把半截内容渲染给用户看了,这下既没法补全、也收不回来。复盘这件事,我才真正理解了流式输出的本质,以及我犯的认知错误:问题出在我把"流式增量到达的、还没结束的部分内容",当成了"已经完整、可以拿去用的结果"。流式输出的本质是:模型把结果一小段一小段(token by token)地、随着生成陆续推送给你,在"流结束(done)"之前,你手里的永远只是"到目前为止的不完整片段";而 JSON 这种结构化数据,是"必须完整才有意义、才能解析"的——一个缺了后半截、括号没闭合的 JSON,不是"一个不太完整的 JSON",而是"一段无法解析的非法字符串";所以我在流还没结束时就去 JSON.parse 累积的片段,自然解析失败;而把半截内容直接展示/使用,更是把"过程中的临时状态"当成了"最终结果",一旦中途出错就覆水难收。根本原因是:流式输出是增量、陆续到达的,在流结束前手里始终是不完整片段;而 JSON 等结构化数据必须完整才能解析/使用;我误把"流式过程中的不完整片段"当成"完整结果"去解析和展示,导致解析报错、以及中途出错时半截内容无法收回。问题的根,是误用流式输出——把增量、未结束的不完整片段当成完整结果去解析(JSON 须完整)和展示,既解析失败,又在中途出错时把半截脏数据暴露给了用户。这篇就把这次"流式输出处理不当"的坑,从头到尾复盘一遍。
故障现场:边收边解析,拿到的全是半截 JSON
问题在于把流式过程中的不完整片段当完整 JSON 解析:
// 我的错误写法: 流式接收, 每收到一段就尝试 JSON.parse(以为"边收边解析更快")
let buffer = "";
for await (const chunk of llmStream) { // 流式: 一段段陆续到达
buffer += chunk.delta; // 累积
const obj = JSON.parse(buffer); // ✗ 此刻buffer往往是残缺的半截JSON!
render(obj); // ✗ 还把半截/临时内容展示给用户
}
/*
为什么报错 / 出问题:
1. 流式输出是"一段段陆续到达"的: 某一刻 buffer 可能是 '{"name": "张三", "age":'
——一个【还没传完、括号没闭合】的残缺JSON;
2. JSON 必须【完整】才能解析: 残缺的JSON不是"不太完整的JSON", 而是"非法字符串";
→ JSON.parse('{"name": "张') → SyntaxError: Unexpected end of JSON input;
3. 我在"流没结束"时就去parse → 绝大多数时候都在解析半截 → 报错;
4. 更糟: 我还把过程中的内容render给用户; 若流到一半模型出错/网络断,
前面已展示的半截内容收不回来 → 用户看到残缺/错误的结果, 且无法回滚。
流式输出的本质:
- 它是为了"边生成边展示"(像打字机效果), 优化的是"首字延迟"和体验;
- 在流"结束(done事件/[DONE])"之前, 你手里永远是"到目前为止的不完整片段";
- "不完整片段"对【纯文本展示】是OK的(显示已生成的部分, 越来越多);
- 但对【需要完整才有意义的东西】(JSON解析、要校验的结构、要据此做决策的结果)不行!
★ 关键: 流式 = 增量、陆续、未结束前不完整;
- "能增量消费的"(纯文本逐步展示): 适合流式, 边收边显示;
- "必须完整才能用的"(JSON解析/结构校验/据此决策): 必须等流结束、拿到完整结果再处理;
别把"过程中的不完整片段"当"完整的最终结果"去解析、去据此行动、去不可逆地展示。
*/
看着控制台一连串 Unexpected end of JSON input,再想到用户那边偶尔闪现的半截乱码,我又懊恼又恍然:"我以为'流式=更快=边收边处理',就急着去解析每一段……可 JSON 哪能解析半截?它要么完整、要么就是堆废字符。流式给我的是'正在生成的过程',我却当成了'已经生成好的结果'。"这个坑最隐蔽的地方在于:它偶尔会"碰巧成功"——当某一段流恰好让 buffer 凑成一个完整 JSON 时(极少),parse 就成功了,造成"有时好有时坏"的诡异表象;而"展示半截内容"的问题只在"流中途出错"这个小概率事件下才暴露,平时流都正常结束,根本发现不了。下面就来拆解,流式输出到底该怎么用。
第一件事:搞懂流式的本质与"增量 vs 完整"
我顺着这次事故,把流式输出的本质和正确用法彻底理清了。
流式输出到底是什么? 什么能边收边用、什么必须等完整?
【核心: 流式是增量陆续推送、未结束前手里始终是不完整片段; "纯文本展示"可边收边显示, 但"JSON解析/
结构校验/据此决策"必须等流结束拿完整结果; 别把过程中的不完整片段当完整结果去解析/行动/不可逆展示】
1. 流式输出(streaming)是什么:
- 模型边生成边把token一段段推给你(SSE/chunked), 而非等全部生成完一次性返回;
- 目的: 降低"首字延迟"、提升体验(打字机效果)、让长输出能尽早开始展示;
- 代价/特性: 在"流结束(done/[DONE])"之前, 你手里永远是"到此为止的不完整片段"。
2. 关键区分: "能增量消费的" vs "必须完整才有效的"
- 能增量消费(适合边收边用): 纯文本逐步展示(聊天回复一个字一个字显示)、
逐行处理(每完整一行就处理一行)、增量统计;
- 必须完整才有效(必须等流结束):
* JSON/XML等结构化解析: 残缺的就是非法的, 解析必失败;
* 需要校验的结构、需要据此做决策/调用工具的结果;
* 任何"半截就用会出错或不可逆"的场景。
3. 我错在哪:
- 把"必须完整才能解析的JSON", 当"能增量消费的"去边收边parse → 解析半截 → 报错;
- 把"过程中的临时片段", 当"最终结果"去渲染给用户 → 中途出错时半截内容收不回。
4. 正解方向:
① 结构化输出(JSON): 流式累积到流结束(done), 再对【完整buffer】做一次JSON.parse;
(若要流式展示进度, 可展示纯文本进度提示, 但解析只在完整后做)
② 真要"流式解析JSON": 用专门的"增量/容错JSON解析器"(能处理不完整片段), 别用标准JSON.parse;
③ 纯文本展示: 才适合边收边追加显示(这是流式的主场);
④ 处理中途错误/中断: 监听error/abort, 流没正常结束(没收到done)就【不采用】这次结果、
回滚已展示的临时内容(或标注"生成中断"), 别把残缺当成功;
⑤ 区分"展示"和"消费": 给用户看可以流式(临时态), 但程序要据此做逻辑/解析的, 必须用完整结果。
5. 本质: 区分"过程态"和"结果态"
- 流式给你的是"正在进行的过程"(逐步逼近最终结果);
- "过程中的中间态"和"最终的结果态"是不同的, 不能混用;
- 凡"需要完整性/确定性"的操作, 必须基于"结果态"(流结束后的完整内容), 而非"过程态"。
一句话: 流式是增量陆续、未结束前不完整; 纯文本可边收边展示, 但JSON解析/结构校验/据此决策必须等流
结束拿完整结果; 别把过程中的不完整片段当完整结果去解析/行动; 处理好中途错误, 别把残缺当成功。
这套认知,是整个坑的根。流式是什么:模型边生成边一段段推送,目的是降首字延迟、提升体验;代价是流结束前手里始终是不完整片段。关键区分:能增量消费的(纯文本逐步展示、逐行处理)适合边收边用;必须完整才有效的(JSON 解析、结构校验、据此决策)必须等流结束。我错在:把必须完整的 JSON 当能增量消费的去边收边 parse、把过程中的临时片段当最终结果去渲染。正解方向:结构化输出累积到 done 再整体 parse、要流式解析用容错解析器、纯文本才边收边显示、处理中途错误(没 done 就不采用并回滚)、区分展示与消费。本质:区分"过程态"和"结果态"——凡需要完整性/确定性的操作必须基于结果态。一句话:流式是增量陆续、未结束前不完整;纯文本可边收边展示,但 JSON 解析/结构校验/据此决策必须等流结束拿完整结果;别把过程中的不完整片段当完整结果去解析/行动;处理好中途错误,别把残缺当成功。
第二件事:正解——结构化等完整、纯文本才流式、处理中断
知道了"过程态≠结果态",正解就清楚了:按"能不能增量消费"决定怎么处理流。
// 正解1: 结构化输出(JSON)——累积到流结束, 再对完整内容解析一次(本次该做的)
let buffer = "";
let done = false;
try {
for await (const chunk of llmStream) {
if (chunk.type === "done") { done = true; break; } // 流结束标志
buffer += chunk.delta;
// (可选)展示纯文本进度: showProgress(buffer) ← 只展示, 不解析
}
if (!done) throw new Error("流未正常结束(中断)"); // 中途断了, 不采用
const obj = JSON.parse(buffer); // ✓ 流结束后, 对完整buffer解析一次
useResult(obj); // ✓ 基于完整结果做逻辑
} catch (e) {
rollbackOrShowError(e); // 解析失败/中断: 回滚、报错, 别用残缺
}
// 正解2: 纯文本展示——这才是流式的主场, 边收边追加显示
let text = "";
for await (const chunk of llmStream) {
text += chunk.delta;
appendToUI(chunk.delta); // ✓ 逐字追加显示(打字机效果), 不完整也无所谓(只是显示进度)
}
// 文本展示对"不完整"是容忍的: 显示已生成的部分, 越来越多, 自然过渡到完整。
// 正解3: 真要"流式解析结构化"——用容错的增量解析器, 别用标准JSON.parse
// - 用支持"部分JSON(partial JSON)"的库(如 best-effort-json-parser / 流式JSON解析器);
// - 它能从残缺片段里尽力解析出"目前已确定的字段", 容忍未闭合;
// - 适合"想流式展示结构化结果的已生成部分"的高级场景(但复杂, 非必要不用)。
// 正解4: 一定要处理流的"中途错误 / 中断"
// - 监听 error / abort 事件、检查是否收到正常的结束标志(done/[DONE]);
// - 没正常结束 → 这次结果【不算数】: 别采用、回滚已展示的临时内容、提示用户"生成中断, 请重试";
// - 别把"残缺的半截"当成"成功的结果"。
// 正解5: 想清"展示给人" vs "程序消费"
// - 展示给人看: 可以流式(用户能接受逐步出现的临时态);
// - 程序要据此解析/校验/调工具/做决策: 必须用流结束后的【完整、确定】结果。
// 核心: 按"能否增量消费"分流——纯文本边收边展示(流式主场); JSON等结构化累积到流结束再整体解析;
// 处理好中途错误(没done就不采用并回滚); 区分"展示用的过程态"和"消费用的结果态"。
这套正解的关键,是按"这东西能不能增量消费"来决定怎么对待流,并严格区分"展示"和"消费"。结构化等完整:JSON 累积到流结束(收到 done)再对完整 buffer 解析一次,基于完整结果做逻辑——这正是本次该做的。纯文本才边收边显示:这是流式的主场,逐字追加、容忍不完整。真要流式解析结构化:用容错的增量 JSON 解析器,别用标准 parse。处理中途错误/中断:没收到正常结束标志就不采用、回滚临时内容、提示重试,别把残缺当成功。区分展示与消费:给人看可流式(过程态),程序据此解析/决策必须用完整结果(结果态)。
第三件事:其他几个"把不完整/中间态当完整结果"的坑
顺着这次流式,我把"误把中间态当最终态"的几类坑也一并理了:
几类"把不完整/中间态当完整结果"的坑:
坑1: 读流式/分块网络数据, 按固定大小切分就当一条记录——TCP是字节流, 一次read到的
可能是半条或多条消息(同343粘包半包); 要按协议边界拼完整再解析。
坑2: 文件还在写就去读——另一个进程正在写文件, 你读到了写了一半的内容;
正解: 写完原子重命名(write临时文件+rename), 读方读到的总是完整的。
坑3: 事务未提交就读到中间状态——读未提交(脏读)拿到别人事务中途的、可能回滚的数据;
正解: 合适的隔离级别, 别依赖未提交的中间数据。
坑4: 异步操作没等完成就用结果(同591/587)——Promise没resolve、查询没执行完就用;
正解: 等待完成(await/回调)再用结果。
坑5: 分批/分页处理时, 拿到部分页就当全部——只处理了第一页就以为是全量(同589);
正解: 确认是否还有更多, 处理完整或明确知道是采样。
坑6: 进度条/中间统计当最终结论——任务跑到一半的中间指标, 当成最终结果汇报;
正解: 等任务完成再下结论, 中间态只作参考。
共同的根: 很多东西在"完成/结束/提交"之前, 处于"不完整、可能还会变、可能会失败"的【中间态】;
把中间态当成"已完成的最终态"去解析、去据此决策、去不可逆地使用, 就会出错;
要清楚地区分"过程中"和"已完成", 凡需要完整性/确定性的操作, 必须基于"已完成的结果态"。
这些坑看似不同,根却是同一个:很多东西在"完成/结束/提交"之前,处于"不完整、可能还会变、可能会失败"的中间态;把中间态当成"已完成的最终态"去解析、决策、不可逆地使用,就会出错。认清这个根("区分过程态与结果态,需要确定性的操作必须基于结果态"),就能避开一大类"用了还没好的东西"的问题。
第四件事:适合流式 vs 必须完整——两张对照表
我把哪些场景适合流式边收边用、哪些必须等完整,整理成对照表,贴在了团队的 LLM 接入规范里:
| 场景 | 能边收边用吗 | 怎么处理 |
|---|---|---|
| 聊天回复纯文本展示 | ✓ 能 | 边收边追加显示(打字机) |
| JSON 结构化结果 | ✗ 不能 | 累积到流结束再 parse |
| 要校验的结构/Schema | ✗ 不能 | 完整后校验 |
| 据此调用工具/做决策 | ✗ 不能 | 用完整确定结果 |
| 逐行/逐段的文本处理 | △ 按完整行 | 凑够一行再处理一行 |
| Markdown 渲染 | △ 容忍 | 可流式,但代码块等需完整 |
| 处理要点 | 说明 |
|---|---|
| 等结束标志 | 收到 done/[DONE] 才算完整 |
| 整体解析 | JSON 对完整 buffer 解析一次 |
| 处理中断 | 没正常结束就不采用、回滚 |
| 区分展示/消费 | 展示可流式,消费要完整 |
| 容错解析器 | 真要流式解析结构化才用 |
| 错误回滚 | 半截内容能撤回/标注中断 |
这两张表的核心,第一张是区分"能增量消费(纯文本展示)"和"必须完整(结构化、决策)"——流式是给"展示"用的,不是给"解析结构化"用的;第二张是处理流式必做"等结束标志、整体解析、处理中断、区分展示与消费"。记住一条:流式输出优化的是"展示体验",凡是"程序要解析、校验、据此行动"的,都要等流结束、用完整结果。
第五件事:关于流式输出的几组容易想当然的认知
这次事故也让我厘清了几组关于流式输出的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 流式=更快,啥都该边收边处理 | 流式优化体验,结构化仍要等完整 |
| 边收边 parse JSON 更高效 | 残缺 JSON 解析必失败 |
| 流式过程中的内容就是结果 | 是过程态,流结束才是结果态 |
| 展示给用户和程序消费一样处理 | 展示可流式,消费必须完整 |
| 流总会正常结束 | 可能中途出错/断,要处理中断 |
| 偶尔 parse 成功说明写法对 | 是 buffer 碰巧凑完整,不可靠 |
| 展示了半截没关系 | 中途出错时半截脏数据收不回 |
这张表里,我栽的是第一行和第三行:把"流式=更快"理解成"所有东西都该边收边处理",又把"流式过程中的片段"当成了"结果",结果对着半截 JSON 一通解析、还把临时内容暴露给了用户。厘清这些,核心是一个意识:流式输出给你的是"正在生成的过程"而非"已生成的结果";它适合"逐步展示"这种容忍不完整的场景,但凡需要"完整性/确定性"(解析、校验、决策)的,都必须等流结束、拿到完整的结果态。
第六件事:用 LLM 流式输出时,我现在的自检习惯
现在每当我要用 LLM 的流式输出,我都会先按这张图问自己:
这张图的精髓,是"纯文本才流式展示、结构化等完整、处理中断、区分展示与消费"。先问输出是纯文本还是结构化、结构化就等流结束整体 parse、处理中途出错、区分给人看还是程序消费。这套习惯,让我从"流式就边收边解析"变成了"过程态归过程态、结果态才用于解析决策"——核心始终是:流式是增量陆续、未结束前不完整;纯文本可边收边展示,但 JSON 解析/结构校验/据此决策必须等流结束拿完整结果;别把过程中的不完整片段当完整结果。
我立下的几条规矩
这场"边收边解析半截 JSON、还暴露残缺内容"的事故,换来了我用 LLM 流式输出时,刻进骨子里的几条铁律:
- 流式输出是增量、陆续推送的,在流结束前手里始终是不完整片段。
- 纯文本展示适合流式(边收边追加显示),这是流式的主场。
- JSON 等结构化数据必须完整才能解析;累积到流结束(收到 done)再整体 parse。
- 残缺的 JSON 不是"不完整的 JSON",而是"非法字符串",parse 必失败。
- 必须处理流的中途错误/中断:没正常结束就不采用、回滚临时内容、提示重试。
- 区分"展示给人(可流式过程态)"和"程序消费(必须完整结果态)"。
- 凡需要完整性/确定性的操作(解析、校验、决策),必须基于流结束后的完整结果。
附:一个安全消费 LLM 流式 JSON 的封装
借这次的坑,我封装了一个工具:它对外提供"纯文本进度回调(可流式展示)"和"完整结果回调(流结束后才给完整 JSON)"两个口子,把"过程态"和"结果态"从 API 层面就分开。
/**
* 安全消费 LLM 流式输出:
* - onProgress(text): 流式过程态, 给纯文本展示用(可不完整);
* - 返回值: 流正常结束后的完整结果(结构化), 解析失败/中断则抛错。
*/
async function consumeLLMStream(stream, { onProgress } = {}) {
let buffer = "";
let finished = false;
try {
for await (const chunk of stream) {
if (chunk.type === "done" || chunk.done) { finished = true; break; }
buffer += chunk.delta ?? "";
onProgress?.(buffer); // 过程态: 只给展示, 不解析
}
} catch (e) {
throw new Error(`流式中断: ${e.message}`); // 中途出错, 明确抛
}
if (!finished) throw new Error("流未正常结束, 结果不完整"); // 没收到done, 不算成功
try {
return JSON.parse(buffer); // 结果态: 流结束后, 对完整buffer解析一次
} catch (e) {
throw new Error(`完整输出仍非合法JSON: ${e.message}`);
}
}
// 调用方: 展示和消费职责清晰分离
try {
const result = await consumeLLMStream(stream, {
onProgress: (text) => showTypingEffect(text), // 流式展示(过程态, 容忍不完整)
});
useStructuredResult(result); // 只在拿到完整结果后, 才据此做逻辑(结果态)
} catch (e) {
showError("生成失败或中断, 请重试", e); // 中断/解析失败: 不采用残缺
}
// 原则: 从API设计上就把"过程态(展示用、可不完整)"和"结果态(消费用、必完整)"分成两个口子,
// 让调用方不可能"误把过程态当结果态"——把正确用法变成唯一顺手的用法。
这个封装的价值,在于它从接口层面就把"过程态"(onProgress 的文本,供展示)和"结果态"(返回值的完整 JSON,供消费)分成了两个明确的口子:展示用过程态、消费用结果态,调用方不可能再像我当初那样把半截片段拿去解析。把"区分过程与结果"这个容易搞混的认知,固化进了 API 的形状里。
写在最后
回头看,这场由"把流式的不完整片段当完整结果"引发的、解析报错又暴露残缺内容的事故,真正教给我的,远不止"JSON 要等流结束再解析"这一个技巧。它让我对"一个东西在'正在形成的过程中'和'已经形成的结果'是两种本质不同的状态; 处于'过程中'的它, 是不完整、不确定、随时可能变化或夭折的, 绝不能当成'已经定型的结果'去依赖和使用",有了一次刻骨的体会。我栽跟头,是因为我急于使用一个"还在形成中"的东西——我看到流式数据陆续到来, 就迫不及待地把"到目前为止收到的部分"当成"已经完成的结果"去解析、去展示;可它还没"长成": 那半截 JSON 还没闭合、那段内容还没说完、这个生成过程还可能在下一刻出错夭折;我把"一个正在进行、尚未定型的过程"误当成了"一个已经定型、可以信赖的成品", 于是要么解析失败(它还不是个合法的成品), 要么把一个"可能会变、可能会废"的半成品不可逆地用了出去(展示给用户)。这让我领悟到一个关于"过程与结果、未完成与已完成"的深刻认知:许多事物都有一个"正在形成"到"已经完成"的过程; 处于"形成中(in-progress)"的中间态, 具有"不完整、不确定、可逆、可能失败"的本质属性, 而"已完成(committed/finalized)"的结果态才具有"完整、确定、可依赖"的属性;把"中间态"当"结果态"来对待——基于一个"还没定的东西"去做"需要它已经定了"的事(解析、校验、决策、对外承诺、不可逆的行动)——是一类极其普遍的错误根源;这不仅是流式输出: 等承诺兑现再宣布、等数据落库再确认、等流程走完再结论、等对方真正同意再行动——本质都是"不要把'还在进行中的过程态'当成'已经成立的结果态'去依赖"。这给了我一种对待"进行中之事"的清醒:每当我想使用一个东西时,要先分清"它是已经'定型/完成/确认'了, 还是仍在'形成/进行/待定'中?"——对于"需要它确定才能做"的操作(解析、校验、决策、不可逆地行动), 必须耐心等到它真正'完成'(拿到确定的结果态)再做, 而不是急于消费一个还在变化中的中间态;"清醒区分'进行中的过程态'和'已完成的结果态', 只在结果态上做需要确定性的操作",是避免'把半成品当成品用'式错误的关键。认清形成中的中间态不完整不确定不可依赖、需要确定性的操作必须基于已完成的结果态、别急于消费还在变化的过程态——这,是我用一次流式输出处理不当的事故,换来的、关于 AI、也关于如何区分过程与结果的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次用 LLM 流式返回 JSON 时,忍住"边收边 parse"的冲动、稳稳等到流结束再解析完整内容,那我对着满屏 Unexpected end of JSON input 和用户那闪现的半截乱码懊恼的这段时间,就值了。
—— 别看了 · 2026