Prhub

#50289 [Rust Frontend] Add standalone Rust renderer

原始 PR 作者 sagearc 合并时间 2026-08-06 19:23 文件变更 15 提交数 16 评论 15 代码增减 +720 / -40

执行摘要

新增 vllm-rs render 独立渲染服务,复用 Rust 前端预处理管线

RFC#49047 指出,vLLM 已有 Python 版 vllm launch render 可独立运行渲染管线,但渲染在分布式部署中可能成为瓶颈(见 llm-d-router#2025);Rust 前端已有完整的请求预处理路径,暴露出来可独立扩展渲染容量。原型基准测试显示,在 Apple M1 Max 上使用 72K token 中位长度的 Codex SWE-bench Pro 请求,Rust 原型单并发达 63 req/s,是 Python 版 6.25 req/s 的 10.1 倍,8 并发时达 14 倍;Docker 镜像也仅 92.9 MB,远小于官方 CPU 镜像的 3.5 GB。Stage 1(#49045)已把请求准备提取为 engine-free 的 ChatRequestProcessor / TextRequestProcessor,本 PR 是 Stage 2:基于这些处理器搭建独立渲染服务。

值得精读。该 PR 是“引擎无关服务复用现有前端管线”的典型设计案例:通过把 parser 选择下沉到 ChatRequestProcessor、用狭窄 facade 函数复用 openai 校验与 lower 路径,避免了在渲染服务中重复实现请求准备逻辑。重点关注 serve_render 的启动/关闭竞态处理、lower_render_request 的降级策略,以及尚未解决但必须跟踪的认证缺陷。若你负责部署 Rust 前端或 llm-d-router 类中间层,建议合入后尽快补齐认证中间件与真实模型端到端测试。

讨论亮点

review 中出现了三条关键讨论线:

  • 统一 parser 模型在 render 模式被破坏(P2):chatgpt-codex-connector 指出,对 Gemma 4 / Kimi K3 这类仅支持 unified parser 的模型,render 模式若硬编码 reasoning_parser 为 None,DefaultChatOutputProcessor::new 会走 split-parser 路径,导致 dummy/Unavailable tool parser 或缺失 structural-tag 调整,/v1/chat/completions/render 可能失败或返回不同结果。BugenZhao 认可并询问是否应跟随 Python CLI 的 parser 选择;sagearc 随后修复,改为默认 Auto 并保留显式覆盖,同时把 reasoning parser 的自动选择从“匹配 publisher 命名空间”修正为“匹配模型家族”(如 QwQ)。

  • 返回类型是否对齐 /v1/generate:BugenZhao 询问返回的内部 GenerateRequest 是否是预期行为,以及是否需要与 Python 前端对齐。sagearc 解释 llm-d 当前调用 /render 仅为了拿 prompt token 做路由,转发原始请求会重复渲染;本 PR 改为返回公开的 /v1/generate 请求,让 text-only 渲染结果可被直接提交,避免重复渲染。

  • 安全风险提示(未解决):depthfirst-app 指出渲染路由未挂载 authenticate_api_key 中间件、RenderArgs 会静默丢弃 --api-key / --ssl-* 配置、且默认绑定 0.0.0.0(与主 serve 命令默认 127.0.0.1 不一致)。这些提示在最终合入前未见代码层面回应或修复。

实现拆解

