# PR #33011 完整报告

- 仓库：`sgl-project/sglang`
- 标题：config: preserve resolved config across nested publishes + mutation ratchets
- 合并时间：2026-08-01 06:05
- 原文链接：http://prhub.com.cn/sgl-project/sglang/pull/33011

---

# 执行摘要

- 一句话：配置生命周期快照恢复，双基线护栏锁定迁移
- 推荐动作：值得精读。这是 RFC #30696 配置命名空间迁移的奠基 PR，核心看点有三：一是 `preserve_config()` 按值快照恢复的设计，解决“恢复即重建、重建即丢失”的经典问题；二是双向 ratchet（只减不增 + 减少必须下调基线）作为 CI 演进护栏的范式，值得其他大型迁移借鉴；三是 Codex 评审提出的两个真实缺陷与作者修复的往返过程，展示了上下文管理器设计中“必须有生产调用方”和“必须恢复对象内部状态而非引用”两条原则。

# 功能与动机

RFC #30696（RuntimeContext as the configuration API）指出 `ServerArgs` 同时承担“用户输入”与“运行时决议”双重身份、`__post_init__` 职责过载、多进程配置安装缺乏类型身份等问题，目标之一是“One write path for resolution: publish（plus namespace .override for audited post-publish mutation）”。本 PR 解决该迁移中的具体落地难点：draft worker 等嵌套构建会 publish 私有 `ServerArgs` 副本，旧代码靠 `finally` 里的 `set_server_args(saved)` 恢复外层配置，但该恢复会从原始 `ServerArgs` 重新投影 bags，静默丢弃目标进程加载期通过 `override()` 写入的解析值（如 KV cache dtype，作者在评审回复中确认这是 main 上的活跃问题）。PR body 还明确引入两条 ratchet：`ServerArgs.override` 调用点精确下限、迁移延迟测试列表精确下限，防止迁移过程中写入面回涨或测试被继续模块级跳过。

# 实现拆解

### 1. 变更入口与核心 API：`preserve_config()`

`python/sglang/srt/runtime_context.py` 新增 `@contextmanager` 装饰的 `RuntimeContext.preserve_config()`，进入时快照 6 项状态：`_server_args` 槽位引用、`_config_bags` 引用、bag 树逐叶子值、`_overrides_log` 拷贝、`parallel._config`、`flags.capture.enable_torch_compile`；`finally` 中原样恢复。设计核心是“原样恢复”而非“重新投影”——重新投影会丢失 post-publish override。

### 2. 按值快照而非引用快照

新增模块级函数 `_snapshot_bag_values()` 和 `_restore_bag_values()`，递归遍历 `_ConfigBag` 的 `_fields` 与 `_subs`，对每个叶子做 `dict()` 拷贝并按路径回写；`_overrides_log` 用 `list()` 新拷贝。原因：`get_context().override()` 会就地修改 bags 并向同一个 log 列表 append，引用快照在 `finally` 中恢复的仍是已被改动的同一对象——这是 Codex P2 评审指出的真实缺陷，修复后新增了对应回归测试。

### 3. 生产接线：`build_draft_tp_worker`

`python/sglang/srt/speculative/draft_worker_common.py` 删除 `get_server_args` 导入与 `try/finally` 手动保存恢复，改为 `with get_context().preserve_config():` 包裹 `TpModelWorker` 构建，在修复 main 上丢配置 bug 的同时，让新 API 有了第一个生产调用方（回应 Codex P1 评审）。

### 4. 双向 ratchet 护栏

新增两个测试文件，均注册 CPU CI（`register_cpu_ci(est_time=5, suite="base-a-test-cpu")`）：

- `test/registered/unit/test_server_args_writer_ratchet.py`：用正则计数 `server_args.override(` / `\bargs.override(` / `\bsa.override(` 调用点，基线 49，排除 `srt/server_args.py`、`srt/arg_groups`、`multimodal_gen`；
- `test/registered/unit/test_migration_deferral_ratchet.py`：扫描 `test/` 树下带 `config-namespace migration` marker 的模块跳过文件数，基线 15。

两者都是双向失败：数量增多直接 fail（禁止新增写入面或新延迟），数量减少也 fail（要求下调基线，把迁移进展锁死在 CI 里）。

### 5. 回归测试配套

`test/registered/unit/test_runtime_context_override.py` 新增 3 个用例：`test_preserve_config_keeps_post_publish_overrides`（嵌套 publish 后外层 override 原样保留，并逐条校验 `overrides_log()`）、`test_preserve_config_restores_in_scope_override_without_republish`（无 republish 的 in-scope override 按值回滚）、`test_preserve_config_restores_on_exception`（异常路径同样恢复）。

