Prhub

#38693 Add granite_thinking_parser reasoning parser for Granite 4.2

原始 PR 作者 yousafshah 合并时间 2026-09-11 22:18 文件变更 4 提交数 6 评论 2 代码增减 +250 / -0

执行摘要

新增 Granite 4.2 思考解析器与模板自动检测规则

Granite 4.2 的 chat template 在推理与正文边界处写的是 \n</think>\n,普通 DeepSeek-R1 风格的解析器会把前导换行带进 content;同时 enable_thinking=False 或输出被 max_tokens 截断时会出现正文为空的情况。此前用户必须挂载 HF 插件才能正确解析,PR body 明确目标是「enabling --reasoning-parser granite_thinking_parser without the HF plugin」,并让 parser 名称与 vLLM PR#55957 保持一致以实现跨引擎对齐,行为上对齐 HF 插件 granite_thinking_parser.py。

值得精读,尤其是想新增模型 reasoning parser 的工程师:GraniteThinkingDetector 是「薄覆写基类检测器」的典型范例,展示如何在不动核心解析逻辑的前提下实现模型特化行为。建议重点看三处设计决策——(1) 用 content_absent 显式区分「正文缺失」与「正文仅含换行」的 swap 语义;(2) 流式剥离为何必须同时采样调用前后状态才能做到分块无关;(3) 模板判别为何选 defer_loading 而非更显眼的 <parameter= 工具块。另外建议沿提交 47dd55e 阅读被修复的三个 bug,理解自动检测规则的误判成本;测试裁剪的取舍(删除伪 parity 测试)也值得作为测试设计案例讨论。

讨论亮点

讨论集中在维护者 JustinTong0323 推入的修复提交 47dd55e(review 中评论:「Pushed a fix commit (47dd55e) addressing three issues found in review」):

「Auto-detection misrouted Nemotron-3 — <parameter= can't discriminate: the Granite 4.2 and Nemotron-3 templates share that tool-call block verbatim, so --reasoning-parser auto switched Nemotron deployments to the Granite detector. Now uses defer_loading」

「Empty-content fallback missed the end-tag boundary — with force_nonempty_content, text ending exactly at </think> returned empty content; the HF plugin swaps reasoning→content whenever parsed content is absent.」

「Streaming newline strip was chunking-dependent — _reasoning_seen sampled only pre-call state, so a whole think block in one chunk leaked the leading newline.」

他声称用官方模板逐一验证:Granite 4.2 3B/8B/30B/MLX 命中 granite_thinking_parser,Nemotron-3 Nano/Super/Ultra 命中 nemotron_3,并提出 79/79 独立验证检查。后续两个提交(4ddd9da、44005a6)系统性削减测试,理由是「its fixed cases duplicate TestGraniteThinkingDetector… hardcoded expectations cannot prove live HF plugin parity」,即不保留无法证明真实对齐的伪 parity 测试。最终 review 结论为 APPROVED「LGTM」,并以 /tag-and-rerun-ci 触发重跑。

