把“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_namelookuptime_connecttime_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 接收窗口会收缩,服务端写入也会变慢。表现出来的症状同样是“块间隔变大”。排查办法是在接收事件的回调里先只做最小操作,例如把原文追加到变量或缓冲区,渲染和格式化放到另一个异步任务或队列里,避免阻塞网络读取。

中间层是否在“攒批”

流式响应经过代理或网关时,中间层可能为了减少转发次数而缓存响应。常见场景有两类:

  1. 代理开启了对响应体的缓冲,要攒够一定字节或等上游结束后才转发给客户端。
  2. 对 SSE 响应启用了 gzipbr 压缩。压缩层为了获得更高压缩率,可能把多个事件攒成一个块再发送,实时性被削弱。

排查方法很直接:记录请求实际经过哪些转发层,把其中一层旁路掉,再做同一条 prompt 的对照测试。如果直连源站时块间隔明显下降,说明瓶颈在中间层,而不是 API 本身。观察响应头里的 Content-Encoding 也有帮助;如果流式路径存在压缩,可以临时关闭再比较。

超时时间应基于“块间隔”,而不是“总时长”

对流式请求设置总时长超时,逻辑上经常自相矛盾。只要输出内容足够长,网络再快也可能超过总时长。连接被强行掐断后,上层如果自动重试,等于同一个问题同时启动两个流,服务端负载上升,延迟进一步恶化。

合理的做法是:

  • 连接超时设置得较短,快速暴露网络不可达问题;
  • 读超时设置成“允许连续多久收不到任何字节”,而不是“整个响应必须在多久内结束”;
  • 先让探针脚本跑一段时间,记录正常块间隔的 p95 或 p99,再把读空闲超时设置为该值的数倍。

另外要确认业务代码能区分“流正常结束”和“异常中断”。很多流式协议会在最后返回一个明确的结束标记。如果代码只看到连接 EOF 就视为成功,或看到任何 EOF 就重试,部分场景下会丢掉尾部内容,另一部分场景会重复请求。

并发和连接池才是隐蔽延迟源

流式请求另一个特点是一个请求会占用一条连接较长时间。并发上来后,真正拖慢首字延迟的可能不是 API 服务端,而是客户端连接池被占满,后续请求在本地排队。

需要观察两类现象:

  • 发起请求的线程数越多,首字延迟越高,但块间隔变化不大。这通常是并发请求在客户端侧排队,或服务端排队。
  • 每次请求都新建一个 HTTP Client/Session,导致 TCP 和 TLS 握手反复发生。此时 time_connecttime_appconnect 会占掉首字延迟中的很大比例,而且每次请求都重复付出。

实践中应该复用同一个连接池,并把连接池大小设置成与真实并发匹配。不要无上限地创建线程或协程去请求流式接口;更稳重的做法是用信号量限制同时进行的流数量,超出的请求进入队列,而不是全部涌到上游。

从工程角度看,流式长连接场景比普通短请求更强调“并发上限意识”。一个用户界面同时开启多条流式请求时,要为每条流预留连接名额;否则总连接数达到池上限后,新增请求会表现为莫名其妙的等待。

请求输入和上游排队的判别

在生成式大模型服务里,首字延迟通常包含对输入内容的处理时间。处理较长的历史消息、系统提示词或检索片段都要消耗时间。如果业务代码把大量历史消息完整放入每次请求,首字延迟会随消息长度上升。

如果探针数据表现为“长 prompt 首字慢,短 prompt 首字快”,应该从前置链路下手:压缩历史消息、总结旧对话、减少重复注入的固定上下文,而不是继续调客户端。

如果表现为“并发一旦超过某个值,首字延迟整体上升”,更接近上游排队或容量问题。这类问题不能靠客户端超时解决,反而要控制业务侧并发、错峰调用,或者和上游确认限流与排队行为。

拿到数据后按现象分类处理

现象 优先排查方向 下一步动作
DNS/TCP/TLS 耗时高 网络链路、代理、出口 换网络或旁路代理做对照
响应头快,但首个 data 事件慢 服务端输入处理或排队 缩短 prompt、降低并发、再测
首字正常,块间隔整体均匀偏大 生成长度或链路往返 对比不同输出长度的耗时分布
块间隔整体短,但周期性卡顿 中间层缓冲、压缩、消费端阻塞 逐层旁路、临时关闭压缩
服务端事件已到,界面仍卡 本地解析与渲染 接收回调只追加原文,渲染异步化

这套排查顺序不需要预先知道服务端全部内部参数。先拿到首字延迟和块间隔,再判断瓶颈在哪一段链路,最后针对那一段做改动,才不至于把网络抖动误判成服务端变慢,也不会把连接池排队误算成上游超时。