Skip to content

一次 SSE 推理:连接复用、取消、超时和重试边界 ​

Go HTTP/1 服务一般每连接一条服务 goroutine;HTTP/2 多路复用改变了连接与并发请求的关系。复用 Transport 才能复用 TCP 连接,必须及时关闭 response body;只调 MaxIdleConns 不限制在途连接。SSE 输出需要及时 Flush,同时为慢消费者限制写时间;取消 context 要沿网关传到后端。收到 SIGTERM 应先停止接新流量,再让活跃连接在截止时间前排空。仅调用 Shutdown 后立即退出 main 仍可能截断请求,因此需要等待排空完成。

工程接口应该定义在使用方:controller 依赖 client.Client,可以换真实 API Server 或测试客户端;接口值持有动态类型与值,带类型的 nil 指针放进接口不等于 nil。wrap 错误时用 %w 保留 cause,再用 errors.Is 决定重试或结束。泛型适合对不同类型共用同一算法,不替代只需要少数行为的方法接口。独立 Go module 将较重的 K8s 依赖留在 Operator,避免给单机演示增加编译成本。

排障顺序:先看请求率、错误率、排队和超时;CPU 忙时用 CPU profile;CPU 不忙但请求慢时查 block/mutex profile、trace、连接等待;RSS 上升时对比 heap 与系统内存。PGO 以代表性负载 CPU profile 指导内联等优化;用模拟帧的 profile 优化真实 vLLM 网关很可能优化错路径。下一步:控制平面。

本次 fresh 分支 alloc_space profile 的主要分配位置是 bytes.growSlice;这里只指累计分配,不能说服务“占了 6 GiB 常驻内存”。相同工作负载下改变 GOGC,4976 B/op 与 3 allocs/op 保持不变,说明调整回收频率没有移除分配源。测量原始输出保存在 evidence/runtime-bench.txt、evidence/gogc-sweep.txt 和 evidence/allocation-profile.txt。对比有并发流量的真实服务时还应采集 goroutine 数、请求排队和 P99,不能把微基准的时间差直接写成端到端加速比。

从请求到流结束,谁持有什么 ​

网关从候选列表选端点,在锁内增加在途数,解锁后创建上游 HTTP 请求;共享 Transport 管理 TCP 连接。Dial 超时控制建连,ResponseHeaderTimeout 等首部,Client.Timeout 覆盖整个响应流。服务器的 ReadHeaderTimeout 限制读请求头,逐次设置写截止时间保护慢客户端,不能用一个短的全局 WriteTimeout 截断所有长生成。

完整源码:modeldock/operator/cmd/gateway/main.go · 第 212–237 行

go
			return float64(g.active[peers[i]])+g.penalty[peers[i]] < float64(g.active[peers[j]])+g.penalty[peers[j]]
		})
		p := peers[0]
		peers = peers[1:]
		g.active[p]++
		g.Unlock()
		req, _ := http.NewRequestWithContext(r.Context(), "POST", p+"/v1/chat/completions", bytes.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		resp, err := g.http.Do(req)
		if err != nil || resp.StatusCode == 503 {
			if resp != nil {
				resp.Body.Close()
			}
			g.Lock()
			g.active[p]--
			g.Unlock()
			if r.Context().Err() != nil {
				return
			}
			continue
		}
		defer resp.Body.Close()
		defer func() { g.Lock(); g.active[p]--; g.Unlock() }()
		w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
		w.Header().Set("Cache-Control", "no-store")
		w.WriteHeader(resp.StatusCode)

SSE 是单方向的 HTTP 事件流,以空行结束事件;服务端写入后 Flush,中间代理也不能长时间缓冲。WebSocket 是双向消息协议,需单独管理读循环、心跳和写并发;本项目只实现 SSE,并不要求客户端升级协议。Darwin 的 kqueue 和 Linux 的 epoll 都能通知可轮询 FD 就绪,由 Go netpoll 唤醒等待 G。复用空闲 TCP 需要关闭响应体,且 HTTP/1.x 通常需读到 EOF;不能为了复用而无界吞下大响应。

可重试不代表可拼接 ​

发出第一个下游字节之后只要连接断开,就不能切另一模型接着写;两次生成的前缀、采样状态和计费可能不同。集群网关只在头部出错/503 前尝试其他候选。限流针对调用者;熔断针对持续失败的后端。当前实现有端点过期回退与有限候选重试,没有完整的半开熔断状态机。排空时先停止接新流,再等待 Shutdown;超过 grace 的长请求可能仍中断。

sh
(cd modeldock && go test -count=1 -run TestCancelReachesUpstream ./labs/runtime)
(cd modeldock/operator && go test -v ./cmd/gateway)
# 部署网关后用真实流测首个 content 与 DONE,而非 HTTP 200
python3 scripts/inference_bench.py --requests 12 --concurrency 4

追问。 收到 200 却没有内容,是模型慢还是代理缓冲?先记录收到头、第一条非空 content、DONE 三个时间点,在直连和代理链分别观测,再考虑 prefill、排队或 flush。网关实现与负载选择。

实验:把等待外部连接与 CPU 忙分开 ​

先在本地执行以下命令,保留固定输入,不要把采样 CPU 的窗口放在空闲期。CPU 消耗会出现在 sha256 或其底层实现,等待型任务则要在 trace 中看等待原因;仅用 CPU profile 不能看到一秒内一直 park 的 G。

sh
cd modeldock
go test ./labs/runtime -run '^$' -bench 'BenchmarkWork/cpu' -benchtime=2s -cpuprofile /tmp/cloudlab-cpu.out -o /tmp/cloudlab-cpu.test
go tool pprof -top /tmp/cloudlab-cpu.test /tmp/cloudlab-cpu.out
go test ./labs/runtime -run TestCancelReachesUpstream -trace=/tmp/cloudlab-http.trace
go tool trace /tmp/cloudlab-http.trace
# 退出交互工具后
rm /tmp/cloudlab-cpu.test /tmp/cloudlab-cpu.out /tmp/cloudlab-http.trace

数据库等连接时,db.Stats().WaitCount 和 WaitDuration 的区间增长能区分等连接与数据库执行。实际查询的取消传播见下面的租户隔离路径;当前 submit 使用普通 Exec,不能据此宣称所有写入都会随客户端断开而取消。后台投递独立于请求是有意的,但仍需要自己的超时预算。

完整源码:modeldock/admission/api.go · 第 56–66 行

go
	r.GET("/requests/:id", func(c *gin.Context) {
		var phase string
		var node sql.NullString
		e := db.QueryRowContext(c.Request.Context(), "SELECT state,node FROM reservation WHERE tenant=? AND id=?", c.GetString("tenant"), c.Param("id")).Scan(&phase, &node)
		if e != nil {
			c.Status(404)
			return
		}
		c.JSON(200, gin.H{"phase": phase, "logical_pool": node.String})
	})
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)

只丢首部和只丢中途数据,差在哪里? ​

设上游已生成一部分文本,网关尚未向调用者写回首部就遇到错误。切换候选可能浪费一次计算,但客户端仍只看到一条完整响应。如果已经发送前三个片段,再从新端点重试,即使两个后端都返回 200,拼出的文本也未必属于同一次生成。排障先问哪个方向已经写入,而不是笼统地问“POST 能不能重试”。要承诺跨连接恢复则需协议层保存生成 ID、序列位置和可重放内容,当前网关没有这个承诺。