实现拆解

  1. 新增检测器:python/sglang/srt/parser/reasoning_parser.py 中新增 GraniteThinkingDetector,继承 BaseReasoningFormatDetector,沿用 <think>/</think> 标记、<tool_call> 工具起始标记、reasoning_default="enable_thinking"。非流式 detect_and_parse 在基类解析结果上做两步后处理:先判断 </think> 是否存在与正文是否为空(content_absent),对存在的正文 lstrip("\n");再在 force_nonempty_content 且正文为空且(正文确实缺失或根本没有 </think>)时把 reasoning_text 与 normal_text 对调。
  2. 注册与参数映射:DetectorMap 新增 "granite_thinking_parser": GraniteThinkingDetector;ReasoningParser.__init__ 新增分支,当 chat_template_kwargs["enable_thinking"] is False 时自动注入 force_nonempty_content=True,把「不思考」语义直接映射为「内容不得为空」的解析行为,避免上层调用方各自处理。
  3. 流式状态机:新增 parse_streaming_increment 覆写,用 _reasoning_seen(跨 chunk 累积「见过推理段」的证据)和 _content_started(正文是否已经开始)控制只在正文起点剥离一次换行,正文开始后换行原样保留。_reasoning_seen 的证据来源同时包含调用前的 _in_reasoning、调用后的 _in_reasoning、stripped_think_start 和本次返回的 reasoning_text,保证整个 think 块落在单个 chunk 时也不会漏判。
  4. 模板自动检测:python/sglang/srt/parser/template_detection.py 新增谓词 _is_granite_thinking_parser,判据为 truncate_history_thinking + defer_loading + reasoning_config 的 enable_thinking/默认开启,并在 REASONING_PARSER_RULES 中注册在 nemotron_3 之前。关键点是放弃用 <parameter= 工具调用块做判别——Granite 4.2 与 Nemotron-3 模板逐字共享该块。
  5. 测试与裁剪:test/registered/unit/parser/test_reasoning_parser.py 新增 TestGraniteThinkingDetector(含非流式、流式、ReasoningParser 集成、enable_thinking=False 映射用例);test/registered/unit/parser/test_template_manager.py 新增两条模板判别测试(Nemotron-3 不误判为 Granite、Granite 经 defer_loading 命中)。首版提交中的 test_granite_thinking_parity.py 在后续提交中被删除,理由是固定用例与检测器测试重复且硬编码期望无法证明与 HF 插件真实一致。
文件 模块 状态 重要度
python/sglang/srt/parser/reasoning_parser.py 推理解析器 modified 8.31
python/sglang/srt/parser/template_detection.py 模板检测 modified 6.46
test/registered/unit/parser/test_reasoning_parser.py 解析器测试 modified 7.16
test/registered/unit/parser/test_template_manager.py 模板测试 modified 5.88

关键符号

GraniteThinkingDetector.__init__ GraniteThinkingDetector.detect_and_parse GraniteThinkingDetector.parse_streaming_increment ReasoningParser.__init__ _is_granite_thinking_parser test_streaming_result_is_chunking_independent test_granite_detected_via_defer_loading_signature

关键源码片段

python/sglang/srt/parser/reasoning_parser.py core-logic

核心变更文件:新增 GraniteThinkingDetector 类(含非流式 swap/lstrip 与流式状态机),注册 DetectorMap,并在 ReasoningParser.__init__ 中把 enable_thinking=False 映射为 force_nonempty_content。

class GraniteThinkingDetector(BaseReasoningFormatDetector):
    """Granite 4.2 thinking 模型(ibm-granite/granite-4.2-*)专用检测器。
​
    Granite chat template 在边界处写入 ``\n</think>\n``,因此正文以换行开头,
    需要剥离;行为对齐 HF 插件 granite_thinking_parser.py。
    """
​
    def __init__(
        self,
        stream_reasoning: bool = True,
        force_reasoning: bool = False,
        continue_final_message: bool = False,
        previous_content: str = "",
        force_nonempty_content: bool = False,
    ):
        super().__init__(
            "<think>",
            "</think>",
            tool_start_token="<tool_call>", # 与 Nemotron-3 共用,因此不能作为模板判据
            reasoning_default="enable_thinking", # 与 Granite 模板的 toggle 参数一致
            force_nonempty_content=force_nonempty_content,
            # ... 其余参数(stream/force/continue_final_message)同基类直传
        )
        self._content_started = False # 正文是否已经开始:只在起点剥离一次换行
        self._reasoning_seen = False # 是否见过推理段:跨 chunk 累积的判据
