当AI像人一样“一个字一个字”地回复时用户体验直接拉满。但背后的流式输出你真的搞懂了吗写在前面最近在做一个AI聊天应用需求很明确调用大模型接口时要让回复内容像ChatGPT那样一个字一个字地“蹦”出来而不是转圈圈等半天后一次性显示完整答案。说实话刚开始我以为这很简单——不就是stream: true嘛配上前端EventSource就完事了。结果真正上手才发现坑比想象中多得多。尤其是JSON截断、双重退出逻辑和协议解析这三个点让我熬夜到凌晨三点。这篇文章就把我从踩坑到填坑的全过程记录下来包含最容易被忽略的底层原理希望能帮你省下几个小时的调试时间。一句话总结核心痛点LLM返回的是二进制文本流可能粘包、可能截断、有双重结束信号你必须精准处理每一帧否则UI必崩。一、先看数据长啥样服务器返回的原始流示例在写代码之前我们必须先搞懂服务器到底吐出来的是什么。下面是一次真实请求中response.body流经TextDecoder解码后按顺序收到的原始文本块已格式化展示1.1 正常情况一条完整的data行data: {id:chatcmpl-abc123,object:chat.completion.chunk,created:1721088000,model:deepseek-v4-flash,choices:[{index:0,delta:{content:从前},finish_reason:null}]}这就是一条完整且合法的JSON数据。注意它以data:开头结尾没有逗号是一个独立的JSON对象。1.2 粘包情况一次收到多条data行data: {id:1,choices:[{index:0,delta:{content:有座}}]} data: {id:2,choices:[{index:0,delta:{content:山}}]} data: {id:3,choices:[{index:0,delta:{content:山上}}]}由于网络传输效率优化服务器可能一次性把多条消息打包发送。如果不用\n切割这三条会混成一个长字符串直接JSON.parse必报错。1.3 拆包情况最坑一条JSON被物理切成了两半第一次reader.read()拿到data: {id:1,choices:[{index:0,delta:{con第二次reader.read()拿到tent:有座庙},finish_reason:null}]}这就是TCP拆包的典型表现。因为网络MTU最大传输单元限制或服务端缓冲区刷新策略一个完整的逻辑行被切成了两个物理块。直接按行分割的话第一行{con解析必然失败。1.4 结束信号[DONE]data: [DONE]这是业务层面的结束标记表示LLM已经生成完所有内容。它不包含任何JSON数据只是一个约定好的特殊字符串。1.5 解析流程图解原始二进制流Uint8Array ↓ TextDecoder.decode() 文本字符串可能包含多个 \n ↓ .split(\n) 数组[data: {...}, data: {...}, ] ↓ .filter(line line.startsWith(data: )) 有效行[data: {...}, data: {...}] ↓ .slice(6) 去掉前缀 裸JSON字符串[{...}, {...}] ↓ JSON.parse()可能失败进buffer JavaScript对象 → 提取 delta.content ↓ 累加拼接 最终页面渲染内容二、核心流程全貌先看图再读代码用户点击提交 ↓ fetch发起POST请求stream: true ↓ 服务器返回ReadableStream二进制流TCP分片传输 ↓ getReader()获取读取器进入while轮询 ↓ await reader.read() 嘬一口数据拿到上面示例中的某个chunk ↓ TextDecoder解码二进制 → 文本字符串 ↓ 【关键】按 \n 分割成多行解决TCP粘包问题 ↓ 过滤出以 data: 开头的行 ↓ 去掉 data: 前缀 → 得到裸JSON字符串 ↓ 尝试 JSON.parse() ├─ 成功 → 提取 delta.content → 拼接到页面增量渲染 └─ 失败 → 存入buffer等下一块拼上再解析处理拆包截断 ↓ 遇到 data: [DONE] → 主动退出循环业务结束 ↓ reader.read() 返回 done: true → 被动退出循环连接关闭三、代码实战每一行都是知识点3.1 基础配置与状态管理import { ref } from vue const question ref(讲一个中国龙的故事); const content ref(); const stream ref(true); // 开关方便对比流式/非流式这里用的是Vue3的ref但核心逻辑和框架无关你用React的useState、原生JS都行。3.2 发起请求别忘了stream参数const response await fetch(endpoint, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY} }, body: JSON.stringify({ model: deepseek-v4-flash, messages: [{ role: user, content: question.value }], stream: stream.value // 关键告诉服务器我要流式 }) });关键点stream: true这个参数是告诉LLM服务端——“别等生成完再返回生成多少给我多少”。3.3 流式处理核心带完整注释对应上面数据示例的解析逻辑if (stream.value) { content.value ; const reader response.body?.getReader(); const decoder new TextDecoder(); let done false; // 【退出条件1】开关变量 let buffer ; // 存放被截断的“半截JSON行” while (!done) { // 嘬一口拿到 Uint8Array 二进制块 const { value, done: doneReading } await reader.read(); done doneReading; // 【退出条件2】流读取完毕底层EOF // ★ 把上一轮没解析完的buffer拼到当前chunk最前面解决拆包截断 const chunkValue buffer decoder.decode(value); buffer ; // 拼完后立即清空用完即弃 // ★★★ 灵魂操作按 \n 分割解决TCP粘包 ★★★ // 对应数据示例将 data: {...}\ndata: {...} 切成 [data: {...}, data: {...}] const lines chunkValue.split(\n) .filter(line line.startsWith(data: )); for (const line of lines) { // 切掉 data: 头裸JSON登场 // 对应数据示例data: {id:1...} → {id:1...} const incoming line.slice(6); // ★★★ 退出条件1服务端发送的显式结束信号 ★★★ if (incoming [DONE]) { done true; break; } try { // 解析裸JSON字符串 → JavaScript对象 const data JSON.parse(incoming); // 提取增量补丁 delta.content const delta data.choices[0].delta.content; if (delta) { content.value delta; // 增量追加逐字渲染 } } catch (e) { // JSON解析失败被截断了如上面示例的 data: {con // 存到buffer下轮再战补上后续的 tent:有座庙... buffer data: ${incoming}; } } } }四、三大灵魂拷问理解这些才算真正掌握4.1 为什么必须按\n分割直接JSON.parse不行吗这是SSE协议规范 TCP流特性的双重约束。SSE协议硬规定大模型厂商都遵循Server-Sent Events规范要求每条消息以data:开头行尾用\n或\r\n分隔。服务器发送的是“文本行流”而不是“JSON数组流”。TCP粘包/拆包现实TCP是流式协议没有“消息边界”。你调用一次reader.read()可能收到拆包半条数据data: {con——如示例1.3粘包三条数据挤在一起data: {A}\ndata: {B}\ndata: {C}——如示例1.2如果不用\n切分你根本无法从data: {con}和tent:hi}}这种乱流中找回边界。split(\n)的本质是把“不可靠的字节流”还原为“可靠的逻辑消息帧”。\n是数据帧的“地桩”没有它流式数据就是一盘散沙。4.2 返回的delta到底是什么为什么不是全量contentdelta是“增量补丁”Incremental Patch而不是“完整快照”。非流式返回的message.content是完整的、最终版的答案。流式返回的delta.content是相对于上一条消息新增的那几个字符。结合数据示例第1个chunkdelta: 从前→ 页面显示“从前”第2个chunkdelta: 有座→ 页面显示“从前有座”第3个chunkdelta: 山→ 页面显示“从前有座山”设计成增量而非全量的原因节省带宽重复传输完整的“从前”“从前有座”是巨大的浪费。降低延迟增量包体积小传输更快。前端只需做字符串拼接content.value delta逻辑极简。4.3 为什么有两个退出条件done和[DONE]不重复吗这是底层协议TCP/HTTP与应用层协议SSE的双重保险不仅不重复而且缺一不可。退出机制来源触发时机对应数据示例如果不处理会怎样doneReading trueReadableStream底层读取器HTTP/TCP层服务器主动关闭HTTP连接或网络中断无特定数据突然读不到任何字节了即使收到[DONE]若连接没关循环会永远等待reader.read()页面卡死incoming [DONE]LLM服务端业务层SSE协议大模型生成完最后一段主动发送结束标记示例1.4data: [DONE]若LLM生成完但没发[DONE]或延迟发送即使连接没断你也不知道回答完了页面会一直转圈执行顺序通常是LLM生成完毕 → 发送最后一条data: [DONE]业务结束→ 约几百毫秒后服务器关闭TCP连接底层结束。你的代码必须同时处理这两种情况碰到[DONE]立即跳出提前渲染完成态不用傻等TCP挥手。万一网络异常导致没收到[DONE]连接断了doneReading也能兜底退出避免死循环。五、Buffer截断处理详解最容易被忽略的神器这是整个方案里最考验功底、最容易被忽略、但最关键的地方也是上面示例1.3拆包的解决方案。5.1 问题复现对应示例1.3LLM服务端返回的数据长这样data: {id:xxx,choices:[{index:0,delta:{content:从前}}]} data: {id:xxx,choices:[{index:0,delta:{content:有座}}]} data: {id:xxx,choices:[{index:0,delta:{content:山}}]} data: [DONE]每一行都是一个完整的JSON直接JSON.parse()没问题。但实际情况往往没这么理想。由于网络传输、TCP分片、服务端缓冲区等因素你收到的chunk可能长这样data: {id:xxx,choices:[{index:0,delta:{con tent:从前有座山}}]}看到没{con和tent:从前有座山}}]}被分到了两个chunk里。如果直接按行分割并尝试JSON.parse()第一行解析必然失败。4.2 buffer的解决方案buffer的核心逻辑就是“这行解析不了我先存着等下一块数据来了拼在一起再试”。try { const data JSON.parse(incoming); // 解析成功正常处理 } catch (e) { // 解析失败把这一行原样存到buffer里 buffer data: ${incoming}; }下一轮循环时const chunkValue buffer decoder.decode(value); // 把上一轮没解析完的拼到新数据前面再重新分割说白了就是解析失败不丢弃而是作为“未完成的拼图碎片”等下一块数据来了一起拼。4.3 为什么不能用简单的字符串拼接你可能会想“那我直接把所有chunk拼成一个完整字符串最后一次性解析不行吗”不行。原因有二内存问题如果回复内容很长几千字全拼在内存里会占用大量资源实时性问题用户要的是“边生成边显示”不是“等全生成完再显示”所以buffer方案的精髓是只缓存“当前这一行”未完成的部分不缓存整个响应。金句buffer不是“存更多”而是“存得巧”——只存当前解析失败的那一小块用完即清。六、避坑指南我替你们踩过的雷\n不止一个服务器可能一次发多行示例1.2必须split(\n)后for循环逐行处理。[DONE]可能粘包它可能和上一条数据挤在同一个chunk里比如data: {...}\ndata: [DONE]所以循环里判断incoming [DONE]要及时break。decoder.decode(value)可能返回空流结束时value为undefined稳妥写法是decoder.decode(value || new Uint8Array())。buffer拼合后记得清空buffer 必须放在拼合语句的下一行否则会无限累积。data:后面可能有空格有些服务端实现是data:不带空格但主流都是data:带一个空格slice(6)稳妥。如果怕不兼容可以用line.replace(/^data:\s*/, )。七、完整可运行代码Vue3版script setup import { ref } from vue const question ref(讲一个中国龙的故事); const content ref(); const stream ref(true); const update async () { if (!question.value) return; content.value 思考中....; const endpoint https://api.deepseek.com/v1/chat/completions; const headers { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY} }; const response await fetch(endpoint, { method: POST, headers, body: JSON.stringify({ model: deepseek-v4-flash, messages: [{ role: user, content: question.value }], stream: stream.value }) }); if (stream.value) { content.value ; const reader response.body?.getReader(); const decoder new TextDecoder(); let done false; let buffer ; while (!done) { const { value, done: doneReading } await reader.read(); done doneReading; const chunkValue buffer decoder.decode(value); buffer ; const lines chunkValue.split(\n) .filter(line line.startsWith(data: )); for (const line of lines) { const incoming line.slice(6); if (incoming [DONE]) { done true; break; } try { const data JSON.parse(incoming); const delta data.choices[0].delta.content; if (delta) { content.value delta; } } catch (e) { buffer data: ${incoming}; } } } } else { const data await response.json(); content.value data.choices[0].message.content; } }; /script template div classcontainer div label输入/label input classinput v-modelquestion / button clickupdate提交/button /div div classoutput div labelStreaming/label input typecheckbox v-modelstream / /div div{{ content }}/div /div /div /template八、一图流总结┌─────────────────────────────────────────────────────────────────┐ │ 流式输出终极心法 │ ├─────────────────────────────────────────────────────────────────┤ │ 数据样本data: {delta:{content:你好}} │ │ ✂️ 分割术\n 是地桩split 拆粘包slice(6) 去头 │ │ 容错术buffer 缓存截断行如 {con拼合后再解析 │ │ 数据层delta 是增量补丁前端只需累加拼接 │ │ 退出层双重保险——业务 [DONE] 底层 EOF(doneReading) │ │ ⚡ 体验层边生成边渲染首字延迟从秒级降至毫秒级 │ └─────────────────────────────────────────────────────────────────┘最后一句流式输出不是炫技而是让AI的“思考过程”变得可见、可感、可等待。搞懂了数据样本怎么拆、\n怎么切、delta怎么拼、双重退出怎么兜底你就稳稳掌握了AI对话交互的核心地基。