Prhub

#34627 fix: preserve output logprobs without input logprobs

原始 PR 作者 jain-ria 合并时间 2026-08-19 01:07 文件变更 2 提交数 1 评论 4 代码增减 +39 / -10

执行摘要

修复 output-only logprobs 被误丢弃的问题

PR body 明确说明:convert_logprob_style() 目前只要 input_token_logprobs_val 为 None 就直接返回,即使存在有效的 output logprobs 也会被一并丢弃,导致 logprobs=true、top_logprobs=0 这类 output-only 请求在 Dynamo 侧拿不到任何 logprob;下游上下文为 ai-dynamo/dynamo 的 PR#12820,Dynamo 需要把 token ID 与 logprob 组合补全 OpenAI 响应的 token 文本与 bytes 字段。

值得精读。核心设计决策是“把 input 与 output 两条 logprob 数据流彻底解耦”,并统一增加 None 防御,这种模式可推广到其他按请求维度切分的元数据组装逻辑;同时可对照 Dynamo 侧 PR#12820 理解端到端闭环。

讨论亮点

唯一的 review 线程集中在测试文件归属上:审阅者 ishandhanani 认为这个回归测试可能很重要,询问是否应放在 tokenizer 相关测试文件里(原先是新增的 test_tokenizer_manager_logprobs.py);作者 jain-ria 随即回复已移动到 test/registered/unit/managers/test_flat_raw_top_logprobs.py。讨论已解决,无未决技术争议。

实现拆解

  1. 定位根因convert_logprob_stylepython/sglang/srt/managers/tokenizer_manager.py)开头存在 if recv_obj.input_token_logprobs_val is None: return,把“输入侧缺失”当成整条 logprob 处理路径的终止条件,output 侧数据被连带丢弃。
  2. 解耦两条数据流:将 input 侧处理改为三重条件(is not Nonelen > 0、当前请求位非 None)才累积;output 侧独立成分支,仅当 output_token_logprobs_val 及其当前请求位均非 None 时才累积到 state.output_token_logprobs_valstate.output_token_logprobs_idx
  3. 保持其余分支不动top_logprobs_num > 0 时的 top-k 候选累积、input_top_logprobs_val_flat 的 scheduler-flat 三元组缓存以及 token_ids_logprob 分支均保持原逻辑,最小化回归面。
  4. 测试配套:在 test/registered/unit/managers/test_flat_raw_top_logprobs.py 中为 _TokenizerManagerStub 补充 convert_logprob_style 引用,并新增 TestTokenizerManagerLogprobs.test_output_logprobs_without_input_logprobs,断言 input 为空、output 为 [(-0.25, 42, None)]、长度 1。
文件 模块 状态 重要度
python/sglang/srt/managers/tokenizer_manager.py 请求处理 modified 6.0
test/registered/unit/managers/test_flat_raw_top_logprobs.py 对数概率 modified 5.22

关键符号

convert_logprob_style test_output_logprobs_without_input_logprobs

关键源码片段

python/sglang/srt/managers/tokenizer_manager.py core-logic

核心修复文件:convert_logprob_style 移除提前 return,input 与 output logprobs 各自独立判断,是本次 bug 的根因所在。

def convert_logprob_style(
    self,
    meta_info: dict,
    state: ReqState,
    top_logprobs_num: int,
    token_ids_logprob: List[int],
    return_text_in_logprobs: bool,
    recv_obj: BatchStrOutput,
    recv_obj_index: int,
):
    # 修复核心:旧实现先判断 input_token_logprobs_val 为 None 就提前 return,
    # 导致 output 侧 logprobs 在 logprobs=true、top_logprobs=0 这类请求中被整体丢弃。
    # 现在 input 与 output 各走独立分支,互不阻塞。
    if (
        recv_obj.input_token_logprobs_val is not None
        and len(recv_obj.input_token_logprobs_val) > 0
        and recv_obj.input_token_logprobs_val[recv_obj_index] is not None
    ):
        # 仅当本请求确实带输入 logprob 数据时才累积到 state。
        state.input_token_logprobs_val.extend(
            recv_obj.input_token_logprobs_val[recv_obj_index]
        )
        state.input_token_logprobs_idx.extend(
            recv_obj.input_token_logprobs_idx[recv_obj_index]
        )
    if (
        recv_obj.output_token_logprobs_val is not None
        and recv_obj.output_token_logprobs_val[recv_obj_index] is not None
    ):
        # 输出 logprob 独立保留:即使输入侧没有数据,生成 token 的概率
        # 仍可透传给下游(如 Dynamo),由其按 token_id 补全文本与 bytes。
        state.output_token_logprobs_val.extend(
            recv_obj.output_token_logprobs_val[recv_obj_index]
        )
        state.output_token_logprobs_idx.extend(
            recv_obj.output_token_logprobs_idx[recv_obj_index]
        )
