共享的文本栈
主线位置:一个请求的一生里「模板与分词」和「反分词、流式返回」两段,以及输出里的工具调用、推理内容的抽取。默认路径上两个引擎都在 Python 里做这些事(分词调 HF
tokenizers);打开 Rust 前端、或经过 Rust 路由器的 gRPC 后端时,换成本页的 crate。
版本:vLLM v0.32.0、SGLang v0.5.22、vllm-project/router v0.1.16;对照 mistral.rs v0.9.5
先说结论:
- Rust 文本栈有三套,各自独立。 vLLM 自己写(
vllm-tokenizer、vllm-chat、vllm-parser、vllm-text);SGLang 的 Rust 部分包一层 NVIDIA Dynamo 的 crate(dynamo-tokenizers、dynamo-renderer、dynamo-parsers);SMG 用自己抽出去的 crate(llm-tokenizer、tool-parser、reasoning-parser、openai-protocol)。 - 跨项目的依赖只有几条,而且多半不在主路径上。 vLLM 对 SMG 的
tool-parser只是 dev-dependency,用来做对照基准;真正进二进制的是smg-project的llm-multimodal。vllm-project/router 用 git 依赖拉 vLLM 的三个 crate。SGLang 的 Rust 代码不依赖任何 SMG crate。 - 都建在同一批底层库上。 HF
tokenizers、minijinja出现在每一套里;vLLM 在它们之上加了更快的替代后端(fastokens、riptoken),失败再回退。 - vLLM 试过让 Python 前端调 Rust 解析器(PyO3 模块
vllm._rust_tool_parser),v0.24.0 加入、v0.32.0 删除,期间只有 MiniMax-M3 一个解析器用过它。
三套之间没有做过完整的速度对比。测过的只有两组单机微基准:vLLM 的解析器对 SMG 的 tool-parser(3 个格式),vLLM 的分词后端对 HF tokenizers 和 Python tokenizers,见文末「实测」;其余见「待实测」。
flowchart LR
subgraph base["底层库(crates.io)"]
hf["tokenizers"]
mj["minijinja"]
fk["fastokens"]
rt["riptoken / tiktoken-rs"]
tk["tekken"]
end
subgraph V["vLLM rust/"]
vt["vllm-tokenizer"]
vc["vllm-chat"]
vp["vllm-parser"]
vx["vllm-text"]
end
subgraph S["SGLang rust/"]
sp["sglang-processor"]
ss["sglang-server"]
end
subgraph D["NVIDIA Dynamo(crates.io)"]
dt["dynamo-tokenizers"]
dr["dynamo-renderer"]
dp["dynamo-parsers"]
end
subgraph M["SMG 抽出的 crate"]
lt["llm-tokenizer"]
tp["tool-parser"]
rp["reasoning-parser"]
op["openai-protocol"]
lm["llm-multimodal"]
end
smg["SMG<br/>sgl-model-gateway"]
vr["vllm-project/router"]
exp["experimental/sgl-router"]
vt --> hf
vt --> fk
vt --> rt
vt --> tk
vc --> mj
vc --> lm
vp --> vt
vx --> vt
vp -.->|"dev:对照基准"| tp
vp -.->|"dev"| op
sp --> dt
sp --> dr
sp --> dp
ss --> sp
exp --> sp
exp --> dt
smg --> lt
smg --> tp
smg --> rp
smg --> op
vr -->|"git v0.29.0"| vc
vr -->|"git v0.29.0"| vt
vr -->|"git v0.29.0"| vx
实线是普通依赖,虚线是只在测试和基准里用的 dev-dependency。
谁依赖谁
| 使用方 | 分词 | 聊天模板 | 工具 / 推理解析 | 出处 |
|---|---|---|---|---|
vLLM vllm-rs | vllm-tokenizer(fastokens → HF tokenizers;riptoken → tiktoken-rs;tekken) | vllm-chat(minijinja + 手写渲染器) | vllm-parser | rust/Cargo.toml#L47 |
SGLang sglang-server | dynamo-tokenizers 3.1.0 | dynamo-renderer 7.0.1 + minijinja | dynamo-parsers =9.2.8,经 sglang-processor | Cargo.toml#L41 |
SGLang experimental/sgl-router | dynamo-tokenizers =3.1.0 | dynamo-renderer =7.0.1 + sglang-processor | 不解析 | Cargo.toml#L25 |
| SMG | llm-tokenizer =1.3.2 | 在外部 crate 里 | tool-parser =1.0.0、reasoning-parser =1.0.0 | Cargo.toml#L82 |
| vllm-project/router(gRPC 后端) | vllm-tokenizer | vllm-chat | 不解析 | Cargo.toml#L102 |
| mistral.rs | HF tokenizers 0.21.4 | minijinja 2.14 | 自带(mistralrs-core/src/tools/parsers/、reasoning_parsers/) | Cargo.toml#L115 |
Python 默认路径不在表里:vLLM 和 SGLang 的 Python 前端都用 HF tokenizers(Rust 写的库,PyO3 绑定),vLLM 要求 tokenizers >= 0.23.2,注释写着「Required for fast incremental detokenization」(requirements/common.txt#L12),SGLang 钉 tokenizers==0.23.2(pyproject.toml#L87)。模板和解析器在 Python 里。
几条跨项目的边,逐条看:
- vLLM → SMG 的 crate。
vllm-parser的[dev-dependencies]里有openai-protocol和tool-parser(parser/Cargo.toml#L27),只为基准对照(下一节)。进到vllm-rs二进制的是llm-multimodal,vllm-chat和vllm-server用它做多模态预处理,按 git rev 从smg-project取(rust/Cargo.toml#L64)。 - vllm-project/router → vLLM。
vllm-chat、vllm-tokenizer、vllm-text是 git 依赖,tag v0.29.0;这几个 crate 没有发到 crates.io,注释说 Cargo 按包名在rust/src/下找。gRPC 后端注释写明只用这两个 crate 做模板和分词,「There is no HuggingFace/minijinja fallback」(preprocess.rs#L1)。 - SGLang → Dynamo。
sglang-processor自称「A thin wrapper over Dynamo's frontend crates」,规则是能直接调 Dynamo 就直接调,只在行为和 SGLang 的 Python 不同、或 Dynamo 缺模型时写自己的代码(README.md#L3)。三个功能用 feature 分开,默认全开(Cargo.toml#L9)。 - SMG → 自己的外部 crate。 2026-01-25 前后,SMG 删掉仓库里的
tool_parser、reasoning_parser、tokenizer、protocols模块,换成发布出去的 crate(2c1e2674cc等,首个包含的发布 v0.5.9),之后钉成精确版本。谱系见路由层。
vllm-project/router 里还有一份自己的 src/tokenizer/(2,784 行 Rust code,直接用 tokenizers、tiktoken-rs、minijinja,Cargo.toml#L68),是从 SMG 分出来时带过来的。在 v0.1.16 的源码里,除了它自己,只有两个 benches 引用它;gRPC 后端走的是 vLLM 的 crate,HTTP 后端不分词。
分词
vLLM:按文件选后端,再按速度选实现
先按模型目录里有什么文件定哪种分词器(model_files.rs#L339):
- 有
tekken.json:Mistral 的 Tekken。注释说 Mistral 模型的 HFtokenizer.json有一个已知的正则错误。 - 文件是
.tiktoken/tiktoken.model:tiktoken。 tokenizer_config.json的tokenizer_class含Tiktoken:在同目录找 BPE 文件,按 tiktoken 加载。- 其余:HF 格式的
tokenizer.json。
再在每种里按速度选实现:
tokenizer.json:先 fastokens,失败回退 HFtokenizers。 hf.rs#L145 的注释:「tries to load withfastokensfirst for better performance, then falls back to HuggingFace'stokenizersif the former fails」。解码器只有一个 ByteLevel 阶段时,再走一条绕开Decoder::decode拼字符串的快路径(Backend::FastokensByteLevel,hf.rs#L28)。- tiktoken:先 riptoken,失败回退 tiktoken-rs;设了环境变量
VLLM_RS_DISABLE_RIPTOKEN就直接用 tiktoken-rs(tiktoken.rs#L261)。
// rust/src/tokenizer/src/hf.rs
pub fn new(path: &Path) -> Result<Self> {
match Self::new_fastokens(path) {
Ok(tokenizer) => Ok(tokenizer),
Err(error) => {
warn!(
path = %path.display(),
error = %error.as_report(),
"failed to load tokenizer with fastokens; falling back to HuggingFace tokenizers"
);
Self::new_hf(path)
}
}
}
回退只发生在加载时:fastokens 能加载,就整个生命周期都用它。两种实现对同一输入是否给出相同的 token,靠的是 hf.rs 里的对照测试(例如 fastokens_ordinary_matches_original_encode_with_added_empty),不是运行期比对。
vllm-tokenizer 还负责增量反分词(incremental.rs:「Stateful incremental decoder that emits text chunks one token at a time」),流式输出时一次喂一个 token、吐出能确定的文本片段。
SGLang 与 SMG
dynamo-tokenizers 和 llm-tokenizer 的源码不在本地,里面用哪种实现,这里不下结论。能确定的是:
- SGLang 的
sglang-server和experimental/sgl-router都依赖dynamo-tokenizers3.1.0(前者写"3.1.0",后者钉成"=3.1.0"),后者的注释强调要「hashes the same tokens the engine caches」,KV 事件路由靠这个(见路由层)。 sglang-grpc和sglang-bench直接用 HFtokenizers0.21。
聊天模板
vLLM:minijinja 为主,几个模型手写
vllm-chat 先看模型的 model_type 决定用哪个渲染器(selection.rs#L49):
// rust/src/chat/src/renderer/selection.rs
pub fn resolve(self, model_type: &str) -> Self {
match self {
Self::Auto => match model_type {
Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32,
Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
Self::DEEPSEEK_V41_LITERAL => Self::DeepSeekV41,
Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
Self::INKLING_MODEL_TYPE => Self::Inkling,
Self::KIMI_K3_LITERAL => Self::KimiK3,
_ => Self::Hf,
},
selection => selection,
}
}
Hf:用 minijinja 执行模型自带的 Jinja 模板,配minijinja-contrib的pycompat,补上 Python 字符串方法(rust/Cargo.toml#L66)。- DeepSeek V3.2 / V4 / V4.1、Kimi K3、Inkling:手写渲染器,在
rust/src/chat/src/renderer/下各一个目录。这几个模型的格式不是(或不只是)一段 Jinja。 - GPT-OSS:Harmony 格式,用
oss-harmony的 git 依赖,以openai-harmony的名字引入(rust/Cargo.toml#L70)。
默认 Auto,也可以强制指定。
SGLang:交给 dynamo-renderer
SGLang 的 Rust 一侧用 dynamo-renderer 渲染,sglang-processor 只在和 Python 行为不一致的地方补代码。它在 serde_json 上打开 preserve_order 和 float_roundtrip,注释说是为了「Prompt parity」:工具 schema 按请求里的顺序序列化,浮点数按 Python json.loads 的方式舍入(Cargo.toml#L20)。一个字段顺序不同,渲染出来的提示就差几个 token,前缀缓存和 KV 事件路由都会受影响。
工具调用与推理解析
解析器的工作是从模型的流式输出里把三样东西分开:给用户看的正文、推理内容(<think> 一类)、工具调用。难点在流式:一个标签可能被切在两个 chunk 之间,解析器要能先扣住、后确认。
vLLM:vllm-parser
24,201 行 Rust code(总 27,553),分三块:
tool/:DeepSeek(JSON 与 DSML 两种)、GLM(XML)、Kimi K2、MiniMax M2 / M3、Qwen Coder、Seed-OSS、MiMo、HY3 / HY4(hy.rs)等。reasoning/:DeepSeek R1 / V3、Qwen3、GLM-4.5、Kimi、MiniMax M3、Cohere 等,多数共用一个按分隔符切分的实现(delimited.rs)。unified/:「Unified parser interface for reasoning and tool-call deltas」,一个解析器同时吐Text、Reasoning、ToolCall三种事件,CombinedParser把独立的推理解析器和工具解析器串起来。
strict 和 required 两种工具调用另外接了 xgrammar-structural-tag(提交 4ed8eaafb),用工具 schema 生成约束输出的语法。
对照基准:vLLM 的解析器对 SMG 的 tool-parser
rust/src/parser/benches/ 下 12 个基准,其中 8 个(DeepSeek V3 / V3.1、GLM-4.5 MoE、Kimi K2、Llama3 JSON、MiniMax M2、Qwen3 Coder、Qwen3 JSON)同时跑 vLLM 自己的解析器和外部 tool-parser 的同名解析器(qwen3_coder.rs#L7):
// rust/src/parser/benches/qwen3_coder.rs
use tool_parser::parsers::QwenCoderParser as ExternalQwenCoderParser;
use vllm_parser::tool::{Qwen3CoderToolParser, Tool, ToolParser};
// ...
group.bench_function("external_reuse_parser", |b| {
let mut parser = ExternalQwenCoderParser::new();
b.iter(|| {
let result = feed_external_parser(&mut parser, &openai_tools, black_box(&chunks));
// ...
})
});
输入按固定字符数(这个文件里是 7 个字符)切成 chunk 逐块喂,模拟流式。基准都要 --features test-util。本卷跑了其中 Qwen3 Coder、Llama3 JSON、DeepSeek V3.1 三个,结果见文末「实测」。
SGLang:dynamo-parsers
sglang-server 用 dynamo-parsers =9.2.8(精确版本),经 sglang-processor 的 parser feature 引入。实现在 Dynamo 的 crate 里,本地没有源码。
被删掉的 PyO3 解析器桥
vLLM 在 Rust 前端之外试过另一条路:不换掉整个 Python 前端,只把 Rust 解析器编成 Python 扩展,让 Python 的工具解析器调它。
flowchart LR
subgraph A["v0.24.0 – v0.31.x"]
pyf["Python 前端"] --> mm["MinimaxM3ToolParser<br/>继承 RustToolParser"]
mm -->|"PyO3"| ext["vllm._rust_tool_parser<br/>Rust 解析器"]
end
subgraph B["v0.32.0"]
pyf2["Python 前端"] --> mm2["MinimaxM3ToolParser<br/>继承 Python parser engine 适配器"]
rs["vllm-rs 进程"] --> vp["vllm-parser<br/>Rust 直接调用"]
end
| 日期 | 提交 | 内容 | 首个发布 |
|---|---|---|---|
| 2026-06-11 | 43914dd74 | 「Add Python bridge for Rust tool parsers」:PyO3 crate(当时在 rust/src/tool-parser/python/)、Python 适配 rust_tool_parser.py、测试,加上构建与 Dockerfile 改动,全提交 +1302 / −38 行 | v0.24.0 |
| 2026-06-25 | 76c3c4ff6 | 「Introduce unified parser interface & combined parser」:rust/src/tool-parser/ 改名为 rust/src/parser/,原来单独的 rust/src/reasoning-parser/ 也在这次并入;桥的 crate 随之到了 rust/src/parser/python/ | v0.25.0 |
| 2026-10-06 | 5145e35e5 | 「Port the MiniMax M3 tool parser to the parser engine」:在 Python 的 vllm/parser/ 里重写 MiniMax M3 | v0.32.0 |
| 2026-10-06 | b3082d304 | 「Remove the PyO3 tool-parser bridge」,−1193 行(PyO3 crate 398、Python 适配 322、测试 328) | v0.32.0 |
几个事实:
- 用户只有一个。 v0.24.0、v0.26.0、v0.28.0、v0.30.0、v0.31.0 五个 tag 上,
vllm/里继承RustToolParser的只有minimax_m3_tool_parser.py。v0.31.0 上它的文档字符串写着「The real M3 grammar lives in the Rust tool-parser crate」。 - 随主 wheel 构建。 v0.31.0 的
tools/build_rust.py里,vllm._rust_tool_parser(Binding.PyO3)和vllm.vllm-rs(Binding.Exec)并列在同一个列表里,用同一个optional开关。 - 先补 Python 实现,再删桥。 同一天两个提交:先把 MiniMax M3 移植成 Python 的 parser engine 适配器,再删 PyO3 模块。v0.32.0 的
MinimaxM3ToolParser继承MinimaxM3ParserToolAdapter。Rust 的 MiniMax M3 解析器(rust/src/parser/src/tool/minimax_m3.rs)还在,由vllm-rs直接调用。 - 提交信息没写原因。 两个提交的正文只有署名。所以这里只陈述结果:vLLM 的 Rust 从「独立进程 + 一个进程内扩展」收敛为只剩独立进程;现在
build_rust.py里留着一段注释掉的示例(build_rust.py#L58)。
对照 SGLang:它的 Rust 全是进程内 PyO3 扩展(见两种前端),但文本栈并没有以「Python 前端调 Rust 解析器」的形式出现,要用 Rust 解析器就整个打开 SGLANG_RUST_SERVER。
实测:解析器与分词器微基准
两组都在 M2 Max 笔记本上跑,代码是 vLLM v0.32.0 的 rust/。都是单独调用解析器或分词器的微基准,不含 HTTP、模板和引擎,不代表一个请求的端到端延迟。
解析器:vLLM vllm-parser 对外部 tool-parser
cargo bench --features test-util,criterion 报的中位数。「vLLM」是 vllm-parser 自己的解析器,「外部」是作为 dev-dependency 钉住的 tool-parser 同名解析器。下表是复用同一个解析器(reuse_parser)的行;每次新建解析器(create_parser)的行与之相差约 10%–40% 以内。
| 格式 | 输入 | vLLM | 外部 |
|---|---|---|---|
| qwen3_coder | mixed_text_tool_call | 5.57 µs | 92.6 µs |
| qwen3_coder | long_normal_text | 1.26 ms | 5.09 ms |
| qwen3_coder | long_tool_call_body | 33.6 µs | 6.93 ms |
| llama3_json | mixed_text_tool_call | 3.40 µs | 43.5 µs |
| llama3_json | long_normal_text | 0.273 ms | 1.99 ms |
| deepseek_v31 | mixed_text_tool_call | 2.28 µs | 22.3 µs |
| deepseek_v31 | long_normal_text | 0.768 ms | 11.9 ms |
| deepseek_v3 | mixed_text_tool_call | 2.34 µs | 18.6 µs |
| deepseek_v3 | long_normal_text | 0.720 ms | 8.56 ms |
| glm45 | mixed_text_tool_call | 5.68 µs | 8.51 µs |
| glm45 | long_normal_text | 0.987 ms | 2.04 ms |
| kimi_k2 | mixed_text_tool_call | 1.49 µs | 16.8 µs |
| kimi_k2 | long_normal_text | 0.721 ms | 6.54 ms |
| minimax_m2 | mixed_text_tool_call | 7.89 µs | 32.7 µs |
| minimax_m2 | long_normal_text | 1.43 ms | 7.31 ms |
| qwen3_json | mixed_text_tool_call | 3.61 µs | 25.1 µs |
| qwen3_json | long_normal_text | 3.93 ms | 2.33 ms |
8 个对照格式都跑了(glm45 的基准文件里还有一组 glm47,和 glm45 相差 5% 以内,没有列出)。外部除以 vLLM,大多数行在 1.5–206 倍之间:最小是 glm45 mixed_text_tool_call 的 8.51 / 5.68 ≈ 1.5 倍,最大是 qwen3_coder long_tool_call_body 的 6.93 ms / 33.6 µs ≈ 206 倍。例外是 qwen3_json 的 long_normal_text:vLLM 3.93 ms,外部 2.33 ms,vLLM 反而慢约 1.7 倍,是表里唯一的一行。原因没有查。
分词器:fastokens、HF tokenizers、Python tokenizers
Rust 两行来自 vllm-tokenizer 的 benches/hf.rs(criterion 中位数);Python 一行用 Python tokenizers 0.22.2 读同一个文件、编码同一段文本,取 2000 次的中位数。分词文件是 Qwen3.5-0.8B 的 tokenizer.json,文本是基准自带的样例重复 32 遍,12,032 字节,2,816 个 token。
| 实现 | 编码 | 解码 |
|---|---|---|
| fastokens(vLLM 默认先试的后端) | 0.194 ms | 55.2 µs |
HF tokenizers 0.22.2(Rust) | 1.80 ms | 184.8 µs |
Python tokenizers 0.22.2 | 2.41 ms | 230.5 µs |
fastokens 对 HF tokenizers:编码 1.80 / 0.194 ≈ 9.3 倍,解码 184.8 / 55.2 ≈ 3.3 倍。Python tokenizers 对 Rust 直接调用 HF tokenizers:编码 2.41 / 1.80 ≈ 1.3 倍,解码 230.5 / 184.8 ≈ 1.2 倍。Rust 两行用 criterion 计时,Python 一行用自己的循环取 2000 次中位数,计时方式不同,只能粗比;而且只测了一个分词文件、一段文本。
tiktoken 格式:riptoken 对 tiktoken-rs
cargo bench -p vllm-tokenizer --bench tiktoken,同一台 M2 Max,criterion 中位数。分词文件是基准自己从 Hugging Face 取的 moonshotai/Kimi-K2.5 的 tiktoken.model(取到的修订 4d01dfe0);文本是基准里的一段样例重复 32 次,11,776 字节,和上面 HF 那组不是同一段文本。
| 实现 | 编码 | 解码 |
|---|---|---|
| riptoken(vLLM 用的后端) | 158.8 µs | 15.7 µs |
| tiktoken-rs | 796.6 µs | 90.4 µs |
riptoken 对 tiktoken-rs:编码 796.6 / 158.8 ≈ 5.0 倍,解码 90.4 / 15.7 ≈ 5.8 倍。
模板渲染加分词
一个请求从 JSON 到 token id 的整段(聊天模板 + 分词,含 HTTP),vllm-rs render 对 Python 渲染服务,在 两种前端 一页实测:单个请求 Rust 0.3–1.2 ms、Python 4.7–14.5 ms,两边输出的 token id 一致。
待实测
- Dynamo 与 SMG 的 crate 没有现成的对照基准,本卷不测。
小结
- Rust 文本栈分三套:vLLM 自写,SGLang 包 Dynamo,SMG 用自己抽出的 crate;mistral.rs 另有一套,直接用 HF
tokenizers和 minijinja。 - 真正跨项目进二进制的依赖只有两条:vLLM 用
smg-project的llm-multimodal,vllm-project/router 用 vLLM 的vllm-chat/vllm-tokenizer/vllm-text。vLLM 对tool-parser只用于基准对照。 - vLLM 分词先按文件选种类(tekken > tiktoken >
tokenizer.json),再按速度选实现(fastokens、riptoken 优先,加载失败回退)。 - vLLM 的聊天模板以 minijinja 为主,DeepSeek V3.2 / V4 / V4.1、Kimi K3、Inkling、GPT-OSS 走手写渲染器;SGLang 交给
dynamo-renderer,并为提示逐字一致调整 JSON 序列化。 - PyO3 解析器桥在 v0.24.0–v0.31.x 存在,只服务 MiniMax-M3;同一天先移植到 Python、再删桥。
- 单机微基准(M2 Max 笔记本):8 个对照格式里,vLLM 解析器在多数输入上比外部
tool-parser快约 1.5–206 倍,qwen3_json 的长纯文本是例外(慢约 1.7 倍);fastokens 编码比 HFtokenizers快约 9.3 倍、解码约 3.3 倍;riptoken 比 tiktoken-rs 编码快约 5.0 倍、解码约 5.8 倍。模板渲染加分词的整段,Rust 渲染服务比 Python 快约 9–34 倍(见两种前端一页)。