Prhub

#28906 fix(anthropic): detect-and-passthrough mid-conversation system messages

原始 PR 作者 JustinTong0323 合并时间 2026-06-26 08:14 文件变更 4 提交数 5 评论 13 代码增减 +226 / -107

执行摘要

自动检测 chat template,修复 mid-conversation system 消息导致 prefix cache 分叉

PR #26773 无条件将 mid-conversation system 消息提升到顶层,导致支持内联 system 的 template(如 GLM、Kimi、Qwen3)的 prefix cache 每轮分叉,TTFT 和吞吐量严重下降(Issue #28883)。需要根据 template 的实际能力决定是否合并 system 消息,而不是一刀切。本 PR 采用与 vLLM 一致的方案(vllm-project/vllm#46025),通过检测 template 对非首 system 消息的支持能力来动态决策。

该 PR 值得精读。它展示了如何在不增加用户配置开销的前提下,通过运行时的模板探针来适配不同模型的行为差异,是解决多模型兼容性问题的良好范例。同时移除了一个与架构不匹配的协议层 validator,使关注点分离更正确。建议关注 detect_inline_system_support 的检测逻辑和 _convert_to_chat_completion_request 的转换分支。

讨论亮点

review 中 gemini-code-assist[bot] 提出使用 tokenizer 的 apply_chat_template 方法进行检测,因为 tokenizer 已经注册了正确的自定义过滤器和全局变量(如 raise_exception),可以避免手动 Jinja 环境可能导致的假阳性。但最终实现未采纳该建议,仍使用 ImmutableSandboxedEnvironment,原因可能是保持检测过程轻量和独立。此外,该 bot 还建议注册一个 dummy raise_exception 来更好处理保险箱模板,但最终代码通过捕获 jinja2.TemplateError 来处理,也足够。这些建议未在最终代码中体现,但 PR 已被批准合并。

实现拆解

  1. 新增模板检测函数:在 template_detection.py 中添加 detect_inline_system_support(chat_template) 函数。该函数使用 ImmutableSandboxedEnvironment 渲染一个 [system, user, system, user] 测试对话,通过检查第二个 system 的 sentinel 文本是否出现在渲染结果中,来判断 template 是否支持内联 system。如果 template 抛出异常(如 Qwen 的首 system 守卫)或 sentinel 被静默丢弃,则返回 False(需要合并)。

  2. 服务层集成检测:在 serving.pyAnthropicServing.__init__ 中调用该检测,并将结果存入 self._merge_inline_system 标志。新增 _chat_template() 方法从 tokenizer 获取 chat template;新增 _extract_system_text() 辅助函数统一提取 system 内容文本。

  3. 转换逻辑调整:在 _convert_to_chat_completion_request 中,当 _merge_inline_systemTrue 时,遍历 messages 提取所有 system 角色的文本并与顶层 system 字段合并为一个 system 消息;同时跳过这些 system 消息避免重复。当为 False 时,保持 system 消息在原位不动。handle_count_tokens 复用同一转换路径。

  4. 移除协议层 validator:删除 protocol.py 中的 move_mid_conversation_system_messages model_validator,因为协议层无法感知 template,该决策已上移到服务层。

  5. 更新测试:重写原有测试用例,分别验证 merge(使用 Qwen-style 首 system 守卫模板)和 passthrough(使用任意位置 system 模板)两种场景。新增 TestDetectInlineSystemSupport 覆盖 guarded / inline / silent-drop / missing-template 四种情况。

文件 模块 状态 重要度
python/sglang/srt/entrypoints/anthropic/serving.py 服务层 modified 7.69
python/sglang/srt/entrypoints/anthropic/protocol.py 协议层 modified 7.18
python/sglang/srt/managers/template_detection.py 模板检测 modified 7.01
test/registered/unit/entrypoints/anthropic/test_serving.py 测试 modified 7.13

关键符号

detect_inline_system_support _extract_system_text _chat_template _convert_to_chat_completion_request __init__

关键源码片段

python/sglang/srt/entrypoints/anthropic/serving.py core-logic

核心服务层,新增内联 system 检测、system 文本提取函数,以及根据检测标志决定是否合并 system 消息的逻辑。

# python/sglang/srt/entrypoints/anthropic/serving.pydef _extract_system_text(
    content: Union[str, list[AnthropicContentBlock]],
) -> Optional[str]:
    """将 system 消息的 content 展平为字符串,返回 None 表示无有效文本。    处理 str 和 struct 两种 Anthropic API 允许的格式。"""
    if isinstance(content, str):
        return content.strip() or None
    texts = []
    for block in content:
        # 支持 Pydantic model 和 dict 两种形态(HTTP / 编程两种路径)
        if isinstance(block, BaseModel) and getattr(block, "type", None) == "text":
            text = getattr(block, "text", "")
        elif isinstance(block, dict) and block.get("type") == "text":
            text = block.get("text", "")
        else:
            continue
        text = (text or "").strip()
        if text:
            texts.append(text)
    return "\n".join(texts) if texts else None
​
​
class AnthropicServing:
    def __init__(self, openai_serving_chat: OpenAIServingChat):
        self.openai_serving_chat = openai_serving_chat
        # 在初始化时检测 template 能力,一次检测,后续服用
        self._merge_inline_system = not detect_inline_system_support(
            self._chat_template()
        )
​
    def _chat_template(self) -> Optional[str]:
        """从 tokenizer 提取 chat template 字符串。"""
        tokenizer_manager = getattr(self.openai_serving_chat, "tokenizer_manager", None)
        if tokenizer_manager is None:
            return None
        tokenizer = getattr(tokenizer_manager, "tokenizer", None)
        if tokenizer is None:
            return None
        return getattr(tokenizer, "chat_template", None)
