把“DeepSeek API 流式输出慢”当成一个单一问题去解决,很容易越调越糊涂。“慢”至少应该拆成两个不同指标:
首字延迟:请求发出后,到第一段内容真正到达的耗时。
块间隔:第一段之后,相邻两个内容块到达的时间差。
两者都表现为“结果出得慢”,但成因和手段差别很大。首字延迟主要来自网络链路、服务端排队和输入处理;块间隔则涉及生成速度、链路缓冲以及客户端消费速度。在拿到这两个数字之前,不要急着调 timeout,也不要改重试策略。没有这两项数据,改哪个参数都只是在猜。
先跑一段可复现的探针脚本
下面的脚本只做一件事:记录首事件耗时和所有相邻事件间隔。把 、、`` 替换成实际值,放到测试环境运行:
import time
import requests
url = ""
headers = {
"Authorization": "Bearer ",
"Accept": "text/event-stream",
}
payload = {
"model": "",
"messages": [{"role": "user", "content": "用三句话介绍什么是流式响应"}],
"stream": True,
}
with requests.post(
url,
headers=headers,
json=payload,
stream=True,
timeout=(10, 300),
) as resp:
start = time.time()
first_event_at = None
last = start
for line in resp.iter_lines():
if not line:
continue
now = time.time()
if first_event_at is None:
first_event_at = now
print(f"[first_event] {now - start:.2f}s")
print(f"[gap] {now - last:.3f}s")
last = now
不要用“整个流从开始到结束花了多少秒”作为判断依据。长输出场景下,总耗时天然会被内容长度放大。如果首字延迟和块间隔都正常,只是总耗时长,那问题可能出在生成内容太长,而不是“流变慢了”。
用 curl 拆分网络链路各阶段耗时
如果首事件延迟高,下一步要知道延迟发生在“到服务器的路上”还是“服务器准备回答的过程中”。curl 的 -w 可以一次打出各阶段耗时:
curl -sS -N -o /tmp/sse.out \
-w "dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} header=%{time_starttransfer}\n" \
"" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer " \
-d '{"model":"","stream":true,"messages":[{"role":"user","content":"hi"}]}'
解读时分清两个层级:
time_namelookup、time_connect、time_appconnect差别大,优先怀疑网络出口、强制代理、TLS 链路;从业务服务器所在网络再跑一次,能快速区分是用户端网络还是服务端问题。time_starttransfer只代表响应头到达时间,不代表第一条流式内容已经到达。很多流式接口的响应头很快就到,但第一个data事件要更晚。真正的首事件耗时还是要看探针脚本输出。
如果响应头到达很快、第一个 data 事件却明显偏晚,问题大概率不在公网链路上,而在服务端准备首段内容的阶段。可以进一步做两个对照实验:把输入 prompt 缩短后再测;把并发请求数降到 1 后再测。前者变化明显,说明首字延迟和输入长度相关;后者变化明显,说明服务端排队参与其中。
检查客户端是否真的在“消费流”
一个容易被忽略的瓶颈是客户端把流式响应变成了全量响应。
在 Python requests 里,如果请求没有设置 stream=True,或者代码最终调用的是 resp.json()、resp.text,客户端会等服务端把完整响应体传完才返回。对用户来说,体验就是“一直不出字,最后一次性出现”。
Node 环境下同理:await response.text() 或 await response.json() 都会等待完整 body。正确做法是从流式 body 上迭代读取,或使用专门解析 SSE 的库。
即使网络读取已经正确,还要注意消费速度本身会不会拖慢上游。HTTP 是基于 TCP 的,如果应用的接收循环被渲染、日志、告警等同步逻辑卡住,TCP 接收窗口会收缩,服务端写入也会变慢。表现出来的症状同样是“块间隔变大”。排查办法是在接收事件的回调里先只做最小操作,例如把原文追加到变量或缓冲区,渲染和格式化放到另一个异步任务或队列里,避免阻塞网络读取。
中间层是否在“攒批”
流式响应经过代理或网关时,中间层可能为了减少转发次数而缓存响应。常见场景有两类:
- 代理开启了对响应体的缓冲,要攒够一定字节或等上游结束后才转发给客户端。
- 对 SSE 响应启用了
gzip或br压缩。压缩层为了获得更高压缩率,可能把多个事件攒成一个块再发送,实时性被削弱。
排查方法很直接:记录请求实际经过哪些转发层,把其中一层旁路掉,再做同一条 prompt 的对照测试。如果直连源站时块间隔明显下降,说明瓶颈在中间层,而不是 API 本身。观察响应头里的 Content-Encoding 也有帮助;如果流式路径存在压缩,可以临时关闭再比较。
超时时间应基于“块间隔”,而不是“总时长”
对流式请求设置总时长超时,逻辑上经常自相矛盾。只要输出内容足够长,网络再快也可能超过总时长。连接被强行掐断后,上层如果自动重试,等于同一个问题同时启动两个流,服务端负载上升,延迟进一步恶化。
合理的做法是:
- 连接超时设置得较短,快速暴露网络不可达问题;
- 读超时设置成“允许连续多久收不到任何字节”,而不是“整个响应必须在多久内结束”;
- 先让探针脚本跑一段时间,记录正常块间隔的 p95 或 p99,再把读空闲超时设置为该值的数倍。
另外要确认业务代码能区分“流正常结束”和“异常中断”。很多流式协议会在最后返回一个明确的结束标记。如果代码只看到连接 EOF 就视为成功,或看到任何 EOF 就重试,部分场景下会丢掉尾部内容,另一部分场景会重复请求。
并发和连接池才是隐蔽延迟源
流式请求另一个特点是一个请求会占用一条连接较长时间。并发上来后,真正拖慢首字延迟的可能不是 API 服务端,而是客户端连接池被占满,后续请求在本地排队。
需要观察两类现象:
- 发起请求的线程数越多,首字延迟越高,但块间隔变化不大。这通常是并发请求在客户端侧排队,或服务端排队。
- 每次请求都新建一个 HTTP Client/Session,导致 TCP 和 TLS 握手反复发生。此时
time_connect和time_appconnect会占掉首字延迟中的很大比例,而且每次请求都重复付出。
实践中应该复用同一个连接池,并把连接池大小设置成与真实并发匹配。不要无上限地创建线程或协程去请求流式接口;更稳重的做法是用信号量限制同时进行的流数量,超出的请求进入队列,而不是全部涌到上游。
从工程角度看,流式长连接场景比普通短请求更强调“并发上限意识”。一个用户界面同时开启多条流式请求时,要为每条流预留连接名额;否则总连接数达到池上限后,新增请求会表现为莫名其妙的等待。
请求输入和上游排队的判别
在生成式大模型服务里,首字延迟通常包含对输入内容的处理时间。处理较长的历史消息、系统提示词或检索片段都要消耗时间。如果业务代码把大量历史消息完整放入每次请求,首字延迟会随消息长度上升。
如果探针数据表现为“长 prompt 首字慢,短 prompt 首字快”,应该从前置链路下手:压缩历史消息、总结旧对话、减少重复注入的固定上下文,而不是继续调客户端。
如果表现为“并发一旦超过某个值,首字延迟整体上升”,更接近上游排队或容量问题。这类问题不能靠客户端超时解决,反而要控制业务侧并发、错峰调用,或者和上游确认限流与排队行为。
拿到数据后按现象分类处理
| 现象 | 优先排查方向 | 下一步动作 |
|---|---|---|
| DNS/TCP/TLS 耗时高 | 网络链路、代理、出口 | 换网络或旁路代理做对照 |
| 响应头快,但首个 data 事件慢 | 服务端输入处理或排队 | 缩短 prompt、降低并发、再测 |
| 首字正常,块间隔整体均匀偏大 | 生成长度或链路往返 | 对比不同输出长度的耗时分布 |
| 块间隔整体短,但周期性卡顿 | 中间层缓冲、压缩、消费端阻塞 | 逐层旁路、临时关闭压缩 |
| 服务端事件已到,界面仍卡 | 本地解析与渲染 | 接收回调只追加原文,渲染异步化 |
这套排查顺序不需要预先知道服务端全部内部参数。先拿到首字延迟和块间隔,再判断瓶颈在哪一段链路,最后针对那一段做改动,才不至于把网络抖动误判成服务端变慢,也不会把连接池排队误算成上游超时。