Prhub

#35323 fix(openai): avoid duplicate routed expert in response when `return_meta_info = True`

原始 PR 作者 guapisolo 合并时间 2026-08-21 06:21 文件变更 3 提交数 2 评论 12 代码增减 +110 / -5

执行摘要

修复双标志下 routed_experts 在 chat 响应中重复返回

PR body 指出症状:The base64 routed_experts payload appeared in both choices[*].meta_info and response-level sglext when both return flags were enabled。根因是 _build_chat_response 把第一个 choice 的 routed_experts 投影进 SglExt,而 choice_meta_info 又为每个 choice 序列化同一份路由数据。对 RL 工作流影响明显,因为 routed_experts 是按 [num_tokens, num_layers, top_k] 展开的完整专家路由历史,重复回传既浪费带宽又可能让客户端拿到两份不一致数据。

建议精读。虽然源码改动仅 5 行,但 review 中关于「return_meta_info 是否应成为全局 sglext 开关」的设计交锋很有价值,最终选择最小化抑制、保持扩展字段独立性的决策适用于同类 API 兼容层的字段治理;测试通过序列化断言(精确计数 "routed_experts" 出现次数)锁住了去重语义,这种字段级测试写法值得借鉴。

讨论亮点

nanjiangwill(design 质疑):Do we want return_meta_info to disable the entire sglext object?It removes the duplicated routed_experts and cached_tokens_details, but it would also remove unrelated extension fields. main just added spec_tokens_details in #33518, so after rebasing, would combining these flags also drop sglext.spec_tokens_details?Would an explicit return_sglext=false be safer for callers like miles?

guapisolo(回应):Updated... return_meta_info now suppresses only response-level sglext.routed_experts. Independently requested cached_tokens_details and spec_tokens_details remain in sglext, so this avoids a broader return_sglext switch。非 routed expert 字段体积不大,重复开销可接受。

nanjiangwill(测试覆盖):should we also cover return_spec_tokens_details=true here after rebasing?I'm wondering whether this assertion makes the test assume that return_meta_info should always remove every current and future sglext field。

guapisolo(答复):Addressed in 584a0880b... 组合测试覆盖 return_meta_info + routed + cached + spec + n=2,显式断言 serialized sglext.spec_tokens_details 仍保留,且 sglext 缺失仅发生在 routed_experts 为唯一扩展字段时。

评审结论:两位 reviewer nanjiangwillhnyls2002 均批准;讨论确认了“字段独立性优先、不做全局 sglext 开关”的取舍,并用组合测试锁住未来回归。

实现拆解

  1. 变更入口与根因定位:核心改动集中在 python/sglang/srt/entrypoints/openai/serving_chat.py_build_chat_response。该函数先构造响应级 SglExt(从 first_ret 提取 routed experts、cached token 明细与 spec token 明细),再逐 choice 构建响应,而每个 choice 的 meta_info 又会携带 routed experts,这是重复数据的来源。
  2. 核心逻辑改造:将 routed_experts = process_routed_experts_from_ret(first_ret, request) 改为三元表达式,request.return_meta_info 为真时直接置 None。由于 SglExt 的创建条件是“任一扩展字段非空”,当 routed_experts 是唯一请求的扩展字段时,响应级 sglext 整体消失;当同时请求 cached/spec 明细时,sglext 仍存在但只含后两者。这一设计刻意避开为“禁用 sglext”再引入一个新的布尔开关。
  3. 测试配套test/registered/unit/entrypoints/openai/test_serving_chat.py 新增 3 个高信号用例:test_non_streaming_extension_fields_emit_sglext_without_meta_inforeturn_meta_info=false 时扩展字段全部进 sglext,choice 无 meta_info);test_non_streaming_meta_info_omits_response_level_routed_experts(双标志且 n=2sglext 缺失,序列化后 "routed_experts" 恰好出现 2 次,防重复);test_non_streaming_meta_info_preserves_cache_and_spec_in_sglext(组合标志下 sglext 保留 cache/spec,仅省略 routed experts,防未来误删)。
  4. 文档配套docs/docs/basic_usage/openai_api_completions.mdx 更新 return_routed_experts 一节,明确 return_meta_info=true 时导出数据只出现在 choice 的 meta_info,其余扩展字段仍留在 sglext