​
    # 在 _convert_to_chat_completion_request 中的应用:
    system_parts: list[str] = []
    if anthropic_request.system:
        # 处理顶层 system 字段
        if isinstance(anthropic_request.system, str):
            if anthropic_request.system.strip():
                system_parts.append(anthropic_request.system)
        else:
            for block in anthropic_request.system:
                if block.type == "text" and block.text:
                    system_parts.append(block.text)
​
    # 当需要合并时,遍历 messages 提取 system 角色
    if self._merge_inline_system:
        for msg in anthropic_request.messages:
            if msg.role != "system":
                continue
            text = _extract_system_text(msg.content)
            if text:
                system_parts.append(text)
​
    if system_parts:
        openai_messages.append(
            {"role": "system", "content": "\n".join(system_parts)}
        )
​
    # ... 然后当合并时,跳过 messages 中的 system 角色消息
    for msg in anthropic_request.messages:
        if msg.role == "system" and self._merge_inline_system:
            continue
        # ... 其余转换
python/sglang/srt/managers/template_detection.py dependency-wiring

新增 detect_inline_system_support 函数,是智能决策的核心检测逻辑。

# python/sglang/srt/managers/template_detection.pydef detect_inline_system_support(chat_template: Optional[str]) -> bool:
    """检测 chat template 是否支持内联 system 消息。    返回 True 表示模板可以将 system 消息渲染在任何位置(passthrough);
    返回 False 表示必须合并到首位 system 块(merge)。
    检测方法:渲染一个 [system, user, system, user] 探针,
    如果第二个 system 的 sentinel 文本出现在输出中,说明模板处理了非首 system。
    如果模板抛出异常或静默丢弃第二个 system,返回 False。"""
    if not chat_template:
        return False # 无模板时保守默认:合并
    sentinel = "__sglang_inline_system_sentinel__"
    try:
        # 使用 sandbox 环境避免模板注入风险
        env = jinja2.sandbox.ImmutableSandboxedEnvironment(
            trim_blocks=True,
            lstrip_blocks=True,
            extensions=[jinja2.ext.loopcontrols],
        )
        rendered = env.from_string(chat_template).render(
            messages=[
                {"role": "system", "content": "t"},
                {"role": "user", "content": "t"},
                {"role": "system", "content": sentinel},
                {"role": "user", "content": "t"},
            ],
            add_generation_prompt=False,
        )
        return sentinel in rendered
    except jinja2.TemplateError:
        return False # 模板抛出异常(如 Qwen 的 loop.first 守卫)→ 合并
    except Exception:
        return False # 任何其他异常 → 保守合并

评论区精华

使用 tokenizer.apply_chat_template 替代手动 Jinja 渲染 设计

gemini-code-assist[bot] 建议在 `detect_inline_system_support` 中优先使用 tokenizer 的 `apply_chat_template` 方法,因为 tokenizer 已经注册了正确的自定义过滤器和全局变量(如 raise_exception),可以减少假阳性。同时建议注册一个 dummy raise_exception 来更好地处理有第一 system 守卫的模板。

结论:PR 作者未采纳该建议,最终代码仍使用 ImmutableSandboxedEnvironment 手动渲染。但通过捕获 TemplateError 也达到了类似效果。PR 被批准合并,说明 reviewer 认为当前方案足够。 · 已解决

风险与影响

  • 模板检测误判风险detect_inline_system_support 使用手动 Jinja 环境,可能缺失某些自定义全局变量或过滤器,导致模板渲染异常被当成不支持内联(返回 False)而合并 system 消息;相反,如果模板返回不符合预期但 sentinel 巧合出现,可能返回 True 导致 system 消息被放置在不适用的位置。当前通过 sentinel 检测和异常捕获,风险较低但存在边缘情况。
  • API 兼容性回归:对于原本需要合并的模板(如 Qwen),本 PR 的行为应与之前一致;但对于支持内联的模板,其行为从合并变为保留原位,可能打破依赖之前行为(如期望 system 消息总在首位)的客户端。不过这更符合 Anthropic API 规范。
  • prefix cache 性能改善:对于支持内联的模板,性能显著提升;但若误判为不支持,则性能下降(回到合并状态)。
  • 代码维护风险:检测逻辑嵌入在 AnthropicServing 中,如果 tokenizer 的 chat template 在运行时动态变化(少见),会导致不一致。
  • 用户影响:Claude Code 用户的多轮 agent 场景下,mid-conversation steer 消息能够正确生效,且 prefix cache 保持稳定,显著降低 TTFT 和提升吞吐量。对于只使用单轮或不支持内联模板的用户无影响。
  • 系统影响:增加了初始化时的模板渲染开销(一次),但检测结果可缓存。转换路径增加分支,但开销可忽略。
  • 团队影响:新增了一个可复用的模板检测函数,未来可用于其他需要判断模板能力的地方。代码结构更清晰(协议层不再负责语义决策)。
  • 影响程度:中等到高,因为修复了一个真实性能回归,且未引入新的配置项,自动适配。
Anthropic API 核心路径 模板检测误判风险 API 兼容性回归 初始化开销

关联 Issue

#28883 [Anthropic] Mid-conversation system messages are hoisted to top-level, forking the prefix cache on inline-capable templates
#46025 fix(anthropic): auto-detect template support for mid-conversation system messages

完整报告

参与讨论