Prhub

#39038 [Session] Work with PD and Fix empty continuations

原始 PR 作者 ByronHsu 合并时间 2026-09-13 13:24 文件变更 4 提交数 1 评论 7 代码增减 +34 / -6

执行摘要

修复 session 在 PD 下丢失连接元数据与空续写失败

PR body 明确指出两个痛点:Session requests lose the connection fields needed by prefill and decode workers,以及 Empty continuations fail before stored session history can supply the input。在 PD 部署下,session 请求由 session_controller 重建 Req 时未转发 bootstrap_host/port/room、worker ranks 与 disagg 模式,decode worker 拿到的 host 为 None,构建连接地址时抛出 AttributeError: 'NoneType' object has no attribute 'startswith';而客户端依赖会话历史继续(空输入)的请求,在 io_struct 的输入校验里就因 input_ids 为空被拒绝。

值得精读,重点是理解 session 与 PD 之间的元数据流:create_req 重建 Req 时为何必须透传 bootstrap 三元组与 worker ranks,以及“输入校验放行 + 恢复历史后二次校验”的双层策略如何平衡 API 便利性与正确性。若你在维护 session 或多机推理入口,可参考本 PR 的接线方式;建议后续补一条空 continuation 的显式回归用例。

讨论亮点

该 PR 无代码评审评论(review_comments_count = 0),讨论集中在 PR body 的验证记录与 CI 重跑。ByronHsu 在 body 中用两个场景说明修复效果:空 continuation 场景 “Before: [] -> input validation -> ERROR: input_ids cannot be empty. After: history + [] -> [1, 2, 3, 4] -> accepted”;session 元数据场景 “After: metadata ----------------> same host, room, ranks, and mode”,端口省略时落到默认值 8999。此外作者先后对 test_disaggregation_decode_radix_cache.py(8-gpu-h20)与 session control / streaming session extra(1-gpu-h100)发起 /rerun-test 均通过,并给出结论 “Failures are unrelated”,即 CI 失败被判定为与本次改动无关。

实现拆解

  1. 输入校验放行(python/sglang/srt/managers/io_struct.py):在 _determine_batch_size 中,当 input_ids 为空且 session_params 为 dict 并携带 id 时不再抛 ValueError,而是把请求标记为单样本请求(is_single=True, batch_size=1),把“是否真的缺输入”的判断推迟到会话重建之后;无 session id 的空输入仍按原逻辑报错,保证普通请求不受影响。
  2. 会话重建与二次校验(python/sglang/srt/session/session_controller.py):Session.create_req 新增可选参数 disagg_mode(类型为 DisaggregationMode);在 token 数组组装(_share_token_arrays 或 _concat_token_arrays)之后新增终审:若当前未 abort 且组装后的 input_ids 仍为空,则以 FINISH_ABORT 拒绝并提示 “A session request must contain input tokens after restoring history.”。构造新 Req 时补传 bootstrap_host/port/room、routed_dp_rank、disagg_prefill_dp_rank 与 disagg_mode,保证 PD 下 prefill/decode worker 能拿到连接信息。
  3. 调度器接线(python/sglang/srt/managers/scheduler.py):把默认 bootstrap 端口赋值从普通请求分支内提前到 handle_generate_request 入口统一执行,使 session 请求同样获得默认端口;在 session 分支调用 session.create_req 时传入 disagg_mode=self.disaggregation_mode。
  4. 测试配套(test/registered/unit/mem_cache/test_session_token_share_unit.py):仅给 _recv fixture 补上 bootstrap_host/port/room、routed_dp_rank、disagg_prefill_dp_rank = None 缺省字段,保证单测构造的请求能通过新增的透传逻辑;未新增针对空 continuation 或 PD 元数据透传的专门用例,PR 验证依赖 CPU component checks(main 分支 12 失败 → 修复后 20 全过)与 46 个既有测试。
文件 模块 状态 重要度
python/sglang/srt/session/session_controller.py 会话控制 modified 6.33
python/sglang/srt/managers/io_struct.py 输入校验 modified 6.19
python/sglang/srt/managers/scheduler.py 调度器 modified 5.81
test/registered/unit/mem_cache/test_session_token_share_unit.py 会话测试 modified 3.86