文件 模块 状态 重要度
python/sglang/srt/entrypoints/openai/serving_chat.py 聊天接口 modified 6.04
test/registered/unit/entrypoints/openai/test_serving_chat.py 聊天测试 modified 6.95
docs/docs/basic_usage/openai_api_completions.mdx 使用文档 modified 2.35

关键符号

_build_chat_response test_non_streaming_extension_fields_emit_sglext_without_meta_info test_non_streaming_meta_info_omits_response_level_routed_experts test_non_streaming_meta_info_preserves_cache_and_spec_in_sglext

关键源码片段

python/sglang/srt/entrypoints/openai/serving_chat.py core-logic

非流式 chat 响应构建的核心入口,`_build_chat_response` 在此将 `routed_experts` 提取改为受 `return_meta_info` 控制,是全部行为变更所在。

def _build_chat_response(
    self,
    request: ChatCompletionRequest,
    ret: List[Dict[str, Any]],
    created: int,
) -> Union[ChatCompletionResponse, ORJSONResponse]:
    """根据生成结果构建非流式 chat 补全响应。"""
​
    # kimi_k3 编码规格需要重写 prompt_tokens 的上报口径,其余模型直接透传
    if self.chat_encoding_spec == "kimi_k3":
        ret = [
            {
                **item,
                "meta_info": {
                    **item["meta_info"],
                    "prompt_tokens": self._reported_prompt_tokens(item["meta_info"]),
                },
            }
            for item in ret
        ]
​
    choices = []
​
    # 响应级 sglext 从第一个 ret_item 提取请求级扩展字段;
    # routed_experts 是逐 token 的专家路由数据,体积大,当 return_meta_info 开启时
    # 由每个 choice 的 meta_info 承担,避免同一份 base64 数据在响应中出现两次
    first_ret = ret[0]
    routed_experts = (
        None
        if request.return_meta_info
        else process_routed_experts_from_ret(first_ret, request)
    )
    cached_tokens_details = process_cached_tokens_details_from_ret(
        first_ret, request
    )
    spec_details = [
        detail
        for detail in (
            process_spec_tokens_details_from_ret(item, request) for item in ret
        )
        if detail is not None
    ]
    # n > 1 时 spec 详情按 choice 顺序平铺,n == 1 时退化为单值或 None
    spec_tokens_details = (
        spec_details
        if request.n > 1
        else (spec_details[0] if spec_details else None)
    )
    response_sglext = None
    if routed_experts or cached_tokens_details or spec_tokens_details:
        # 任一扩展字段存在才创建 SglExt;routed_experts 被抑制时
        # 只要 cached/spec 仍在,sglext 就继续存在,字段彼此独立
        response_sglext = SglExt(
            routed_experts=routed_experts,
            cached_tokens_details=cached_tokens_details,
            spec_tokens_details=spec_tokens_details,
        )
    # 后续循环为每个 ret_item 构建 choice,meta_info 中保留 routed_experts 原值
test/registered/unit/entrypoints/openai/test_serving_chat.py test-coverage

测试配套主体,新增 3 个字段级用例覆盖各标志组合,用序列化断言精确锁住去重语义,是评审讨论的焦点之一。

def test_non_streaming_meta_info_omits_response_level_routed_experts(self):
    # n=2 且双标志开启:每个 choice 需要各自携带路由数据
    req = ChatCompletionRequest(
        model="x",
        messages=[{"role": "user", "content": "Hi?"}],
        max_tokens=100,
        n=2,
        return_meta_info=True,
        return_routed_experts=True,
    )
    ret = [
        {
            "text": f"Response {index}",
            "meta_info": {
                "id": "chatcmpl-meta-test",
                "prompt_tokens": 10,
                "completion_tokens": 2,
                "cached_tokens": index,
                "routed_experts": routed_experts,
                "finish_reason": {"type": "stop", "matched": None},
                "weight_version": "default",
            },
        }
        for index, routed_experts in enumerate(["cm91dGUtYQ==", "cm91dGUtYg=="])
    ]
