推理结果流式返回的前端协同设计,核心是让后端每生成一个token或一个片段就立刻推给前端,前端边收边渲染,而不是等全部生成完再一次性返回。 这个设计直接决定了用户感觉的是“机器在思考”还是“机器在憋大招”。
推理结果流式返回怎么实现?前后端分工先搞清楚
流式返回不是简单地把接口响应体换成流,而是前后端各管一段,再把中间那条“管道”打通。
- 后端负责:把模型输出的token序列切成小块,通过流协议按序发送,同时控制心跳、中断和结束标记。
- 前端负责:接收字节流,解码成文本,按业务需求做缓冲、渲染、状态更新和异常恢复。
很多人踩坑的第一处,就是把后端flush的每个chunk直接append到DOM上,结果界面要么跳字,要么整个页面卡住,真正的协同设计,需要在前端做一层“防抖+批量渲染”的缓冲区。
后端推流:SSE和WebSocket怎么选
行业共识认为,纯对话场景用SSE就够了,需要复杂交互才上WebSocket。
| 维度 | SSE | WebSocket |
|---|---|---|
| 连接方向 | 服务端单向推送 | 双向全双工 |
| 底层协议 | HTTP | TCP |
| 自动重连 | 内置 | 需自己实现 |
| 自定义请求头 | EventSource原生不支持,需用fetch兜底 | 支持 |
| 消息格式 | 文本为主 | 文本或二进制 |
| 中断/反馈 | 不太方便 | 适合 |
SSE的思路更贴近“推理输出”本身模型不会突然问你问题,它只是在持续回答,WebSocket更适合用户随时打断、动态改参数、需要多路消息交互的情形,但维护成本也更高。
前端接流:从EventSource到fetch的ReadableStream
EventSource虽然简单,但没法在POST请求里带上JSON参数,也没法自定义Authorization头,所以大多数生产级项目选择用fetch + ReadableStream。
具体操作路径很简单,但有几个坑要避开:
- 用
fetch发起请求,设置signal用于取消。 - 通过
response.body.getReader()读取流,而不是用response.json()。 - 使用
TextDecoder的stream: true模式,解决中文等多字节字符被截断导致乱码的问题。

下面是一个最简可运行的核心骨架:
const controller = new AbortController();
const res = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ prompt: '你好' }),
signal: controller.signal
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
appendToUI(chunk);
}
注意,appendToUI不能直接做innerHTML +=,最好用insertAdjacentText,或者先把增量存到数组里,等到一定时间再统一更新。
前端流式输出与后端推理协同设计:三种常见的渲染模式
拿到增量文本后,前端到底该怎么渲染?不同场景差别很大。
对话模式:增量追加
最常见的是大模型对话,用户问完话,回答是连续生成的,前端只需要把新来的文字追加到当前消息气泡的末尾,这种模式最接近“打字机”效果。
- 每次收到chunk,先追加到当前消息的文本变量中。
- 然后更新DOM中对应的消息节点,而不是重新渲染整个列表。
- 如果历史消息很多,只让最后一条消息的节点在变化,其他节点要保持稳定。
结构化输出模式:局部diff更新
有些场景要求返回JSON片段,比如搜索结果、推荐理由、步骤列表,前端不能把原始JSON直接显示,而是先解析增量,再更新对应卡片。
这时候协同设计的重点变成了:后端要保证每个chunk的拼接都能构成合法的JSON片段,前端要维护一个累积的解析对象,用“试解析”的办法,能解析多少就渲染多少,解析不完整时,保留在缓冲区等待下一个chunk。
长文档模式:虚拟滚动配合缓冲
生成一篇2000字的文章时,如果每10个token就更新一次DOM,浏览器的渲染压力会很大,业内专家指出,这种场景下最好只渲染用户当前可见区域,用虚拟列表或content-visibility忽略屏幕外内容。
前端流式返回卡顿怎么解决:缓冲、节流和优先级
卡顿几乎都发生在主线程被频繁的DOM更新占用,解决思路是把“网络到达频率”和“渲染频率”解耦。
第一步:加一个令牌桶缓冲
维护一个数组pendingChunks,每收到一个chunk就push进去,然后通过