​
    # 以下分支保持原逻辑:仅在请求 top-k 候选时处理 top logprobs。
    if top_logprobs_num > 0:
        if len(recv_obj.input_top_logprobs_val) > 0:
            state.input_top_logprobs_val.extend(
                recv_obj.input_top_logprobs_val[recv_obj_index]
            )
            state.input_top_logprobs_idx.extend(
                recv_obj.input_top_logprobs_idx[recv_obj_index]
            )
        if (
            recv_obj.input_top_logprobs_val_flat is not None
            and recv_obj.input_top_logprobs_val_flat[recv_obj_index] is not None
        ):
            # scheduler-flat 路径:直接缓存数组三元组,避免嵌套行开销。
            state.input_top_logprobs_scheduler_flat = (
                recv_obj.input_top_logprobs_val_flat[recv_obj_index],
                recv_obj.input_top_logprobs_idx_flat[recv_obj_index],
                recv_obj.input_top_logprobs_flat_null_prefix[recv_obj_index],
            )
        state.output_top_logprobs_val.extend(
            recv_obj.output_top_logprobs_val[recv_obj_index]
        )
        state.output_top_logprobs_idx.extend(
            recv_obj.output_top_logprobs_idx[recv_obj_index]
        )
​
    if token_ids_logprob is not None:
        # 后续 token_ids 分支保持原实现不变,与本次修复点无关。
        pass
test/registered/unit/managers/test_flat_raw_top_logprobs.py test-coverage

回归测试载体:新增 TestTokenizerManagerLogprobs 覆盖“只有 output logprobs、没有 input logprobs”的关键场景,并让 Stub 暴露 convert_logprob_style 方法。

class TestTokenizerManagerLogprobs(CustomTestCase):
    # 回归测试:只请求输出 logprobs、不请求输入 logprobs 的场景。
    # 当 recv_obj.input_token_logprobs_val 为 None 时,旧实现会提前 return,
    # 导致 output 侧 (-0.25, 42) 这一条 logprob 丢失。
    def test_output_logprobs_without_input_logprobs(self):
        state = _make_state(return_logprob=True, top_logprobs_num=0)
        recv_obj = SimpleNamespace(
            input_token_logprobs_val=None,
            input_token_logprobs_idx=None,
            output_token_logprobs_val=[[-0.25]],
            output_token_logprobs_idx=[[42]],
        )
        meta_info = {}
​
        _TokenizerManagerStub().convert_logprob_style(
            meta_info,
            state,
            top_logprobs_num=0,
            token_ids_logprob=None,
            return_text_in_logprobs=False,
            recv_obj=recv_obj,
            recv_obj_index=0,
        )
​
        self.assertEqual(meta_info["input_token_logprobs"], [])
        # 关键断言:输出 logprob 必须被保留为 (logprob, token_id, 文本 ) 三元组。
        self.assertEqual(meta_info["output_token_logprobs"], [(-0.25, 42, None)])
        self.assertEqual(meta_info["output_token_logprobs_length"], 1)

评论区精华

回归测试文件的放置位置 测试

审阅者 ishandhanani 认为这个测试可能很重要,询问是否应放在 tokenizer 相关的测试文件里(最初是新增的 test_tokenizer_manager_logprobs.py)。

结论:作者 jain-ria 回复已移动到 test/registered/unit/managers/test_flat_raw_top_logprobs.py,与现有 logprobs 测试聚合。 · 已解决

风险与影响

  1. 核心路径变更convert_logprob_style 是 TokenizerManager 组装 logprob 元数据的必经路径,控制流从“提前 return”改为“逐项条件判断”,虽然对 input 侧语义等价,仍需关注既有 input-only 请求的行为是否完全不变。
  2. None 分支行为变化:旧代码在 output_token_logprobs_val 为 None 时会抛 AttributeError,新代码改为静默跳过,可能掩盖上游数据异常,需要观察日志或增加告警。
  3. 覆盖范围有限:本仓库只增加了单元级回归,缺少对完整生成链路(如 OpenAI API 嵌套 logprobs 结构、流式输出)的端到端验证,主要依赖 Dynamo 侧的外部 e2e 矩阵兜底。

影响面集中在 logprob 返回链路:Dynamo 等下游消费方在 output-only 请求时能获得完整输出 logprobs 元数据;对模型 forward、采样、KV cache 等计算路径零改动。对团队而言,这是一个低风险高价值的契约修复,改动量小(源码 +12/-10),但补上了 Python Engine API 与下游推理框架之间的关键断点。

核心服务路径变更 None 分支行为变化 缺少端到端 logprobs 回归

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论