# PR #31811 完整报告

- 仓库：`sgl-project/sglang`
- 标题：config: make ServerArgs read-only with a single audited mutation entry
- 合并时间：2026-07-22 16:16
- 原文链接：http://prhub.com.cn/sgl-project/sglang/pull/31811

---

# 执行摘要

- 一句话：ServerArgs 变为只读，新增 get_context().override 审计入口
- 推荐动作：该 PR 是配置重构系列的关键步骤，具有重要的设计决策（强制只读、单一审计入口）。推荐精读 `runtime_context.py` 中的 `override` 实现和 `server_args.py` 中的守卫变更。技术管理者应关注未迁移路径的及时处理，避免回归。注意到同一系列的多个 PR 正在并行推进，建议持续追踪。

# 功能与动机

引入结构化的配置 API，通过领域命名空间读取解析后的配置，并提供一个审计的单一路径来变更运行时配置，确保 ServerArgs 保持为启动时的只读记录，防止读写不一致。

# 实现拆解

实现按以下步骤进行：
1. **RuntimeContext 新增 override 入口**：在 `runtime_context.py` 中添加 `_overrides_log` 属性和 `override(source, **fields)` 方法。每个字段通过 `namespace_of` 查找命名空间路径，定位到配置 bag，验证字段存在后一次性写入，并记录来源。若任意字段未知或未发布，则完全回滚，保证原子性。
2. **ServerArgs 守卫强化**：在 `server_args.py` 中修改 `__setattr__`，移除对 `SGLANG_STRICT_CONFIG_MUTATION` 环境变量的判断，使解析后的裸字段赋值直接引发 `AttributeError`，强制开发者使用 `override` 方法。
3. **生产调用迁移**：将原来直接赋值 `server_args` 的代码改为调用 `override`，包括 GDN 后端默认 FlashInfer 设置（`gdn_backend.py`）、编码器 DP 工作线程 specialization（`encode_server.py`）、以及 VLM 引擎示例（`token_in_token_out_vlm_engine.py` 改为 CLI 参数）。
4. **测试覆盖**：新增 `test_runtime_context_override.py`，全面测试 override 的正向写入、跨命名空间路由、未知字段原子性、未发布时拒绝、来源日志记录、重发布重置、以及只读守卫。并更新 `test_gdn_prefill_backend_policy.py` 适配新 API。

关键文件：
- `python/sglang/srt/runtime_context.py`（模块 配置层；类别 source；类型 dependency-wiring；符号 override, overrides_log）: 核心变更：新增 override 方法和 overrides_log 属性，是配置变更的唯一审计入口。
- `python/sglang/srt/server_args.py`（模块 配置层；类别 source；类型 dependency-wiring；符号 __setattr__）: 关键改动：使 ServerArgs 解析后只读，裸字段赋值无条件引发 AttributeError。
- `test/registered/unit/test_runtime_context_override.py`（模块 配置层；类别 test；类型 test-coverage；符号 TestContextOverride, setUp, tearDown, _publish）: 新增测试文件，全面验证 override 行为。
- `python/sglang/srt/layers/attention/linear/gdn_backend.py`（模块 注意力层；类别 source；类型 core-logic；符号 maybe_set_default_flashinfer_gdn_prefill）: 迁移生产调用：使用 override 设置 FlashInfer 默认后端。
- `python/sglang/srt/disaggregation/encode_server.py`（模块 编码器；类别 source；类型 core-logic；符号 run_dp_worker）: 迁移生产调用：使用 override 设置编码器工作线程参数。
- `examples/runtime/token_in_token_out/token_in_token_out_vlm_engine.py`（模块 示例；类别 source；类型 core-logic）: 示例调整：移除直接赋值，改为 CLI 参数传递。
- `test/registered/unit/layers/attention/test_gdn_prefill_backend_policy.py`（模块 测试；类别 test；类型 test-coverage；符号 _override）: 测试适配：为 override 提供辅助方法。

关键符号：RuntimeContext.override, RuntimeContext.overrides_log, ServerArgs.__setattr__, maybe_set_default_flashinfer_gdn_prefill, run_dp_worker

## 关键源码片段

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

核心变更：新增 override 方法和 overrides_log 属性，是配置变更的唯一审计入口。

```python
def override(self, source: str, **fields) -> None:
    """The business mutation entry: write resolved config
    leaves onto the namespace bags — the single source of truth. It does
    **not** touch ``server_args`` (the pristine startup record) and there is
    no write-through, so the old "wrote one store, read another" desync class
    cannot occur.

    Each flat field name is routed to its bag by the ``NS`` metadata (flat
    names are unique across namespaces). Validation is all-or-nothing: an
    unknown / unprojected field aborts before any write. ``source`` is
    recorded for provenance / reproduction.
    """
    if not fields:
        return
    bags = self._config_bags
    if bags is None:
        raise ValueError("config not published; cannot override")
    from sglang.srt.arg_groups.arg_utils import namespace_of

    nsmap = namespace_of(type(self._server_args))
    targets = []  # (bag, leaf, value) — resolved before any write
    for name, value in fields.items():
        path = nsmap.get(name)
        if path is None:
            raise ValueError(
                f"override: unknown config field {name!r} (no NS namespace) — "
                "not a resolved config leaf"
            )
        parts = path.split(".")
        bag = bags.get(parts[0])
        if bag is None:
            raise ValueError(f"override: namespace {parts[0]!r} not published")
        for seg in parts[1:]:
            bag = object.__getattribute__(bag, "_subs").get(seg)
            if bag is None:
                raise ValueError(
                    f"override: subgroup {seg!r} missing under {path!r}"
                )
        if name not in bag:
            raise ValueError(f"override: field {name!r} not projected on {path!r}")
        targets.append((bag, name, value))
    for bag, name, value in targets:
        bag._set(name, value)
    self._overrides_log.append((source, dict(fields)))

def overrides_log(self) -> list:
    """Provenance of post-publish ``override`` calls: ``[(source, {field: value})]``."""
    return list(self._overrides_log)

```

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