​
    def detect_and_parse(self, text: str) -> StreamingParseResult:
        """非流式解析:先继承基类切分,再施加两条 Granite 特化行为。"""
        ret = self._detect_and_parse_impl(text)
        think_end_present = self.think_end_token in text
        # 注意同时要求「有 </think>」且「正文为空」,才认定为正文确实缺失;
        # 仅含换行的正文不属于此列,交由下面的 lstrip 处理而不参与 swap。
        content_absent = think_end_present and not ret.normal_text
        if think_end_present and ret.normal_text:
            ret.normal_text = ret.normal_text.lstrip("\n")
        if (
            self._force_nonempty_content
            and not ret.normal_text
            and (content_absent or not think_end_present)
        ):
            # enable_thinking=False 或输出被截断时,把推理内容反转为正文,
            # 避免向用户返回空 content
            ret.normal_text, ret.reasoning_text = ret.reasoning_text, ret.normal_text
        return ret
​
    def parse_streaming_increment(self, new_text: str) -> StreamingParseResult:
        """流式解析:保证剥离结果与 chunk 切分粒度无关。"""
        was_in_reasoning = self._in_reasoning
        ret = super().parse_streaming_increment(new_text)
        # 同时采样调用前状态(was_in_reasoning)、调用后状态(_in_reasoning)、
        # stripped_think_start 与本次生成的 reasoning_text——只采样调用前状态
        # 会让「整个 think 块落在同一个 chunk」时漏判,导致前导换行泄漏。
        if (
            was_in_reasoning
            or self._in_reasoning
            or self.stripped_think_start
            or ret.reasoning_text
        ):
            self._reasoning_seen = True
        if self._reasoning_seen and not self._content_started and ret.normal_text:
            ret.normal_text = ret.normal_text.lstrip("\n")
            if ret.normal_text:
                self._content_started = True # 正文一旦开始,后续换行原样保留
        return ret
python/sglang/srt/parser/template_detection.py core-logic

新增模板自动检测谓词 _is_granite_thinking_parser 并在 REASONING_PARSER_RULES 中注册,用 defer_loading 与 Nemotron-3 区分,是 auto 模式正确路由的关键。

def _is_granite_thinking_parser(ctx):
    # Granite 4.2 与 Nemotron-3 的模板逐字共享 <function=name>/<parameter=key>
    # 工具调用说明块,因此它无法用来区分;defer_loading 是 Granite 独有的
    # 延迟工具加载特征,作为唯一判别信号。
    return (
        ctx.has_text("truncate_history_thinking") # 与 Nemotron-3 共有的推理历史裁剪
        and ctx.has_text("defer_loading") # Granite 独有:延迟工具加载
        and ctx.reasoning_config is not None
        and ctx.reasoning_config.toggle_param == "enable_thinking"
        and ctx.reasoning_config.default_enabled is True
    )
​
​
# 规则按顺序匹配,Granite 规则必须排在 nemotron_3 之前:
# 两者共享 truncate_history_thinking 特征,靠更严格的 defer_loading 抢先命中。
REASONING_PARSER_RULES = (
    DetectionRule(name="k2_horizon", value="k2_horizon", predicate=_is_k2_v3),
    # ... 前置规则省略
    DetectionRule(name="kimi_k2", value="kimi_k2", predicate=_is_kimi_k2),
    DetectionRule(
        name="granite_thinking_parser",
        value="granite_thinking_parser",
        predicate=_is_granite_thinking_parser,
    ),
    DetectionRule(name="nemotron_3", value="nemotron_3", predicate=_is_nemotron_3),
    # ... 后续规则省略
)
test/registered/unit/parser/test_reasoning_parser.py test-coverage

新增 TestGraniteThinkingDetector,覆盖非流式 swap 语义、流式换行剥离、分块无关性与 ReasoningParser 集成,是本次行为契约的主要护栏。

class TestGraniteThinkingDetector(CustomTestCase):
    def setUp(self):
        self.detector = GraniteThinkingDetector()
​
    def test_force_nonempty_no_swap_when_think_end_present(self):
        """</think> 存在时即使剥离后正文为空也不 swap(仅换行不算正文缺失),
        与 HF 插件行为一致。"""
        detector = GraniteThinkingDetector(force_nonempty_content=True)
        result = detector.detect_and_parse("<think>reasoning</think>\n\n")
        self.assertEqual(result.reasoning_text, "reasoning")
        self.assertEqual(result.normal_text, "")