关键符号

Session.create_req TokenizedGenerateReqInput._determine_batch_size Scheduler.handle_generate_request

关键源码片段

python/sglang/srt/session/session_controller.py entrypoint

会话请求重建的核心入口:新增 disagg_mode 参数、透传 bootstrap 连接字段与 worker ranks、并在恢复历史后对空输入做终审 abort,是本 PR 的主修复点。

def create_req(
    self,
    req: TokenizedGenerateReqInput,
    tokenizer,
    vocab_size: int,
    eos_token_ids=None,
    disagg_mode: Optional[DisaggregationMode] = None,
):
    assert req.session_params is not None
    session_params = req.session_params
​
    # ... streaming 分支校验与 last_req 选择逻辑 ...
​
    carry_fill = None
    if last_req is not None:
        self._strip_bos_token(req, tokenizer)
        can_share_token_arrays = (
            self.streaming
            and self.committed_origin_len is not None
            and not session_params.drop_previous_output
            and not (session_params.offset and session_params.offset != 0)
        )
        if can_share_token_arrays:
            input_ids, input_ids_unpadded, carry_fill = self._share_token_arrays(
                last_req, req.input_ids
            )
        else:
            input_ids, input_ids_unpadded = self._concat_token_arrays(
                last_req, req, session_params
            )
    else:
        # 会话内首个请求:没有历史可拼接,输入只能来自请求本身
        input_ids = req.input_ids
        input_ids_unpadded = req.input_ids
​
    # io_struct 已放行空输入,这里基于真实历史做终审:
    # 恢复历史后仍为空(如空首请求)必须拒绝,防止空序列进入执行队列
    if not abort and len(input_ids) == 0:
        abort = True
        abort_message = (
            'A session request must contain input tokens after restoring history.'
        )
​
    new_req = Req(
        rid=req.rid,
        origin_input_text=None,
        origin_input_ids=input_ids,
        origin_input_ids_unpadded=input_ids_unpadded,
        sampling_params=req.sampling_params,
        lora_id=req.lora_id,
        session=self,
        # PD 场景必需:decode / prefill worker 依赖这些字段构造连接地址,
        # 修复前 host 为 None 时会抛 AttributeError: 'NoneType' object has no attribute 'startswith'
        bootstrap_host=req.bootstrap_host,
        bootstrap_port=req.bootstrap_port,
        bootstrap_room=req.bootstrap_room,
        disagg_mode=disagg_mode,
        routed_dp_rank=req.routed_dp_rank,
        disagg_prefill_dp_rank=req.disagg_prefill_dp_rank,
        # ... 其余字段与 session 登记逻辑保持不变 ...
    )
python/sglang/srt/managers/io_struct.py core-logic

输入校验主路径:把空 input_ids 的硬校验改为“有 session id 时放行”,是支持空 continuation 的前提,与 session_controller 的终审形成双层校验。

elif self.input_ids is not None:
    if len(self.input_ids) == 0:
        # 空 continuation:会话历史可能提供全部输入,这里仅放行;
        # 终审由 session_controller.create_req 基于恢复后的历史完成
        session_id = (
            self.session_params.get('id')
            if isinstance(self.session_params, dict)
            else None
        )
        if not session_id:
            raise ValueError('input_ids cannot be empty.')
        # 空输入按单样本处理,让请求进入会话重建流程
        self.is_single = True
        self.batch_size = 1
    elif isinstance(self.input_ids[0], int):
        self.is_single = True
        self.batch_size = 1
    else:
        self.is_single = False
        self.batch_size = len(self.input_ids)
    self.input_embeds = None
python/sglang/srt/managers/scheduler.py core-logic

调度路由入口:默认 bootstrap 端口补齐从普通请求分支提前到函数入口,使 session 请求也能拿到默认端口,并向 create_req 传入 disaggregation 模式。

