Prhub

#44222 [Rust Frontend] Add /tokenize and /detokenize endpoints

原始 PR 作者 TanNgocDo 合并时间 2026-06-09 20:11 文件变更 9 提交数 17 评论 30 代码增减 +836 / -39

执行摘要

为 Rust 前端添加 /tokenize 和 /detokenize 端点

该 PR 是 Rust 前端功能对等路线图(#44280)的一部分,旨在填补 Rust 前端与 Python 前端在 tokenize/detokenize 功能上的差距。这些端点对于客户端调试、token 计数和多模态内容处理至关重要。

该 PR 设计合理,代码质量较高,与 Python 端行为逐项对齐。推荐作为 Rust 前端功能完善的参考实现进行精读,特别是设计模式:使用 untagged 枚举区分请求变体、共享验证函数、复用工件进行模板渲染而不提交引擎。安全性问题(SSTI/SSRF)应在后续全局修复,建议追踪 issue。

讨论亮点
  • 路由位置争议:coder3101 指出 /tokenize 和 /detokenize 不是标准 OpenAI API,不应放在 openai 子模块。作者同意并说明理由,最终维护者 BugenZhao 直接提交 commit 将其移出到独立模块。
  • 工具类型选择:BugenZhao 建议使用 vllm_server::routes::openai::utils::types::Tool 而非 vllm_chat::ChatTool,以保证与 chat completions 端点的兼容性,作者确认修改。
  • 消息空校验:Codex 机器人(P2)和 BugenZhao 指出 chat 形式的 tokenize 缺少空消息验证会导致 500 错误,作者随后添加了共享的 validate_messages 校验。
  • 安全风险讨论:depthfirst-app[bot] 提出两点:
    1) chat_template 字段允许用户提供任意模板(SSTI 风险);
    2) finalize_rendered_prompt 可能加载图片 URL 导致 SSRF。作者回应这些风险在 chat completions 端点中已经存在,应当统一修复而非在此 PR 增加范围,BugenZhao 未反对,因此作为已知问题推迟处理。

实现拆解

  1. 新增请求类型和处理函数:在 rust/src/server/src/routes/tokenize/types.rs 定义 TokenizeRequest(untagged 枚举区分 completion/chat)、DetokenizeRequest、响应类型以及 into_chat_request 转换方法;在 tokenize.rs 实现 tokenizedetokenize 处理函数,复用 check_modeltokenize_request_id 等辅助逻辑。

  2. 扩展 ChatLlm 核心逻辑:在 rust/src/chat/src/lib.rs 添加 pub async fn tokenize_chat 方法,该方法复用与 chat() 相同的渲染 → finalize_rendered_prompt → encode 管线,但只返回 token ID 而不提交引擎,确保 token 计数与真实生成一致。

  3. 抽取共享验证函数:将 validate_messageschat_completions/types.rs 移至 openai/utils/types.rs 成为 pub(crate),使 tokenize 和 chat completions 端点共用相同的消息校验逻辑,避免行为偏差。

  4. 路由注册与模块重组:初始路由放在 openai 模块下,经 review 后由维护者(BugenZhao)直接提交 commit 将 tokenize/detokenize 移出 openai 模块,放到顶层 routes/tokenize,与 Python 文件布局保持一致。

  5. 测试套件增强:在 tests.rs 添加 400+ 行集成测试,覆盖 completion/detokenize 往返、add_special_tokens 开关、return_token_strs、chat 形式的 generation prompt 计数、冲突标志错误等场景;同时扩展 FakeChatTokenizer 以支持模拟 BOS token 和 id_to_token 映射,使测试更加真实。

文件 模块 状态 重要度
rust/src/server/src/routes/tokenize/types.rs 路由层 added 9.12
rust/src/server/src/routes/tokenize.rs 路由层 added 9.0
rust/src/server/src/routes/tests.rs 测试 modified 8.84
rust/src/chat/src/lib.rs 聊天内核 modified 7.23
rust/src/server/src/routes/openai/utils/types.rs 工具集 modified 5.92

关键符号

into_chat_request tokenize_request_id check_model token_strs tokenize tokenize_completion tokenize_chat detokenize validate_messages tokenize_chat (ChatLlm method)

关键源码片段

rust/src/server/src/routes/tokenize/types.rs entrypoint

定义 tokenize/detokenize 的请求 / 响应类型及 untagged 枚举区分,是端点的数据契约。