关键文件：
- `python/sglang/srt/runtime_context.py`（模块 配置上下文；类别 source；类型 core-logic；符号 _snapshot_bag_values, walk, _restore_bag_values, preserve_config）: 本 PR 核心：新增 `preserve_config()` 上下文管理器与 `_snapshot_bag_values` / `_restore_bag_values` 按值快照恢复逻辑，这是配置命名空间迁移的基础设施，直接决定嵌套 publish 能否保留外层 post-publish override。
- `python/sglang/srt/speculative/draft_worker_common.py`（模块 草稿构建；类别 source；类型 dependency-wiring；符号 build_draft_tp_worker）: preserve_config 的第一个生产调用方：`build_draft_tp_worker` 由手动 save/republish 改为 `with preserve_config()` 包裹，修复 main 上嵌套构建丢 post-publish override 的活跃 bug，影响 DFLASH/DSPARK 等投机解码路径。
- `test/registered/unit/test_server_args_writer_ratchet.py`（模块 写入护栏；类别 test；类型 test-coverage；符号 TestServerArgsWriterRatchet, test_server_args_override_call_sites_match_the_baseline）: 新增的写入面 ratchet：用正则精确钉住 `ServerArgs.override` 调用点基线 49，双向失败机制阻止迁移过程中写入面回涨，是本次迁移的 CI 护栏核心。
- `test/registered/unit/test_migration_deferral_ratchet.py`（模块 迁移护栏；类别 test；类型 test-coverage；符号 TestMigrationDeferralRatchet, test_deferred_test_files_match_the_baseline）: 新增的迁移延迟 ratchet：精确钉住带 `config-namespace migration` marker 的模块跳过文件数基线 15，防止新测试被静默跳过、强制恢复被延迟的迁移测试。
- `test/registered/unit/test_runtime_context_override.py`（模块 覆盖测试；类别 test；类型 test-coverage；符号 test_preserve_config_keeps_post_publish_overrides, test_preserve_config_restores_in_scope_override_without_republish, test_preserve_config_restores_on_exception）: preserve_config 的回归测试：覆盖嵌套 publish 后 post-publish override 保留、无 republish 的 in-scope override 值回滚、异常路径恢复三个关键场景。

关键符号：preserve_config, _snapshot_bag_values, _restore_bag_values, build_draft_tp_worker, test_preserve_config_keeps_post_publish_overrides, test_preserve_config_restores_in_scope_override_without_republish, test_preserve_config_restores_on_exception, test_server_args_override_call_sites_match_the_baseline, test_deferred_test_files_match_the_baseline

## 关键源码片段

### `python/sglang/srt/runtime_context.py`

本 PR 核心：新增 `preserve_config()` 上下文管理器与 `_snapshot_bag_values` / `_restore_bag_values` 按值快照恢复逻辑，这是配置命名空间迁移的基础设施，直接决定嵌套 publish 能否保留外层 post-publish override。

```python
# —— 配置 bag 树的按值快照 / 恢复 ——
# bags 会被 get_context().override() 就地修改，快照必须逐叶子拷贝，
# 否则 finally 里恢复的仍是同一个已被改动的对象（引用别名问题）。

def _snapshot_bag_values(bags: dict | None) -> dict | None:
    """Per-leaf value snapshot of a config-bag tree."""
    if bags is None:
        return None
    snap: dict = {}

    def walk(prefix: str, bag) -> None:
        # 用 dict() 拷贝该叶子的 _fields，以完整路径作为 key
        snap[prefix] = dict(object.__getattribute__(bag, "_fields"))
        for name, sub in object.__getattribute__(bag, "_subs").items():
            walk(f"{prefix}.{name}", sub)

    for name, bag in bags.items():
        walk(name, bag)
    return snap


def _restore_bag_values(bags: dict, snap: dict) -> None:
    # 只回写快照里存在的 key，作用域内新增的叶子不会残留
    def walk(prefix: str, bag) -> None:
        for key, value in snap[prefix].items():
            bag._set(key, value)
        for name, sub in object.__getattribute__(bag, "_subs").items():
            walk(f"{prefix}.{name}", sub)

    for name, bag in bags.items():
        walk(name, bag)


# —— preserve_config：嵌套构建的保护壳 ——
# 槽位与 bags 可直接保存引用，但 bags 内部值、overrides_log 可变，
# 必须按值快照；parallel._config 与 capture 标志也一并纳入保护。

@contextmanager
def preserve_config(self):
    """Snapshot the full config lifecycle and reinstate it verbatim on exit."""
    prev_server_args = self._server_args
    prev_bags = self._config_bags
    prev_bag_values = _snapshot_bag_values(prev_bags)
    prev_overrides_log = list(self._overrides_log)  # 拷贝，防止 append 泄漏
    prev_parallel_config = self.parallel._config
    prev_capture = self.flags.capture.enable_torch_compile
    try:
        yield
    finally:
        self._server_args = prev_server_args
        self._config_bags = prev_bags
        if prev_bags is not None:
            # 恢复叶子值而非仅重新赋值对象，保证 in-scope override 回滚
            _restore_bag_values(prev_bags, prev_bag_values)
        self._overrides_log = prev_overrides_log
        self.parallel._config = prev_parallel_config
        self.flags.capture.enable_torch_compile = prev_capture

```