本 PR 的变更可按以下步骤拆解:

  1. CLI 入口与配置(rust/src/cmd/src/cli.rs、rust/src/cmd/src/main.rs):新增 Command::Render 与 RenderArgs 结构,提供 --model、--host、--port、--served-model-name、--tool-call-parser、--reasoning-parser、--tokenizer-mode(映射到 RendererSelection)、--chat-template、--max-model-len、--max-logprobs 等参数,并通过 into_config() 转换为 RenderConfig。main.rs 增加对应分发分支。配套测试 render_args_build_supported_config 验证参数解析与配置转换。

  2. 渲染服务核心(rust/src/server/src/render.rs,新增):定义 RenderConfig、RenderState 和 serve_render()。RenderConfig::validate() 在初始化后端与绑定监听前校验 parser 覆盖项和 max_logprobs 合法性;build_state() 调用 load_model_backends() 仅加载语言模型相关后端(language_model_only: true),并用 TextRequestProcessor::new 与 ChatRequestProcessor::render_only(...).with_parser_selections(...) 构建状态;serve_render() 通过 tokio::select! 在启动期间响应关闭信号,绑定 TCP 监听后启动 axum 服务并支持优雅关闭。

  3. 路由层(rust/src/server/src/routes/render.rs,新增):build_router() 挂载 5 个端点。render_chat() 与 render_completion() 复用 openai 模块的 lower_chat_request / lower_completion_request 与 resolve_request_context,经过 state.chat.prepare() 或 state.text.prepare() 得到 TextRequest,再经 lower_render_request() 降级为公开的 GenerateRequest,并复用 generate 路由的 validate_generate_request 做兼容性校验,确保渲染输出可被 /inference/v1/generate 直接消费。list_models 返回 served_model_names 与 max_model_len 信息。

  4. 处理器重构(rust/src/chat/src/lib.rs、rust/src/server/src/routes/openai/completions.rs、chat_completions.rs、generate.rs):把 tool_call_parser / reasoning_parser 两个字段从 ChatLlm 移入 ChatRequestProcessor,新增 with_parser_selections() 构建器,prepare() 不再接收外部 NewChatOutputProcessorOptions,而是在内部用处理器自持的 parser 选择构造。ChatLlm::with_tool_call_parser 等改为转发到 processor,行为保持不变。openai 路由相应适配新的 prepare 签名;generate 路由新增 validate_request_compat 供渲染路由复用。

  5. 测试配套(rust/src/server/src/routes/tests.rs、rust/src/cmd/src/cli/tests.rs):tests.rs 新增 test_render_app / test_render_app_with_parser_selections 及 5 个路由测试,覆盖 header/body request_id 优先级、parser 选择透传、completion 渲染,以及“渲染结果可原样提交给 generate 路由”的兼容性场景;但测试均基于 FakeChatBackend,未覆盖真实 tokenizer/模型端到端路径。

文件 模块 状态 重要度
rust/src/server/src/routes/render.rs 渲染路由 added 8.72
rust/src/server/src/render.rs 渲染服务 added 8.71
rust/src/chat/src/lib.rs 请求处理器 modified 7.15
rust/src/server/src/routes/tests.rs 路由测试 modified 8.15
rust/src/cmd/src/cli.rs CLI 入口 modified 6.37
rust/src/cmd/src/cli/tests.rs CLI 测试 modified 6.1

关键符号

build_router health list_models model_resolution lower_render_request render_chat render_completion RenderConfig::validate build_state serve_render ChatRequestProcessor::with_parser_selections ChatRequestProcessor::prepare ChatLlm::with_tool_call_parser ChatLlm::with_reasoning_parser RenderArgs::into_config

关键源码片段

rust/src/server/src/routes/render.rs entrypoint

新增渲染服务全部 HTTP 端点,是独立服务的对外入口;`render_chat`/`render_completion` 复用 OpenAI 校验与 lower 路径,`lower_render_request` 把结果降级为可提交给 `/inference/v1/generate` 的公开请求。

// 渲染服务路由:只暴露预处理相关端点,不包含任何引擎提交路由
pub(crate) fn build_router(state: Arc<RenderState>) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/ping", get(health).post(health))
        .route("/v1/models", get(list_models))
        .route("/v1/chat/completions/render", post(render_chat))
        .route("/v1/completions/render", post(render_completion))
        .with_state(state)
        .layer(DefaultBodyLimit::max(DEFAULT_REQUEST_BODY_LIMIT_BYTES))
}
​
/// 将渲染结果降级为公开的 `GenerateRequest`,可被 `/inference/v1/generate` 直接消费
fn lower_render_request(
    state: &RenderState,
    text_request: TextRequest,
    model: String,
    stream: bool,
    stream_options: Option<StreamOptions>,
) -> Result<GenerateRequest, ApiError> {
    // 复用引擎路径的文本预处理:tokenize、logprobs 限制、上下文长度校验等
    let prepared = state
        .text
        .prepare(text_request)
        .map_err(|error| text_submit_error("failed to prepare render request", error))?;
    let request = GenerateRequest {
        request_id: Some(prepared.text_request.request_id),
        model: Some(model),
        token_ids: prepared.generate_request.prompt_token_ids,
        sampling_params: prepared.text_request.sampling_params,
        stream,
        stream_options,
        cache_salt: prepared.text_request.cache_salt,
        priority: prepared.text_request.priority,
        kv_transfer_params: None,
        ec_transfer_params: None,
        other: Default::default(),
    };
    // 复用 generate 路由的同款兼容性校验,保证输出可被下游接受
    validate_generate_request(&request, &state.served_model_names)?;
    Ok(request)
}
​
async fn render_chat(
    State(state): State<Arc<RenderState>>,
    headers: HeaderMap,
    ValidatedJson(body): ValidatedJson<ChatCompletionRequest>,
) -> Result<Json<GenerateRequest>, ApiError> {
    let model = body.model.clone();
    let request_context = resolve_request_context(&headers, body.request_id.as_deref());
    // 走与线上 chat 完全相同的 lower + prepare 管线
    let chat_request = lower_chat_request(body, &model_resolution(&state), request_context)?;
    let (text_request, _) = state
        .chat
        .prepare(chat_request)
        .await
        .map_err(|error| ApiError::invalid_request(error.to_report_string(), None))?;
    Ok(Json(lower_render_request(
        &state, text_request, model, stream, stream_options,
    )?))
}
rust/src/server/src/render.rs core-logic

