第 1 章 流式代理和取消
草稿:实验、动画和检查已经可以用;真卡对照还在做。
这一章写一个只转发给一个上游的流式代理。它看起来只是「把请求转过去,把响应转回来」,但有两件事做错了,用户和 GPU 都会吃亏:
- 流式:引擎每生成一个 token,用户就该马上看到。做错了,用户要等整段生成完才看到第一个字。
- 取消:用户关掉页面,引擎就该停下。做错了,GPU 会继续为一个已经走掉的人生成。
先预测
动手之前,先写下你对这三个问题的答案:
- 代理把上游的响应全部读完,再一次性发给用户。引擎 0.22 秒就生成了第一个 token,用户什么时候看到第一个字?
- 代理每读到一块就写给用户,但从不 flush。用户看到的字是什么节奏?
- 用户读到一半关掉页面,引擎还会继续生成吗?如果会,要生成到什么时候?
实验怎么做
go run ./cmd/lab -chapter 1 在同一个进程里启动三样东西:
- 引擎:llm-d-inference-sim 模拟的 vLLM。提示词 200 个 token,固定开销 20 ms 加每个提示 token 1 ms,所以 220 ms 生成第一个 token;之后每 10 ms 一个,300 个 token 一共 3.21 秒。
- 路由器:要测的代理,外面套着
record.Observe,每个请求写一行记录(router.jsonl)。 - 用户:按时间表发请求,记下每个 token 到达的时间(
client.jsonl)。同时每 25 ms 读一次引擎的指标,记下它正在为几个请求生成、一共生成了多少 token(engine.jsonl)。
三份记录用同一个时钟。下面的动画就是直接回放这些记录,没有一帧是画出来的示意。
现象一:字什么时候到
6 个用户,每隔 200 ms 来一个,都读到最后。切换不同的写法看看:
▶ 这里是回放动画,请到网站上看:tinyinferencecluster.com/ch01
正在加载回放数据…
- 参考实现:每一行一开始就是密密的竖线,首字时间和引擎的 220 ms 几乎一样。
- 整段缓冲:每一行前面都是长长的虚线,最后一下子全到。用户看到第一个字,已经是 3 秒多以后了。
- 不 flush:字是一簇一簇到的,簇和簇之间空着 0.2 秒左右,首字也晚了一截。
现象二:用户走了以后
还是 6 个用户,但用户 2、4、6 在发出请求 1 秒后关掉了页面(红色 ×)。注意最下面「引擎」那一行:
▶ 这里是回放动画,请到网站上看:tinyinferencecluster.com/ch01
正在加载回放数据…
- 参考实现:每走一个用户,引擎在生成的请求就少一个。
- 忽略取消:用户走了,橙色的条还在变长:路由器还在读上游,引擎还在为他生成。三个用户剩下的几百个 token,引擎一个不落地生成完,没有任何人收到。
看记录找原因
动画只是现象。排查线上问题时,你手里只有记录。上面每种错误,在记录里都有固定的样子:
| 现象 | 记录里看什么 | 原因 |
|---|---|---|
| 首字很晚,之后一下子全到 | 路由器记录的 first_delta_ms 接近 end_ms | 代理先读完整个上游响应才开始写 |
| 字一簇一簇地到 | 路由器记录的 first_delta_ms 正常,用户收到 token 的时间却是成批的 | 写了没有 flush,数据卡在 net/http 的缓冲里 |
| 用户走了,引擎还在忙 | 路由器记录的 end_ms 比 client_gone_ms 晚好几秒;引擎的 num_requests_running 没有降 | 上游请求没用客户端的 context,或者往客户端写失败后没有返回 |
第二行要多说一句:路由器的记录只能看到代理什么时候写,看不到数据什么时候真正发出去。net/http 给每个响应准备了 2 KB 的缓冲(bufferBeforeChunkingSize),连接上还有一层 4 KB。一个 SSE 事件一两百字节,不 flush 的话,攒满二十来个才发一次。所以只看路由器的记录会觉得一切正常,要和用户那一侧的时间对着看。
你要写什么
chapters/ch01/proxy.go 里的 New(upstream):把请求转给上游,把响应一块一块地流回客户端;客户端一走,就让上游马上停下。
make test-ch01 会用一个可控的假后端跑下面 5 项检查:
| 检查 | 要求 |
|---|---|
| streams-before-upstream-ends | 上游每发一块,客户端在下一块发出前就要收到 |
| propagates-client-cancel | 客户端离开后,1 秒内上游请求要被取消 |
| passes-errors-through | 上游的错误状态码和响应体原样返回 |
| non-stream-passthrough | 非流式请求完整返回,带上上游的 Content-Type |
| unreachable-upstream-is-502 | 连不上上游时返回 502 |
参考实现在 ref/ch01/proxy.go。同一目录下的 broken.go 是上面动画里那三种错误写法:整段缓冲、不 flush、忽略取消。检查必须把它们全部抓出来,make check 会确认这一点。
提示:
- 每写一块就 flush:
http.NewResponseController(w).Flush()。 - 上游请求用客户端的 context:
http.NewRequestWithContext(r.Context(), ...)。 - 往客户端写失败,就立刻返回。
换一份负载:代理放手了,引擎停了吗?
前面两个场景里,用户都是在流式输出的中途离开的。现在换两种情况:
- 用户 1、4 的提示词有 2000 个 token,引擎要 2.02 秒才生成第一个 token,而他们 0.5 秒就走了。
- 用户 2、5 发的是非流式请求,1 秒后走了。
▶ 这里是回放动画,请到网站上看:tinyinferencecluster.com/ch01
正在加载回放数据…
参考实现在这两种情况下都马上放手了:路由器记录里,end_ms 和 client_gone_ms 几乎相同。但引擎那一行没有马上降下来:
- 首 token 之前离开的两个请求,引擎照样做完了预填充,到首 token 的时间才停。
- 两个非流式请求,引擎一直生成到了最后。
原因在引擎这一侧:这个模拟器只在往连接上写数据的时候,才发现对端已经断开。首 token 之前、非流式请求生成完之前,它都不写,所以发现不了。
所以「代理把取消传下去了」和「引擎真的停了」是两回事,要分开验收:前者看路由器的记录,后者看引擎的指标。真实的 vLLM 在这两种情况下会不会停,要在真卡上测,结果会补在这里。
实测结果
下面每条结论都由程序检查:每个场景、每种写法各跑 5 轮,5 轮都成立才算数。CI 每次都会用提交进仓库的原始记录重新算一遍,这张表也是程序生成的。
结果表见 RESULTS.md。
实验 20261104-094205:每个场景、每种写法各跑 5 轮,每条结论要求 5 轮全部成立。环境:llm-d-inference-sim v1.1.0,AMD EPYC 9K65 192-Core Processor(16 核),Go 1.27.3,代码版本 eb8d4e4(有未提交的改动)。原始记录在 results/ch01/20261104-094205。
| 结论 | 每轮实测 | 结果 |
|---|---|---|
| 参考实现:用户看到首 token 的时间,和引擎生成它相差不超过 30 ms(引擎是 220 ms) | 221.4 · 221.1 · 221.0 · 221.4 · 220.9 ms | ✅ 成立 |
| 参考实现:token 一个一个到,最长间隔不超过 50 ms(引擎每 10 ms 一个) | 10.9 · 11.0 · 10.9 · 10.9 · 10.9 ms | ✅ 成立 |
| 整段缓冲:用户要等整段生成完才看到首 token(至少 3.05 s) | 3368.0 · 3367.3 · 3384.4 · 3381.2 · 3354.8 ms | ✅ 成立 |
| 不 flush:token 一批一批到,批与批之间至少隔 100 ms | 222.0 · 223.4 · 221.1 · 222.0 · 222.0 ms | ✅ 成立 |
| 参考实现:用户一走,路由器 50 ms 内放手 | 0.0 · 0.0 · 0.0 · 0.0 · 0.0 ms | ✅ 成立 |
| 参考实现:用户走后,引擎为每个走掉的用户最多再生成 10 个 token | 9 · 9 · 9 · 9 · 10 token | ✅ 成立 |
| 忽略取消:用户走后,路由器还要读上游好几秒(至少 1.77 s) | 2344.2 · 2344.8 · 2356.6 · 2358.3 · 2371.9 ms | ✅ 成立 |
| 忽略取消:走掉的用户剩下的 token,引擎照样生成(至少 90%) | 675 · 675 · 677 · 677 · 677 token | ✅ 成立 |
| 首 token 前离开、非流式请求中途离开:参考实现也 50 ms 内放手 | 0.0 · 0.0 · 0.0 · 0.0 · 0.0 ms | ✅ 成立 |
| 但这个模拟器要到首 token 之后才发现用户走了:6 个请求全部做完预填充 | 6 · 6 · 6 · 6 · 6 个 | ✅ 成立 |
| 这个模拟器也会把用户已经走掉的非流式请求生成完:只有首 token 前离开的 2 个被截断,引擎完成 4 个 | 4 · 4 · 4 · 4 · 4 个 | ✅ 成立 |
| 每轮实验都完整、自洽:每个请求各有一条客户端和路由器记录,没走的用户收到全部 token,引擎最后空闲,生成的 token 不少于用户收到的 | 60 次实验 | ✅ 成立 |
这套检查也抓到过我们自己的 bug。用户离开时,记录器里有两件事同时发生:一个回调要记下离开的时间,handler 也因为取消而返回。如果 handler 先返回,离开的时间就漏记了,大约每 300 次漏 2 次。修复在 internal/record/observe.go,能复现它的测试是 TestRecordsALeaveEvenIfTheHandlerReturnsFirst。
生产里怎么做
- Go 标准库的
httputil.ReverseProxy遇到text/event-stream响应,每读到一块就立刻 flush(Go 1.25 源码)。 - 在 llm-d 里,转发流的不是 Go 代码,而是 Envoy 网关;llm-d 的 EPP 通过 ext_proc 帮 Envoy 挑选副本。第 5 章会讲到。
- Nginx 默认会缓冲上游的响应(
proxy_buffering on)。转发 SSE 时要关掉它,或者让上游返回X-Accel-Buffering: no。
面试追问
追问
1. 为什么 SSE 代理要逐块 flush?Go 的 net/http 在哪里缓冲,缓冲多大?
参考答案
handler 写进 http.ResponseWriter 的数据,先进 net/http 的两层缓冲:每个响应 2 KB,连接上 4 KB。攒满了,或者 handler 返回了,才真正发出去。SSE 一个事件只有一两百字节,不 flush 就要攒二十来个才发一次。每写一块,调用一次 http.NewResponseController(w).Flush()。
2. 客户端断开以后,你的 handler 怎么知道?上游请求又是怎么被取消的?
参考答案
net/http 服务端读完请求体以后,会在后台继续读这条连接;读到 EOF 或者出错,就取消 r.Context()。handler 用这个 context 发上游请求,context 一取消,http.Transport 就关掉到上游的连接。另外,往已经断开的客户端写数据会返回错误,这时要立刻返回,不要再读上游。
3. 取消一个上游请求,在 HTTP/1.1 和 HTTP/2 下分别是网络上的什么动作?
参考答案
HTTP/1.1 没有「取消某个请求」的消息,只能关掉整条 TCP 连接。HTTP/2 发一个 RST_STREAM 帧,只取消这一个流,连接还能继续给别的请求用。所以在 HTTP/2 下,引擎要看的是流被重置,而不是连接断开。
4. 代理已经取消了上游请求,引擎就一定停了吗?怎么验证?
参考答案
不一定。代理取消,只说明它关掉了连接;引擎要自己发现断开才会停。本章的模拟器只在写数据时才发现,所以首 token 前离开的请求和非流式请求都停不下来。验证要看引擎这一侧:正在运行的请求数(vllm:num_requests_running)有没有降,生成的 token 数还在不在涨。
5. 代理前面还有一层 Nginx,用户看到的字变成一卡一卡的。你会查哪些配置?
参考答案
proxy_buffering:默认开,会把上游的响应缓冲起来。SSE 要关掉,或者让上游返回X-Accel-Buffering: no。gzip:压缩要攒数据,对text/event-stream应该关掉。proxy_read_timeout:默认 60 秒,两个事件之间隔得太久,连接会被断开。
顺带确认 proxy_ignore_client_abort 是默认的 off:客户端断开时,Nginx 会关掉到上游的连接,取消才能传下去。
排障题
线上反馈:「字是一顿一顿出来的,每顿大概 0.2 秒。」你查了路由器的记录,首字时间、结束时间都正常,没有报错。问题可能出在哪?为什么路由器的记录看不出来?你还需要哪一侧的数据来确认?
参考答案
最可能是代理写了数据但没有 flush(本章的「不 flush」写法就是这样),数据在 net/http 的缓冲里攒到 4 KB 左右才发出去。路由器的记录是在代理调用 Write 时打的点,那时数据还在缓冲里,所以看起来一切正常。要用客户端一侧的到达时间来确认:如果 token 成批到达、批与批之间的间隔差不多,就是缓冲。也要排除中间还有一层代理(比如 Nginx)在缓冲。
设计题
一个 agent 平台每天 100 万次请求,平均每个回答 1000 个 token,20% 的用户在回答生成到一半时离开。假设一张卡每秒能 decode 2000 个 token。
- 如果取消没有传到引擎,每天白白生成多少 token?折合多少卡时?
- 网关、路由器、引擎三层,你分别怎么保证取消生效?上线以后,用什么指标证明它确实生效了?
参考答案
100 万 × 20% × 500 = 1 亿个 token,按每秒 2000 个算,是 5 万秒,约 14 卡时,每天都在白白浪费。
分层来看:
- 网关:确认客户端断开时会关掉到上游的连接,比如 Nginx 的
proxy_ignore_client_abort off。 - 路由器:上游请求用客户端的 context,写失败立刻返回。
- 引擎:确认它在断开时会中止请求,并且覆盖首 token 之前和非流式这两种情况,本章的模拟器就做不到。
- 兜底:
max_tokens上限和请求总时长上限。
指标方面,看路由器记录里
end_ms - client_gone_ms的分布,应该接近 0;再对比引擎的num_requests_running和路由器的在途请求数,两者应该一致。- 网关:确认客户端断开时会关掉到上游的连接,比如 Nginx 的