### `python/sglang/srt/speculative/draft_worker_common.py`

preserve_config 的第一个生产调用方：`build_draft_tp_worker` 由手动 save/republish 改为 `with preserve_config()` 包裹，修复 main 上嵌套构建丢 post-publish override 的活跃 bug，影响 DFLASH/DSPARK 等投机解码路径。

```python
def build_draft_tp_worker(
    *,
    server_args: ServerArgs,
    gpu_id: int,
    ps: ParallelState,
    nccl_port: int,
    target_model_config: ModelConfig,
    algo_label: str,
    attention_backend_override: Optional[str] = None,
) -> DraftWorkerBundle:
    draft_server_args = deepcopy(server_args)
    # draft 副本的解析值调整必须走 audited mutation point：
    # 解析后的 ServerArgs 拒绝裸赋值，override() 保留写入来源
    draft_server_args.override(
        "draft_worker.build",
        skip_tokenizer_init=True,
        speculative_draft_attention_backend=draft_backend,
        prefill_attention_backend=None,
        decode_attention_backend=None,
        attention_backend=draft_backend,
        context_length=target_model_config.context_len,
    )

    # 用 preserve_config() 替代旧的 finally: set_server_args(saved)：
    # 旧实现从原始 ServerArgs 重新投影 bags，会丢弃目标进程加载期
    # 通过 override 写入的解析值（如 ModelRunner 记录的 KV cache dtype）
    with get_context().preserve_config():
        draft_worker = TpModelWorker(
            server_args=draft_server_args,
            gpu_id=gpu_id,
            ps=ps,
            nccl_port=nccl_port,
            is_draft_worker=True,
        )

    draft_model_runner = draft_worker.model_runner
    draft_worker.draft_runner = draft_model_runner
    return DraftWorkerBundle(
        draft_worker=draft_worker,
        draft_model_runner=draft_model_runner,
        draft_model=draft_model_runner.model,
        resolved_attention_backend=draft_backend,
    )

```

### `test/registered/unit/test_server_args_writer_ratchet.py`

新增的写入面 ratchet：用正则精确钉住 `ServerArgs.override` 调用点基线 49，双向失败机制阻止迁移过程中写入面回涨，是本次迁移的 CI 护栏核心。

```python
# —— 写入面 ratchet：ServerArgs.override 调用点只减不增 ——
# ServerArgs.override 只改实例本身，resolved-config bags 永远看不到写入，
# 命名空间读取方（get_exec() / get_memory()）会与写入方失步；迁移终态里
# post-publish 的进程级修改必须走 get_context().override(source, **fields)

_WRITER_PATTERNS = [
    re.compile(r"server_args\.override\("),
    re.compile(r"\bargs\.override\("),
    re.compile(r"\bsa\.override\("),
]
# 解析管线自身的 declare 面按设计会转发到 override，multimodal_gen 的
# ServerArgs 是另一个类，不在本契约内，均排除
_EXCLUDED = ("srt/server_args.py", "srt/arg_groups", "multimodal_gen")
_BASELINE = 49


class TestServerArgsWriterRatchet(CustomTestCase):
    def test_server_args_override_call_sites_match_the_baseline(self):
        count = 0
        for path in sorted(_SGLANG_ROOT.rglob("*.py")):
            rel = path.relative_to(_SGLANG_ROOT).as_posix()
            if rel.startswith(_EXCLUDED):
                continue
            source = path.read_text()
            count += sum(len(p.findall(source)) for p in _WRITER_PATTERNS)
        # 双向失败：增多提示改走 get_context().override()，
        # 减少要求下调基线，把迁移进展锁死在 CI 里
        if count > _BASELINE:
            self.fail("ServerArgs.override call-sites grew...")
        if count < _BASELINE:
            self.fail("ServerArgs.override call-sites shrank...")

```

# 评论区精华

评审主要由 Codex bot 提出两个 P1/P2 级问题，作者逐一修复：