def handle_generate_request(self, recv_req, *, mm_input_error=None):
    session_id = (
        recv_req.session_params.id if recv_req.session_params is not None else None
    )
    # 默认端口提前到路由入口统一补齐:
    # 修复前只在普通请求分支内生效,session 请求重建时端口为 None
    if recv_req.bootstrap_port is None:
        recv_req.bootstrap_port = get_disagg().disaggregation_bootstrap_port
​
    radix_native_session = (
        recv_req.session_id is not None and self.enable_session_radix_cache
    )
​
    if session_id is None or radix_native_session:
        # 普通请求或 radix-native session 请求:原有处理逻辑
        ...
    elif (
        session_id in self.session_controller
        and not self.session_controller.get(session_id).close_on_finish
    ):
        session = self.session_controller.get(session_id)
        req = session.create_req(
            recv_req,
            self.tokenizer,
            self.model_config.vocab_size,
            eos_token_ids=self.model_config.hf_eos_token_id,
            # 把 prefill / decode 分离模式透传给重建的 Req
            disagg_mode=self.disaggregation_mode,
        )
        ...

评论区精华

空 continuation 的放行与二次校验策略 设计

PR body 演示了空输入行为:history [1, 2, 3, 4] + [] 应被接受为 [1, 2, 3, 4],而空首请求必须被拒绝。作者把放行放在 io_struct._determine_batch_size(仅当 session_params 为 dict 且含 id),把终审放在 session_controller.create_req(恢复历史后仍为空则 abort)。

结论:采用“输入校验放行 + 会话重建后二次校验”的双层策略,保证空 continuation 可用且空首请求失败。 · 已解决

PD 模式下 session 请求丢失 transport 元数据 正确性

PR body 记录:重建 session 请求后 host 变为 None,decode worker 构建连接地址时抛 AttributeError: 'NoneType' object has no attribute 'startswith';同时默认端口此前只对普通请求生效,session 请求的端口也为空。

结论:create_req 透传 bootstrap_host/port/room、routed_dp_rank、disagg_prefill_dp_rank 与 disagg_mode,调度器把默认端口补齐提前到路由入口,问题解决。 · 已解决

CI 重跑与失败归属 测试

ByronHsu 多次发起 /rerun-test:test_disaggregation_decode_radix_cache.py(8-gpu-h20)与 session control / streaming session extra(1-gpu-h100)均通过,最终注明 Failures are unrelated;main 分支 CPU component checks 12 failed → 修复后 20 passed。

结论:回归用例通过,剩余 CI 失败被判定为与本次改动无关。 · 已解决

风险与影响

  • 输入校验放宽的形态依赖:io_struct.py 里 session_id 仅在 session_params 为 dict 且含 id 时得到放行;若某些调用路径以对象形态传入 session_params(如既有单测用 SimpleNamespace),空输入仍会抛 ValueError,行为不对称,不同入口表现可能不一致。
  • 核心路由路径变更:scheduler.py 把默认 bootstrap_port 赋值提前到 handle_generate_request 入口,影响所有请求类型,需确认未启用 get_disagg() 时默认端口取值不会破坏非 PD 路径。
  • 调用点同步风险:Session.create_req 新增 disagg_mode 参数后,若未来新增调用点未传该参数,重建的 Req 会缺失 prefill/decode 模式信息,PD 场景下同样会退化。
  • 缺少新增回归测试:测试仅补 fixture 缺省字段,没有为“空 continuation 接受”和“PD 元数据透传”新增专门单测或 e2e 用例,后续回归主要依赖 CPU component checks 覆盖。
  • 用户/API 层面:session 续写现在允许客户端只传 session id 而输入为空(依赖历史继续),PD 部署下 session 请求的连接元数据不再丢失,decode worker 可正常构建连接地址。
  • 系统层面:非 session 请求的行为基本不变(仅端口填充位置提前);session 请求新增 disagg 状态透传,radix-native session 走原有分支不受影响。
  • 团队层面:修复了 CPU component checks 中 12 个失败用例,session 相关 46 个既有测试保持通过;改动范围小、单 commit,便于回溯。
核心调度路径变更 输入校验放宽依赖调用方传参形态 缺少新增回归测试 disagg 元数据需调用点同步

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论