Skip to content

第 1 章 流式代理和取消 ​

草稿:实验、动画和检查已经可以用;真卡对照还在做。

这一章写一个只转发给一个上游的流式代理。它看起来只是「把请求转过去,把响应转回来」,但有两件事做错了,用户和 GPU 都会吃亏:

  • 流式:引擎每生成一个 token,用户就该马上看到。做错了,用户要等整段生成完才看到第一个字。
  • 取消:用户关掉页面,引擎就该停下。做错了,GPU 会继续为一个已经走掉的人生成。

先预测 ​

动手之前,先写下你对这三个问题的答案:

  1. 代理把上游的响应全部读完,再一次性发给用户。引擎 0.22 秒就生成了第一个 token,用户什么时候看到第一个字?
  2. 代理每读到一块就写给用户,但从不 flush。用户看到的字是什么节奏?
  3. 用户读到一半关掉页面,引擎还会继续生成吗?如果会,要生成到什么时候?

实验怎么做 ​

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

正在加载回放数据…

引擎已生成 0 个 token用户已收到 0 个 token
用户收到的 token等首字×用户离开用户已走,路由器还在读上游引擎在生成的请求数
  • 参考实现:每一行一开始就是密密的竖线,首字时间和引擎的 220 ms 几乎一样。
  • 整段缓冲:每一行前面都是长长的虚线,最后一下子全到。用户看到第一个字,已经是 3 秒多以后了。
  • 不 flush:字是一簇一簇到的,簇和簇之间空着 0.2 秒左右,首字也晚了一截。

现象二:用户走了以后 ​

还是 6 个用户,但用户 2、4、6 在发出请求 1 秒后关掉了页面(红色 ×)。注意最下面「引擎」那一行:

▶ 这里是回放动画,请到网站上看:tinyinferencecluster.com/ch01

正在加载回放数据…

引擎已生成 0 个 token用户已收到 0 个 token
用户收到的 token等首字×用户离开用户已走,路由器还在读上游引擎在生成的请求数
  • 参考实现:每走一个用户,引擎在生成的请求就少一个。
  • 忽略取消:用户走了,橙色的条还在变长:路由器还在读上游,引擎还在为他生成。三个用户剩下的几百个 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

正在加载回放数据…

引擎已生成 0 个 token用户已收到 0 个 token
用户收到的 token等首字×用户离开用户已走,路由器还在读上游引擎在生成的请求数

参考实现在这两种情况下都马上放手了:路由器记录里,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 ms222.0 · 223.4 · 221.1 · 222.0 · 222.0 ms✅ 成立
参考实现:用户一走,路由器 50 ms 内放手0.0 · 0.0 · 0.0 · 0.0 · 0.0 ms✅ 成立
参考实现:用户走后,引擎为每个走掉的用户最多再生成 10 个 token9 · 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。

  1. 如果取消没有传到引擎,每天白白生成多少 token?折合多少卡时?
  2. 网关、路由器、引擎三层,你分别怎么保证取消生效?上线以后,用什么指标证明它确实生效了?
参考答案
  1. 100 万 × 20% × 500 = 1 亿个 token,按每秒 2000 个算,是 5 万秒,约 14 卡时,每天都在白白浪费。

  2. 分层来看:

    • 网关:确认客户端断开时会关掉到上游的连接,比如 Nginx 的 proxy_ignore_client_abort off。
    • 路由器:上游请求用客户端的 context,写失败立刻返回。
    • 引擎:确认它在断开时会中止请求,并且覆盖首 token 之前和非流式这两种情况,本章的模拟器就做不到。
    • 兜底:max_tokens 上限和请求总时长上限。

    指标方面,看路由器记录里 end_ms - client_gone_ms 的分布,应该接近 0;再对比引擎的 num_requests_running 和路由器的在途请求数,两者应该一致。

代码 Apache-2.0 · 正文 CC BY-SA 4.0