- **P1（正确性）**：新增的 `preserve_config()` 当时没有任何生产调用方，`build_draft_tp_worker` 仍在 `finally` 中执行 `set_server_args(saved_server_args)`，当目标初始化已通过 `get_context().override()` 记录解析值（如 KV cache dtype）时，republish 会从原始 `ServerArgs` 重建 bags 并清空 override log，DFLASH/DSPARK 初始化仍可能丢弃目标配置。作者将 `build_draft_tp_worker` 改为 `with preserve_config():` 包裹构建，并确认旧路径是“main 上的活跃问题”；栈中下一 PR（#33012）会在该作用域内再做 draft-scoped publish。
- **P2（正确性）**：初始实现直接保存 bags 与 overrides_log 的引用，作用域内不 republish 直接 `override()` 会就地改动对象，finally 恢复的是已被改动的同一对象，解析值与 provenance 会泄漏出作用域。作者新增 `_snapshot_bag_values` / `_restore_bag_values` 按叶子快照，并补测试 `test_preserve_config_restores_in_scope_override_without_republish`。

两个问题指向同一类陷阱：**“恢复”如果只是重新赋值对象引用，而不是恢复对象内部状态，就不算恢复**。

- preserve_config 必须有真实生产调用方（draft 构建路径） (correctness): 作者在合入前修复：build_draft_tp_worker 改用 with get_context().preserve_config(): 包裹 TpModelWorker 构建，替代手动 save/republish；作者确认旧路径在 main 上就是活跃问题，栈的下一 PR（#33012）会进一步在该作用域内做 draft-scoped publish。
- 快照必须按值拷贝，防止 in-scope override 泄漏 (correctness): 作者按值快照重写：新增 _snapshot_bag_values / _restore_bag_values 逐叶子拷贝 _fields，overrides_log 用 list() 拷贝，并新增回归测试 test_preserve_config_restores_in_scope_override_without_republish 覆盖该场景。

# 风险与影响

- 风险：
 - **核心配置路径变更**：`RuntimeContext` 是每个进程唯一的配置持有者，`preserve_config()` 的快照清单（slot、bags、provenance、parallel 配置、capture 标志）若漏掉未来新增的状态字段，嵌套构建后会静默丢失或残留状态；后续 PR（如 #33012 的 role publish）必须同步维护这份清单。
 - **文本匹配护栏的固有误差**：writer ratchet 用正则 `server_args.override(`、`args.override(`、`sa.override(` 计数，docstring、注释与测试工具中的匹配也会计入（作者已注明“the count is textual”）；新代码若用新的变量别名可能漏报，若恰好叫 `args` 又可能误报，护栏是演进约束而非精确清单。
 - **投机解码路径回归**：`build_draft_tp_worker` 的改动影响 DFLASH/DSPARK 等 draft 场景的初始化，`preserve_config` 与后续 draft-scoped publish 的叠加顺序需要验证；本 PR 有单元级回归测试，但未见端到端 draft 模型验证。
 - **栈依赖**：本 PR 单独合入安全，但设计价值依赖 #33012/#33013 落地；若栈后续停滞，ratchet 会持续约束 `ServerArgs.override` 写入面，短期可能给并行开发的其他 PR 带来 CI 摩擦。
- 影响：
 - **运行时行为**：修复嵌套 publish 场景中外层配置 post-publish override 被丢弃的问题，主要惠及带 draft worker（DFLASH/DSPARK）的投机解码部署，解析后的 KV cache dtype 等配置不再静默回退。
 - **开发流程**：两个 ratchet 测试常驻 CPU CI 套件，对所有后续 PR 施加“只减不增”的硬约束：新增 `ServerArgs.override` 调用点或新增模块级迁移延迟都会让 CI 失败，指引开发者改走 `get_context().override()` 或恢复被跳过的测试。
 - **架构演进**：作为 3-PR 栈的基石，为 #33012（role 化 publish）与 #33013（628 处读取点迁移到命名空间访问器）提供“嵌套构建不破坏外层配置”的语义保证，是配置命名空间迁移能否安全推进的关键前提。
 - 风险标记：核心配置路径变更 , 文本匹配基线测试可能误报 , 依赖后续栈 PR 落地 , 投机解码路径变更

# 关联脉络

- PR #33012 runtime_context: record the publishing process role: 同一 3-PR 栈的下一部分：在 preserve_config 作用域内引入 draft-scoped publish，并让全进程入口迁移至角色化 publish，与本 PR 的配置生命周期保护语义直接衔接。
- PR #33013 config: read resolved config via namespace accessors: 同一 3-PR 栈的最终落地：覆盖 160 文件 628 处配置读取点迁移到命名空间访问器，依赖本 PR 的 preserve_config 与 ratchet 提供的安全底座。