Prhub

#27386 [router] Apply chat template before cache-aware hashing (fix overlap=0 on chat traffic)

原始 PR 作者 Kangyan-Zhou 合并时间 2026-06-11 01:27 文件变更 9 提交数 6 评论 1 代码增减 +1289 / -76

执行摘要

为路由器添加聊天模板渲染,修复缓存感知哈希覆盖率为零

引用 PR Body:'For an EAGLE/bigram model (DeepSeek-V4-Flash), the experimental sgl-router's cache_aware_zmq policy reaches match_prefix on every request but matches zero blocks (sgl_router_overlap_blocks_sum stuck at 0) — even with bigram hashing in place.' 原因是引擎缓存的是聊天模板化后的 token(如 BOS+角色标记+内容),而路由器的哈希路径直接对原始 messages[*].content 进行哈希,导致查询哈希与引擎存储的块从第一个 token 开始就产生差异,重叠始终为零。同时 DSV4 等模型没有 Jinja 模板,引擎通过 Python 代码构建 prompt,因此需要内置编码器。

值得精读。该 PR 展示了如何通过引入与引擎一致的编码逻辑来修复缓存感知路由的关键正确性问题。设计决策方面,选择 minijinja 实现模板渲染,并为 DSV4 单独编写编码器,体现了对现有引擎行为的深度对齐。但需关注对 JSON 对象模板的兼容性缺失。建议后续补充对不同模型的模板渲染测试,并考虑添加运行时 fallback 计数器。

讨论亮点

只有一条 review 评论来自 gemini-code-assist[bot],指出 extract_chat_template 不支持 chat_template 为 JSON 对象(map)的情况,会静默禁用聊天模板路由。评论建议添加对 Object 变体的支持,通过查找 'default' 键或降级到第一个模板。当前 PR 未对此进行修改,存在兼容性风险。

实现拆解

  1. 新增 chat_template.rs:使用 minijinja 实现 HuggingFace 兼容的聊天模板渲染。构建时从 tokenizer_config.json 解析模板和特殊 token,设置 trim_blocks、lstrip_blocks、半严格未定义行为和 Python 方法兼容回调,以对齐引擎的渲染输出。

  2. 新增 dsv4.rs:为 DeepSeek-V4 模型实现内置 prompt 编码器。复现引擎中 encoding_dsv4.py 的核心逻辑,包括隐式插入空 system 消息、合并连续 user 轮次、以及使用与引擎完全一致的字面量标记(BOS、USER、ASSISTANT 等),并与其 tokenize 结果进行字节级别对齐验证。

  3. 修改 tokenizer/mod.rs:添加 ChatEncoder 枚举(Jinja 或 DeepSeekV4)和 ChatEncoderEntry 结构,在 TokenizerRegistry 中增加 encoders 字段。通过 resolve_chat_encoder 方法选择编码器,并在 load_from_config 中记录各分支的日志(INFO/WARN),确保模板启用状态可诊断。提供 encode_chathas_chat_encoder 接口。

  4. 修改 tokenizer/adapter.rs:新增 load_tokenizer_config 函数,用于加载与 tokenizer 同目录或同一 HF 仓库的 tokenizer_config.json。对于远程下载失败时区分 404(良性)和网络/权限错误,通过 WARN 日志提醒。

  5. 修改 cache_aware_zmq.rs:路由策略的核心调整。在 tokens_for_request 方法中,对于 messages 字段的 chat 请求,若模型有聊天编码器,则通过编码器渲染消息后 tokenize(encode_chat);否则走原始 prompt 提取路径。渲染失败时回退到原始文本,并通过 log_fallback 机制记录首次失败为 WARN、后续为 DEBUG,避免日志淹没。同时将原有的 extract_prompt_text 拆分为 byte-slice 版本和解析后版本 extract_prompt_text_from_value,以支持测试。

  6. 测试与配置调整:端到端测试 test_two_router_convergence.py 移除 passthrough 聊天模板,启动 worker 时使用模型的真实模板,验证路由器与引擎的哈希对齐。移除 passthrough_chat_template.jinja 文件。Cargo.toml 添加 minijinja、minijinja-contrib、serde_json 等依赖。

