两种前端
主线位置:一个请求的一生里「HTTP 进来 → 模板与分词」和「反分词、流式返回」这两段。默认两个引擎都用 Python 做;打开 Rust 前端后,这两段换成 Rust,调度、KV 分配和 GPU 前向仍是 Python + CUDA。
版本:vLLM v0.32.0、SGLang v0.5.22
两个引擎都有一个可选的 Rust 前端,默认都关着。做的事几乎一样:接 HTTP、渲染聊天模板、分词、把引擎吐出的 token 增量反分词再流式返回。接法相反:
- vLLM
vllm-rs:独立子进程。Python 先绑好 HTTP 和 ZMQ 的监听 socket,拉起vllm-rs frontend,把 fd 传给它;引擎那边看到的还是原来的 ZMQ + msgpack,一行不用改。 - SGLang
sglang-server:不另起进程。它是 rank-0 调度器进程里的一组 Rust 线程,Python 调度循环直接从 Rust 的 flume 通道里取请求,生成结果整批推回去。
flowchart TB
subgraph VL["vLLM:打开 VLLM_USE_RUST_FRONTEND 后"]
sup["Python 主进程<br/>绑定 socket 后监护"] -->|"Popen + pass_fds"| rs["vllm-rs frontend 进程"]
sup -->|启动| ec["EngineCore 进程<br/>调度 + GPU"]
rs <-->|"ZMQ + msgpack"| ec
end
subgraph SG["SGLang:打开 SGLANG_RUST_SERVER 后"]
mp["Python 主进程<br/>只等调度器退出"]
subgraph r0["rank-0 调度器进程"]
rt["Rust 线程<br/>HTTP 分词 反分词"] <-->|"flume 通道"| py["Python 调度循环 + GPU"]
end
mp -.->|启动| r0
end
vLLM:vllm-rs 独立进程
怎么打开
两个环境变量:
VLLM_USE_RUST_FRONTEND:默认"0"(envs.py#L1475)。VLLM_RUST_FRONTEND_PATH:默认"auto",指包目录里的vllm/vllm-rs;找不到就抛FileNotFoundError,不会悄悄退回 Python 前端(envs.py#L582)。
二进制随主 wheel 构建:tools/build_rust.py 把它声明成 setuptools-rust 的 Binding.Exec 目标 vllm.vllm-rs(build_rust.py#L48)。默认是可选构建,只有 VLLM_REQUIRE_RUST_FRONTEND 设成真值时缺了才报错(setup.py#L69)。
vllm-rs 有四个子命令(cli.rs#L91):frontend(被 Python 监护的子进程,本页的主角)、serve(反过来由 Rust 拉起一个 Python headless 引擎)、bench、render(不连引擎,只做模板渲染和预处理)。
Python 怎么拉起它、传哪些 fd
vllm serve 发现打开了 Rust 前端,就走多 API 服务器的启动路径 run_multi_api_server(serve.py#L151)。顺序是:
- 绑 HTTP 监听 socket;有
--grpc-port时再绑一个 gRPC 的(serve.py#L298)。 - 用裸 socket 绑好前端一侧的 ZMQ 监听:输入一个、输出一个(serve.py#L333)。这里还不是 ZMQ socket,只是
bind+listen过的 TCP 或 Unix socket,留给子进程「领养」(network_utils.py#L409)。 - 启动 EngineCore 进程,等握手完成。
- 构造
RustFrontendProcessManager,拼命令行,Popen(cmd, pass_fds=...)(serve.py#L354)。
命令行在 vllm/v1/utils.py(utils.py#L311):
# vllm/v1/utils.py
cmd = [
binary_path,
"frontend",
"--listen-fd", str(listen_fd),
"--input-listener-fd", str(input_fd),
"--output-listener-fd", str(output_fd),
"--engine-start-index", str(engine_start_index),
"--engine-count", str(engine_count),
"--data-parallel-size", str(data_parallel_size),
]
if grpc_sock is not None:
# ...
cmd.extend(["--grpc-listen-fd", str(grpc_fd)])
# ...
cmd.extend(["--args-json", args_json])
# ...
self._proc = subprocess.Popen(cmd, pass_fds=inherited_fds)
(原文每个参数一行,这里合并了。)
| 参数 | 是什么 | Rust 一侧 |
|---|---|---|
--listen-fd | HTTP 监听 socket | axum 直接在继承的 fd 上 accept |
--input-listener-fd | 输入方向 ZMQ 监听(Python 以 zmq.ROUTER 的身份绑定) | 领养成 RouterSocket |
--output-listener-fd | 输出方向 ZMQ 监听(以 zmq.PULL 绑定) | 领养成 PullSocket |
--grpc-listen-fd(可选) | gRPC 监听 | 起 vllm.Inference / vllm.Control 服务 |
--coordinator-address(可选) | 数据并行协调器的统计地址(stats_update_address),是地址字符串不是 fd | 对接 DP 协调器 |
--args-json | 其余 CLI 参数的 JSON | serde 反序列化,绕过 clap |
Rust 一侧的参数定义写明了这几个 fd 的角色(cli.rs#L709)。--args-json 绕过了 clap,所以 #[arg(env = ...)] 不生效;Python 把 VLLM_ENGINE_READY_TIMEOUT_S 和 VLLM_HTTP_TIMEOUT_KEEP_ALIVE 显式塞进 JSON(utils.py#L356)。日志里打印的是去掉 api_key、hf_token 的版本。
先绑后传的好处:端口在 Python 里就定下来了,EngineCore 启动时已经知道往哪连;子进程起得慢也不会丢连接,内核的 listen 队列先替它排着。
ZMQ 套接字配对
前端一侧绑定(bind),引擎一侧连接(connect)。Rust 前端和 Python 前端用的类型完全一样,所以引擎分不出对面是谁。
| 方向 | 前端(bind) | 引擎 EngineCore(connect) | 说明 |
|---|---|---|---|
| 请求 → 引擎 | ROUTER | DEALER,identity = 引擎序号 | ROUTER 按 identity 把请求投给指定的数据并行引擎 |
| 结果 → 前端 | PULL | PUSH | 所有引擎往同一个 PULL 推 |
出处:
- Python 父进程按这两种类型绑监听:utils.py#L1169(ROUTER)、utils.py#L1172(PULL)。
- Rust 领养:transport.rs#L358(
RouterSocket)、transport.rs#L364(PullSocket)。 - 引擎连接:core.py#L1849(DEALER)、core.py#L1952(PUSH)。
- Python 前端的同一位置:core_client.py#L642(ROUTER)、core_client.py#L650(PULL)。
// rust/src/engine-core-client/src/transport.rs
let mut input_socket = RouterSocket::new();
let input_address = input_socket
.bind_listener(adopt_listener(input_listener_fd)?)
.await?
.to_string();
let mut output_socket = PullSocket::new();
let output_address = output_socket
.bind_listener(adopt_listener(output_listener_fd)?)
.await?
.to_string();
Rust 用的是纯 Rust 的 zeromq crate,不是 libzmq 绑定。frontend 子命令走 connect_bootstrapped:引擎已经由 Python 握过手,Rust 只按连续的序号等每个引擎在输入 socket 上报到(transport.rs#L331)。serve 子命令不同,它自己绑一个 ROUTER 做握手(transport.rs#L182)。
sequenceDiagram participant P as Python 主进程 participant E as EngineCore participant R as vllm-rs P->>P: bind HTTP 与 ZMQ 监听(裸 socket) P->>E: 启动并握手 P->>R: Popen,pass_fds 传监听 fd R->>R: 领养 fd,成为 ROUTER 与 PULL E->>R: DEALER connect,按引擎序号报到 R->>E: 请求(msgpack,经 ROUTER) E->>R: 输出(msgpack,经 PUSH 到 PULL)
msgpack 兼容性怎么保证
线上格式是 msgspec 编出来的 msgpack,Rust 一侧手写了对应的 serde 类型。保证两边一致的是一条字节级的夹具测试 python_msgpack_fixtures_match_rust_encoding(client.rs#L2741):
// rust/src/engine-core-client/src/tests/client.rs
#[test]
fn python_msgpack_fixtures_match_rust_encoding() {
// ...
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/tests/python_compat.py");
let output = Command::new(&script)
.output()
// ...
let request_hex = lines.next().expect("missing request fixture line");
// ...
}
脚本用 msgspec 编出请求、输出、logprobs、KV 连接器统计、就绪响应等十几种消息的十六进制,Rust 解码后再编码比对。要注意的一点:脚本不 import vLLM,而是在文件里复刻了一份结构体定义(例如 EngineCoreSamplingParams 写明「Mirror of real SamplingParams」,python_compat.py)。所以它证明的是「Rust 与这份复刻一致」;Python 那边改了 SamplingParams 而复刻没跟上,这条测试不会红。
覆盖了哪些端点,缺什么
Rust 路由表(routes.rs#L109):
// rust/src/server/src/routes.rs
let mut router = Router::new()
.route("/health", get(health::health))
.route("/metrics", get(metrics::scrape))
.route("/load", get(load::load))
.route("/version", get(version::version))
.route("/kv_event_sources", get(kv_event_sources::kv_event_sources))
.route("/v1/models", get(openai::list_models))
.route("/v1/completions", post(openai::completions))
.route("/v1/chat/completions", post(openai::chat_completions))
.route("/tokenize", post(tokenize::tokenize))
.route("/detokenize", post(tokenize::detokenize));
// ...
另外几组按开关挂:
/inference/v1/generate(token 进、token 出)要--enable-scale-out,否则挂一个返回错误的占位(routes.rs#L124)。- LoRA 加载 / 卸载要运行时 LoRA 开关;
/sleep、/pause、权重更新等控制端点只在开发模式挂(routes.rs#L137)。
Python 前端有、Rust 前端没有的端点:
| 端点 | Python 一侧出处 |
|---|---|
/v1/responses | responses/api_router.py |
/v1/embeddings | embed/api_router.py |
/v1/score、/v1/rerank | scoring/api_router.py |
/v1/audio/transcriptions | transcription/api_router.py |
/v1/messages(Anthropic 兼容) | anthropic/api_router.py |
还有一处行为差异:多模态的 mm_device_do_normalize 在打开 Rust 前端时被强制关掉,显式打开也只记一条 warning(model.py#L1091)。归一化于是留在 CPU 预处理里做。
Rust 前端也不做的事:路由、负载均衡、PD 协调、KV 索引。KV cache 管理器仍在 Python(vllm/v1/core/);Rust 只暴露一个 /kv_event_sources。
api_server_count 只能是 1
Python 前端在数据并行时可以起多个 API 服务器进程分摊 CPU 工作,个数默认等于 DP 大小。Rust 前端是一个多线程进程,不需要也不支持多个副本:
- 没给
--api-server-count时,打开 Rust 前端就默认 1,注释写的是「not applicable as it's multithreaded」(serve.py#L109)。 - 显式给了大于 1 的值,不报错,打一条 warning 后改成 1(serve.py#L131)。
run_multi_api_server里还有一道ValueError(serve.py#L277),走vllm serve时前面已经改成 1,碰不到它。
--grpc-port 反过来只能和 Rust 前端一起用,和 Python 的 --grpc 互斥(cli_args.py#L470)。
被删掉的 PyO3 解析器桥
vLLM 试过另一条路:把 Rust 的工具调用解析器编成 PyO3 模块 vllm._rust_tool_parser,让 Python 前端调用。
| 时间 | 提交 | 首个包含它的发布 |
|---|---|---|
| 2026-05-20 | f2ace1d57 Rust 前端集成 RFC | v0.22.0 |
| 2026-05-22 | 39910f2b2 代码从独立仓库搬进 rust/ | v0.22.0 |
| 2026-06-11 | 43914dd74 加 Rust 工具解析器的 Python 桥 | v0.24.0 |
| 2026-10-06 | b3082d304 删除 PyO3 工具解析器桥 | v0.32.0 |
桥存在于 v0.24.0 到 v0.31.x。在 v0.31.0 上,只有 MiniMax-M3 的解析器继承了 RustToolParser。同一天先有 5145e35e5 把它移植到 Python 的 parser engine(vllm/parser/),再由 b3082d304 删桥,去掉 1193 行(PyO3 crate 398 行、Python 适配 322 行、测试 328 行)。现在它只剩 build_rust.py 里一段注释掉的示例(build_rust.py#L58):
# tools/build_rust.py
# Example PyO3 module (the removed tool-parser bridge):
# RustExtension(
# target="vllm._rust_tool_parser",
# path="rust/src/parser/python/Cargo.toml",
# features=["pyo3/abi3-py38"],
# binding=Binding.PyO3,
# ...
结果是 vLLM 的 Rust 只剩一个形态:Binding.Exec 的独立二进制,wheel 里没有任何 Rust 写的 Python 扩展模块。
crate 与规模
rust/ 工作区共 115,234 行 Rust code(含注释和空行 131,409 行)。下表是 tokei 的 code 行,括号里是总行数,都含各 crate 的 tests/ 和 benches/:
| crate | 行数 | 做什么 |
|---|---|---|
vllm-server | 26,345(29,743) | axum HTTP、tonic gRPC |
vllm-parser | 24,201(27,553) | 工具调用和推理内容解析 |
vllm-chat | 19,864(22,598) | 聊天模板(minijinja 加手写渲染器)、多模态预处理 |
vllm-bench | 14,698(17,004) | 压测客户端 |
vllm-engine-core-client | 12,032(13,732) | ZMQ + msgpack 传输 |
vllm-text | 5,065(5,723) | 分词、增量反分词 |
vllm-tokenizer | 3,727(4,392) | HF tokenizers、tiktoken 等后端 |
vllm-cmd | 3,311(3,731) | CLI,产出二进制 vllm-rs |
vllm-llm | 2,947(3,345) | token 进 token 出的门面 |
| 其余 6 个 | 合计约 3,000 | metrics、mock 引擎、tracing、proto 等 |
vllm-mock-engine(784 行)是一个假引擎:预填充立即完成、解码吐随机 token,用来单独压前端。
SGLang:sglang-server 嵌进调度器
怎么打开,谁来跑
SGLANG_RUST_SERVER 默认 False(environ.py#L1902)。打开后:
- 主进程不再起 Python HTTP 服务器和 TokenizerManager,只等调度器退出(http_server.py#L2943)。
- 每个调度器进程判断自己是不是宿主:
pp_rank、attn_tp_rank、attn_cp_rank都是 0 的那个(scheduler.py#L2331)。开了 DP attention 时每个 DP rank 各有一个宿主,监听port + DP rank(lib.rs#L184)。 - 宿主启动 Rust 服务器,并把它直接当成「从 tokenizer 收请求」的那一端(scheduler.py#L2352):
# python/sglang/srt/managers/scheduler.py
rust_server = self.get_rust_server_class().launch(self)
self.rust_server = rust_server
# The rust server *is* the ingress source: SchedulerRequestReceiver
# drains its request ring (rust_server_mode) instead of a zmq socket.
self.recv_from_tokenizer = rust_server
Python 包装类的文档字符串说得最直白:它替换掉 Python 的 api-server、TokenizerManager、DetokenizerManager,在调度器进程里以 Rust 线程运行(server.py#L1)。
flowchart LR
cli["HTTP / gRPC 客户端"] --> ax["axum 与 tonic 线程"]
subgraph proc["rank-0 调度器进程"]
ax --> tm["TokenizerManager 线程<br/>模板 分词"]
tm -->|"ToSchedulerTx<br/>flume,容量 8192"| loop["Python 调度循环<br/>recv_requests 非阻塞取"]
loop -->|"push_decode_result_batch<br/>整批一帧"| dt["反分词分片线程"]
dt --> ax
loop --> gpu["前向与采样<br/>GPU"]
end
flume 通道
边界只有两条队列。通道文件开头写明了设计(channel.rs#L1):
// rust/sglang-server/src/tokenizer_manager/channel.rs
//! In embedded mode the Rust frontend threads and the Python scheduler loop
//! share one process, so these are in-process `flume` channels — literal
//! `mpsc`/`mpmc`, no shared memory, no serialization beyond the msgpack bytes
//! the payload already is.
// ...
/// ToSchedulerTx: TokenizerManager → scheduler `recv_requests`.
/// Producers are Rust TM workers; the single consumer is the Python thread.
/// Carries [`SchedulerRequest`] (scalar msgpack header + named buffers), not a
/// single msgpack blob, so no tensor ever goes through msgpack or gets copied.
PyO3 暴露给 Python 的几个方法(lib.rs#L166):
| 方法 | 做什么 |
|---|---|
Server(server_args, ..., to_scheduler_cap=8192, from_scheduler_cap=8192, ...) | 启动所有线程后立即返回 |
recv_requests(max=256) | 非阻塞地取一批请求;每个是 msgpack 头加命名缓冲区,缓冲区以 numpy 数组移交,不复制 |
wait_request(timeout_ms=1000) | 空闲时停在通道上,释放 GIL(py.detach,lib.rs#L226) |
push_decode_result_batch(header, data_cols) | 一个解码步的输出整批一帧推回;满了就阻塞,形成背压 |
push_error(rid, msg) / push_control_result | 单请求失败、控制请求的结果 |
请求头仍用 msgpack 解码成和 ZMQ 路径相同的 TokenizedGenerateReqInput,所以调度器看到的对象不变(server.py#L241)。变的是 input_ids 这类大列不再进 msgpack,而是作为缓冲区单独传;输出方向同样把 token id 和 logprob 列按原始字节拼进一帧。
通道注释说「no shared memory」,指的是通道本身。多模态特征的分发用了 POSIX 共享内存:Cargo.toml 里引入 rustix 的 shm 特性注明「for the MM feature fan-out」(Cargo.toml#L54),缓冲区可能是一个 ShmBuffer,由 Python 在 TP 广播后映射。
接管了什么
- HTTP:
/generate、/health、/health_generate(native_api.rs#L78);/v1/chat/completions(chat.rs#L49)、/v1/completions(completions.rs#L38)、/v1/models(models.rs#L16);/server_info、/model_info(info.rs#L27)。 - 分词与反分词:原来是两个独立的 Python 进程(TokenizerManager 在主进程,DetokenizerManager 单独一个),现在是同一进程里的线程。
- gRPC:
src/api_server/grpc.rs。 - PD bootstrap 注册表:预填充 / 解码分离时节点配对用的
/route、/register_dp_rank、/query_dp_ranks(bootstrap.rs#L353)。原来由 Python TokenizerManager 承担。 - 多模态:只有图像,且只支持有 Rust 流水线的模型族。不支持的模型在监听之前就报错,提示取消
SGLANG_RUST_SERVER(server.py#L89)。流水线处理不了的请求直接拒绝,没有 Python 回退(lib.rs#L270)。
不能一起用的选项
| 选项 | 报错位置 |
|---|---|
离线 sgl.Engine | engine.py#L309:只替换 sglang serve 的 HTTP 路径 |
--enable-watermark | validation_hook.py#L104 |
--sidecar | serving_hook.py#L386:不跑 Python sidecar 的生命周期 |
| 没有 Rust 图像流水线的多模态模型 | server.py#L93 |
依赖 Dynamo 的 crate
聊天模板、分词器、解析器不是自己写的,而是 NVIDIA Dynamo 发布在 crates.io 上的 crate(Cargo.toml#L41):
# rust/sglang-server/Cargo.toml
dynamo-tokenizers = "3.1.0"
# Keep the HTTP adapter on Dynamo's public OpenAI types, renderer, and parsers.
# `dynamo-llm` stays excluded because it pulls the full runtime just to reuse
# small service helpers.
dynamo-parsers = "=9.2.8"
dynamo-protocols = "7.0.0"
dynamo-renderer = "7.0.1"
sglang-processor = { path = "../sglang-processor", default-features = false, features = ["parser"] }
sglang-processor 自称「A thin wrapper over Dynamo's frontend crates」,规则是能直接调 Dynamo 就直接调,只在 Dynamo 和 SGLang 的 Python 行为不同或缺模型时才写 SGLang 自己的代码(README.md#L3)。对照 vLLM:它的模板和解析器是自己写的 vllm-chat / vllm-parser。
sglang-server 20,073 行 Rust code(总 22,867),比 vLLM 的 rust/ 小得多,一部分原因就在这里:文本栈外包给了 Dynamo。
两边对照
vLLM vllm-rs | SGLang sglang-server | |
|---|---|---|
| 开关 | VLLM_USE_RUST_FRONTEND=1 | SGLANG_RUST_SERVER=1 |
| 形态 | 独立进程,Binding.Exec 二进制 | PyO3 扩展,调度器进程里的线程 |
| 和引擎的边界 | ZMQ ROUTER/PULL ↔ DEALER/PUSH,msgpack | flume 通道,msgpack 头 + 原始缓冲区 |
| 引擎侧改动 | 无,引擎不知道对面是 Rust | 调度器的收发两端改为调 Rust 对象 |
| 模板 / 分词 / 解析 | 自写 crate | Dynamo 的 crate |
| OpenAI 端点 | completions、chat、models、tokenize | completions、chat、models,加原生 /generate |
| 已知不支持 | responses、embeddings、score、rerank、audio、Anthropic messages | 离线 Engine、watermark、sidecar、无 Rust 流水线的多模态 |
| 副本数 | 1 个进程(多线程) | 每个 DP rank 一个宿主 |
| Rust 出错的影响面 | 前端进程,Python 监护可见 | 调度器进程本身 |
实测:只测前端开销
这一组只测 vLLM 的前端,不测引擎。对面是 vllm-mock-engine:预填充立即完成,每个输出块 1 个随机 token(词表 32000),引擎一侧几乎不花时间。所以下面的数是「前端本身能跑多快」,不是「打开 Rust 前端后服务快多少」:真实服务里大头是 GPU 前向和调度,这部分开销对两种前端相同,见 C3-2。
做法:两个前端都只起前端、不起本地引擎(--data-parallel-size-local 0),vllm-mock-engine 从握手端口接上。Python 前端是 v0.32.0 Linux wheel 的 vllm serve(VLLM_TARGET_DEVICE=cpu),Rust 前端是同一 tag 构建的 vllm-rs serve。压测客户端是 vllm-rs bench serve:random 数据集,输入 512、输出 128 个 token,分词器用 Qwen3-0.6B 的配置和分词文件(没有权重)。
一台 4 核 x86 Linux 机器上限 3 核、3 GB 的容器,两个前端轮流跑,所有轮次失败请求数都是 0:
| 并发 | 请求数 | Python req/s | Rust req/s | Python TTFT 均值 / P99 | Rust TTFT 均值 / P99 |
|---|---|---|---|---|---|
| 1 | 2000 | 35.49 | 190.54 | 27.62 / 41.33 ms | 2.33 / 7.01 ms |
| 64 | 2000 | 57.38 | 1,180.79 | 1,101.54 / 1,656.26 ms | 34.35 / 70.87 ms |
| 512 | 5120 | 65.54 | 1,021.79 | 7,422.52 / 76,721.94 ms | 320.58 / 702.61 ms |
按表里的 req/s 相除,Rust 对 Python:并发 1 时 190.54 / 35.49 ≈ 5.4 倍,并发 64 时 1,180.79 / 57.38 ≈ 20.6 倍,并发 512 时 1,021.79 / 65.54 ≈ 15.6 倍。Python 前端从并发 64 起请求吞吐基本不再涨(57.38、65.54 req/s),TTFT 随并发急剧变长。
M2 Max 笔记本上只跑了 Rust 前端:v0.32.0 在 PyPI 上没有 macOS 的 wheel,Python 前端装不上。
| 并发 | 请求数 | req/s | 输出 tok/s | TTFT 均值 | TTFT P99 |
|---|---|---|---|---|---|
| 1 | 4000 | 578.7 | 74,075 | 0.41 ms | 0.93 ms |
| 64 | 4000 | 5,732.8 | 733,425 | 6.72 ms | 24.28 ms |
| 512 | 20480 | 7,261.7 | 928,778 | 53.33 ms | 116.60 ms |
两张表的机器不同,不要跨表比。
局限:
- 容器里的 3 个 CPU 由前端、mock 引擎和压测客户端共用,三者互相抢核;宿主机上还跑着别的服务。
- mock 引擎让引擎侧的开销为零,测的只是前端开销。换成真模型后两种前端的差距会被 GPU 前向摊薄,摊薄多少待实测(C3-2)。
- 每个并发档位只跑了一轮。
- ITL 没有比:Python 前端在并发 64 和 512 时把多个输出块合进一个 SSE 事件,压测客户端算出的 ITL / TPOT 显示为 0.00。
实测:只渲染(模板 + 分词)
两边都有一个不带引擎的渲染服务:Rust 是 vllm-rs render,Python 是 vllm launch render,都提供 POST /v1/chat/completions/render,返回套上聊天模板、分好词的 token id。这一段只测请求进来后、交给引擎之前的 CPU 工作。
做法:同一台 Linux 机器、同样限 3 核 3 GB 的容器,两边轮流跑。两个服务都来自 v0.32.0 的 Linux wheel(wheel 里自带 vllm-rs 二进制),模型用 Qwen3-0.6B 的配置和分词文件。Rust 自动选了工具解析器 qwen3_json、推理解析器 qwen3;Python 启动时给同样的参数(--enable-auto-tool-choice --tool-call-parser qwen3_json --reasoning-parser qwen3),不给的话,带工具的请求直接返回 400。压测客户端是 oha 1.8.0,和服务在同一个容器里;每格先预热 300 次,再发 3000 次,全部返回 200。
三种请求体:
- 短:system 加一轮 user,187 字节,30 个 token。
- 长:8 轮问答,11.8 KB,2,188 个 token。
- 工具:一轮 user 加 8 个工具定义,3.0 KB,932 个 token。
两边输出的 token id 三种请求都完全一致。
| 请求 | 并发 | Rust req/s | Python req/s | Rust 均值 / P99 | Python 均值 / P99 |
|---|---|---|---|---|---|
| 短 | 1 | 3,009 | 214 | 0.33 / 1.60 ms | 4.66 / 8.16 ms |
| 短 | 16 | 8,984 | 267 | 1.76 / 5.46 ms | 59.8 / 87.2 ms |
| 长 | 1 | 850 | 68.8 | 1.17 / 2.42 ms | 14.5 / 22.0 ms |
| 长 | 16 | 2,270 | 124 | 7.02 / 19.8 ms | 128.7 / 193.8 ms |
| 工具 | 1 | 859 | 92.0 | 1.16 / 2.31 ms | 10.9 / 16.0 ms |
| 工具 | 16 | 2,806 | 139 | 5.67 / 17.6 ms | 115.2 / 173.1 ms |
按 req/s 相除,Rust 对 Python:短请求并发 1 / 16 约 14 / 34 倍,长请求约 12 / 18 倍,工具约 9.3 / 20 倍。单个请求(并发 1)的渲染时间,Rust 在 0.3–1.2 ms,Python 在 4.7–14.5 ms。
局限:Python 的渲染服务是单进程、一个事件循环,Rust 用满 3 个核,并发 16 的差距里有一部分来自这一点;客户端和服务共用这 3 个核;每格只跑了一轮;只测了一个模型的模板和分词文件。
待实测
- 真模型、高并发下 Python 前端与 Rust 前端的对比,两个引擎都做。待实测(C3-2)。SGLang 的 Rust 服务器离不开调度器,只能在 GPU 机器上测。
小结
- 两个 Rust 前端做的事相同:HTTP、模板、分词、反分词、流式返回。默认都关着。
- vLLM 把 Rust 放在进程外:Python 绑 socket、传 fd,Rust 领养后成为 ROUTER / PULL,引擎侧零改动;兼容性靠一份复刻结构体的 msgpack 夹具测试。PyO3 桥试过,v0.32.0 前删掉了。
- vLLM 的缺口在端点(无 responses、embeddings 等)和副本数(
api_server_count被改成 1)。 - SGLang 把 Rust 放在进程内:rank-0 调度器直接从 flume 通道取请求,大列走缓冲区不进 msgpack;文本栈用 Dynamo 的 crate。
- SGLang 的缺口在选项兼容(离线 Engine、watermark、sidecar)和多模态覆盖。
- 只测前端开销(引擎是立即返回的假引擎)时,3 核容器里 vLLM 的 Rust 前端在并发 1 / 64 / 512 下的请求吞吐是 Python 前端的约 5.4 / 20.6 / 15.6 倍;只做模板加分词的渲染服务,Rust 在三种请求上快约 9–34 倍,输出的 token id 一致。真模型下的差距、以及 SGLang 两种前端的对比,待实测(C3-2)。