流式为什么改变了体验
一句话总结
如果生成完 300 个 token 后一次性发送,用户要面对空白屏幕等待 6 秒。采用流式传输,200ms 后就会开始出现文字。总耗时相同。
为什么需要了解这一点
很少有领域的感知性能与实际性能会有如此大的差距。即使总生成时间同为 6 秒,6 秒后一次性出现与 200ms 后开始连续流出,完全是两种不同的产品。后一种方式让用户能够开始阅读,发现方向不对时也可以中途停止。
因此,对于 LLM 服务,流式传输不是可选项,而是基本配置。
工作原理
传输方式有三种。WebSocket 支持双向通信但较为笨重,长轮询已经过时,SSE(Server-Sent Events)最适合这一用途。它是单向的,运行在 HTTP 之上,并且浏览器原生支持。
SSE 的格式很简单。以 Content-Type: text/event-stream 响应,每个事件用一行 data: <내용> 和一个空行分隔后发送。
data: {"token":"안녕"}
data: {"token":"하세요"}
data: [DONE]
空行是事件分隔符,这一点很重要。遗漏空行后,客户端无法识别事件,只会把内容累积在缓冲区中。
实现时必须注意四件事。
第一,禁用缓冲。如果服务器框架或前置代理缓冲响应,流式传输就失去了意义。放在 NGINX 后方时,需要 X-Accel-Buffering: no 响应头。
第二,结束信号。必须告知流何时结束,通常使用 [DONE] 之类的约定标记。
第三,处理取消。用户关闭浏览器标签页后,服务器应停止生成。否则 GPU 会继续生成无人查看的 token,费用照常产生。
第四,传递错误。流传输途中发生错误后,无法再更改 HTTP 状态码,因为响应头已经以 200 发出。因此,错误也必须作为事件发送。
中间环节会阻塞数据流
即使正确实现了 SSE,浏览器仍常常一次性收到全部内容。原因不在 应用程序,而在于中间的环节。
| 阻塞项 | 症状 | 修复方法 |
|---|---|---|
| nginx 代理缓冲 | 每 4KB 聚成一批到达 | proxy_buffering off 或在响应中添加 X-Accel-Buffering: no |
| 压缩中间件 | 一直没有输出,结束时一次性到达 | 仅从 gzip 中排除该路径 |
| 框架缓冲 | 第一个片段延迟 | 使用框架的流式响应类型 |
| CDN | 为缓存而接收全部内容 | 从缓存对象中排除该路径 |
最常见的是第一项。nginx 默认会缓冲上游响应,因此不做任何配置直接放在前面,
就会让流式传输完全失效。只需一行响应头(X-Accel-Buffering: no)即可仅为
该请求关闭缓冲,所以这种方法较为安全。
必须发现断开的连接,才能节省 GPU
即使用户关闭标签页,服务器也会继续生成 token。也就是说,GPU 会花 6 秒 生成无人查看的答案。在并发请求较多的服务中,这种浪费非常可观。
因此要检测客户端断开并停止生成。在 FastAPI 中,可以对每个 token 检查
await request.is_disconnected();在 vLLM 中,取消请求后会从批处理中移除
对应序列。
async def stream(request, prompt):
async for chunk in llm.generate(prompt):
if await request.is_disconnected():
await llm.abort(chunk.request_id) # GPU 를 놓아준다
return
yield f"data: {json.dumps(chunk.model_dump())}
"
yield "data: [DONE]
"
在流内传递错误
这是流式传输的棘手之处。在已经发送 200 OK 和响应头之后发生失败,就无法更改
状态码。因此,错误也要作为事件发送。
data: {"token":"안녕하"}
event: error
data: {"code":"context_length","message":"입력이 너무 깁니다"}
客户端还必须处理没有收到 [DONE] 流就结束的情况。通常这是网络断开,
此时最好保留已经收到的部分,并显示“响应在中途断开”,而不是完全不显示任何内容。
衡量什么
流式服务有两个指标。
- TTFT(Time To First Token)——用户感受到的响应速度,目标为 200~500ms。
- TPOT(Time Per Output Token)——内容流出的速度。只要快于人的阅读速度(每秒 5~10 个 token)就足够了。
总生成时间只是这两者作用的结果,因此不把它作为目标。增大批量可以提高 整体吞吐量,但会让 TTFT 变差——吞吐量与感知速度之间存在取舍关系, 应由产品决定优先保证哪一方。
实际工作中的表现
启用流式传输后,测量方式也会改变。原本只测量响应完成时间,现在必须拆分为第一个 chunk 到达时间和 chunk 之间的间隔。由于这些值会随批处理状况大幅波动,还必须查看 p99。
遗漏取消处理所造成的事故悄无声息,却代价高昂。用户经常在长响应生成到一半时转去提出另一个问题;如果没有取消机制,这些请求会继续占用批处理槽位。并发处理量莫名下降,只有 GPU 账单不断增加。
下个练习将做什么
构建确定性的 token 生成器,并亲自实现通过 SSE 将其流式传出的服务器。测量 TTFT 和 chunk 间隔,再用计数器证明取消时生成确实停止。