​
    def test_force_nonempty_swaps_when_text_ends_at_think_end(self):
        """正文确实缺失(例如 max_tokens 恰好截在 </think>)时执行 swap。"""
        detector = GraniteThinkingDetector(force_nonempty_content=True)
        result = detector.detect_and_parse("<think>reasoning</think>")
        self.assertEqual(result.reasoning_text, "")
        self.assertEqual(result.normal_text, "reasoning")
​
    def test_streaming_result_is_chunking_independent(self):
        # 同一段文本在不同切分粒度(整段 / 两段 / 三段 / 逐字符)下,
        # 流式累积输出必须完全一致,这是流式剥离逻辑的核心回归护栏。
        # 空 think 块这一组只触发 stripped_think_start 证据:
        # 此时 reasoning_text 为空、调用前后 _in_reasoning 均为 False。
        for text, exp_r, exp_c in (
            ("<think>r</think>\nHello", "r", "Hello"),
            ("<think></think>\nHello", "", "Hello"),
        ):
            for stream_reasoning in (True, False):
                for chunks in (
                    [text],
                    [text[: text.index("</think>") + len("</think>")], "\nHello"],
                    ["<think>", text[len("<think>") : text.index("</think>")], "</think>", "\nHello"],
                    list(text),
                ):
                    with self.subTest(text=text, stream_reasoning=stream_reasoning, chunks=chunks):
                        detector = GraniteThinkingDetector(stream_reasoning=stream_reasoning)
                        all_r = all_c = ""
                        for chunk in chunks:
                            ret = detector.parse_streaming_increment(chunk)
                            all_r += ret.reasoning_text
                            all_c += ret.normal_text
                        end = detector.finish()
                        all_r += end.reasoning_text
                        all_c += end.normal_text
                        self.assertEqual(all_r, exp_r)
                        self.assertEqual(all_c, exp_c)
​
    def test_enable_thinking_false_swaps_truncated_reasoning(self):
        """经 ChatCompletionRequest 的 chat_template_kwargs 注入
        enable_thinking=False 后,ReasoningParser 应自动启用内容非空回退。"""
        from sglang.srt.entrypoints.openai.protocol import ChatCompletionRequest
​
        request = ChatCompletionRequest(
            model="granite-4.2-30b",
            messages=[{"role": "user", "content": "hi"}],
            chat_template_kwargs={"enable_thinking": False},
        )
        parser = ReasoningParser("granite_thinking_parser", request=request)
        reasoning, normal = parser.parse_non_stream("<think>truncated")
        self.assertEqual(reasoning, "")
        self.assertEqual(normal, "truncated")

评论区精华

模板自动检测误把 Nemotron-3 路由到 Granite 正确性

原实现用 `<parameter=` 工具调用块作为 Granite 判别特征,但该块与 Nemotron-3 模板逐字相同,导致 `--reasoning-parser auto` 下 Nemotron 部署被切到 Granite 检测器。JustinTong0323 在 47dd55e 中改用 Granite 独有的 `defer_loading` 特征,并声称用官方模板逐一验证 Granite 4.2 与 Nemotron-3 各型号。

结论:改用 `defer_loading` 作为唯一判别信号,规则顺序排在 nemotron_3 之前;补充两条互斥性回归测试。 · 已解决

force_nonempty_content 在 </think> 边界处漏掉 swap 正确性

文本恰好结束于 `</think>` 时,解析后正文为空但未触发 reasoning→content 反转,返回空 content;HF 插件在解析正文缺失时一律 swap。需同时保证「仅含换行的正文」不参与 swap。

结论:引入 `content_absent = think_end_present and not ret.normal_text` 作为 swap 的额外条件,只换行内容仍保持空正文;新增 `test_force_nonempty_swaps_when_text_ends_at_think_end` 与 `test_force_nonempty_no_swap_when_think_end_present` 锁定两种语义。 · 已解决

