流式输出为何没改善整体延迟
文章区分首字节时间、完整响应时间与吞吐量,指出流式输出主要改善感知延迟并支持提前取消。通过分块时间线探针分析端点表现,避免把首字节耗时全部归因于模型预填充。
AI 深度解读
聊天窗口一开始往外吐字,就容易让人觉得模型变快了。但首字出现、正文持续输出、整段答案完成,是三件不同的事。这篇文章拆解了流式响应的四个常见误区:流式不等于提速,首 token 延迟不全是模型耗时,一个 chunk 也不等于一个 token,开发环境能流式更不代表生产环境能。
作者给出的办法很直接:记录首块时间、块间停顿和总耗时,再沿着真实请求链路定位问题。这个方向适合正在接入 AI 对话的前端和全栈开发者,不过附带的探针没有实测结果,代码本身也有几处需要修正。
字开始动了,为什么总耗时没降?
流式响应改变的是计算结果何时被用户看见。模型仍然要处理输入提示词,也就是预填充(prefill),然后逐个生成输出 token。把结果分批传出来,并不会省掉这些计算。
所以,最终字节到达的时间通常差不多,有时还会因分帧、分块传输等额外开销稍晚一些。作者没有提供流式与非流式的对照数据,这里讲的是工作机制,不能拿来推断某个接口一定慢多少。
流式真正有用的地方有两个:用户可以边生成边阅读;发现答案跑偏,也能在第 20 个 token 时取消,不必等到第 400 个 token。这能改善体验,也给提前终止请求提供机会,但不能直接算成吞吐量提升。
先确定你要缩短的是完整回答时间,还是用户看到第一段有用内容的时间。
二者甚至不能都用「首字时间」代替。接口先发一个角色信息或空 delta,客户端虽然收到了事件,用户却还没有东西可读。
这也是作者第二个提醒的落点:首 token 延迟(TTFT)是一包混合耗时。DNS、TCP 建连、HTTPS 的 TLS 协商、请求体上传、网关排队和鉴权,都可能发生在模型开始输出之前;随后还有预填充、首块发出,以及回程代理或压缩器的缓冲。
如果其中有 300 ms 花在 TLS 握手上,把整段都叫「模型思考时间」就会误导排查。作者建议先预热连接,再比较连接复用与冷连接的差距。这个比较能帮助判断:等待究竟来自模型,还是模型之外的链路。
一个 chunk 不等于一个 token,生产代理还可能把它攒起来
chunk 是传输和接口输出的分块单位,token 是模型处理文本的单位,协议没有保证二者一一对应。
OpenAI-compatible 服务可能合并多个 token,也可能先发只有 role 的 delta,或者在结束时返回带 usage、但没有正文的事件。因此,用 chunk 数除以耗时计算 tokens/s,会得到一个偏差不固定的数字。作者建议把解码后的字符数当作粗略参考,服务提供 usage 时再使用对应数据;缺失的 usage 应标成缺失,不能填成零。
接下来才是前端常遇到的部署问题:本地逐段显示,上线后却整段出现。
可疑位置包括开启响应缓冲的反向代理、等压缩块攒满才刷出的中间件、对 Server-Sent Events(SSE)做 gzip 的应用服务器,以及先读完整个响应体才返回的客户端库。nginx 的 proxy_buffering on 是作者点名的常见原因,对应处理包括响应头 X-Accel-Buffering: no,或在相关 location 设置 proxy_buffering off。
客户端读取方式也会改变结果。原文比较了 requests.get(..., stream=True) 配合 iter_content,以及会缓冲完整内容的 response.text;还提醒 Python 的文本 iter_lines() 可能在载荷中的 \r 处分行,探针因此采用 httpx 的异步行迭代器。
对接 AI 接口时,浏览器、Node 服务、网关和上游接口要作为一条链路检查。只证明上游会分块返回,还不能证明用户端会持续收到正文。
探针该记录什么,代码又漏了什么?
作者提出每次至少观察三项:首块到达时间、块与块之间的间隔,以及总耗时。探针要求 Python 3.10+ 和 httpx,通过 BASE_URL、API_KEY、MODEL 指向 OpenAI-compatible 的 /chat/completions,设置 "stream": True,默认运行 5 次,客户端超时为 60.0 秒。
下面是原探针里负责读取 SSE 和计时的关键部分:
async for line in r.aiter_lines():
if not line.startswith("data:"):
continue
payload = line[5:].strip()
if payload == "[DONE]":
break
now = time.perf_counter()
if first is None:
first = now - t0
gaps.append(now - last)
last = now
delta = json.loads(payload)["choices"][0].get("delta", {})
if delta.get("content"):
content_deltas += 1
要看清 first = now - t0:它记录的是首个非 [DONE] 的 data: 事件,事件里未必有正文。因此,结果字段虽然叫 ttft_s,更准确的理解仍是首事件延迟。
另一处问题在 gaps.append(now - last)。原脚本把 last 初始化为请求开始时间 t0,所以第一项间隔包含首块等待时间。最终计算出来的 max_gap_ms,并不是纯粹的块间最大停顿。后面若拿它判断「首块慢但后续稳定」,就可能被这项初始等待干扰。
还有一个兼容性缺口:["choices"][0] 假设 choices 至少有一个元素,遇到空列表会报错。作者前面提醒过末尾事件可能没有正文,这段解析却没有完整防住这种情况。
原文另给了一段 curl 检查,适合先查看连接耗时、首字节耗时和响应头:
curl -sS -N -D /tmp/headers.txt -o /tmp/stream.txt \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"Count to 40\"}]}" \
"$BASE_URL/chat/completions" \
-w 'connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n'
grep -i -E 'content-encoding|x-accel-buffering|transfer-encoding' /tmp/headers.txt
这里要注意 -N,它关闭的是 curl 自身的缓冲,不能替你关闭服务器或代理缓冲。
作者把时间形状分成三类:首块慢而后续间隔小,先查冷连接、长提示词或排队;首块快但中途长停顿,查缓冲、超时和上游重试;首块快、分块多、总时间又接近首块时间,可能只是生成内容很短。
这些都是排查线索。原文把 first_byte 接近 total 直接判成「没有流式」,说得过满,因为它自己也列出了短回答的情况,不能只凭两个数接近就定性。
我的判断
我赞同把模型性能与链路体验分开测量。对于聊天界面,首段正文出现得早不早、后面会不会长时间停住,比单报一个平均总耗时更有解释力。
但这篇提供的是诊断框架和未经执行的脚本,不是性能报告。作者明确说没有对读者的端点运行探针,也没给出证明 p95 改善的对照数据。默认跑 5 次适合先看现象,不能据此得出稳定的尾延迟结论。
相比直接换模型或升级实例,先检查连接复用和代理缓冲是合理的排查顺序;若证据最终指向模型解码速度,再讨论模型选择。批处理任务则应优先看总耗时和吞吐量,没必要照搬聊天界面的指标。
文章还披露它属于 MonkeyCode 的产品推广。免费模型访问和免费服务器都是运营方提供的可用性说法,不是基准测试。固定探针所在区域有助于比较,但这一方法并不依赖某个免费服务。
能拿来做什么
- 给 AI 聊天界面分别记录首事件、首段非空正文和完整结束时间,让网络可达性与用户实际等待各有对应指标。
- 在 Node 转发层或代理配置变更前后,用相同提示词和固定区域复测,并保留请求 ID,方便把一次停顿对应到日志。
- 改造探针时先排除首次等待对
max_gap_ms的影响,再处理空 choices、空 delta 和缺失 usage,避免测量代码制造错误结论。 - 把连接复用测试与冷连接测试分开执行;先跑 5 次观察形状,再根据真实流量补充样本,评估 p95。