​
    response = self.chat._build_chat_response(req, ret, 1234567890)
​
    # routed_experts 是唯一请求的扩展字段时,响应级 sglext 整体缺失
    self.assertIsNone(
        response.sglext,
        "sglext is absent only when routed_experts is the sole extension",
    )
    # 每个 choice 的 meta_info 保持权威数据,sglext 不再重复投影
    self.assertEqual(
        [choice.meta_info for choice in response.choices],
        [ret_item["meta_info"] for ret_item in ret],
    )
    dumped_response = response.model_dump()
    self.assertNotIn("sglext", dumped_response)
    # 序列化后 routed_experts 只出现 2 次(两个 choice 的 meta_info),无重复
    serialized_response = json.dumps(dumped_response)
    self.assertEqual(serialized_response.count('"routed_experts"'), 2)

评论区精华

return_meta_info 是否应整体禁用 sglext / 是否引入 return_sglext 开关 设计

nanjiangwill 指出若 `return_meta_info` 抑制整个 `sglext`,会连带删除与 `meta_info` 无关的扩展字段(如 #33518 刚加入的 `spec_tokens_details`),并建议评估显式 `return_sglext=false` 对 miles 等调用方是否更安全。

结论:guapisolo 改为仅抑制 `sglext.routed_experts`,`cached_tokens_details` 与 `spec_tokens_details` 保持独立,不引入新开关。 · 已解决

组合标志测试是否应覆盖 spec_tokens_details 防止未来回归 测试

nanjiangwill 担心测试断言暗示 `return_meta_info` 会移除所有当前及未来的 `sglext` 字段;guapisolo 在 `584a0880b` 提交中补充组合测试,显式断言 `sglext.spec_tokens_details` 在 `return_meta_info=true` 时仍被序列化。

结论:已解决;测试明确字段级行为,并注明 `sglext` 缺失仅发生在 `routed_experts` 为唯一扩展字段时。 · 已解决

风险与影响

  1. 公共契约变更return_meta_info=true + return_routed_experts=true 时响应级 sglext.routed_experts 消失,依赖该位置的客户端(如 miles 类 RL 调用方)需迁移到 choices[*].meta_info;属于轻微 breaking change,文档已同步但缺少显式的迁移提示。
  2. 隐式耦合:抑制完全依赖 return_meta_info 与「meta_info 内含 routed_experts」的假设。若后续某个模型/编码规格(如 kimi_k3 分支)在 meta_info 中改变该字段的填充方式,路由数据会被静默丢弃;当前仅靠单测锁住这一假设。
  3. 行为修正的负载影响:修复后 n>1 时各 choice 的 meta_info 各自携带独立路由数据,而此前所有 choice 共享第一份;整体响应体积在多数场景下降,但并行采样下 per-choice 数据量会随 n 线性增长,大 n 场景需关注序列化时长。
  4. 回归面:改动集中在非流式 chat 单分支,流式与 completions 端点不受影响,CI 中相关 130 个单测与 55 个子测试全部通过。

对用户与调用方而言,双标志请求的响应格式发生变化(routed_experts 位置从响应级 sglext 移到每个 choice 的 meta_info),重复数据被消除,客户端不再需要处理两份可能不一致的副本;对系统而言减少了同一份大体积 base64 路由数据的序列化与网络传输,性能只增不减;对团队而言,该修复明确了扩展字段的归属原则——per-choice 数据进 meta_info、per-request 数据进 sglext,为后续新增扩展字段(如 spec_tokens_details 这类)提供了规范参考。影响范围限定在非流式 /v1/chat/completions 的特定标志组合,整体可控。

公共 API 响应契约变更 字段来源隐式耦合 非流式 chat 路径行为修正

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论