流式换行剥离依赖 chunk 切分粒度 正确性

`_reasoning_seen` 只采样调用前的 `_in_reasoning`,当整个 think 块落在同一个 chunk 时判定失效,前导换行会泄漏到 content;不同切分下输出不一致会直接破坏流式 API 的稳定性。

结论:证据来源扩展到调用后 `_in_reasoning`、`stripped_think_start` 和本次 `reasoning_text`,并新增多组切分粒度的 chunking-independence 测试(覆盖 stream_reasoning True/False 与逐字符切分)。 · 已解决

是否保留 granite parity 测试与冗余单测 测试

首版提交包含 `test_granite_thinking_parity.py` 和较多单测;维护者认为 parity 测试的固定用例与 `TestGraniteThinkingDetector` 重复、流式扫描已被分块无关性测试覆盖,且「hardcoded expectations cannot prove live HF plugin parity」。

结论:删除 parity 测试文件,保留唯一的 `enable_thinking=False` 映射检查并改写成通过 ChatCompletionRequest 断言最终输出;其余按「一个失败模式一个用例」原则精简。结果是本 PR 净增 -0 删除但测试从更宽收敛到更精准。 · 已解决

enable_thinking=False 的参数映射位置 设计

Granite 在 enable_thinking=False 时应把推理内容当作正文输出,这一语义映射放在 ReasoningParser.__init__ 而非检测器内部,与已有的通用 `force_nonempty_content` kwarg 处理并列。

结论:在 `ReasoningParser.__init__` 中以 `model_type.lower() == "granite_thinking_parser"` 分支注入 `force_nonempty_content=True`;该分支目前没有配独立的 `--reasoning-parser auto` 端到端测试,仅由单测通过 ChatCompletionRequest 覆盖。 · 已解决

风险与影响

  1. 自动检测规则顺序敏感:_is_granite_thinking_parser 与 _is_nemotron_3 共享 truncate_history_thinking 核心特征,仅靠 defer_loading 区分。若后续模型模板引入 defer_loading 字段,或 Nemotron 系模板加入该关键字,--reasoning-parser auto 可能再次误路由,属于隐性耦合。
  2. 流式状态机新增可变状态:_content_started/_reasoning_seen 在检测器实例生命周期内只前进不回退,continue_final_message 多轮续写场景复用同一实例时行为需额外验证;现有测试仅覆盖单次请求。
  3. 剥离范围较窄:只 lstrip("\n"),不处理空格或 `

;若 Granite 后续模板或不同 tokenizer 产出

` 会遗漏,属于与 HF 插件语义绑定的取舍。

  1. 对 HF 插件的对齐缺少自动化护栏:原 test_granite_thinking_parity.py 被删除后,对齐性只靠人工比对与注释声明,HF 插件侧行为变更不会触发本仓库测试失败。
  2. CI 状态不完整:PR body 中 Extra 与 AMD ROCm 两个测试 job 显示为失败(:x:),最终在 Base job 进行中即被合并,未留下全绿记录。

对用户:Granite 4.2 thinking 用户可直接使用 --reasoning-parser granite_thinking_parser,无需 HF 插件,且与 vLLM 端命名一致,迁移成本降低;auto 模式下的 Granite 部署能自动命中正确 parser。对系统:变更集中在 parser 层,不触及调度、KV cache、注意力等核心路径,回归面可控;但 REASONING_PARSER_RULES 的判别顺序属于全局共享逻辑,所有使用 auto 的模型部署都受其影响,Nemotron-3 用户是本次修复的直接受益方。对团队:本次 PR 展示了「新增检测器 + 自动检测谓词 + 单测」的标准扩展范式,可作为后续新模型 reasoning parser 的模板;同时维护者直接在贡献者分支上推修复提交,说明外部贡献与核心团队的协作路径高效但要求 reviewer 具备交叉模板的领域知识。

自动检测规则顺序敏感 流式状态机新增可变状态 HF 插件对齐无自动化验证 CI 未全绿即合入

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论