独立渲染服务的核心逻辑:配置校验、后端加载、监听绑定与优雅关闭;定义了 `RenderConfig` 与 `RenderState`,是服务生命周期管理的关键。

/// 引擎无关的文本渲染服务入口。
///
/// 与引擎连接型 `AppState` 相互独立,仅持有 `TextRequestProcessor` 与
/// `ChatRequestProcessor`,不建立 IPC 也不提交生成任务。
pub async fn serve_render(config: RenderConfig, shutdown: CancellationToken) -> Result<()> {
    // 启动前先验证配置,避免带着非法 parser 或 max_logprobs 去初始化后端
    config.validate().context("invalid render server configuration")?;
​
    // 加载 tokenizer / chat-template 等后端;期间响应关闭信号可提前退出,
    // 避免在 shutdown 竞态下仍拉起监听端口
    let state = tokio::select! {
        result = build_state(&config) => result?,
        _ = shutdown.cancelled() => return Ok(()),
    };
​
    let listener_mode = HttpListenerMode::BindTcp {
        host: config.host.clone(),
        port: config.port,
    };
    let listener = Listener::bind(&listener_mode)
        .await
        .with_context(|| format!("failed to bind {}:{}", config.host, config.port))?;
    let address = listener.local_addr_display()?;
    info!(
        %address,
        model = %config.model,
        "starting engine-free Rust render server"
    );
    // 与引擎服务共用 axum 优雅关闭机制
    axum::serve(listener, crate::routes::render::build_router(state))
        .with_graceful_shutdown(shutdown.cancelled_owned())
        .await
        .context("render server failed")
}
rust/src/chat/src/lib.rs core-logic

核心处理器重构:parser 选择从 `ChatLlm` 下沉到 `ChatRequestProcessor`,新增 `with_parser_selections`,`prepare()` 不再接收外部选项,是渲染服务与推理路径共用预处理的基石。

/// 将 parser 选择从 `ChatLlm` 下沉到处理器内部,渲染服务与推理路径共用
pub fn with_parser_selections(
    mut self,
    tool_call_parser: ParserSelection,
    reasoning_parser: ParserSelection,
) -> Self {
    self.tool_call_parser = tool_call_parser;
    self.reasoning_parser = reasoning_parser;
    self
}
​
/// 准备请求但不下发引擎
pub async fn prepare(
    &self,
    mut request: ChatRequest,
) -> Result<(TextRequest, DynChatOutputProcessor)> {
    request.validate()?;
    // 输出处理器使用处理器自身持有的 parser 选择,
    // 这样 render_only 模式也可获得与完整 ChatLlm 一致的解析行为
    let output_processor = self.backend.new_chat_output_processor(
        &mut request,
        NewChatOutputProcessorOptions {
            tool_call_parser: &self.tool_call_parser,
            reasoning_parser: &self.reasoning_parser,
        },
    )?;
    let text_request = self.prepare_text_request(request).await?;
    Ok((text_request, output_processor))
}

评论区精华

render 模式硬编码 parser 破坏 unified-parser 模型 正确性

chatgpt-codex-connector 指出:对 Gemma 4 / Kimi K3 等仅支持 unified parser 的模型,render 模式若硬编码 `reasoning_parser` 为 `None`,`DefaultChatOutputProcessor::new` 会走 split-parser 路径,导致 dummy/Unavailable tool parser 或缺失 structural-tag 调整,`/v1/chat/completions/render` 可能失败或返回不同结果。BugenZhao 认可并询问是否应像 Python CLI 一样接受 parser 选择。

