路由层
主线位置:一个请求的一生在「HTTP 进来」之前还有一跳:部署了多个引擎副本时,请求先到路由器,由它挑一个副本(PD 分离时挑一对)。这一跳不在 vLLM 和 SGLang 的主包里,是单独发布的 Rust 程序。
版本:SGLang v0.5.22(
sgl-model-gateway/、experimental/sgl-router/)、vllm-project/router v0.1.16;对照 vLLM v0.32.0
先说结论:
- 两个引擎的 Rust 路由器是同一份代码分出来的。 2024-10 SGLang 在
rust/目录写了一个 Rust 路由器,后来改名sgl-router、再改名sgl-model-gateway(SMG);vllm-project/router 是 SMG 的分支,v0.1.16 起它的 gRPC 后端直接用 vLLM 自己的 Rust crate 做模板和分词。 - SMG 的
cache_aware不看引擎的真实缓存。 它按请求文本的字符在路由器里维护一棵近似前缀树,记的是「这段文本上次发给了谁」;负载差得多时干脆不看树,按排队长度挑。 - 实验中的
experimental/sgl-router换了思路。 它订阅引擎发布的 KV 事件(ZMQ SUB + msgpack),用和引擎相同的哈希把 token 块串成链建索引,查到的是引擎确实缓存着的块。 - PD 分离时 SMG 一次挑一对,往请求体里塞同一个
bootstrap_room,把预填充和解码请求同时发出;vllm-project/router 对 vLLM 默认是两段式:先预填充、拿到 KV 传输参数再发解码。
测过的只有 experimental/sgl-router 自带的微基准(树的插入、前缀匹配,选一次副本的耗时),见文末「实测」;各策略的命中率、路由器挡在副本前的附加延迟都没测过,见「待实测」。
flowchart LR r0["rust/<br/>2024-10 v0.3.5"] --> r1["sgl-router<br/>2024-12 v0.4.0.post2"] r1 --> smg["sgl-model-gateway(SMG)<br/>2025-12 v0.5.6.post1"] smg -->|"2025-12 分支"| vr["vllm-project/router<br/>v0.1.16"] smg -.->|"2026-01 文本栈抽成外部 crate"| ext["tool-parser 等<br/>crates.io"] vc["vLLM rust/ 的<br/>vllm-chat、vllm-tokenizer、vllm-text"] -->|"git 依赖 v0.29.0"| vr exp["experimental/sgl-router<br/>2026-05 v0.5.13"] smg -.->|"思路不同的新实现"| exp
谱系
时间线
全部来自本地克隆的 git 历史(按路径的 git log 和 git tag --contains)。「首个发布」指最早包含该提交的 tag。
| 日期 | 提交 | 事件 | 首个发布 |
|---|---|---|---|
| 2024-10-28 | SGLang 3839be2913 | 「[Router] Add a rust-based router」,代码在 rust/ | v0.3.5 |
| 2024-12-12 | SGLang e3b3acfa6f | rust/ 改名 sgl-router/ | v0.4.0.post2 |
| 2025-12-05 | SGLang 49dfa1d891 | sgl-router/ 改名 sgl-model-gateway/ | v0.5.6.post1 |
| 2025-12-05 | router d8bd221 | vllm-project/router 首个提交「Initial commit: VLLM Router」 | 本地最早的 tag 是 v0.1.9(2026-01-21) |
| 2026-01-25 | SGLang 2c1e2674cc 等 | SMG 删掉自带的工具解析器、推理解析器、分词器、协议类型,改用已发布的 crate | v0.5.9 |
| 2026-02-23 | SGLang b2166a2f33 | 「pin internal crate dependencies to exact versions」 | v0.5.10rc0 |
| 2026-05-25 | SGLang 6e8fe176be | 新建 experimental/sgl-router/,「experimental Rust HTTP router for SGLang worker pools」 | v0.5.13 |
| 2026-09-24 | router 3bf32ef | 「Rust gRPC Engine-Client Backend」,引入 vllm-chat 等 | v0.1.16 |
| 2026-10-08 | router 682446b | 「multi-tier KV cache placement index from engine events」 | v0.1.16 |
两点要注意:
- 今天 SGLang 的
rust/目录和 2024 年的rust/不是一回事。 2024 年那个就是路由器,改名后目录空出来;现在的rust/放的是 2026 年起的进程内扩展(sglang-server、sglang-radix-tree等,见两种前端)。按路径查历史时会把两段混在一起。 - SMG 在 SGLang 仓库里的活跃度在 2026-01 之后掉下来了。
sgl-model-gateway/路径下按月的提交数:2025-12 是 174,2026-01 是 134,之后每月 2–15。同期它把文本栈换成了 crates.io 上的外部 crate,并钉死版本。
外部 crate 钉死在 1.0.x
SMG 的依赖里,原来自带的几块现在都是精确版本(Cargo.toml#L82):
# sgl-model-gateway/Cargo.toml
reasoning-parser = "=1.0.0"
openai-protocol = { version = "=1.0.0", features = ["axum"] }
tool-parser = "=1.0.0"
llm-tokenizer = "=1.3.2"
smg-auth = "=1.0.0"
wfaas = "=1.0.2"
data-connector = "=1.0.0"
smg-mcp = "=1.0.0"
smg-wasm = "=1.0.0"
smg-mesh = "=1.0.0"
# ...
smg-grpc-client = "=1.0.0"
同一批 crate,vLLM v0.32.0 的工作区引用的是 openai-protocol = "1.6.0"、tool-parser = "1.2.0"(rust/Cargo.toml#L71、#L133),vLLM 还从 git 组织 smg-project 取 llm-multimodal(#L64)。也就是说这些 crate 在 SGLang 仓库之外还在发新版本,SGLang 里的 SMG 停在 1.0.x。主开发现在在哪个仓库,离线无法确认。文本栈的依赖关系见共享的文本栈。
发布形态
- SMG:PyPI 包名
sglang-router,maturin 构建,模块sglang_router.sglang_router_rs(pyproject.toml#L53);镜像lmsysorg/sgl-model-gateway(release-docker-gateway.yml#L37)。它不是sglangwheel 的依赖;PD 部署的 cookbook 用python -m sglang_router.launch_router --pd-disaggregation启动它(DeepSeek-V3_2.mdx#L421)。同一个 Python 包里还有一个纯 Python 的MiniLoadBalancer,注释写着「for testing」(mini_lb.py#L1)。 experimental/sgl-router:publish = false,只出二进制(Cargo.toml#L11),夜间镜像lmsysorg/sglang-staging:experimental-router-dev(nightly-experimental-sgl-router-docker.yml#L13)。- vllm-project/router:二进制
vllm-router加一个 PyO3 cdylib,PyPI 包vllm-router。README 自称「a fork of SGLang Model Gateway」,目前「only minimal changes」(README.md#L339)。vLLM 不依赖它;vLLM 文档在 MoRIIO 连接器的 PD 部署处推荐它作代理(moriio_connector_usage.md#L81)。
规模(tokei,Rust code 行,含测试和 benches):
| 项目 | Rust code | 其中 src/ |
|---|---|---|
SMG sgl-model-gateway/ | 74,372 | 49,083 |
experimental/sgl-router/ | 72,197(含 sgl-kv-indexer 5,218) | — |
| vllm-project/router | 60,240 | 46,114 |
SMG 的 cache_aware:按字符的近似前缀树
一句话
路由器不问引擎缓存了什么,只记自己把什么文本发给了谁。下一个请求来时,在这棵树上找最长前缀,前缀够长就发给上次那个副本,赌它的 KV cache 还在。文件头的注释把整个策略讲完了(cache_aware.rs#L1):
// sgl-model-gateway/src/policies/cache_aware.rs
A system is considered imbalanced if both conditions are met:
1. (max - min) > abs_threshold
2. max > rel_threshold * min
// ...
This strategy maintains an approximate radix tree for each worker based on request history,
eliminating the need for direct cache state queries. The tree stores raw text characters
instead of token IDs to avoid tokenization overhead.
flowchart TB
req["请求到达<br/>取路由文本"] --> load{"负载不均?<br/>max-min 大于 abs 且 max 大于 rel 乘 min"}
load -->|"是"| sq["最短队列<br/>并列随机"]
load -->|"否"| match["在树上找最长前缀<br/>match_rate = 匹配字符数 / 总字符数"]
match --> th{"match_rate 大于 cache_threshold?"}
th -->|"是"| hit["发给前缀所在节点的副本"]
th -->|"否"| ml["最短队列<br/>并列随机"]
sq --> ins["把文本插入树<br/>记到选中的副本名下"]
hit --> ins
ml --> ins
走一遍源码
select_worker(cache_aware.rs#L412)先判断负载:
// sgl-model-gateway/src/policies/cache_aware.rs
let is_imbalanced = max_load.saturating_sub(min_load) > self.config.balance_abs_threshold
&& (max_load as f32) > (min_load as f32 * self.config.balance_rel_threshold);
if is_imbalanced {
return self.select_worker_min_load(/* ... */);
}
load() 是路由器自己记的在途请求数,不是引擎上报的队列。不均衡时走 select_worker_min_load:在健康副本里挑负载最小的,仍然把文本插进树,记到选中的副本名下(#L345)。
均衡时查树(#L436):
// sgl-model-gateway/src/policies/cache_aware.rs
let result = tree.prefix_match_with_counts(text);
let match_rate = if result.input_char_count == 0 {
0.0
} else {
result.matched_char_count as f32 / result.input_char_count as f32
};
let selected_idx = if match_rate > self.config.cache_threshold {
// ... 按 URL 找到 result.tenant 对应的副本,且要健康
} else {
// Low cache match: use worker with minimum load. Tie break randomly.
// ...
};
命中率不够时挑负载最小的副本(#L452)。找到的副本已经下线时,把它从树里删掉,退回第一个健康副本(#L511)。
树按 池::模型 分开,预填充池、解码池、普通池各一棵(#L84)。注释说明了原因:同一个提示先发预填充再发解码,如果共用一棵树,后一次插入会把前一次的归属覆盖掉,策略就在两个池之间来回翻。
树长什么样
// sgl-model-gateway/src/policies/tree.rs
struct Node {
/// Children nodes indexed by first character.
children: DashMap<char, NodeRef, CharHasherBuilder>,
text: RwLock<NodeText>,
/// Per-tenant last access epoch for LRU ordering.
tenant_last_access_time: DashMap<TenantId, u64>,
parent: RwLock<Option<NodeRef>>,
last_tenant: parking_lot::RwLock<Option<TenantId>>,
}
- 键是
char。 子节点按首字符索引,边上存一段文本(压缩前缀树)。比较时先按字节比,公共部分全是 ASCII 就直接用字节数,否则退回逐字符比较,保证不在 UTF-8 中间切开。 - 一棵树装所有副本。 每个节点记一张「副本 → 最近访问时间」的表(文档里叫 tenant)。查前缀时走到最深的匹配节点,取那里的一个副本,优先用缓存的
last_tenant(#L531)。多个副本共享同一前缀时,选哪个和负载无关。 - 并发靠细粒度锁。 子节点表是 DashMap,根节点 32 个分片、其他节点 8 个;节点文本是
RwLock。 - 访问时间是一个全局计数器,不是时钟。 查前缀时只有八分之一的概率更新时间戳,减少写竞争(#L601);插入时总是更新。
驱逐由后台线程按 eviction_interval 周期跑 evict_tenant_by_size(#L718):遍历全树,把每个副本的「叶子」(该副本在这个节点有记录、子节点里没有)按时间戳放进最小堆,从最旧的开始删,直到这个副本名下的字符数不超过 max_tree_size(#L750)。节点上没有副本、也没有子节点时整个摘掉。
路由文本是什么
树里存的不是模板渲染后的提示,而是 extract_text_for_routing() 抽出来的文本。PD 路由器的注释说它会走遍所有消息(system、历史轮次、当前消息、工具内容),普通路由器用的是同一个函数(pd_router.rs#L956)。这个函数在外部 crate openai-protocol 里,具体怎么拼接,本地没有源码,无法确认。
所以「近似」有三层:
- 树记的是发过什么,不是引擎还留着什么。引擎那边驱逐了,路由器不知道。
- 单位是字符,不是 token。两个文本前缀相同,token 化后的前缀长度未必成比例。
- 匹配的是消息文本,不是加上聊天模板后的完整输入。
好处是不需要分词器、不需要和引擎通信,路由器可以挡在任何 OpenAI 兼容的后端前面。
阈值和默认值
同一组参数有三套默认值,取决于怎么启动:
| 参数 | Rust 结构体 Default | Rust 二进制命令行 | Python 启动器 launch_router |
|---|---|---|---|
cache_threshold | 0.5 | 0.3 | 0.3 |
balance_abs_threshold | 32 | 64 | 64 |
balance_rel_threshold | 1.1 | 1.5 | 1.5 |
| 驱逐间隔(秒) | 30 | 120 | 60 |
max_tree_size(字符/副本) | 10,000 | 67,108,864(2^26) | 2^26 |
| 默认策略 | — | cache_aware | cache_aware |
出处:policies/mod.rs#L106、main.rs#L150、router_args.py#L54。实际部署走命令行,结构体的 Default 只在库内直接构造时用到。驱逐间隔两种启动方式不一样:二进制 120 秒,Python 启动器 60 秒。
按命令行默认值算一个例子:两个副本在途请求数 100 和 40,差 60,不超过 64,判为均衡,照常查树;100 和 30,差 70 且 100 > 1.5 × 30,判为不均衡,直接发给 30 那个。
vllm-project/router v0.1.16 的 cache_aware 是同一份代码:结构体默认值和命令行默认值与上表前两列相同(policies/mod.rs#L154、main.rs#L163),默认策略也是 cache_aware(main.rs#L120)。
策略代码相同,喂给树的文本却不同。vllm-project/router 自己实现 extract_text_for_routing():completions 请求取 prompt(spec.rs#L888);chat 请求只取 session_params.session_id,没有就是空串,消息内容完全不参与(spec.rs#L563)。HTTP 路由器只从这一个函数拿路由文本(router.rs#L1084)。所以在 v0.1.16 上,不带会话 id 的 chat 请求走 cache_aware 时,所有请求的路由键都一样,前缀亲和不起作用;要靠前缀把 chat 请求分到同一副本,得在请求里带 session_params.session_id,或者用 completions 接口。下文的实测能看到这一点。
SMG 命令行可选的策略还有 random、round_robin、power_of_two、prefix_hash、manual;src/policies/ 下另有 bucket、consistent_hashing。
experimental/sgl-router:订阅 KV 事件,按 token 块建索引
换了什么
新路由器要回答的问题不变(哪个副本缓存了这个请求最长的前缀),但信息来源换成引擎自己:
SMG cache_aware | experimental/sgl-router | |
|---|---|---|
| 索引的单位 | 字符 | token 块(块大小由引擎上报) |
| 信息来源 | 路由器自己的发送记录 | 引擎发布的 BlockStored / BlockRemoved / AllBlocksCleared 事件 |
| 引擎驱逐后 | 不知道 | 收到 BlockRemoved 就删 |
| 路由器要做的事 | 抽文本 | 渲染聊天模板、分词、算块哈希 |
| 引擎侧前提 | 无 | 引擎打开 KV 事件发布 |
为了「哈希的是引擎缓存的同一串 token」,它用 Dynamo 的 crate 和 sglang-processor 渲染模板、分词(Cargo.toml#L25):
# experimental/sgl-router/Cargo.toml
# Pin Dynamo versions and commit Cargo.lock so builds are reproducible.
dynamo-tokenizers = "=3.1.0"
# Renders the model's chat template (HF Jinja, or Dynamo's built-in encoder for
# template-less models like DeepSeek-V4) so cache-aware routing hashes the same
# tokens the engine caches.
dynamo-renderer = "=7.0.1"
# ...
sglang-processor = { path = "../../rust/sglang-processor", default-features = false, features = ["render", "openai"] }
引擎一侧的发布默认关:SGLang 的 --kv-events-config 默认 None,给了才发布(observability.py#L144)。路由器一侧的默认策略是 round_robin(换成 --chat-routing reorg 时默认 power_of_two),要显式 --policy cache_aware 才用索引(cli.rs#L235)。
sequenceDiagram participant E as SGLang 引擎(每个 DP rank) participant S as 订阅任务(每个 worker × rank 一个) participant P as pump 任务 participant T as HashTree(分片) participant R as 路由请求 E->>S: PUB 三帧:topic、seq、msgpack 批 S->>S: seq 前跳:向 replay ROUTER 补取 S->>P: mpsc 按序转发 P->>T: BlockStored 插入 / BlockRemoved 删除 R->>R: 渲染模板、分词、算块哈希链 R->>T: prefix_depths:各副本命中多少块 T-->>R: 命中块数,选副本
线上格式与重放修复
订阅端的文件头写清了协议(subscriber.rs#L13):
// experimental/sgl-router/src/state/kv_events/subscriber.rs
//! # Wire format (3-frame multipart)
//!
//! 1. `topic_bytes` — empty by default, present even when empty.
//! 2. `seq_bytes` — 8-byte big-endian signed `i64`, dense per publisher. The
//! `-1` sentinel (`ZmqEventPublisher.END_SEQ`) ends a replay; seen on the
//! PUB stream it becomes a [`WorkerEvent::PublisherReset`].
//! 3. `payload` — msgpack-encoded [`KvEventBatch`].
//!
//! # Sequence repair
//!
//! A seq that regresses on one socket is a publisher restart and is
//! forwarded as a reset, except batch 0, which the pump already resolves from
//! the stream's origin. A forward gap is re-fetched from the publisher's
//! replay ROUTER when `/server_info` advertises one; live frames wait in the
//! SUB socket meanwhile, so the pump always sees this rank in order.
- 每个
(worker, dp_rank)一个 SUB socket、一个 tokio 任务,用纯 Rust 的zeromqcrate;地址是tcp://{host}:{port_base + dp_rank},host 从 worker 的/server_info取。 - 序号倒退当作发布端重启,向下游发一个 reset;序号前跳说明丢了批,用 DEALER 向发布端的 replay ROUTER 补取,等待上限 2 秒(
REPLAY_TIMEOUT,注释写着「arbitrary」)。补取期间新到的帧留在 SUB socket 里,所以同一个 rank 的事件总是按序。 - 发布端是 SGLang 的
ZmqEventPublisher,PUB 加可选的 replay ROUTER,内存里留最近若干批供重放,END_SEQ是-1(kv_events.py#L388)。replay 端点要在--kv-events-config里显式配。 - 背压:订阅任务
await tx.send(),通道满了就不再读 ZMQ,丢弃发生在发布端的高水位线,不在路由器里堆积。
msgpack 的形状在 wire.rs#L1:外层 EventBatch 是数组 [ts, events, attn_dp_rank],每个事件是带 type 键的 map;注释说这和 vLLM 的 KV 事件编码相同。
块哈希要和引擎逐位一致
hash.rs#L57 复刻 SGLang 的页哈希:每页 SHA256,前一页的完整 32 字节摘要作为下一页的输入,token 按 4 字节小端写入,最后取摘要前 64 位当有符号 i64。EAGLE 一类模型用相邻 token 二元组算哈希,另有一个 compute_block_hashes_bigram。
只要一侧的分词、模板或哈希和引擎差一个 token,后面整条链都对不上,命中就是 0。这也是它要把 Dynamo 和 sglang-processor 拉进来的原因。
索引结构
tree.rs#L1 是一棵以块哈希为键的前缀树:每个节点是一个块,子节点按下一个块的哈希索引,节点上记哪些 worker 在哪一层存储(设备、主机内存等)持有这条链。
- 按根块哈希分片,每片一把
RwLock。一条链只落在一个分片里,路由查询只拿一把读锁,事件写入不再挡住所有查询。 - 反向索引
block_hash → 节点集合:BlockRemoved只带哈希不带父块,没有它就得扫全树。 - 在途前缀:KV 事件比路由决定晚到。
--cache-pending-prefix-ttl-ms让路由器在事件确认之前,先把刚发出去的前缀记在目标 worker 名下一段时间,让同一冷前缀的一批请求落到一起;默认 0,即关闭(cli.rs#L309)。
前缀来源有两种:本地这棵树(radix_tree,默认),或外部的 sgl-kv-indexer gRPC 服务(给了 --kv-indexer-endpoint 时)(cli.rs#L301)。
命中后不是直接选最深的那个。候选要命中至少 1024 个 token(cache_affinity_min_matched_tokens,types.rs#L655),候选数在 8 到 32 之间按健康 worker 的 5% 取,再经过压力保护和负载比较。SMG 用的是「命中字符占比超过 0.3」,这里换成了绝对 token 数。
vllm-project/router 也在做同样的事
v0.1.16 的 src/kv_index/ 是一个 ZMQ SUB 加 DEALER 重放的 KV 事件索引,src/kv_hash.rs 复刻 vLLM 的 sha256_cbor 块哈希。两者都只在 lib.rs 里声明(lib.rs#L9),没有任何策略或配置引用,还没接上。
PD 路由:一次挑一对
SMG:双发,注入 bootstrap
预填充 / 解码分离时,SMG 的 select_pd_pair 分别用预填充策略和解码策略各挑一个 worker(pd_router.rs#L976),然后往请求体里写三个字段(#L242):
// sgl-model-gateway/src/routers/http/pd_router.rs
obj.insert(
Self::BOOTSTRAP_HOST_KEY.to_string(),
Value::from(prefill_worker.bootstrap_host()),
);
obj.insert(
Self::BOOTSTRAP_PORT_KEY.to_string(),
// ... prefill_worker.bootstrap_port()
);
obj.insert(
Self::BOOTSTRAP_ROOM_KEY.to_string(),
Value::from(super::pd_types::generate_room_id()),
);
bootstrap_room 是一个 [0, 2^63-1] 的随机数,和 Python 一侧 random.randint(0, 2**63 - 1) 对齐(pd_types.rs#L60)。批量请求每条一个。同一个 JSON 同时发给两边,两边的引擎凭同一个 room 号在预填充节点的 bootstrap 服务上配对,KV 从预填充节点传到解码节点。
两个请求在同一个 handler 任务里并发发出,没有用 tokio::spawn,这样客户端断开时解码请求一起取消(#L701)。先等预填充的结果:预填充失败就丢掉解码的 future,否则解码那边会一直等 KV,直到 300 秒超时(#L732)。
sequenceDiagram
participant C as 客户端
participant G as SMG
participant P as 预填充 worker
participant D as 解码 worker
C->>G: POST /v1/chat/completions
G->>G: 各挑一个 P、D,生成 bootstrap_room
par 同时发出
G->>P: 原请求 + bootstrap_host/port/room
and
G->>D: 原请求 + bootstrap_host/port/room
end
P-->>D: 按 room 配对后传 KV
P-->>G: 预填充完成(失败则取消 D)
D-->>G: 流式输出
G-->>C: 流式输出
bootstrap 注册表本身在引擎里:Python 默认路径由 TokenizerManager 承担,打开 SGLANG_RUST_SERVER 后由 Rust 的 sglang-server 承担(见两种前端)。
vllm-project/router 对 vLLM:默认两段式
vLLM 的 KV 连接器协议不同,没有 room 号,靠 kv_transfer_params。VllmPDRouter 的默认流程(vllm_pd_router.rs#L1279):
- 预填充请求改成
max_tokens = 1、stream = false(#L578),附上do_remote_decode: true的传输参数,发给预填充节点并等它返回。 - 从预填充响应里取
kv_transfer_params,附到原请求上发给解码节点。
并发的只有两种情况:NIXL 连接器且能拿到预填充节点的推送身份(try_build_concurrent_requests,#L1680);服务发现模式下再加上 MoRIIO 的 WRITE 传输模式(#L936)。其余情况(包括 Mooncake)走两段。两段式多等了一次预填充响应,代价待实测。
gRPC 后端:路由器自己做模板和分词
SMG 和 vllm-project/router 都有 gRPC 路由:路由器在 Rust 里渲染模板、分词,向 worker 发 token id,再把输出反分词。
- SMG 的 gRPC 路由用外部的
llm-tokenizer(在lib.rs里重新导出为tokenizer)、tool-parser、reasoning-parser,对接实现了 SMG gRPC 协议的 worker。SGLang 的--smg-grpc-mode和 vLLM 的--grpc(grpc_server.py#L31,依赖smg-grpc-servicer)都是这种 worker,所以 SMG 也能驱动 vLLM。 - vllm-project/router 的
grpc://worker 只走 vLLM 自己的 crate(backend/mod.rs#L1):
// src/backend/mod.rs(vllm-project/router)
//! - All-HTTP: policy selects, then `router.rs` reverse-proxies OpenAI JSON
//! (no chat/tokenizer frontend on the critical path).
//! - All-gRPC: `EngineFrontend::prepare(chat)` → `token_ids`, then policy
//! selects, then `EngineFrontend::dispatch` (`convert` + GenerateStream +
//! detok).
//!
//! gRPC wire types come from crates.io `vllm-proto`. Chat+tokenize is
//! `vllm-chat` + `vllm-tokenizer` only (Cargo git, not `pip install`).
对面是 vllm-rs 的 vllm.Inference/GenerateStream,也就是打开 Rust 前端并给了 --grpc-port 的 vLLM。依赖钉在 vLLM 的 v0.29.0 tag(Cargo.toml#L102),比本卷的 v0.32.0 早三个版本。
实测:experimental/sgl-router 的微基准
在 M2 Max 笔记本上对 SGLang v0.5.22 的 experimental/sgl-router 跑 cargo bench --locked,下面是 criterion 报的中位数。这是单独调用数据结构和策略函数的耗时,没有网络、没有 worker,不等于路由器挡在副本前多出来的延迟。
benches/tree_lookup.rs 测的是按块哈希建的那棵树(HashTree)。参数 wA_bpwB_qC 表示 A 个 worker、每个 worker B 个块、查询 C 个块:
| 操作 | 条件 | 耗时 |
|---|---|---|
| 插入 32 个块 | 不带父哈希(parent_none) | 1.27 µs |
| 插入 32 个块 | 带父哈希、要先跨分片找父节点(parent_some) | 1.35 µs |
| 前缀匹配 | w4_bpw32_q8 | 0.20 µs |
| 前缀匹配 | w16_bpw64_q32 | 0.67 µs |
| 前缀匹配 | w64_bpw128_q64 | 1.29 µs |
| 前缀匹配 | w128_bpw256_q128 | 2.54 µs |
| 前缀匹配,后台线程同时插入 / 删除 | 从根写入 | 1.43 µs |
| 前缀匹配,后台线程同时插入 / 删除 | 接续写入 | 1.55 µs |
benches/policy_select.rs 测选一次 worker:
| 策略 | worker 数 | 耗时 |
|---|---|---|
round_robin | 4、16、64、256 | 5.8–5.9 ns,与 worker 数无关 |
random | 16 / 64 / 256 | 181 ns / 563 ns / 2.24 µs |
power_of_two | 4、16、64、256 | 65–72 ns |
树的两类操作都在微秒量级,前缀匹配随 worker 数和查询长度增长。只跑了这一台机器、这一个仓库的基准。
实测:vllm-project/router
微基准
cargo bench --locked --bench routing_input,M2 Max,criterion 中位数。输入是仓库自带的确定性语料,不联网。
| 项目 | 200 B | 2 KiB | 16 KiB |
|---|---|---|---|
| 取路由文本:completions | 16.5 ns | 40 ns | 412 ns |
| 取路由文本:chat | 1.5 ns | 1.5 ns | 1.5 ns |
选副本:cache_aware(已预热的热集合) | 1.50 µs | 2.75 µs | 12.3 µs |
选副本:rendezvous_hash | 3.51 µs | 12.7 µs | 83.6 µs |
chat 那一行不随长度变,原因就是上面说的:它只读会话 id。
更长的输入:cache_aware 命中时 128 KiB 88 µs、1 MiB 693 µs,冷请求 18 µs、134 µs;1 MiB 的中文文本命中要 3.85 ms。rendezvous_hash 对请求体文本做哈希,1 MiB 要 4.99 ms,带会话头时 1.36 µs。副本数 1 / 4 / 16 / 64 时,cache_aware 选一次 2.66 / 2.75 / 3.08 / 4.25 µs,rendezvous_hash 11.5 / 12.4 / 16.0 / 30.3 µs。
挡在假 worker 前
用仓库自带的端到端测试(tests/router_overhead_bench.rs):起一个路由器进程,后面 4 个假 worker(不解析 JSON、不 sleep),客户端发完一个等返回再发下一个;同一组输入还会直连假 worker 跑一遍,两者相减就是路由器的附加延迟。M2 Max,每格预热 3 s、测 10 s,整组重复 3 次取中位数;驱逐间隔设成 3600 s,树只增不减。
| 场景 | 输入 | 并发 1 均值 / 比直连多 | 并发 64 req/s(直连) | 负载集中度 并发 1 / 64 | 最大 RSS |
|---|---|---|---|---|---|
completions,cache_aware,热语料 | 200 B | 0.86 / +0.43 ms | 1,872(3,685) | 4.00 / 1.00 | 38 MiB |
completions,cache_aware,热语料 | 16 KiB | 1.27 / +0.71 ms | 1,107(2,315) | 4.00 / 1.12 | 51 MiB |
completions,cache_aware,冷语料 | 16 KiB | 1.23 / +0.68 ms | 1,106(2,269) | 4.00 / 1.04 | 310 MiB |
completions,rendezvous_hash,热语料 | 16 KiB | 1.70 / +1.06 ms | 1,115(2,315) | 1.13 / 1.13 | 47 MiB |
chat,cache_aware,热语料 | 200 B | 0.93 / +0.45 ms | 1,791(3,539) | 4.00 / 1.04 | 38 MiB |
chat,cache_aware,热语料 | 16 KiB | 1.24 / +0.69 ms | 1,069(2,312) | 4.00 / 1.04 | 48 MiB |
「负载集中度」是测试里的 hotspot:副本数乘以最忙副本的份额,1 表示完全均匀,4 表示全落在一个副本上。
- 单个请求经过路由器多 0.4–0.7 ms(
cache_aware),rendezvous_hash在 16 KiB 时多 1.06 ms,和它在微基准里要哈希整个请求体一致。 - 并发 1 时
cache_aware把所有请求都发给同一个副本:没有在途请求,负载永远「均衡」,树的匹配结果决定去向。chat 的路由键全是空串,结果一样。并发 64 时在途请求拉开差距,按负载分散开,集中度回到 1 附近。 - 并发 64 时经过路由器的吞吐约为直连的一半,但路由器每千个请求只用 110–370 ms CPU,折合不到半个核,瓶颈不在路由器的 CPU 上;具体在哪里这次没有查。
- 冷语料每个请求往树里加一整段新文本,16 KiB、并发 64 时 RSS 涨到 310 MiB;生产配置有驱逐(命令行默认 120 s),这一项不代表实际占用。
局限:只有一台机器,客户端、假 worker 和路由器共用它;每格 10 s;三次重复之间的均值差多在 5–25%,差距在这个范围内的不要下结论。180 格里有一格(第 3 次重复的 rendezvous_hash / 200 B / 热语料 / 并发 64)路由器进程因端口被占没起来,请求全部失败,这一格只用了另外两次。
待实测
sgl-model-gateway/benches/tree_benchmark.rs:SMG 在锁定的 tag 上没有Cargo.lock,没跑。- KV 事件驱动的那条路(
experimental/sgl-router)挡在假 worker 前的试验:假 worker 要模拟 KV 事件的发布端,没做。 - 真实引擎的前缀命中率和 TTFT:两个以上副本,
round_robin对cache_aware,SMG 对 vllm-project/router;条件允许时加上experimental/sgl-router的 KV 事件模式。待实测(C3-6)。 - PD 两段式与双发的差别:待实测(C3-7)。
小结
- 谱系:SGLang 2024-10 的
rust/路由器 →sgl-router→sgl-model-gateway(SMG)→ 2025-12 分出 vllm-project/router。SMG 2026-01 起把文本栈换成外部 crate 并钉在 1.0.x,此后在 SGLang 仓库里改动很少。 - SMG 的
cache_aware记的是「什么文本发给了谁」:按字符的压缩前缀树,命中率过阈值就跟随,负载差过双阈值就按在途请求数挑。阈值有三套默认值,实际部署看命令行或 Python 启动器。 experimental/sgl-router订阅引擎的 KV 事件,三帧 msgpack,序号倒退当重启、前跳就向 replay ROUTER 补取;按与引擎相同的 SHA256 链给 token 块建索引,要求路由器和引擎的模板、分词逐 token 一致。- vllm-project/router 也在写 KV 事件索引,v0.1.16 还没接进策略;它的 gRPC 后端直接复用 vLLM 的
vllm-chat/vllm-tokenizer/vllm-text。 - PD:SMG 同时发两个请求,用随机
bootstrap_room配对;vllm-project/router 对 vLLM 默认先预填充、再带kv_transfer_params发解码,只有 NIXL 推送和 MoRIIO WRITE 两种情况并发。 experimental/sgl-router的微基准(M2 Max 笔记本):块哈希树插入 32 块 1.27–1.35 µs,前缀匹配 0.20–2.54 µs,选一次 worker 5.8 ns 到 2.24 µs。- vllm-project/router v0.1.16 的 chat 请求只用会话 id 当路由文本,不带会话 id 时
cache_aware的前缀亲和不起作用。挡在 4 个假 worker 前,单个请求多 0.4–0.7 ms(cache_aware)到 1.06 ms(rendezvous_hash,16 KiB);并发 1 时所有请求落在同一个副本,并发 64 时按负载均匀分开。真实引擎上的命中率和 TTFT、SMG 的开销待实测。