DeepSeek 的流式输出走的是 SSE 风格:响应体是 text/event-stream,每帧以 data: 开头,增量文本藏在 choices[0].delta.content 里,最后以 data: [DONE] 收尾。前端要做的不是等一个完整 JSON,而是读 response.body 这个流,按帧切开、逐帧解析、逐帧追加。同时要注意:API Key 不要放在浏览器里,前端通常请求自己的后端,由后端带 Key 转发 DeepSeek,并把流原样转回来。
适用场景:浏览器里实时显示 DeepSeek 回复,需要逐字追加而不是等整段返回。推荐路径:前端用fetch+ReadableStream读取,TextDecoder({stream:true})解码,自己维护缓冲区按空行切帧;后端负责带 Key 转发并关闭反向代理缓冲。验证方式看 Network 面板响应是否逐步增长、控制台是否按帧打印 delta。风险边界:EventSource只支持 GET、无法带自定义请求头,通常不适用于这类 POST 调用;反向代理开启缓冲时前端会看到整包返回,需要先排除链路问题。
先确认当前场景和目标
先确认三件事,再动代码。第一,请求是不是从浏览器直接发往 DeepSeek:如果是,先停下来,把 Key 挪到自己的服务端;浏览器端直连等于把密钥交给任何打开开发者工具的人。第二,看的是对话正文还是思考过程:部分模型或参数组合会在 delta 里返回额外的推理字段,正文和推理要分开拼接,否则前端会出现两段文字交错。第三,链路里有没有反向代理、网关、CDN:这些中间层默认会攒够一定字节再一次性下发,流式效果就消失了。
判断条件可以先这样划分:Network 面板里响应头有 content-type: text/event-stream、没有 content-length,响应体在时间轴上逐步变长,说明链路本身是通的,问题在前端解析;如果响应体一次性出现完整内容,先查代理缓冲和 gzip,再查前端代码。
按这个顺序处理
第一步,前端请求侧。不要用 EventSource,它是 GET 且不能带自定义头,跟 Authorization 和 JSON body 都对不上。用 fetch 并保留 AbortController,让用户能中途停止:
const controller = new AbortController();
const resp = await fetch('/api/chat', { // 换成你自己的后端转发地址
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify({ model: '你的模型名', stream: true, messages }),
signal: controller.signal
});
if (!resp.ok || !resp.body) throw new Error('stream not available: ' + resp.status);
第二步,接收侧。关键是两件事:TextDecoder 必须传 { stream: true },否则一个汉字被拆在两个 chunk 里会解出乱码;必须自己维护 buffer,因为一次 read() 拿到的字节不保证刚好是完整的一帧。下面这段是通用骨架,帧格式按 DeepSeek 的 data: 行处理,可以整段替换进你的封装函数:

const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let text = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
// 按空行切帧,兼容 \n\n 与 \r\n\r\n
while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx).replace(/^\r?\n\r?\n/, '');
const line = raw.split(/\r?\n/).find(l => l.startsWith('data:'));
if (!line) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return text;
try {
const json = JSON.parse(payload);
const delta = json.choices?.[0]?.delta?.content;
if (delta) {
text += delta;
onDelta(text); // 换成你的渲染回调,建议做节流
}
} catch (e) {
// 单帧解析失败不要中断整条流,记录后继续
console.warn('bad chunk', payload);
}
}
}
第三步,后端转发侧。自己的服务拿到 DeepSeek 的流后要原样透传,响应头给足,并且不要自己做二次缓冲:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no
如果前面是 nginx,对应位置要关掉 proxy_buffering 并对 SSE 路由禁用 gzip;如果用的是某些云函数或网关,需要确认它是否支持流式响应,不支持时只能退回非流式或换部署形态。这一步不确认,前端代码写得再对也看不到增量。
改完后怎么验证
验证按顺序做,每步都有明确的观察点。打开开发者工具的 Network,找到那条请求:响应头是否为 text/event-stream、是否没有 content-length;响应体是否在时间轴上逐步出现新的 data: 行。这是判断“流有没有真的流起来”的第一信号。

第二步,在 onDelta 里临时打一行日志,观察回调是否被多次触发、每次拿到的文本是否只比上一次多一小段。如果只触发一次且直接是全文,问题在链路缓冲而不是解析逻辑。第三步,测中断:点击停止按钮触发 controller.abort(),预期是 reader.read() 抛出 AbortError,界面停在当前已生成内容,且不再追加。第四步,测异常:把后端地址改成一个返回 4xx 的地址,确认前端走到错误分支而不是静默卡住;再构造一条非 JSON 的 data: 帧,确认单帧失败不会让整段回复消失。第五步,用一段包含中文、emoji 和多行代码的输入跑一次,检查有没有乱码或断字——这是 TextDecoder 漏传 stream: true 时最典型的表现。
容易忽略的边界
EventSource 不适用于这里,原因不是兼容性而是能力限制:它无法发 POST、无法带 Authorization,也不方便传 JSON body。真要用,只能在前端和后端之间另做一层 GET 桥接,代价通常不值得。
浏览器兼容上,response.body 和 ReadableStream 在较老的环境里可能不存在,需要先探测;探测失败时可以回退到 XMLHttpRequest 的 onprogress 读取 responseText 的增量,或者直接回退为非流式一次性展示,并在界面提示。回退方案只解决“能不能看”,不解决体验,别把它当成流式的等价替代。
渲染层要节流。SSE 帧可能非常密集,每帧直接触发一次状态更新或 Markdown 重渲染,会让长回复明显卡顿,建议合并到 requestAnimationFrame 或按时间片刷新。Markdown 场景还要注意流式过程中代码块和表格必然处于未闭合状态,渲染器要容忍这种中间态,否则会出现内容闪烁或结构错乱。
重试与计费也要提前想清楚:网络断开后不要自动重发同一条请求,容易造成重复消耗和内容重复拼接;可以把已生成文本保留在界面上,由用户手动点击重试。最后,[DONE] 只是正常结束标记,连接被中途关闭、后端超时、上游返回错误 JSON 都不会给这个标记,前端需要用 done 标志和 try/catch 兜底,判断“是正常收尾还是异常中断”,再决定是否提示用户。