文件 模块 状态 重要度
experimental/sgl-router/src/tokenizer/chat_template.rs 模板渲染 added 9.36
experimental/sgl-router/src/tokenizer/dsv4.rs DSV4 编码 added 9.36
experimental/sgl-router/src/policies/cache_aware_zmq.rs 路由策略 modified 9.05
experimental/sgl-router/src/tokenizer/mod.rs Token 注册 modified 9.05
experimental/sgl-router/src/tokenizer/adapter.rs 配置加载 modified 7.96
experimental/sgl-router/tests/e2e/chat_completions/test_two_router_convergence.py 端到端测试 modified 5.94
experimental/sgl-router/tests/e2e/infra/model_pool.py 测试基础设施 modified 5.08
experimental/sgl-router/tests/e2e/infra/passthrough_chat_template.jinja 测试模板 removed 5.04
experimental/sgl-router/Cargo.toml 构建依赖 modified 4.13

关键符号

ChatTemplate::from_tokenizer_config ChatTemplate::render render_messages merge_consecutive_user_turns tokens_for_request extract_prompt_text_from_value TokenizerRegistry::load_from_config TokenizerRegistry::resolve_chat_encoder ChatEncoder::render ChatEncoderEntry::log_fallback load_tokenizer_config download_repo_file

关键源码片段

experimental/sgl-router/src/tokenizer/chat_template.rs entrypoint

核心新增文件:实现 HuggingFace 兼容的聊天模板渲染,是解决查询哈希与引擎缓存不匹配的关键。

// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0//! Chat-template 渲染模块,用于缓存感知路由。
//!
//! 引擎对应用聊天模板后的 token 序列进行 KV 缓存。路由器必须在哈希前
//! 渲染相同的模板,否则查询哈希与引擎缓存不匹配,导致重叠块始终为零。use anyhow::{Context, Result};
use minijinja::{Environment, UndefinedBehavior};
use std::collections::BTreeMap;/// 在环境注册的固定模板名称。
const TEMPLATE_NAME: &str = "chat";/// HuggingFace 注入模板上下文中的特殊 token 键列表。
const SPECIAL_TOKEN_KEYS: [&str; 7] = [
    "bos_token", "eos_token", "unk_token", "sep_token",
    "pad_token", "cls_token", "mask_token",
];/// 编译后的聊天模板及所引用的特殊 token 字符串。
pub struct ChatTemplate {
    env: Environment<'static>,
    /// `(名称, token 字符串)` 对,缺失的 token 为空字符串。
    special_tokens: Vec<(&'static str, String)>,
}impl ChatTemplate {
    /// 从 `tokenizer_config.json` 构建聊天模板。
    ///
    /// 若配置中无 `chat_template` 则返回 `Ok(None)`,模型将走原始文本路由。
    pub fn from_tokenizer_config(cfg: &serde_json::Value) -> Result<Option<Self>> {
        // 提取模板源码。仅支持 String 和 Array 变体;
        // 若为 Object(多命名模板)则返回 None,存在兼容性风险。
        let Some(template_src) = extract_chat_template(cfg) else {
            return Ok(None);
        };        // 收集特殊 token,缺失时用空字符串替代(匹配 jinja2 `none`→`""` 行为)
        let special_tokens = SPECIAL_TOKEN_KEYS
            .iter()
            .map(|&key| (key, extract_token_str(cfg, key).unwrap_or_default()))
            .collect();        let mut env = Environment::new();        // 对齐 HuggingFace 的 trim_blocks + lstrip_blocks,否则空白字符差异
        // 将导致每个块的哈希值永久偏离引擎的缓存。
        env.set_trim_blocks(true);
        env.set_lstrip_blocks(true);        // 使用半严格未定义行为:渲染未提供的变量将产生错误,
        // 从而使调用者降级为原始文本哈希,避免产生看似合理但实际不匹配的 prompt。
        env.set_undefined_behavior(UndefinedBehavior::SemiStrict);        // 添加 Python 字符串 / 字典方法的兼容回调,用于渲染真实聊天模板。
        env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);        // 暴露 `raise_exception` 和 `strftime_now` 函数
        env.add_function("raise_exception", raise_exception);
        env.add_function("strftime_now", strftime_now);        // 编译模板
        env.add_template_owned(TEMPLATE_NAME, template_src)
            .context("compile chat template from tokenizer_config.json")?;        Ok(Some(Self { env, special_tokens }))
    }
}
experimental/sgl-router/src/tokenizer/dsv4.rs entrypoint

核心新增文件:为 DeepSeek-V4 模型实现内置 prompt 编码器,因为该模型不使用 Jinja 模板,引擎在 Python 中构建 prompt。

// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0//! DeepSeek-V4 prompt 编码器,用于缓存感知路由。
//!
//! DSV4 没有 Jinja 模板;引擎在 Python 中构建 prompt。此模块复现了其核心逻辑,
//! 使路由器哈希的 token 序列与引擎缓存一致。/// 以下常量与引擎中定义的 marker token 对应的字面量一致。
const BOS: &str = "<|begin▁of▁sentence|>"; // token id 0
const EOS: &str = "<|end▁of▁sentence|>"; // token id 1
const USER: &str = "<|User|>"; // token id 128803
const ASSISTANT: &str = "<|Assistant|>"; // token id 128804
const THINK_END: &str = "</think>"; // token id 128822/// 将 `messages` 数组渲染为 DSV4 聊天 prompt 字符串。
///
/// 与引擎的 `encoding_dsv4.encode_messages` 对齐(仅聊天模式、纯文本、无 tools/tasks)。
pub fn render_messages(messages: &serde_json::Value) -> String {
    let mut msgs: Vec<(String, String)> = messages
        .as_array()
        .map(|arr| {
            arr.iter()
                .map(|m| {
                    let role = m.get("role")
                        .and_then(|r| r.as_str())
                        .unwrap_or("")
                        .to_string();
                    (role, content_to_string(m.get("content")))
                })
                .collect()
        })
        .unwrap_or_default();    // 引擎会隐式插入空 system 消息(若非 system 开头)
    if msgs.first().map(|(r, _)| r != "system").unwrap_or(true) {
        msgs.insert(0, ("system".to_string(), String::new()));
    }    // 合并连续 user 轮次(用 \n\n 连接)
    merge_consecutive_user_turns(&mut msgs);    let mut out = String::from(BOS);
    for i in 0..msgs.len() {
        render_one(i, &msgs, &mut out);
    }
    out
}/// 合并连续 user 轮次,匹配引擎预处理。
fn merge_consecutive_user_turns(msgs: &mut Vec<(String, String)>) {
    let mut merged: Vec<(String, String)> = Vec::with_capacity(msgs.len());
    for (role, content) in msgs.drain(..) {
        match merged.last_mut() {
            Some((last_role, last_content)) if last_role == "user" && role == "user" => {
                last_content.push_str("\n\n");
                last_content.push_str(&content);
            }
            _ => merged.push((role, content)),
        }
    }
    *msgs = merged;
}/// 渲染单个轮次到 out 字符串。
fn render_one(i: usize, msgs: &[(String, String)], out: &mut String) {
    let (role, content) = &msgs[i];
    match role.as_str() {
        "system" => out.push_str(content),
        "user" | "developer" => {
            out.push_str(USER);
            out.push_str(content);
        }
        "assistant" => {
            // 聊天模式无推理块,直接添加标记
            out.push_str(ASSISTANT);
            out.push_str(content);
            out.push_str(EOS);
        }
        _ => {} // 忽略未知 role
    }
}

评论区精华

extract_chat_template 不支持 JSON 对象格式的聊天模板 设计

review 评论指出 extract_chat_template 函数只处理 String 和 Array 变体,当 chat_template 是 JSON Object(比如包含 default 和 tool_use 等多个命名模板)时返回 None,静默禁用聊天模板路由。建议添加对 Object 的支持,通过查找 'default' 键或回退到第一个模板。

结论:该建议未被采纳,PR 当前状态未处理 Object 变体,存在兼容性风险。 · unresolved

风险与影响

  1. 模板兼容性风险:minijinja 可能不支持所有 Jinja2 特性(如部分 Python 方法扩展),导致某些模型的模板渲染失败或产生与引擎不同的输出,使缓存感知降级。
  2. Object 格式未支持:若 tokenizer_config.json 中的 chat_template 为 JSON 对象(多模板映射),当前 extract_chat_template 返回 None,静默禁用聊天模板路由,用户难以察觉。
  3. 依赖风险:新增 minijinja 和 minijinja-contrib 依赖,增加了二进制体积和潜在的版本冲突。
  4. 冷启动影响:模板编译在启动时进行,但影响很小。
  5. 测试覆盖:仅测试了 Qwen3-0.6B 和 DSV4 模型,其他模板的渲染正确性未覆盖。

影响范围局限于 experimental/sgl-router 模块。对于使用聊天模型的用户,缓存感知路由的缓存命中率从 0 修复到正常水平,显著降低首 token 延迟和显存占用。不涉及核心推理路径,无外部 API 变更。对非聊天模型和 /v1/completions 请求无影响。

Jinja 模板兼容性风险 JSON Object 格式未支持 新增依赖影响 测试覆盖局限

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论