Prhub

#50448 [Rust Frontend] Deduplicate request preprocessing for `/tokenize`

原始 PR 作者 sagearc 合并时间 2026-08-05 16:27 文件变更 5 提交数 3 评论 9 代码增减 +170 / -50

执行摘要

复用推理预处理链路,/tokenize 与生成保持一致

PR body 明确指出:/tokenize currently repeats prompt handling already used by inference. This can make its token IDs drift from what generation receives, especially after chat rendering or multimodal prompt expansion. 即当前 tokenize 端点独立重复实现渲染与编码逻辑,导致其返回的 token ID 与真实生成路径不一致;同时该 PR 复用 #49045 提取出的 chat/text request processor,作为 #49047 分期计划的一部分。

值得精读。该 PR 展示了如何通过提取公共处理器来消除端到端路径与工具端点之间的语义漂移,其核心设计——将 tokenization 与 generation-lowering 解耦、为工具端点提供专用入口——对理解 vLLM Rust 前端的架构分层很有帮助。建议重点阅读 rust/src/chat/src/lib.rs 的 prepare_text_request / prepare_for_tokenization 拆分,以及 rust/src/text/src/lib.rs 的 tokenize 与 prepare 的职责划分。

讨论亮点

review 中最有价值的讨论集中在两个 P1 级行为回归风险上:

  • codex P1:tokenize 不应构造 output processor。chatgpt-codex-connector[bot] 指出,当 chat-form /tokenize 请求带非空 tools 且模型无自动注册 parser 时,调用 prepare 会创建 DefaultChatOutputProcessor,而 into_chat_request 将 tool_choice 设为 Auto,parser 解析返回 ParserUnavailableForModel,导致原本能正常返回 token ID 的请求变成 server error。
  • codex P1:tokenize 不应应用 generation length 检查。机器人指出,路由到 TextRequestProcessor::prepare 会调用 resolve_max_tokens,当 prompt_len >= max_model_len 时抛出 PromptTooLong;而 /tokenize 不生成,Python 路径允许 unlimited total length with zero output tokens,原 Rust 代码也直接返回 IDs,这是行为回退。
  • BugenZhao 的设计讨论:作者本人提出希望绕过 output processor 的构造与验证,考虑用 Option 包裹 NewChatOutputProcessorOptions;同时希望像 into_chat_request 一样为 completion 提供转换方法。
  • codex P3:恢复被删除的请求注释。机器人引用 rust/AGENTS.md,要求保留原注释;从最终 head 版本看该注释未被恢复,属于低优先级遗留。

实现拆解

实现拆解如下:

  1. 提取 chat 预处理公共路径(rust/src/chat/src/lib.rs):将 ChatRequestProcessor::prepare 中不依赖 output processor 的渲染、reasoning 参数构造、多模态 finalize 与 TextRequest 组装逻辑拆成私有方法 prepare_text_request;prepare 只负责调用 new_chat_output_processor 后再委托 prepare_text_request,保持推理路径行为不变。

  2. 新增 tokenization 专用入口(rust/src/chat/src/lib.rs):新增公开方法 prepare_for_tokenization,只做 validate 与 prepare_text_request,不构造任何 output processor,避免带 tools 的请求因 parser 不可用而报错;同时删除 ChatLlm::tokenize_chat,改为暴露 request_processor() getter 供路由层使用。

  3. text 层增加纯 tokenize 能力(rust/src/text/src/lib.rs):抽出私有辅助函数 tokenize_prompt 处理 Prompt::Text / Prompt::TokenIds 两种形态,prepare 复用该函数;新增公开方法 TextRequestProcessor::tokenize,仅做 validate 与编码,不做 lower_text_request,从而绕开 resolve_max_tokens 对生成长度下限的检查,使 /tokenize 可以统计等于或超过 max_model_len 的 prompt。

  4. 路由层接入复用链(rust/src/server/src/routes/tokenize.rs、rust/src/server/src/routes/tokenize/types.rs):TokenizeCompletionRequest 新增 into_text_request 将请求体降为 TextRequest;tokenize_completion 与 tokenize_chat 分别改为通过 state.chat.text().request_processor().tokenize(...) 与 state.chat.request_processor().prepare_for_tokenization(...) 完成,统一走与推理相同的渲染和编码逻辑。

  5. 测试配套(rust/src/server/src/routes/tests.rs):新增 tokenize_chat_with_tools_does_not_require_output_parser 验证带 tools 的 chat tokenize 不依赖 output parser;新增 tokenize_allows_prompts_at_or_above_max_model_len 验证 completion 与 chat 两种形态均接受超长 prompt 并返回 max_model_len。

文件 模块 状态 重要度
rust/src/chat/src/lib.rs 聊天层 modified 8.24
rust/src/text/src/lib.rs 文本层 modified 7.48
rust/src/server/src/routes/tokenize.rs 路由层 modified 6.26
rust/src/server/src/routes/tokenize/types.rs 路由层 modified 6.58
rust/src/server/src/routes/tests.rs 路由层 modified 7.46

关键符号

prepare_text_request prepare_for_tokenization prepare request_processor tokenize_prompt tokenize into_text_request tokenize_completion tokenize_chat