// src/server/src/routes/tokenize/types.rsuse std::collections::HashMap;
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationErrors};
use vllm_chat::{ChatOptions, ChatRequest, SamplingParams, ChatToolChoice};
use crate::error::ApiError;
use crate::routes::openai::chat_completions::convert::{convert_message, convert_tools, normalize_generation_prompt_mode};
use crate::routes::openai::utils::types::{ChatMessage, Tool, Normalizable, default_true, validate_messages};/// `POST /tokenize` body,通过 untagged 枚举区分 completion 和 chat 两种请求。
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum TokenizeRequest {
    Chat(TokenizeChatRequest),
    Completion(TokenizeCompletionRequest),
}#[derive(Debug, Clone, Deserialize)]
pub struct TokenizeCompletionRequest {
    pub model: Option<String>,
    pub prompt: String,
    #[serde(default = "default_true")]
    pub add_special_tokens: bool, // 默认为 true(与 Python 一致)
    #[serde(default)]
    pub return_token_strs: bool,
}#[derive(Debug, Clone, Deserialize, Validate)]
pub struct TokenizeChatRequest {
    pub model: Option<String>,
    #[validate(custom(function = "validate_messages"))] // 共用 chat completions 的消息校验
    pub messages: Vec<ChatMessage>,
    #[serde(default = "default_true")]
    pub add_generation_prompt: bool,
    #[serde(default)]
    pub continue_final_message: bool,
    #[serde(default)] // chat 形式默认不加特殊 token(由聊天模板添加)
    pub add_special_tokens: bool,
    #[serde(default)]
    pub return_token_strs: bool,
    #[serde(default)]
    pub chat_template: Option<String>,
    #[serde(default)]
    pub chat_template_kwargs: Option<HashMap<String, Value>>,
    #[serde(default)]
    pub tools: Option<Vec<Tool>>,
}impl TokenizeChatRequest {
    /// 将 tokenize 请求转换为 ChatRequest,复用工件完成模板渲染。
    /// 只设置渲染相关字段,采样参数等保持默认。
    pub fn into_chat_request(self, request_id: String) -> Result<ChatRequest, ApiError> {
        let messages: Vec<_> = self.messages.into_iter().map(convert_message).try_collect()?;
        let generation_prompt_mode = normalize_generation_prompt_mode(
            Some(self.add_generation_prompt),
            self.continue_final_message,
            &messages,
        )?;        Ok(ChatRequest {
            request_id,
            messages,
            sampling_params: SamplingParams::default(), // tokenize 不生成,默认即可
            chat_options: ChatOptions {
                generation_prompt_mode,
                chat_template: self.chat_template,
                template_kwargs: self.chat_template_kwargs.unwrap_or_default(),
                reasoning_effort: None,
            },
            tools: convert_tools(self.tools)?,
            tool_choice: ChatToolChoice::Auto,
            decode_options: TextDecodeOptions::default(),
            intermediate: false,
            priority: 0,
            documents: None,
            cache_salt: None,
            add_special_tokens: self.add_special_tokens,
            data_parallel_rank: None,
            lora_request: None,
        })
    }
}
rust/src/server/src/routes/tokenize.rs entrypoint

实现核心处理逻辑,包括 completion/chat 分支、request-id 生成、模型校验和 detokenize。

