1. 流式输出卡住时,为什么只调超时时间根本救不了你
大模型应用做到流式输出这一步,很多人第一次遇到“前端半天不吐字”或者“吐到一半突然断掉”的时候,第一反应都是去翻客户端的超时配置。把timeout从 30 秒改成 120 秒,再改成 300 秒,改完发现该卡还是卡,该断还是断。这不是超时时间不够长的问题,而是整条链路上有太多环节都能把流掐断,而你只盯着其中最容易改的那一个。
我先把这条链路摊开说。一次典型的大模型流式对话,请求从浏览器发出,经过反向代理(常见的是 Nginx),到达后端应用服务,应用服务再通过 SDK 或 HTTP 调用推理服务(可能是 vLLM、Ollama 或者云端 API),推理服务把 token 一个个吐回来,再沿着原路反向流回浏览器。这条链路上至少有六个地方会影响流的连续性:浏览器侧的连接管理、代理层的缓冲与超时、应用层的读取与转发逻辑、HTTP 客户端自身的超时与缓冲、推理服务的批处理与排队策略、以及网络中间设备的空闲连接回收。
只调超时,等于你只动了其中一两个旋钮,剩下的环节该出问题照样出问题。更麻烦的是,不同环节出的问题表现还不一样:有的是首字节迟迟不来,有的是前几个字正常然后突然静止,有的是文字到了但前端渲染不出来,还有的是连接直接重置。你得先学会区分这些现象,才能定位到真正的那一环。
这篇内容适合正在做大模型应用、已经跑通了非流式调用、但在流式输出上反复踩坑的开发者。不管你是用 Vue 写聊天界面,还是用 Java 做后端网关,或者只是本地用 Ollama 跑个 demo,这套排查思路都能直接套用。我会按“现象分类 → 逐层定位 → 具体修复 → 验证方法”的顺序讲,每一步都给出可操作的命令和配置,而不是停留在“检查一下配置”这种废话层面。
提示:流式输出的排查核心不是“把超时调大”,而是“找到第一个把流缓冲住或掐断的环节”。定位顺序建议从最靠近推理服务的一端往客户端方向查,因为越靠近源头的问题越容易被下游的缓冲掩盖。
2. 先把“卡住”这件事拆成四种不同的病
很多人描述问题就说“流式输出卡住了”,但这句话信息量几乎为零。卡住的位置不同、时机不同,对应的根因完全不一样。我在实际排查中会把现象先归成四类,分类之后再动手,效率能差出好几倍。
2.1 首字节超时:连接建立了,但一个字都不来
这种表现是浏览器 Network 面板里能看到请求已经发出,状态是 pending,但 response 里迟迟没有内容,直到某个时刻直接失败或超时。这种情况通常和推理服务本身的排队、模型加载、或者应用层在拿到第一个 token 之前做了阻塞操作有关。
一个容易被忽略的点是:有些 HTTP 客户端库默认会等整个响应体接收完才回调,而不是边收边处理。如果你用的是这类客户端,那流式在应用层就已经被“攒”成非流式了,前端自然看不到逐字效果。判断方法很简单,在应用层打印每次读取到数据的日志,如果日志是最后一次性刷出来一大片,而不是零星几条,那问题就在读取方式上。
2.2 中途静止:吐了几个字之后突然不动了
这是最常见也最迷惑人的一种。前几个 token 正常到达,然后流就停在那里,既不报错也不结束,等很久之后要么超时,要么突然又蹦出一段。这种几乎可以锁定是某一层开启了缓冲。
代理层的缓冲是最典型的嫌疑对象。Nginx 默认会对上游响应做缓冲,它觉得“攒够一批再转发”更高效,但对流式场景来说这就是灾难。除此之外,应用层如果用了带缓冲的 Writer 而没有及时 flush,也会造成同样的效果。还有一种情况是推理服务端做了动态批处理,当并发请求进来时,你的请求被排进批次等待,输出节奏就会变得不均匀。
2.3 内容截断:流正常结束了,但文字不完整
这种表现是前端收到了结束信号,但最后一段话明显没说完,或者 JSON 结构缺了右括号。根因往往在解析逻辑上。流式返回的数据是按块(chunk)到达的,一个完整的语义单元可能被拆在两个 chunk 里。如果你的解析代码假设“每个 chunk 都是完整的一行”或者“每个 chunk 都是完整 JSON”,那必然会在边界处丢数据。
SSE(Server-Sent Events)格式里,一个事件以\n\n结尾,但网络传输不保证这个边界和 chunk 边界对齐。所以正确做法是维护一个缓冲区,把收到的数据先拼接,再按分隔符切分,切出完整事件才处理,剩下的留在缓冲区等下一个 chunk。这个细节后面会给出具体代码。
2.4 连接重置:直接报错断开
这种是收到了 RST 或者 502、504 之类的错误。常见原因是某一层的超时时间到了主动断开,或者上游服务崩溃、被重启。代理层的proxy_read_timeout、应用层 HTTP 客户端的 read timeout、推理服务的请求超时,任何一个先到点都会导致断开。这时候你要做的是把各层超时时间列出来,找出那个最短的,它往往就是元凶。
| 现象 | 最可能的环节 | 首要排查动作 |
|---|---|---|
| 首字节不来 | 推理服务排队 / 应用层阻塞读取 | 看应用层读取日志是否一次性刷出 |
| 中途静止 | 代理缓冲 / Writer 未 flush | 关闭代理缓冲,检查 flush 调用 |
| 内容截断 | 解析逻辑未处理 chunk 边界 | 检查缓冲区拼接与切分逻辑 |
| 连接重置 | 某层超时最短 / 上游崩溃 | 列出各层超时,找最小值 |
把这四类分清楚,你就不会再有“到处改超时”的盲目感了。接下来逐层往下拆。
3. 代理层:Nginx 的缓冲才是流式输出的头号杀手
只要你的架构里有 Nginx 做反向代理,流式出问题第一个要查的就是它。Nginx 的设计哲学是“高效转发”,它默认会缓冲上游响应,等攒到一定大小或者上游结束后再发给客户端。这个行为对普通网页请求是优化,对流式输出是致命的。
3.1 关闭 proxy_buffering 是第一步,但不是全部
最关键的配置是proxy_buffering off。关掉之后,Nginx 会把上游来的数据尽快转发给客户端,而不是攒着。但光这一条还不够,配套的还有几个参数需要一起调整。
location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; chunked_transfer_encoding on; }这里逐条解释为什么这么配。proxy_http_version 1.1是因为 HTTP/1.1 才支持 chunked 传输和长连接,默认的 1.0 会在每个请求后关连接,对流式不友好。proxy_set_header Connection ""是清掉默认的Connection: close,让连接保持。proxy_buffering off是核心。proxy_cache off防止缓存层介入。两个 timeout 给足时间,避免推理慢的时候被代理先掐断。chunked_transfer_encoding on确保分块传输编码开启。
注意:
proxy_buffering off要放在具体的 location 里,不要图省事放在 http 或 server 块里全局关。全局关闭会影响其他普通接口的性能,得不偿失。
3.2 为什么关了缓冲还是偶尔卡:gzip 和响应头在捣乱
有时候你确认proxy_buffering off生效了,但流还是不够顺。这时候要查两个东西:gzip 压缩和响应头。
gzip 压缩本身需要攒数据才能压缩,它天然和流式冲突。如果 Nginx 对text/event-stream类型的响应开了 gzip,那数据会被压缩模块缓冲住。解决办法是在流式接口上明确关闭 gzip:
location /api/chat/stream { gzip off; # 其余配置同上 }另一个坑是响应头。有些中间层看到Content-Length就会按固定长度处理,而流式响应的长度是不确定的,应该用Transfer-Encoding: chunked。如果你的后端框架自动加了Content-Length,要手动去掉。还有X-Accel-Buffering: no这个响应头,它是给 Nginx 看的,告诉它这个响应不要缓冲。在后端返回流式响应时加上这个头,相当于在应用层再给代理层打个招呼,双保险。
3.3 实测对比:开缓冲和关缓冲的差异有多大
我在本地做过一组对比测试,后端用同一个推理服务,前端用同一个页面,只改 Nginx 的proxy_buffering配置。开启缓冲时,用户发出问题后平均要等 4 到 6 秒才看到第一批文字,而且是一大段一起出现;关闭缓冲后,首字延迟降到 800 毫秒左右,文字是平滑逐字出现的。这个差异在用户体验上完全是两个产品。
如果你用的是云厂商的负载均衡或者 API 网关,它们内部可能也有类似的缓冲机制,而且不一定暴露给你配置。这种情况下,能自建 Nginx 就自建,把控制权握在自己手里。排查时如果发现所有应用层配置都对了但流还是被攒着,就要怀疑是不是云网关在缓冲,这时候可以考虑换直连或者换网关产品。
4. 应用层:读取方式、flush 时机和超时设置的三重陷阱
代理层搞定之后,下一个高发区是应用服务本身。这里的问题往往更隐蔽,因为它不报错,只是“行为不对”。
4.1 你的 HTTP 客户端可能根本没在流式读
这是最隐蔽的坑之一。很多 HTTP 客户端库默认行为是“等响应体全部接收完再返回”,即使你调的是流式接口,它也会在内部把整个响应攒完。你代码里写了个循环去读,但循环第一次执行时数据其实已经全到了,看起来就像非流式。
判断方法前面提过:在读取循环里打日志,看时间戳。如果所有日志的时间戳几乎一样,说明数据是一次性到的,客户端没在流式读。解决办法是使用支持流式读取的 API。以 Python 的requests为例,必须加stream=True,然后用iter_content或iter_lines逐块读:
import requests resp = requests.post(url, json=payload, stream=True, timeout=(10, 300)) for chunk in resp.iter_content(chunk_size=None): if chunk: yield chunkchunk_size=None表示有多少读多少,不额外攒。timeout用元组分别设置连接超时和读取超时,读取超时给大一点,因为推理可能慢。
Java 生态里,如果用HttpURLConnection,要确保没有开启缓冲;如果用 OkHttp,用ResponseBody.source()逐行读;如果用 WebClient,用bodyToFlux拿流。不同库的写法差异很大,但核心原则一样:确认数据是边到边处理的。
4.2 flush 没调,等于在应用层又加了一层缓冲
即使你流式读到了数据,如果往客户端写的时候没有及时 flush,数据会停在应用的输出缓冲区里。很多 Web 框架的响应对象默认带缓冲,写进去不等于发出去。
以常见的写法为例,往响应里写数据后要显式调用 flush:
def generate(): for chunk in upstream_stream: yield chunk # 框架层面通常由生成器驱动 flush如果用 Flask 的Response配合生成器,框架会在每次 yield 后尝试发送,但底层 WSGI 服务器可能有自己的缓冲。这时候可以在生成器里主动 flush,或者换用支持流式的 ASGI 框架。用 Node.js 的话,res.write()之后如果发现没及时发出,检查是否被压缩中间件缓冲了。
一个实用的验证技巧:在后端每次写数据后打一条带时间戳的日志,同时在前端 Network 面板看数据到达时间。如果后端日志显示数据早就写出去了,但前端很久才收到,那问题在代理或网络;如果后端日志本身就是攒着一起打的,那问题在读取或 flush。
4.3 超时设置要分层,不能一刀切
应用层的超时至少分三个:连接超时、读取超时、整体超时。连接超时管的是“连上推理服务”这件事,一般给几秒就够。读取超时管的是“两次数据之间最多等多久”,这个要给足,因为模型思考时可能停顿。整体超时管的是“整个请求最多跑多久”,防止无限挂起。
很多人只设了一个总超时,结果模型正常思考的停顿被误判成超时,连接被掐断。正确做法是把读取超时设得比模型最长停顿还长,比如 120 秒,而整体超时根据业务设成 5 到 10 分钟。这样既不会误杀正常请求,也不会让异常请求永远挂着。
| 超时类型 | 作用 | 建议值 | 设错的后果 |
|---|---|---|---|
| 连接超时 | 建立到上游的连接 | 5-10 秒 | 设太大,上游挂了要等很久才报错 |
| 读取超时 | 两次数据间隔上限 | 120 秒以上 | 设太小,模型思考停顿被误杀 |
| 整体超时 | 单请求总时长上限 | 5-10 分钟 | 设太大,异常请求占资源 |
5. 推理服务端:批处理、排队和显存压力对流的影响
再往上游走,就是推理服务本身。这一层的问题往往表现为“首字节慢”或者“输出节奏忽快忽慢”,而且和并发量强相关。
5.1 动态批处理是双刃剑
vLLM 这类推理框架为了提升吞吐,会做连续批处理(continuous batching)。多个请求的 token 生成会被编排在一起,谁的批次轮到谁就吐一点。这在低并发时没问题,但高并发时,你的请求可能被排进一个很大的批次,输出节奏就变得不均匀,甚至出现明显的停顿。
这不是 bug,是设计取舍。要缓解的话,可以调整推理服务的批处理参数,比如限制最大批大小,或者给流式请求更高的优先级。有些框架支持按请求设置优先级,把交互式对话的优先级调高,后台批处理的调低。具体参数因框架而异,但思路是:让交互式流式请求少受批处理排队的影响。
5.2 显存不足时的降级行为
当显存吃紧时,推理服务可能会触发一些降级策略,比如把部分请求排队等待、降低批大小、甚至把某些层换出到内存。这些操作都会让输出变慢甚至短暂停顿。表现就是流式输出突然卡一下,然后又恢复。
排查方法是看推理服务的日志和监控,关注显存占用、队列长度、每秒生成 token 数这些指标。如果发现卡顿和显存峰值时间吻合,那就是资源问题,要么加显存,要么限制并发,要么换更小的模型。
5.3 本地部署时容易忽略的模型加载阻塞
用 Ollama 或者自己部署模型时,如果模型还没加载进显存,第一个请求会触发加载,这个加载过程可能几十秒。这期间请求是挂起的,前端看到的就是首字节超时。解决办法是服务启动后先发一个预热请求,把模型加载好,再对外提供服务。或者在网关层做健康检查时,检查模型是否已就绪,没就绪就不转发流量。
6. 前端:EventSource、fetch 流和渲染节奏的配合
前端是最后一环,也是很多“看起来卡住”问题的背锅侠。有时候后端流得好好的,前端就是显示不出来。
6.1 EventSource 和 fetch 流的选择
如果后端用的是标准 SSE 格式,前端用EventSource是最省事的,它自动处理重连和事件解析。但EventSource有个限制:只能发 GET 请求,不能带自定义请求头,也不能发 POST body。如果你的对话接口需要 POST 传参,就得用fetch配合ReadableStream手动解析。
用 fetch 流的时候,解析逻辑要自己写,这就回到了前面说的 chunk 边界问题。必须维护缓冲区,按\n\n切分事件,不完整的留在缓冲区。下面是一个可用的解析骨架:
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split("\n\n"); buffer = parts.pop(); for (const part of parts) { if (part.startsWith("data: ")) { const data = part.slice(6); if (data === "[DONE]") return; appendToUI(JSON.parse(data)); } } }decoder.decode(value, { stream: true })这个stream: true很关键,它保证多字节字符被正确拼接,不会因为一个中文字被拆在两个 chunk 里而变成乱码。
6.2 渲染节奏:别每个 token 都触发一次重排
前端拿到 token 后如果每个都直接操作 DOM,高频更新会导致页面卡顿,看起来像流卡住了。正确做法是用一个缓冲区攒一小段时间(比如 50 毫秒)再批量更新,或者用虚拟 DOM 框架的响应式更新,让框架去合并渲染。
Vue 里可以把累积的文本放在一个响应式变量里,让模板去渲染,Vue 的更新队列会自动合并同一轮事件循环里的多次修改。React 里可以用状态更新配合批处理。关键是不要在每次收到 token 时做重排代价高的操作。
6.3 超时和中断的用户体验
前端也要设超时,但目的不是掐断正常请求,而是给用户一个反馈。如果超过一定时间没有任何数据到达,可以显示“正在思考”之类的提示,而不是让用户对着空白页面干等。同时要提供手动停止的按钮,用户点了之后要能真正中断请求,这需要用到AbortController。
const controller = new AbortController(); fetch(url, { signal: controller.signal }); // 用户点停止时 controller.abort();中断之后后端也要能感知到,及时释放推理资源,否则用户以为停了,后端还在算,白白占资源。
7. 一套可复用的全链路排查清单
讲了这么多,最后给一份可以直接照着走的排查清单。遇到流式问题时,按这个顺序过一遍,基本能定位到问题所在。
第一步,确认推理服务本身是否正常流式输出。绕过所有中间层,直接用 curl 打推理服务,看是否逐字返回。命令类似:
curl -N -X POST http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"stream": true, "messages": [{"role": "user", "content": "你好"}]}'-N是关闭 curl 自己的缓冲,让你能看到实时输出。如果这一步就不流式,问题在推理服务或调用参数。
第二步,加上应用层,用 curl 打应用接口,同样看是否流式。如果推理服务流式但应用接口不流式,问题在应用层的读取或 flush。
第三步,加上代理层,用 curl 打经过 Nginx 的地址。如果不流式了,问题在 Nginx 配置,重点查proxy_buffering和 gzip。
第四步,前端联调,用浏览器 Network 面板看数据到达时间线。如果后端流式但前端显示不对,问题在解析或渲染。
每一步都用同一个测试输入,保证变量唯一。这样一层层剥,比到处乱改配置高效得多。
| 排查步骤 | 测试对象 | 判断依据 | 问题定位 |
|---|---|---|---|
| 1 | 推理服务直连 | curl -N 是否逐字输出 | 否,则推理服务或参数问题 |
| 2 | 应用接口 | curl 是否逐字输出 | 否,则应用层读取/flush 问题 |
| 3 | 经代理的接口 | curl 是否逐字输出 | 否,则代理缓冲/超时问题 |
| 4 | 浏览器端 | Network 时间线是否平滑 | 否,则前端解析/渲染问题 |
这套方法我在多个项目里用过,最快的一次十分钟就定位到了是 Nginx 的 gzip 在缓冲,最慢的一次花了两天,最后发现是推理框架的批处理参数在高并发下把流式请求排到了队尾。慢的那次教训是:不要假设某一层没问题,每一层都要用直连的方式验证过。
提示:排查时把各层的超时时间、缓冲开关、并发参数都记在一张表里,出问题时对照着看,比凭记忆靠谱得多。尤其是多人协作的项目,配置散落在不同文件里,不整理根本看不清全貌。
流式输出的排查本质上是个“找第一个缓冲点”的过程。缓冲可能来自代理、来自应用、来自客户端库、来自推理框架,甚至来自前端的渲染策略。把这条链路在心里画清楚,遇到问题按层剥,比盲目调超时参数有效得多。我自己的习惯是每接入一个新的推理服务或换一个新的代理配置,都先用 curl 从最内层往外层测一遍,确认每一层都是流式的,再交给前端联调。这个习惯帮我省掉了大量“前端说卡、后端说没问题”的扯皮时间。