关键源码片段

rust/src/chat/src/lib.rs core-logic

核心重构文件:拆出 prepare_text_request 与 prepare_for_tokenization,删除 ChatLlm::tokenize_chat,新增 request_processor(),使 chat tokenize 与推理共享同一渲染 /finalize 链路。

// rust/src/chat/src/lib.rs
// 将原先 `prepare` 中与 output processor 无关的部分拆出,
// 使推理与 tokenize 共用同一套渲染 → finalize 逻辑。
async fn prepare_text_request(&self, request: ChatRequest) -> Result<TextRequest> {
    // 在渲染前打时间戳,让 render 与 tokenize 计入 TTFT/e2e。
    let arrival_time = vllm_llm::current_unix_timestamp_secs();
    let rendered = self.backend.chat_renderer().render(&request)?;
    // 结构化输出存在时,把 chat 模板 kwargs 传给 reasoning parser。
    let reasoning_parser_kwargs = request
        .sampling_params
        .structured_outputs
        .is_some()
        .then(|| ReasoningParserKwargs {
            chat_template_kwargs: rendered.effective_template_kwargs.clone(),
        });
    let (prompt, mm_features) = self.finalize_rendered_prompt(&request, rendered).await?;
    Ok(TextRequest {
        request_id: request.request_id,
        prompt,
        mm_features,
        sampling_params: request.sampling_params,
        decode_options: request.decode_options,
        intermediate: request.intermediate,
        priority: request.priority,
        cache_salt: request.cache_salt,
        add_special_tokens: request.add_special_tokens,
        data_parallel_rank: request.data_parallel_rank,
        session_id: request.session_id,
        reasoning_parser_kwargs,
        lora_request: request.lora_request,
        arrival_time: Some(arrival_time),
    })
}
​
/// Tokenize 专用入口:只做渲染与校验,不构造 output processor,
/// 避免带 tools 的请求因 parser 不可用而失败。
pub async fn prepare_for_tokenization(&self, request: ChatRequest) -> Result<TextRequest> {
    request.validate()?;
    self.prepare_text_request(request).await
}
​
/// 推理入口:构造 output processor 后复用 prepare_text_request。
pub async fn prepare(
    &self,
    mut request: ChatRequest,
    options: NewChatOutputProcessorOptions<'_>,
) -> Result<(TextRequest, DynChatOutputProcessor)> {
    request.validate()?;
    let output_processor = self.backend.new_chat_output_processor(&mut request, options)?;
    let text_request = self.prepare_text_request(request).await?;
    Ok((text_request, output_processor))
}
rust/src/text/src/lib.rs core-logic

新增 TextRequestProcessor::tokenize 与 tokenize_prompt 辅助函数,使 /tokenize 可以只编码不进行 generation lowering,避免超长 prompt 被拒绝。

// rust/src/text/src/lib.rs
// 统一处理文本与预分词两种 prompt 形态。
fn tokenize_prompt(
    tokenizer: &DynTokenizer,
    prompt: Prompt,
    add_special_tokens: bool,
) -> Result<Vec<u32>> {
    match prompt {
        Prompt::Text(text) => tokenizer.encode(&text, add_special_tokens).map_err(Into::into),
        // 预分词 prompt 是 completions 侧绕过 chat 渲染与 tokenizer 开销的主要逃生口。
        Prompt::TokenIds(token_ids) => Ok(token_ids),
    }
}
​
/// 仅做 tokenize,不做 generation-specific lowering,
/// 因此不会触发 resolve_max_tokens 的长度下限检查,
/// 允许 /tokenize 统计等于或超过 max_model_len 的 prompt。
pub fn tokenize(&self, request: TextRequest) -> Result<Vec<u32>> {
    request.validate()?;
    Self::tokenize_prompt(
        &self.backend.tokenizer(),
        request.prompt,
        request.add_special_tokens,
    )
}
​
/// 推理路径:tokenize 后还需做 sampling 限幅与请求 lowering。
pub fn prepare(&self, mut request: TextRequest) -> Result<PreparedTextRequest> {
    request.validate()?;
    if request.arrival_time.is_none() {
        request.arrival_time = Some(vllm_llm::current_unix_timestamp_secs());
    }
    let tokenizer = self.backend.tokenizer();
    let prompt_token_ids = Self::tokenize_prompt(
        &tokenizer,
        take(&mut request.prompt),
        request.add_special_tokens,
    )?;
    let sampling_hints = self.backend.sampling_hints()?;
    let sampling_limits = SamplingLimits {
        max_model_len: self.max_model_len,
        max_logprobs: self.max_logprobs,
        model_vocab_size: self.backend.model_vocab_size(),
        tokenizer_vocab_size: self.backend.tokenizer_vocab_size(),
    };
    lower_text_request(
        request,
        prompt_token_ids,
        sampling_hints,
        sampling_limits,
        tokenizer.as_ref(),
    )
}
rust/src/server/src/routes/tokenize.rs entrypoint

路由入口改造:completion 与 chat 两种 /tokenize 请求都改为通过 request processor 链路处理,完成去重。

