做后端或者AI应用开发的朋友应该都有过这种体验:调一个大模型接口,输入提示词后页面就干等着,十几秒甚至几十秒过去,才一次性吐出一整段回答。遇到网络波动,整段全断,只能重来。这种模式叫一次性返回,而真正适合AI对话场景的,其实是流式调用:请求发出后,服务端每生成一小段内容就立刻推给客户端,前端像打字机一样逐字输出。我之所以把这个话题单独拿出来写,是因为它绝不只是加一个stream参数那么简单,背后牵扯到HTTP分块传输、SSE协议、前后端配合、异常恢复一整套链路。这篇文章就从原理讲到实战,适合正在开发聊天机器人、流式输出应用,或者想把大模型接入到低延迟场景的开发者。
1. 流式调用的核心思路与适用场景
1.1 为什么非流式调用让人抓狂
先把问题拆开看。非流式调用的完整流程是:客户端把请求发给服务端,服务端把整个提示词转发给大模型,大模型内部逐字生成完整文本,全部生成完毕后再一次性通过HTTP响应返回客户端。整条链路里,用户能感知到的只有两个节点:请求发出、完整响应到达。对大模型来说,一段长文本的完整生成可能要十几秒,这十几秒对用户完全是黑盒。
做个简单测算:假如你要生成一篇2000字的报告,中文大约对应2500个token,主流模型每个token生成耗时约20毫秒,整个过程接近50秒。非流式调用意味着用户盯着进度条干等50秒,期间看不到任何内容。我实际测过,一段500字的回答,非流式调用的首字延迟通常在8到15秒之间,而流式调用能把首字延迟压到1到2秒。对对话类应用来说,这两种体验之间的差别可能是「这个产品用户是否愿意用第二次」的差别。
另一个致命问题是超时。网页端和移动端都有默认超时限制,很多场景下读超时是30秒,一旦大模型生成时间超过这个阈值,连接直接断开,用户看到的就是「请求失败」,前面等待的时间里一个字都没留下。网络稍差时,整个响应体还会因为中间某段TCP丢包导致整包重传,体验雪上加霜。
用生活类比来讲:非流式就像去餐厅点了一道需要现做的菜,厨房全部做完,服务员才一次性端上来,等的过程你只能干坐着;流式调用则像吃火锅涮菜,涮好一片吃一片。虽然整顿饭的总时长差不多,但你的嘴里始终有东西,体验完全不同。
1.2 流式调用带来的四个直接收益
第一,首字延迟大幅降低。用户发出请求后,第1个token一到就立刻展示,对话应用要的就是这种「正在思考」的即时反馈,哪怕后面生成速度再慢,用户至少知道系统在干活。
第二,网络容错性变好。流式连接如果中断,已经接收到的内容都还在,可以基于已有内容做局部处理或提示用户重新生成,而不是从零再来。某些场景下配合断点续传技术,甚至能从断掉的位置继续接收。
第三,内存压力更平稳。一次性返回必须把完整文本缓存到内存里才能解析,几千字的响应可能无所谓,但当响应体达到几十MB时,客户端内存占用会很难看。流式返回的数据是持续小份到达的,可以边到边消费,内存占用始终在低位。
第四,服务端资源利用更合理。非流式模式下,网关和业务服务必须把一个完整的大响应全部接收完才释放连接,长耗时请求会长期占用连接池。流式模式下,数据持续往外发,连接长期活跃,但每一段处理都很轻,整体并发能力更友好。
1.3 典型适用场景
流式调用最常见的场景就是大模型对话应用。除此之外,我在实际项目中还碰到过不少场景适合走流式:
长文档生成与摘要。一份几千字的行业报告,用户希望看到内容一段段地出来,而不是一个进度条转半小时。流式输出还能让用户在生成过程中提前发现方向不对,及时打断重写,省下大量时间。
实时翻译与语音转写。同声传译和语音识别天然是增量产生的,识别出一句推一句,比等全文识别完再一次性返回要合理得多。音频流本身就是流式的,结果也应当是流式的。
数据导出与日志推送。批量查询结果、监控日志流、系统实时事件,这些数据的特点是量大、持续产生。按行推送给客户端,比全量一次性返回对网络和浏览器都公平,用户也能边接收边处理。
命令执行的实时回显。远程执行脚本、运行构建任务、部署应用时,把标准输出逐行推给前端,执行进度一目了然。中断时还能保留已输出的日志,排查问题方便很多。
这些场景的核心共同点只有一个:数据是逐渐产生的,而不是一次性存在的。如果数据本身就具备流式特征,却用非流式方式传输,那就是在跟物理规律对着干。
| 对比维度 | 非流式调用 | 流式调用 |
|---|---|---|
| 首字延迟 | 高,往往10秒以上 | 低,通常1到2秒 |
| 用户反馈 | 转圈等待 | 逐字输出 |
| 超时风险 | 高,长文本易超时 | 低,连接保持活跃 |
| 内存占用 | 需缓存完整结果 | 边到边消费 |
| 断线恢复 | 数据全丢 | 可部分恢复 |
2. 流式调用的底层原理与关键技术
2.1 HTTP 分块传输与流式响应的关系
很多人以为流式是WebSocket的专属能力,其实用最普通的HTTP就能实现。HTTP/1.1协议中有一个Transfer-Encoding: chunked,允许服务器不事先告诉客户端响应体的总长度,而是把响应体拆成若干块逐一分发。每块的格式是「块大小(十六进制)+ CRLF + 块数据 + CRLF」,最后以大小为0的块结束。
举个例子,原始文本是"Wikipedia",分块传输时可能长这样:
HTTP/1.1 200 OK Transfer-Encoding: chunked 4\r\n Wiki\r\n 5\r\n pedia\r\n 0\r\n浏览器和curl这类工具会自动把这些块组装回完整内容,但对应用层开发者来说,我们感受到的现象是:数据不是一次性到达的,而是一段一段陆续出现的。在命令行里验证流式效果,最简单的办法就是用curl加-N参数:
curl -N https://api.example.com/v1/chat/completions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"some-model","messages":[],"stream":true}'HTTP/2则更进一步,引入了数据帧(DATA frame)的概念,不同流的数据帧可以在连接上交织传输。不过对大多数API调用场景来说,我们感知到的底层模型还是「连接建立 → 陆续收到数据 → 收到结束标记」。
这里要澄清一个常见误解:分块传输只解决「数据分几批到达」的问题,不解决「每批数据的业务含义」。HTTP层可能把一个完整的SSE事件拆成多个网络包,也可能把几个事件合并成一个网络包一起推过来。应用层拿到的是原始字节流,事件边界必须由应用协议自己界定。这个边界问题,在后面的实战环节会反复遇到。
2.2 SSE 协议为什么适合大模型场景
SSE全称Server-Sent Events,是一种基于HTTP的轻量级服务端推送协议。它的数据格式非常简单:响应头里把Content-Type设置为text/event-stream,每一条消息以「data: 内容」字段开头,消息与消息之间用空行分隔。交付给前端后,EventSource对象会自动解析成事件。
大模型平台普遍采用类似SSE的格式,常见的返回流长这样:
data: {"choices":[{"delta":{"content":"你"}}]} data: {"choices":[{"delta":{"content":"好"}}]} data: [DONE]虽然有些平台不完全符合标准SSE规范,有的省略空行,有的用自定义字段,但核心思路是相同的:把一段段JSON逐行推给客户端,客户端按行解析。
相比WebSocket,SSE在大模型场景有几个实打实的优势:
一是实现成本极低。服务端只要会写HTTP响应就能做,不需要额外的握手协议和帧解析。二是天然支持自动重连。EventSource内置断线重试机制,省去自己写心跳和重连的麻烦。三是更安全。SSE是单向推送,不容易触发跨站双向通信问题,也更方便通过代理和防火墙。四是更符合大模型场景的通信模型。请求是单向的,响应是多段的,正好匹配SSE的「一请求、多响应」模式。
2.3 WebSocket 什么时候才需要
WebSocket是双向全双工协议,适合需要客户端和服务端频繁互发的场景。在纯大模型对话场景里,请求是单向的,响应是流式的,用SSE就足够。但如果你的应用需要用户随时中断生成,同时还想持续推送其他状态事件,比如音视频流、多人协作状态、游戏状态,这时候WebSocket会更合适。
我见过不少团队把SSE够用的场景硬改成WebSocket,结果引入心跳、重连、消息序号、二进制帧等一系列复杂度,最后得不偿失。选型之前先问自己:你真正需要的是「客户端频繁往服务端发消息」吗?如果只是「服务端往客户端持续推数据」,SSE就是更优解。
| 对比项 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端到客户端单向 | 双向全双工 |
| 协议基础 | 普通HTTP | 独立握手加TLS |
| 自动重连 | 内置 | 需自行实现 |
| 消息格式 | 文本,data: 开头 | 文本或二进制均可 |
| 复杂度 | 低 | 中高 |
| 适用场景 | 流式文本、事件推送 | 聊天室、实时协作、音视频 |
对大模型流式输出这个具体问题,我的建议很直接:优先用SSE,别想太多。
3. 后端实现流式调用的实操
3.1 Python 客户端发起流式请求
假设已经拿到了某大模型平台的API地址和密钥,接口文档明确写了支持stream参数。最基础的做法是用requests库写一段流式请求:
import requests url = "https://api.example.com/v1/chat/completions" headers = { "Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json" } payload = { "model": "some-model", "messages": [{"role": "user", "content": "请写一篇500字的短文"}], "stream": True } with requests.post(url, json=payload, headers=headers, stream=True, timeout=(10, 60)) as resp: resp.raise_for_status() for line in resp.iter_lines(decode_unicode=False): if not line: continue if line.startswith(b"data:"): data_str = line[len(b"data:"):].strip() if data_str == b"[DONE]": break # 这里拿到一个JSON片段,继续解析 print(data_str.decode("utf-8"))这段代码有四个关键点。
第一,stream=True必须设置,否则requests会在响应结束后一次性把整个内容读入内存,后面的iter_lines就毫无意义。
第二,timeout参数我特意传了元组(10, 60),分别对应连接超时和读取超时。连接阶段通常在几百毫秒内完成,10秒足够;读取阶段要等大模型陆续输出,60秒更稳妥。如果只传一个数字,连接和读取都会用这个值,长文本生成时很容易中途断流。
第三,iter_lines的decode_unicode参数设为False。平台返回的行里,JSON块中可能包含非ASCII字符,不同平台对unicode行的处理方式不完全一致。与其让requests帮你解码,不如自己拿到bytes后统一用utf-8解码,处理更可控。
第四,resp.raise_for_status()不能省。如果密钥错误或参数有问题,服务端可能返回401或400,不检查直接解析会得到一堆无法理解的错误信息。先把状态码抛出来,问题定位最快。
3.2 用 httpx 做异步流式调用
如果后端是FastAPI这类异步框架,requests就不够看了。requests是一个纯同步库,如果在异步事件循环里直接调用,会阻塞整个进程,并发请求时性能断崖式下跌。推荐换用httpx,它同时支持同步和异步,API风格和requests非常接近:
import httpx async def stream_chat(messages): url = "https://api.example.com/v1/chat/completions" headers = {"Authorization": "Bearer YOUR_API_KEY", "Content-Type": "application/json"} payload = {"model": "some-model", "messages": messages, "stream": True} async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=120.0)) as client: async with client.stream("POST", url, json=payload, headers=headers) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line: continue if line.startswith("data:"): data = line[len("data:"):].strip() if data == "[DONE]": break yield datahttpx.Timeout区分了连接超时(10秒)和读取超时(120秒)。这个设计很合理:连接建立不需要太长时间,但读取阶段受模型生成速度影响,必须给足余量。async for line in resp.aiter_lines()会在每行数据到达时立刻返回,配合yield就能把流式数据往外抛。我通常把这个生成器直接接到FastAPI的StreamingResponse里,形成「大模型流 → 后端流 → 前端流」的完整链路。
3.3 服务端用 FastAPI 把流式数据转发给前端
有些场景让前端直接调大模型平台,这样会暴露API密钥,也不方便做内容过滤、统计等中间逻辑。更合理的做法是让后端做代理:后端接收大模型的流式数据,解析或透传成SSE格式,再转发给前端。
FastAPI里最顺手的工具是StreamingResponse:
from fastapi import FastAPI from fastapi.responses import StreamingResponse app = FastAPI() @app.post("/chat") async def chat_endpoint(payload: dict): messages = payload["messages"] async def event_generator(): async for chunk in stream_chat(messages): yield f"data: {chunk}\n\n" return StreamingResponse( event_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"} )这里有两个非常容易踩的坑。
第一个坑是media_type必须设为text/event-stream。浏览器只有看到这个Content-Type,才会识别为SSE流并特殊处理。如果你写成application/json,前端拿到的就是困惑的错误提示。
第二个坑是Cache-Control: no-cache。如果缺少这个响应头,中间代理层可能会把整个流缓存下来,那结果就是从流式退化成一次性返回。这个问题我在5.4节还会详细讲。
如果前端和后端不在同一个域名下,还需要在FastAPI里配置CORS,否则浏览器会因为跨域限制直接拒绝读取流:
from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["POST"], allow_headers=["Content-Type"], )注意allow_origins在生产环境不要用通配符,特别是涉及流式接口携带鉴权信息时,通配符加credentials的组合会被浏览器直接拦截,排查半天才发现是CORS配置问题,很气人。
4. 前端接收流式数据与逐字渲染
4.1 用 fetch + ReadableStream 逐块读取
如果直接用EventSource,它会自动解析SSE消息,确实省事。但它有两个致命限制:EventSource只能用GET方法,而大多数大模型平台的接口都是POST;同时它没法自定义Authorization请求头,这是浏览器层面的限制。所以实际开发中,我几乎不用EventSource做流式对话,而是用fetch配合ReadableStream手动读取。
标准写法如下:
async function chatStream(messages) { const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }) }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8", { stream: true }); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (line.startsWith("data:")) { const data = line.slice(5).trim(); if (data === "[DONE]") continue; try { const json = JSON.parse(data); const delta = json.choices?.[0]?.delta?.content || ""; if (delta) { appendToUI(delta); } } catch (e) { console.warn("解析失败:", data, e); } } } } }reader.read()返回一个Promise,resolve之后得到的对象带两个字段:done表示流是否结束,value是当前这一块的Uint8Array字节数组。循环读取直到done为true,就是整个流式响应走完的标准流程。
注意buffer.split("\n")和lines.pop()这两行代码。因为网络包边界不稳定,最后一行很可能是不完整的半行,比如一个JSON只收到一半。不能直接处理,必须放回buffer,等下一段数据到达后拼完整再处理。这种「保留半行」的手法,是流式文本处理的基本功。
4.2 TextDecoder 的 stream 模式为什么不能省略
这是我在流式调用里遇到频率最高的坑。流式返回的数据是字节流,一个中文字符在UTF-8编码下占3个字节,模型可能一次只吐出一个token的部分字节。举例来说,「你」字的UTF-8编码是E4 BD A0三个字节,网络分段时可能第一块只包含E4 BD,第二块才包含最后的A0。如果直接用decoder.decode(value)而不开启stream模式,第一个块会被当成无效的残缺序列丢弃,中文就会出现「吞字」甚至乱码。
有朋友问:为什么不把value先拼起来,等凑满3个字节再解码?问题的关键在于流的边界不可控,你无法保证一个完整的UTF-8字符不会横跨两个网络包,也无法保证每次拿到的字节数恰好是3的倍数。正确做法就是上面代码里那样,在创建TextDecoder时传入{ stream: true },decode时也传{ stream: true }。这个选项专门用于处理多字节编码字符被截断的边界情况,让解码器内部记住状态,下一段数据到达时接着上一段继续解。
另外再补充一个buffer溢出的预防。如果某一行特别长,远超正常JSON的长度,很可能数据异常,buffer会无限增长导致内存膨胀。稳妥的做法是给buffer设一个上限,超过1MB就清空并打警告:
if (buffer.length > 1024 * 1024) { console.warn("buffer overflow, clear it"); buffer = ""; }4.3 打字机效果与渲染性能
拿到delta后直接DOM操作是最简单的做法:
document.querySelector("#output").textContent += delta;但内容一长,频繁的append会导致页面卡顿,尤其是在低端手机浏览器上,数据到达速度远快于渲染速度,界面会一顿一顿,最后所有内容堆在一起突然全部出现。
我通常维护一个文本队列,用定时器批量追加:
const queue = []; let timer = null; function appendText(chunk) { queue.push(chunk); if (!timer) { timer = setInterval(() => { if (queue.length === 0) { clearInterval(timer); timer = null; return; } const text = queue.shift(); document.querySelector("#output").textContent += text; }, 16); } }16ms对应浏览器一帧的刷新间隔,用这个节奏批量消费队列,肉眼看起来就是平滑的打字机效果。如果不想用setInterval,也可以用requestAnimationFrame配合队列长度判断,原则是一样的:把高频的流式数据变频到与渲染帧率匹配的消费速率。
更进一步的优化是,把输出容器改成虚拟滚动或分段渲染。当文本长度超过几千字时,一次性把全部文本设置到textContent里会导致重排开销飙升。我的做法是每超过2000字就开启一个新的内容块,旧块固定,只向当前块追加文本。
4.4 处理生成中断与用户取消
流式对话应用几乎都有一个「停止生成」按钮。不能简单地把连接断掉就完事,至少需要两件事:调用reader.cancel()释放流资源,然后通过AbortController通知fetch取消整个请求。标准写法:
const controller = new AbortController(); async function chatStream(messages) { const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }), signal: controller.signal }); // ...读取流 } // 用户点击停止 stopBtn.onclick = () => { controller.abort(); };fetch传入signal后,调用controller.abort()会中断所有后续的网络活动,触发AbortError异常。需要在读取循环里捕获这个异常,避免页面报红:
try { while (true) { const { done, value } = await reader.read(); if (done) break; // ... } } catch (e) { if (e.name === "AbortError") { console.log("用户主动取消"); } else { throw e; } }如果平台还提供了取消任务的服务端接口,记得在abort后调用一下,让大模型那边也停止生成,避免后台继续消耗算力。有些模型是按token计费的,生成了用户不要的内容,浪费的是真金白银。
5. 常见问题与排查技巧实录
5.1 中文乱码或丢字
这是流式调用出现频次最高的问题。排查思路按顺序走三步:
先看前端是否用了TextDecoder的stream模式,这是最常见的丢字原因。再看后端在转发时是否对内容做了额外的编码转换。最后确认请求和响应头里的Content-Type是否都带charset=utf-8。
我遇到过一个特别隐蔽的案例:中游有一段代码用String类型拼接后再encode,结果把原本正常的UTF-8字节序列重新decode成了latin-1,前端再解码就全乱了。事后定位才发现是某一层没有显式指定编码。建议所有中间环节统一用bytes类型处理,只在最前端做一次解码,中间不要反复编解码。
5.2 连接固定时间断流
现象是:短提示词正常,长提示词或长输出总在固定时间断流,比如每次都卡在30秒左右。原因多半是读超时设太短,或者中间代理把空闲连接回收了。
解决方案很直接:把请求端的read_timeout调大到60秒以上;如果经过nginx,把proxy_read_timeout也调整到匹配值;如果平台支持心跳或注释输出,开启后连接会更活跃。
这里有个小技巧:把超时拆成connect和read两个维度,connect保持5秒,read单独放大。不要懒省事给一个数字,否则你会在生产环境排查「为什么总是在第30秒断」这种问题。
5.3 事件边界不稳定导致JSON解析失败
不同平台的流式格式差异很大。有的严格按SSE规范用空行分隔,有的每行都直接用data:开头,有的甚至返回连续的JSON数组。解析报错时先别急着改代码,用curl把原始响应打出来看一眼,确认数据长什么样。
我封装流式客户端时,通常先写一个「行解析器」,把字节流转成带边界的行,再针对不同平台写对应的解析策略。解析失败时不要静默吞掉,打印原始行内容和出错现场,比靠猜快得多。
5.4 中间代理缓冲导致流式失效
如果前端拿到的不是逐步到达的内容,而是等了很久后一次性全部出现,大概率是中间代理开了缓冲。检查点有三个:
第一,反向代理是否关闭了proxy_buffering。nginx默认会缓冲后端响应,需要显式设置proxy_buffering off。第二,是否启用了压缩中间件。压缩会引入额外缓冲并延迟数据到达,流式接口一般不建议开gzip。第三,应用层是否设置了Cache-Control: no-cache。
我在本地调试一切正常,一到测试环境就变回一次性返回,排查半天发现是测试环境的接入层给响应加了gzip压缩,压缩器为了凑一个完整的压缩块把数据缓存了。把压缩关掉,流式立刻恢复正常。
5.5 如何设计可观测的流式接口
生产环境里流式接口出了问题很难查,日志如果打全量内容会直接爆炸。我的做法是在网关层为每个流式请求记录三个埋点:首字节时间、总数据量、最后完成时间。再配合指定请求ID的抽样完整日志,流量大的时候只记录关键指标,需要排查具体问题时按请求ID拉取全量链路日志。
这样能快速定位「延迟在哪一段」:是模型生成本身就慢,还是网络传输慢,还是前端渲染慢,还是中间代理在捣乱。没有埋点,这三个环节出了问题长一个样。
5.6 平台差异适配经验
不同大模型平台的流式字段命名并不统一。有的用delta.content,有的用choices[0].message.content,结束标志也有[DONE]和finish_reason两种。我在封装层会写一个统一的数据类,把各平台解析成相同的内部结构之后再向后端传递。
具体做法是:先拉一份样例响应,确认字段和结束标记,再写映射函数。绝不写死单一平台。平台升级接口时,也要回归测试一遍流式解析,防止字段变动导致线上突然全挂。
下面是一个排查速查表,可以直接收藏:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 中文乱码或吞字 | TextDecoder未开stream模式 | 使用{ stream: true }解码 |
| 固定时间断流 | 读超时或代理空闲回收 | 调大read_timeout与proxy_read_timeout |
| JSON解析报错 | 事件边界判断不对 | 按行分割并保留半行缓冲 |
| 流式变成一次性返回 | 中间代理缓冲或gzip | 关闭缓冲并设置no-cache |
| 页面卡顿渲染慢 | 高频DOM更新 | 16ms定时器批量追加 |
| 停止按钮失效 | 未调用reader.cancel或abort | 正确释放资源并处理AbortError |
| 跨域无法读取流 | CORS通配符与凭证冲突 | 配置精确allow_origins |
6. 端到端最小示例:串起整条流式链路
6.1 后端完整代码
把上面所有细节整合起来,一个极简但完整的流式对话后端长这样:
import json import httpx from fastapi import FastAPI from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware MODEL_URL = "https://api.example.com/v1/chat/completions" MODEL_KEY = "YOUR_API_KEY" app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["POST"], allow_headers=["Content-Type"], ) async def sse_generator(messages: list): payload = { "model": "some-model", "messages": messages, "stream": True, } headers = { "Authorization": f"Bearer {MODEL_KEY}", "Content-Type": "application/json", } async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, read=120.0)) as client: async with client.stream("POST", MODEL_URL, json=payload, headers=headers) as resp: resp.raise_for_status() async for line in resp.aiter_lines(): if not line.startswith("data:"): continue data = line[len("data:"):].strip() if data == "[DONE]": break yield f"data: {data}\n\n" @app.post("/chat") async def chat(payload: dict): messages = payload["messages"] return StreamingResponse( sse_generator(messages), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, )这段代码的核心思路是「透传而不是二次解析」。后端不需要理解大模型平台返回的JSON结构,只需要把data:开头的内容原样包装成标准SSE事件转发给前端。事件边界由前面的aiter_lines保证,一层一层传下去。这样前端拿到的是统一的SSE流,解析逻辑只需要维护一份。
6.2 前端完整代码
对应的前端核心逻辑:
const controller = new AbortController(); async function runChat(messages) { const response = await fetch("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages }), signal: controller.signal }); const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8", { stream: true }); let buffer = ""; try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (!line.startsWith("data:")) continue; const data = line.slice(5).trim(); if (data === "[DONE]") continue; try { const json = JSON.parse(data); // 兼容两种常见字段结构 const content = json.choices?.[0]?.delta?.content || json.choices?.[0]?.message?.content || ""; if (content) appendText(content); } catch (e) { console.warn("SSE解析失败,原始片段:", data); } } } } catch (e) { if (e.name === "AbortError") { console.log("用户主动停止生成"); } else { console.error("流式请求异常", e); } } }这一版代码把5.1到5.6提到的坑都处理掉了:TextDecoder开启stream模式、半行缓冲、AbortError捕获、兼容不同字段。实际项目里,再配合前面4.3节的打字机队列,就是一个能直接上线的流式对话前端。
6.3 联调检验清单
链路搭好后,我习惯按下面这个清单逐项验证,一项一项勾掉,基本就能放心上线:
| 检查项 | 方法 | 预期结果 |
|---|---|---|
| 原始大模型是否流式返回 | curl加-N请求平台接口 | 逐段出现文字,非一次性 |
| 后端转发是否流式返回 | curl加-N请求/chat接口 | 同样逐段出现 |
| 浏览器是否逐字输出 | 打开页面发起对话 | 内容从第1秒开始出现 |
| 中文是否有吞字乱码 | 输入一个包含生僻字的测试问题 | 完整显示不丢字 |
| 停止按钮是否生效 | 生成中点击停止 | 立即停止且无控制台报错 |
| 断网是否友好报错 | 生成中关闭网络 | 前端捕获错误并提示重试 |
| 长时间生成是否断流 | 请求一篇2000字长文 | 全程连接不断 |
| 代理是否缓冲 | 观察浏览器网络面板的TTFB | 首字节时间接近模型首token时间 |
这套清单我在每个流式项目里都会跑一遍,每次都能揪出至少一两个藏在细节里的问题。流式调用这个功能,表面看是加一个参数,底层是对网络、编码、协议边界、渲染性能的综合理解。把这些点吃透,再接入任何平台的流式接口,你都能快速判断出问题出在哪一层。
踩过几次坑之后,我的个人体会是:流式调用本身并不难,难点在于把「字节流 → 行 → 事件 → 内容」这条链路里的每个边界都处理得干净利落。中文编码、超时、代理缓冲、平台差异这四个点,真正常见也最折磨人。如果你刚开始做流式调用,建议先把一个最小链路完整跑通:Python发请求、FastAPI转发、前端逐字渲染,代码越简单越好,再逐步叠加异常处理。
最后分享一个小技巧:调试流式接口时,直接在终端里用curl或者Node脚本打原始响应,别急着在前端看效果。先在命令行里确认「字节确实是分批到的」,再考虑浏览器和代理层的问题。这个习惯能帮你把一半的疑难杂症都扼杀在源头。