// src/server/src/routes/tokenize.rsuse axum::Json;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::{IntoResponse, Response};
use crate::error::{ApiError, server_error};
use crate::routes::openai::utils::validated_json::ValidatedJson;
use crate::routes::tokenize::types::*;
use crate::state::AppState;/// 生成 request-id,格式为 tokenize-{base},base 来自 X-Request-Id 或新 UUID。
pub fn tokenize_request_id(headers: &HeaderMap) -> String {
    let base = resolve_base_request_id(
        headers.get("X-Request-Id").and_then(|v| v.to_str().ok()),
        None,
    );
    format!("tokenize-{base}")
}pub async fn tokenize(
    State(state): State<Arc<AppState>>,
    headers: HeaderMap,
    ValidatedJson(body): ValidatedJson<TokenizeRequest>,
) -> Response {
    let request_id = tokenize_request_id(&headers);
    let tokenizer = state.chat.text().tokenizer();
    let max_model_len = state.chat.engine_core_client().max_model_len();    let result = match body {
        TokenizeRequest::Completion(req) => tokenize_completion(&state, &tokenizer, req),
        TokenizeRequest::Chat(req) => tokenize_chat(&state, &request_id, req).await,
    };    match result {
        Ok((tokens, want_strs)) => {
            let token_strs = want_strs.then(|| token_strs(&tokenizer, &tokens));
            Json(TokenizeResponse { count: tokens.len(), max_model_len, tokens, token_strs }).into_response()
        },
        Err(error) => error.into_response(),
    }
}/// completion 形式:直接编码 prompt 字符串。
pub fn tokenize_completion(
    state: &AppState,
    tokenizer: &DynTokenizer,
    req: TokenizeCompletionRequest,
) -> Result<(Vec<u32>, bool), ApiError> {
    check_model(state, req.model.as_deref())?;
    let tokens = tokenizer
        .encode(&req.prompt, req.add_special_tokens)
        .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
    Ok((tokens, req.return_token_strs))
}/// chat 形式:先渲染模板,再编码。
pub 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;
    let tokens = state.chat
        .tokenize_chat(req.into_chat_request(request_id.to_string())?)
        .await
        .map_err(|e| server_error!("tokenize failed: {}", e.to_report_string()))?;
    Ok((tokens, return_token_strs))
}pub async fn detokenize(
    State(state): State<Arc<AppState>>,
    ValidatedJson(body): ValidatedJson<DetokenizeRequest>,
) -> Response {
    if let Err(error) = check_model(&state, body.model.as_deref()) {
        return error.into_response();
    }
    let tokenizer = state.chat.text().tokenizer();
    match tokenizer.decode(&body.tokens, false /* skip_special_tokens == false 与 Python 一致 */) {
        Ok(prompt) => Json(DetokenizeResponse { prompt }).into_response(),
        Err(e) => server_error!("detokenize failed: {}", e.to_report_string()).into_response(),
    }
}

评论区精华

路由位置:是否应放在 openai 模块下 设计

coder3101 指出 /tokenize 和 /detokenize 不是 OpenAI 标准 API,不应放在 openai 模块。作者认为放在 openai 便于复用类型,但同意后续移出。最终维护者 BugenZhao 直接提交 commit 将路由移出到独立模块。

结论:路由从 openai 模块移至顶层 routes/tokenize 模块,与 Python 布局一致。 · 已解决

工具类型选择:ChatTool vs Tool 设计

BugenZhao 建议使用 `vllm_server::routes::openai::utils::types::Tool` 而非 `vllm_chat::ChatTool`,以保持与 chat completions 端点解析一致。

结论:作者更新为使用 Tool 类型。 · 已解决

空消息验证应输出 400 而非 500 正确性

Codex 机器人(P2)和 BugenZhao 指出 chat 形式的 tokenize 缺少 messages 非空校验,导致空消息返回 500 而非正确的 400。

结论:作者添加 shared validate_messages 函数,在 ValidatedJson 层级拒绝空消息。 · 已解决

安全性:chat_template 导致 SSTI 安全

depthfirst-app[bot] 指出 `chat_template` 字段允许用户提供任意模板,可能导致 SSTI,而 Python 端默认禁用此功能。

结论:作者承认这是 chat completions 端点同样存在的问题,认为应统一修复而非在此 PR 扩展范围。BugenZhao 未反对,问题推迟。 · unresolved

安全性:image_url 导致 SSRF 安全

depthfirst-app[bot] 提出 `finalize_rendered_prompt` 可能通过 image_url 加载外部资源导致 SSRF。

结论:作者回应 SSRF 是 Rust 前端整体问题,不应只特殊处理 /tokenize,需要后续统一配置 allowed-media-domains。 · unresolved

风险与影响

  • 安全性chat_template 字段允许用户输入任意 Jinja2 模板,可能导致 SSTI;通过 image_url 内容可能引发 SSRF。这些问题在 Rust 前端的 chat completions 处理程序中同样存在,但本 PR 为 /tokenize 端点引入了相同的攻击面,增加了整体风险敞口。
  • 功能对等差异:与 Python 端点相比,已知缺失 media_io_kwargs / mm_processor_kwargs 支持,可能影响多模态场景的 token 计数准确性。
  • 测试覆盖:集成测试覆盖了核心场景,但缺少对极端边缘情况(如超大 token 列表、非法模型名)的测试。
  • 用户:Rust 前端用户现在可以使用 /tokenize 和 /detokenize 端点,与 Python 前端行为一致,便于进行 token 计数、调试和预处理。
  • 系统:新增两个端点,处理完全在进程中,不引入推理引擎负载,影响极低。
  • 团队:进一步缩小 Rust 前端与 Python 前端的功能差距,为后续用户迁移到 Rust 前端提供基础。
SSTI 风险(chat_template) SSRF 风险(image_url) 功能对等遗漏(media_io_kwargs)

关联 Issue

#44280 [Roadmap] Rust Frontend Feature Parity

完整报告

参与讨论