关键改动：使 ServerArgs 解析后只读，裸字段赋值无条件引发 AttributeError。

```python
def __setattr__(self, name, value):
    # after materialization the fields are the resolved startup
    # configuration -- the pristine, READ-ONLY record. A bare assignment
    # outside ServerArgs.override() (and the resolution pipeline, which runs
    # before materialization) always raises; resolved config is mutated on
    # the context bags via get_context().override(...), not here. (Formerly
    # gated on SGLANG_STRICT_CONFIG_MUTATION; now unconditional.)
    if (
        not name.startswith("_")
        and getattr(self, "_declarations_materialized", False)
        and not getattr(self, "_in_override", False)
    ):
        raise AttributeError(
            f"server_args.{name} assigned after resolution; server_args is "
            "read-only -- use get_context().override(source, ...) to change "
            "resolved config."
        )
    object.__setattr__(self, name, value)

```

# 评论区精华

Review 由 Codex 机器人自动生成，主要指出：
- **P1 风险**：新的无条件守卫会破坏未迁移的直接赋值路径，如模型网关工作线程、文档补丁等，必须迁移到 override；编码器和 GDN 路径已迁移但还有遗漏。
- **P2 设计问题**：来源日志返回浅拷贝，调用者可篡改历史记录；重发布时覆盖日志被清空，可能导致外层覆盖丢失；只读守卫无法保护可变对象的内部变更（如列表追加）。
 - 作者已修复部分指出的路径（encoder、GDN），但其他路径（模型网关、文档补丁）未处理，存在回归风险。

 - 未迁移的赋值路径会崩溃 (correctness): 作者已迁移编码器和 GDN 路径，但模型网关和文档补丁未处理，存在风险。
 - 来源日志返回浅拷贝可篡改 (security): 未在 PR 中修复，建议返回深拷贝或不可变记录。
 - 重发布时覆盖日志清空导致外层覆盖丢失 (correctness): PR 中 set_server_args 重置日志，未保留。需考虑作用域嵌套场景。
 - 只读守卫对可变对象内部变更无效 (correctness): 当前未保护，需额外防御性编程。

# 风险与影响

- 风险：
 1. **未迁移赋值路径崩溃**：模型网关（`launch_server.py`）、文档补丁（`doc_patch.py`）、某些测试夹具等仍直接赋值 server_args，在解析后会触发 AttributeError，导致启动失败。
 2. **读写不一致**：当前 override 只写入配置 bag，但生产代码仍从 server_args 读取（如 Scheduler 读 `pp_max_micro_batch_size`），导致 override 无效。
 3. **原子性局限**：override 的 all-or-nothing 基于 Python 的本地决策，没有分布式事务支持。
 4. **来源记录可篡改**：`overrides_log()` 返回浅拷贝，内部字典仍是可变引用。
 5. **缺少 rollback 机制**：override 写入后没有提供撤销或回滚。
 - 影响：对开发者：所有运行时配置变更必须通过 `get_context().override()`，不能直接赋值 `server_args`。对系统：配置变更可审计，避免 ServerArgs 被意外修改。向后兼容性：第三方代码直接赋值 server_args 会立即失败，需迁移。测试：新增专用测试文件确保行为正确。迁移范围：影响模型加载、调度器、注意力后端、编码器、示例等模块。
 - 风险标记：核心路径变更 , 读写不一致风险 , 强制修改遗留代码 , 缺少部分迁移

# 关联脉络

- PR #31810 [stack base] (unknown): 该 PR 声明 Stacked on #31810，是系列配置重构的依赖基础。
- PR #31812 config: route runtime config adjustments through the namespace bags: 同系列 PR，进一步完善配置调整路由到命名空间 bag。
- PR #31813 runtime_context: record the publishing process role: 同系列 PR，记录配置发布进程角色。
- PR #31814 config: read resolved config via namespace accessors: 同系列 PR，迁移配置读取到命名空间访问器。
- PR #31815 config: load-time declarations write the config bags: 同系列 PR，声明写入配置包而非 server_args。
- PR #31816 config: read parallel config leaves via get_parallel(): 同系列 PR，并行配置读取迁移至 get_parallel() 访问器。
- PR #31817 test: publish resolved config in unit fixtures for the namespace API: 同系列 PR，测试夹具发布解析配置以适配新 API。