TT Lab
开始
学习 学习路径 课程

LLM 服务

流式为什么改变了体验

在 TT Lab 中继续学习

一句话总结

如果生成完 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 变差——吞吐量与感知速度之间存在取舍关系, 应由产品决定优先保证哪一方。

实际工作中的表现

启用流式传输后,测量方式也会改变。原本只测量响应完成时间,现在必须拆分为第一个 chunk 到达时间和 chunk 之间的间隔。由于这些值会随批处理状况大幅波动,还必须查看 p99。

遗漏取消处理所造成的事故悄无声息,却代价高昂。用户经常在长响应生成到一半时转去提出另一个问题;如果没有取消机制,这些请求会继续占用批处理槽位。并发处理量莫名下降,只有 GPU 账单不断增加。

下个练习将做什么

构建确定性的 token 生成器,并亲自实现通过 SSE 将其流式传出的服务器。测量 TTFT 和 chunk 间隔,再用计数器证明取消时生成确实停止。