requestAnimationFrame或定时器,每50ms到100ms取一批进行渲染,这样即使后端每秒推200个chunk,前端最多每秒渲染20次。
let pendingText = '';
let isRendering = false;
function appendChunk(text) {
pendingText += text;
if (!isRendering) {
isRendering = true;
requestAnimationFrame(flushBuffer);
}
}
function flushBuffer() {
if (!pendingText) {
isRendering = false;
return;
}
messageNode.insertAdjacentText('beforeend', pendingText);
pendingText = '';
isRendering = false;
}
第二步:控制光标和滚动
流式输出时,用户通常会盯着屏幕看,如果页面没有自动滚动到最后一行,体验会非常割裂,但每次更新都滚动也容易抖动,正确做法是:只在“用户没有手动向上翻页”时自动滚到底部,并记录上一次滚动位置。
第三步:区分“生成中”和“已完成”状态
状态字段建议至少包含:idle、streaming、done、error,在streaming状态下,显示一个闪烁的竖线光标;进入done后移除,取消请求时,调用controller.abort(),并在UI上保留已经生成的内容。
推理结果流式返回延迟怎么解决?从网络到渲染全链路排查
延迟分为两部分:首字延迟和后续token的流畅度,首字延迟主要看后端从接收请求到输出第一个token的时间,前端很难干预,后续的流畅度则和前端协同有直接关系。
检查后端是否在“攒批”
有些后端为了降低IO压力,会攒够一批才发,结果就是用户看到一段一段地蹦,而不是一个字一个字地冒,排查方法是看网络面板里每个chunk的时间戳,如果间隔不规律且常有突然大量数据,就需要后端调整flush策略,比如每个token或每20个token就flush一次。
检查前端是否被解码阻塞
TextDecoder本身性能很高,但如果你在读取循环里做了额外的事务,比如写localStorage、往日志系统上报、同步更新Vue/React的全局状态,都会拖慢读取节奏,建议把纯展示和持久化操作分离,流式过程中只做展示,结束后再做保存。
检查浏览器并发限制
HTTP/1.1下浏览器对同一个域名最多开6个连接,如果页面上同时有长轮询、批量上传、多个流式请求,后面的流式请求会排队的,开启HTTP/2可以解决大部分队头阻塞问题,不要把视频、图片等大资源放在同一个域名下。

大模型对话场景中的流式返回,前端如何配合后端的“思考”阶段
很多模型在正式生成前会经历“思考”过程,或者调用搜索工具,这时候后端可能在忙,前端如果干等着,用户会觉得没反应,协同设计的做法是:后端提供一个状态事件,比如status: searching,前端先渲染一个“正在搜索资料”的动画,等真正开始生成token了,再切换到打字机模式。
需要特别注意“思考”和“生成中”的切换不能太频繁,后端可以用同一个连接发送控制帧,前端根据帧类型来决定是更新左侧的思考步骤,还是追加正文内容。
处理中断重连的实操
- 流式请求超时后,前端要保留已渲染内容,显示“网络中断,已显示部分结果”。
- 重试时带上一个
resumeId,后端支持的话会从上次结束的位置继续。 - 如果后端不支持续传,至少不要把用户已看到的内容删掉重新渲染。
推理结果流式返回常见问题与排查思路
流式返回和普通HTTP返回有什么区别?
普通HTTP返回是服务端把完整响应体一次性发给浏览器,浏览器解析完才展示,流式返回则利用分块传输编码或SSE,让浏览器在响应未结束时就能边收边渲染,区别的本质在于“响应体的生命周期”被拉长了,前端必须用事件循环持续读取,而不是等待Promise.resolve。
前端如何判断流式接收是否完成?
读取循环中,reader.read()返回的done变成true,说明服务端已经把流结束标记发过来了,如果是SSE,则会收到data: [DONE]或后端约定的固定字符串,前端拿到结束标记后,要立即把状态从streaming切换到done,并隐藏光标。
为什么有时候流式输出会突然卡住不动?
常见原因有三个:后端推理线程被其他任务抢占、网络中间层(如Nginx)开启了缓冲导致数据被攒住、前端在读取循环里做了耗时操作阻塞了事件循环,先在后端日志里确认token是否还在发出,再看浏览器Network面板里响应是否有持续的数据块,最后检查前端是否有同时执行的大计算任务。
流式返回的体验上限,由后端推理速度决定;体验下限,由前端协同设计的细腻程度决定,把缓冲、节流、状态管理和中断恢复做扎实,用户就会觉得“这个AI回答得真顺”。