// rust/src/server/src/routes/tokenize.rs
// completion 形态:将请求体降为 TextRequest 后走 text processor 的 tokenize 方法。
fn tokenize_completion(
    state: &AppState,
    request_id: &str,
    req: TokenizeCompletionRequest,
) -> Result<(Vec<u32>, bool), ApiError> {
    check_model(state, req.model.as_deref())?;
    let return_token_strs = req.return_token_strs;
    let tokens = state
        .chat
        .text()
        .request_processor()
        .tokenize(req.into_text_request(request_id.to_string()))
        .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
    Ok((tokens, return_token_strs))
}
​
// chat 形态:先做渲染与多模态 finalize(不构造 output processor),
// 再交给 text processor 编码,token ID 与推理路径完全一致。
async fn tokenize_chat(
    state: &AppState,
    request_id: &str,
    req: TokenizeChatRequest,
) -> Result<(Vec<u32>, bool), ApiError> {
    check_model(state, req.model.as_deref())?;
    let return_token_strs = req.return_token_strs;
    // `continue_final_message` / `add_generation_prompt` 互斥在
    // `normalize_generation_prompt_mode`(into_chat_request 内)强制。
    let text_request = state
        .chat
        .request_processor()
        .prepare_for_tokenization(req.into_chat_request(request_id.to_string())?)
        .await
        .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
    let tokens = state
        .chat
        .text()
        .request_processor()
        .tokenize(text_request)
        .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
    Ok((tokens, return_token_strs))
}

评论区精华

tokenize 不应构造 output processor 正确性

codex 指出带 tools 的 chat /tokenize 请求在 prepare 中会构造 DefaultChatOutputProcessor,因 parser 不可用而报错,属于行为回退。BugenZhao 也提出希望绕过 output processor 的构造与验证。

结论:最终通过新增 prepare_for_tokenization 方法解决,该方法只做 validate 与 prepare_text_request,不构造 output processor。 · 已解决

tokenize 不应应用 generation length 检查 正确性

codex 指出原实现直接 encode 返回 IDs,改用 prepare 后触发 resolve_max_tokens 的 PromptTooLong 拒绝,无法统计超长 prompt。

结论:最终通过 TextRequestProcessor::tokenize 方法解决,该方法不做 lower_text_request,从而绕过长度下限检查。 · 已解决

为 completion 请求提供转换方法 设计

BugenZhao 建议像 into_chat_request 一样为 completion 形态提供转换方法,而不是在路由内手写 TextRequest 构造。

结论:新增 TokenizeCompletionRequest::into_text_request,路由层调用该方法。 · 已解决

保留 /tokenize 请求注释 style

codex P3 引用 rust/AGENTS.md,要求重构时保留原注释(区分 completion 与 chat 两个 match 分支的说明)。

结论:最终 head 版本中注释未恢复,属于低优先级遗留问题。 · unresolved

风险与影响

主要风险点如下:

  1. 行为变更回归:/tokenize 从直接 tokenizer.encode 改为走完整 request processor 链路,虽然目标是消除 token ID 漂移,但任何渲染或多模态 finalize 逻辑的细微差异都可能改变返回的 token 序列。已有两个新测试覆盖关键场景,但仍有边界(如自定义 chat_template、continue_final_message 组合)未覆盖。
  2. prepare_for_tokenization 的复用耦合:rust/src/chat/src/lib.rs 中 prepare_text_request 现在是推理与 tokenize 的共享路径,未来推理侧改动(如 arrival_time 语义、reasoning kwargs 计算)会同时影响 /tokenize,需要保持两者语义一致。
  3. 超长 prompt 行为变更:TextRequestProcessor::tokenize 不做长度检查,与 prepare 的行为不同;调用方若误用 prepare 处理 /tokenize 请求会重新引入 PromptTooLong 错误,rust/src/server/src/routes/tokenize.rs 已正确选择 tokenize,但后续维护者需注意。
  4. 低优先级风格问题:tokenize.rs 删除了 match 分支注释,违反 rust/AGENTS.md 保留原注释的要求,虽不影响功能,但可能被 CI 或后续 review 诟病。

影响范围集中在 Rust 前端(rust/ 目录)的 /tokenize 端点:

  • 用户侧:/tokenize 返回的 token 计数与推理实际接收的 prompt 更一致,尤其对多模态模型和带 chat template 的请求;带 tools 的 chat tokenize 请求不再因缺少 parser 而失败;可统计超过 max_model_len 的 prompt 长度。
  • 系统侧:chat 与 text 两个 crate 的请求准备逻辑收敛为单一实现,减少重复代码,为 #49047 规划的 renderer 等后续阶段铺路。
  • 团队侧:后续对预处理逻辑的修改只需改动 processor,而不需要同步 /tokenize 路由;但也要意识到共享路径带来的回归面扩大。整体影响中等偏上,属于 Rust 前端核心路径的整合性改动。
共享路径行为变更 超长 prompt 行为放宽 注释删除违反 AGENTS.md

关联 Issue

#49045 [Rust Frontend] Extract request preparation from the inference path

完整报告

参与讨论