执行摘要
本 PR 把 #39004 中“已解析但没人读”的 per-worker WireProtocol 真正接到线路上,并在同一提交里打开入站半场:出站侧 Proxy 从单一客户端拆成 default_client(协商客户端)与 h2c_client(明文 prior knowledge),逐请求按所选 worker 的协议选择;入站侧显式声明 axum 的 http2 feature,使同一个明文端口既服务 h2c prior-knowledge 也服务 HTTP/1.1。改动不引入任何用户配置项,对纯 HTTP/1.1 部署保持功能等价,但把 TLS worker 的行为改为经 ALPN 协商到 HTTP/2,并把 router crate 内 forward_*_to 的签名变成必带协议。三层测试(进程内转发 / 入站监听 / k8s 混合机群)是本次变更最扎实的部分。
功能与动机
动机在 PR 描述中写得很清楚:用 --enable-http2 启动的引擎跑 Granian HTTPModes.auto,明文端口上同时提供 h2c 与 HTTP/1.1,而 router 此前两个方向都只说 HTTP/1.1,于是
“a mesh sidecar in front of it and an h2c-capable engine behind it both fell back to one-request-per-connection instead of multiplexing.”
关键约束是“无新增配置”:“inbound is negotiated per connection, outbound is selected per worker from a value already on the worker.”
此外,本 PR 是 1.7k 行原始改动 #38743 拆分栈的第三层:第一层 #38989 因 /server_info 已返回 enable_http2 而关闭,第二层 #39004(解析规则)已合入,本层只含“转发 + 入站监听”。
实现拆解
1. 出站:单一客户端 → 双客户端 + 逐请求选择
文件 experimental/sgl-router/src/proxy/mod.rs,关键符号 Proxy、build_client、Proxy::new、client_for、admin_client、forward_json_to、forward_streaming_to。
pub client: Client 被替换为私有的 default_client 与 h2c_client;新增自由函数 build_client(protocol) 统一连接池(pool_max_idle_per_host(64))与 5 s 建连超时,只在 Http1/H2c 分支分叉,后者追加 http2_prior_knowledge()。
forward_json_to 与 forward_streaming_to 各新增 protocol: WireProtocol 形参,把 self.client.post(...) 改为 self.client_for(protocol).post(...)。选择逻辑是纯 match-vs-field,无 IO、无分配。
admin_client() 固定返回 default_client,供跨 worker 扇出使用。作者说明这两个客户端必须在构造期建好:“no per-request client construction, and no single shared client whose first writer decides the protocol for the whole fleet.”
- 单测
client_for_maps_each_protocol_to_its_own_client 只断言映射关系,并显式说明“断言两个客户端不相等是空洞的”。
2. 调用点:把协议绑到“它被解析出来的那条腿”
server/routes/chat.rs 的 chat_completions 共 5 处转发全部补上协议实参。PD 分支最微妙:worker 的所有权会被移进 tokio::spawn,所以必须在 spawn 之前 let prefill_protocol = worker.protocol();;decode 腿与普通分支则现场读 decode_worker.protocol() / worker.protocol(),流式与非流式各一处。
server/routes/cache.rs 的 flush_cache 由 ctx.proxy.client 改为 ctx.proxy.admin_client(),把“扇出流量不属于任何被选中 worker”编码进 API。
src/workers/worker.rs 与 src/workers/manager.rs 属于文档/日志同步:WireProtocol::Http1 的语义从“明文与 TLS 一律 HTTP/1.1、不参与协商”改为“明文 HTTP/1.1、TLS 下 ALPN 协商”,log_protocol_resolution 的 (Some(true), Some(false)) 与 (None, _) 两条文案同步改写,避免运维日志与实现打架。
3. 入站:把已经“白捡”的 h2 能力钉到直接依赖
experimental/sgl-router/Cargo.toml:声明 axum 的 http2 feature,并为 reqwest 打开 http2(http2_prior_knowledge() 本身是 #[cfg(feature = "http2")])。作者解释 axum 侧“看似冗余而实则不是”:axum::serve 跑在 hyper-util 的 auto builder 上,HTTP/2 分支由 hyper-util/http2 编译,而这条路径此前只通过 sgl-kv-indexer -> tonic -> hyper-util/server-auto 传递获得,随时可能因该 gRPC 依赖离开依赖图而退回 HTTP/1.1-only。
experimental/sgl-router/src/main.rs 配合入站服务路径 +4 行(未提供该文件 patch 细节,具体行文不确定)。
4. 测试与 CI 配套
| 文件 |
作用 |
tests/proxy/h2c_forward.rs |
用 hyper http2::Builder 起 HTTP/2-only 服务端,验证 h2c 客户端可达、HTTP/1.1 客户端必然失败、forward_streaming_to 的 multi-frame SSE 不截断且 on_first_byte 触发 |
tests/proxy/inbound_h2c.rs |
走真实 socket + axum::serve(协商在 tower service 之下,oneshot 够不到),断言同端口双协议共存 |
tests/proxy/pd_protocol_binding.rs |
prefill mock 只讲 HTTP/2、decode mock 只讲 HTTP/1.1,用线上互斥反证“解析值 = 使用值” |
tests/e2e/k8s_integration/test_h2c_forwarding.py |
三期 uvicorn + 一台 Granian worker 的混合机群,用 x_http_version 断言两种协议同时在用 |
tests/e2e/k8s_integration/fake_worker.py、Dockerfile.fake_worker |
新增 FAKE_WORKER_HTTP2 开关(对 "False" 等写法做归一)、enable_http2 上报、x_http_version 回显,并切换到 Granian HTTPModes.auto |
tests/proxy/main.rs、tests/proxy/chat_routing.rs |
注册新模块与少量适配 |
README.md |
面向运维补充双端 h2c 语义(+44/-1) |
experimental/sgl-router/src/server/routes/chat.rs
协议真正落到线路上的地方:chat_completions 的 5 处转发调用全部补上协议实参,其中 PD 分支的 prefill_protocol 必须在 tokio::spawn 之前捕获,是“解析值 → 使用点”绑定最易出错的接缝。
// experimental/sgl-router/src/server/routes/chat.rs —— 把协议绑到它被解析出来的那条腿
// PD 分支:worker 的所有权会被移进 spawn 出去的任务,所以协议必须在 spawn
// 之前就取下来(`prefill_protocol`),spawn 之后就再也拿不到 worker 了。
let prefill_url = worker.url.clone();
let prefill_protocol = worker.protocol();
let prefill_breaker = Arc::clone(&worker.breaker);
let prefill_headers = headers.clone();
let prefill_body = outgoing_body.clone();
let prefill_proxy = Arc::clone(&ctx.proxy);
tokio::spawn(async move {
match prefill_proxy
.forward_json_to(
&prefill_url,
prefill_protocol, // 绑定 prefill worker 自己解析出的协议
&prefill_breaker,
"/v1/chat/completions",
&prefill_headers,
prefill_body,
)
.await
{
// ... prefill 结果处理保持不变 ...
}
});
// decode 腿:worker 仍在作用域内,直接现取 `decode_worker.protocol()`;
// 流式与非流式两条路径各一处,保证与 decode worker 注册时解析的协议一致。
let fetch = ctx.proxy.forward_streaming_to(
&decode_worker.url,
decode_worker.protocol(),
&decode_worker.breaker,
"/v1/chat/completions",
&headers,
/* stream_guards, on_first_byte, on_stream_end ... */
);
let fetch = ctx.proxy.forward_json_to(
&decode_worker.url,
decode_worker.protocol(),
&decode_worker.breaker,
"/v1/chat/completions",
&headers,
body,
);
// 非 PD 的普通分支同理:`worker.protocol()` 随 `&worker.url` 一起传入,
// 流式与非流式各一处,避免任何“借用别的 worker 协议”的可能。
评论区精华
本 PR 没有留下任何 review 评论(review_comments_count = 0、comments_count = 0),只有 sherlockwu 的一条空 body APPROVED。因此真正的“讨论”发生在 PR 描述里,作者以自问自答的方式预先回应了几处关键质疑:
“The call sites pass worker.protocol() alongside worker.url, which is what binds the resolved value to the leg it was resolved for.”
“HTTP/1.1 stays the fallback wherever an upgrade is not positively safe, and that fallback is the negotiating client, not a downgrade.”
“Admin fan-out (/flush_cache) always uses the default client, since it addresses every worker at once rather than a selected one — hence Proxy::admin_client rather than reaching for any one worker's protocol.”
关于 axum 的 http2 feature“看似冗余”,作者也主动披露了测试的真实界限:
“these do not fail if axum's http2 feature is dropped (other dependencies also enable hyper-util/http2) — verified by removing it. Their job is the property an operator depends on, whatever the feature graph looks like.”
风险与影响
| 风险 |
具体位置 |
说明 |
| 协议错配即硬失败 |
proxy::client_for / chat.rs PD 分支 |
h2c 是 prior knowledge,不做协商;对不提供 h2c 的端点会直接失败。缓解依赖 resolve_protocol 只在 enable_http2 == true 且明文时才选 H2c |
| feature 静默降级 |
Cargo.toml 的 reqwest / axum http2 |
worker.rs 文档明言“Dropping that feature silently reduces this variant to HTTP/1.1 everywhere”,且入站测试在 feature 图被其他依赖补上时不会失败 |
| TLS 行为变更 |
manager.rs / worker.rs 注释与日志 |
http2 feature 打开后,https:// worker 会经 ALPN 协商到 HTTP/2,是线路级行为变化,需真实环境验证 |
| PD 绑定覆盖偏薄 |
chat_completions 与 pd_protocol_binding.rs |
三条表达式传错任一都能编译通过,却只有一处测试兜底;prefill 腿依赖 3 s 轮询,属弱同步 |
| 已知未解决取舍 |
reconcile_unresolved_workers(#39004 引入) |
/server_info 不可达的 worker 终生走 HTTP/1.1,本 PR 未改变该模式,仅是吞吐代价 |
| e2e 环境耦合 |
test_h2c_forwarding.py |
依赖 kind 集群、--cb-threshold 1 的 router manifest、fake-worker 镜像重建与 90 s 收敛窗口 |
影响面:对已启用 --enable-http2 的部署,router→引擎、mesh→router 两段都获得 HTTP/2 多路复用;对纯 HTTP/1.1 部署功能等价(inbound_still_accepts_http1 与 http1_client_cannot_reach_http2_only_worker 双向兜底)。crate 内部 API 有 breaking:Proxy::client 变私有、两个 forward_*_to 新增必填参数,仓库内调用点已在同一 PR 内全部更新。
关联脉络
- #39004(已合入):
WireProtocol 枚举、manager::resolve_protocol、registry::add_with_cb 的协议盖章,当时“changes no bytes on the wire”;本 PR 是它的执行层,并回头修正了它对 ALPN 的错误描述。
- #38743(被取代):1.7k 行单 commit 的原始 h2c 改动,本 PR 是拆分栈的第三层。
- #38989(已关闭):原计划让引擎在
/model_info 上报 enable_http2,后确认 /server_info 的 resolved_dict() 早已包含该字段,故本 PR 直接从 /server_info 读取。
- #39000 / #39014(同期 router PR):同属 router 的配置面与连接生命周期系列,采用同样的拆栈推进方式;#39014 的“在途 HTTP 计数”与本 PR 的多路复用目标都指向连接效率这条主线。
总体判断:这是一个动机清楚、边界自律(落单层、改文档、不改配置面)、测试设计讲究的 PR;主要遗留是 PD 协议绑定的测试覆盖偏薄,以及 http2 feature 缺失时的静默降级只靠文档和断言性质型测试兜底。
参与讨论