Rust in Everything

共享的文本栈

主线位置:一个请求的一生里「模板与分词」和「反分词、流式返回」两段,以及输出里的工具调用、推理内容的抽取。默认路径上两个引擎都在 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-rsvllm-tokenizer(fastokens → HF tokenizers;riptoken → tiktoken-rs;tekken)vllm-chat(minijinja + 手写渲染器)vllm-parserrust/Cargo.toml#L47
SGLang sglang-serverdynamo-tokenizers 3.1.0dynamo-renderer 7.0.1 + minijinjadynamo-parsers =9.2.8,经 sglang-processorCargo.toml#L41
SGLang experimental/sgl-routerdynamo-tokenizers =3.1.0dynamo-renderer =7.0.1 + sglang-processor不解析Cargo.toml#L25
SMGllm-tokenizer =1.3.2在外部 crate 里tool-parser =1.0.0、reasoning-parser =1.0.0Cargo.toml#L82
vllm-project/router(gRPC 后端)vllm-tokenizervllm-chat不解析Cargo.toml#L102
mistral.rsHF tokenizers 0.21.4minijinja 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),之后钉成精确版本。谱系见路由层。
📝NOTE

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):

  1. 有 tekken.json:Mistral 的 Tekken。注释说 Mistral 模型的 HF tokenizer.json 有一个已知的正则错误。
  2. 文件是 .tiktoken / tiktoken.model:tiktoken。
  3. tokenizer_config.json 的 tokenizer_class 含 Tiktoken:在同目录找 BPE 文件,按 tiktoken 加载。
  4. 其余:HF 格式的 tokenizer.json。

再在每种里按速度选实现:

  • tokenizer.json:先 fastokens,失败回退 HF tokenizers。 hf.rs#L145 的注释:「tries to load with fastokens first for better performance, then falls back to HuggingFace's tokenizers if 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-tokenizers 3.1.0(前者写 "3.1.0",后者钉成 "=3.1.0"),后者的注释强调要「hashes the same tokens the engine caches」,KV 事件路由靠这个(见路由层)。
  • sglang-grpc 和 sglang-bench 直接用 HF tokenizers 0.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-1143914dd74「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-2576c3c4ff6「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-065145e35e5「Port the MiniMax M3 tool parser to the parser engine」:在 Python 的 vllm/parser/ 里重写 MiniMax M3v0.32.0
2026-10-06b3082d304「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_codermixed_text_tool_call5.57 µs92.6 µs
qwen3_coderlong_normal_text1.26 ms5.09 ms
qwen3_coderlong_tool_call_body33.6 µs6.93 ms
llama3_jsonmixed_text_tool_call3.40 µs43.5 µs
llama3_jsonlong_normal_text0.273 ms1.99 ms
deepseek_v31mixed_text_tool_call2.28 µs22.3 µs
deepseek_v31long_normal_text0.768 ms11.9 ms
deepseek_v3mixed_text_tool_call2.34 µs18.6 µs
deepseek_v3long_normal_text0.720 ms8.56 ms
glm45mixed_text_tool_call5.68 µs8.51 µs
glm45long_normal_text0.987 ms2.04 ms
kimi_k2mixed_text_tool_call1.49 µs16.8 µs
kimi_k2long_normal_text0.721 ms6.54 ms
minimax_m2mixed_text_tool_call7.89 µs32.7 µs
minimax_m2long_normal_text1.43 ms7.31 ms
qwen3_jsonmixed_text_tool_call3.61 µs25.1 µs
qwen3_jsonlong_normal_text3.93 ms2.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 ms55.2 µs
HF tokenizers 0.22.2(Rust)1.80 ms184.8 µs
Python tokenizers 0.22.22.41 ms230.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 µs15.7 µs
tiktoken-rs796.6 µs90.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 编码比 HF tokenizers 快约 9.3 倍、解码约 3.3 倍;riptoken 比 tiktoken-rs 编码快约 5.0 倍、解码约 5.8 倍。模板渲染加分词的整段,Rust 渲染服务比 Python 快约 9–34 倍(见两种前端一页)。

💬 关联讨论

发起讨论
加载中...