结论:sagearc 将两个 parser 都改为默认 `Auto` 并保留 CLI 显式覆盖,同时把 reasoning parser 自动选择从匹配 publisher 命名空间修正为匹配模型家族(如 QwQ)。 · 已解决

渲染端点返回类型是否应对齐 /v1/generate 设计

BugenZhao 提问:返回的内部 `GenerateRequest` 是否是预期行为,是否应返回可直接作为后续请求的类型,以及是否要与 Python 前端对齐。

结论:sagearc 说明 llm-d 当前调用 `/render` 仅获取 prompt token 做路由,转发原始请求会重复渲染;本 PR 改为返回公开的 `/v1/generate` 请求,text-only 渲染输出可直接提交,避免重复渲染。 · 已解决

渲染路由缺少认证中间件 安全

depthfirst-app 提示:渲染路由未挂载 `authenticate_api_key` 中间件,而 `RenderArgs` 复用 `ServeArgs` 的 `--api-key`,用户配置的 API key 会被静默忽略,`/v1/*` 端点无认证暴露。

结论:PR 合入前未见代码修复或维护者回应,该问题遗留到合入后,需在后续补上认证中间件或至少拒绝不支持的配置。 · unresolved

api-key/TLS 配置被静默丢弃且默认绑定 0.0.0.0 安全

depthfirst-app 指出 `into_render_config()` 会静默丢弃 `--api-key` 与 `--ssl-*` 字段,且 `RenderArgs` 默认 `host` 为 `0.0.0.0`,与主 serve 命令默认 `127.0.0.1` 不一致,可能把无认证服务暴露到所有网卡。

结论:未见代码修复或维护者讨论,建议后续在 `into_render_config()` 中告警或拒绝安全相关配置,并将默认 host 改为 `127.0.0.1`。 · unresolved

风险与影响

以下为具体风险:

  • 未认证端点暴露(rust/src/server/src/routes/render.rs):渲染路由没有接入主服务器使用的 authenticate_api_key 中间件;若用户按常规方式配了 --api-key 或 VLLM_API_KEY,这些配置会被静默忽略,/v1/* 渲染端点裸奔在无认证状态。
  • 默认监听地址风险(rust/src/cmd/src/cli.rs):RenderArgs 默认 host 为 0.0.0.0,与主 serve 命令默认 127.0.0.1 不一致,可能把无认证服务暴露到所有网卡。
  • parser 选择回归(rust/src/chat/src/lib.rs、routes/render.rs):parser 默认值从外部传入改为处理器自持后,需确保 ChatLlm 与 ChatRequestProcessor 两条路径行为一致。本 PR 已把默认设为 Auto 并加测试,但 unified parser 模型(Gemma 4 / Kimi K3)此前暴露过风险,仍需回归验证。
  • 兼容性风险(routes/render.rs):lower_render_request 将 kv_transfer_params、ec_transfer_params 硬编码为 None,且仅限文本;若下游生成请求需要这些字段,渲染结果不能完整表达原请求。
  • 测试覆盖缺口:路由测试基于 FakeChatBackend,没有真实模型/tokenizer 的端到端测试,也没有对并发、超大 prompt(RFC 中提到的 182K token p99 场景)的压力测试。

影响范围集中在新增的独立命令与独立 HTTP 服务,不改变现有引擎推理路径:

  • 用户侧:获得 vllm-rs render 命令,可在不加载模型权重、不启动引擎的情况下独立部署文本渲染/预处理服务,镜像体积显著更小、吞吐更高,适合 llm-d-router 等路由场景独立扩展渲染容量。
  • 系统侧:ChatRequestProcessor 的 API 变化(prepare 签名简化、parser 字段内聚)属于内部重构,ChatLlm 对外行为不变;新增路由与现有 /inference/v1/generate 共享校验逻辑。
  • 团队侧:为后续 Stage 3(多模态渲染、响应后处理)铺路,也确立了 Rust 前端“engine-free 预处理”与“引擎连接服务”共享同一套处理器的架构模式。
未认证的 /v1 端点 默认绑定 0.0.0.0 api-key/TLS 配置被静默丢弃 parser 选择回归风险(unified parser) 缺少端到端集成测试

关联 Issue

#49047 [RFC]: Standalone Rust renderer

完整报告

参与讨论