Prhub

#36618 config: resolution declares, and nothing writes a field

原始 PR 作者 ch-wan 合并时间 2026-08-28 03:53 文件变更 5 提交数 1 评论 3 代码增减 +296 / -37

执行摘要

重构配置解析:resolution 只声明不写字段

PR body 明确说明:此前的系列改造已让 ServerArgs 持有 operator 输入、解析答案进入 declaration stash,但仍有两条通道会在之后写字段,且没有任何断言约束结果("Two channels could still write a field afterwards, and nothing asserted the result")。本 PR 关闭这两条通道并补上断言;同时修复 _a2a_fusion_adjustments_hrm_text_attention_force 未被 @register_post_process 注册、导致所有枚举 POST_PROCESS_PASSES 的检查绕过它们的 registry 漏洞。

值得精读。重点看三点:run_post_process_pass 的 published 拒绝设计(含与 declare_late_resolution 的对称性)、契约测试的双方向验证 + 注入法(先证明两个方向独立失败再断言)、test_chain_read_ratchet.py 用 AST 强制调用形状以保证扫描完备性的思路。注意 2 条未解决的 P2 review 评论,尤其是顺序启动回归,在后续 PR 中确认是否修复。

讨论亮点

两轮 Codex 自动 review 均未人工回复,各提出 1 个未解决的 P2 问题:

  1. 等值替换检测盲区test_record_holds_the_raw_input.py_moved 对 mutable 做 is not 判定,但若 resolver 用等值拷贝替换(如 self.lora_paths = list(self.lora_paths)),is not 为真、相等性为假,guard 放行,record 不再引用 operator 原对象,与声明的 no-rebinding 契约矛盾。建议 identity 变化即视为 moved。
  2. 顺序启动回归overrides.py:241 的 published guard 在 Engine.shutdown() 后重建同一 ServerArgs 实例时误触发——runtime context 未重置,_launch_subprocesses() 在 republish 前调用 check_server_args()_hisparse_validation 走到该 guard 直接 raise,破坏 sequential launch。

实现拆解

实现分四步:

  1. 删除写穿逻辑(核心)python/sglang/srt/arg_groups/overrides.pyrun_post_process_pass 不再在 _resolution_finished 时调用 _apply_fields 写回字段。解析后的 pass 声明统一进入 _resolved_overrides stash,publish 从 stash 投影 config bags;_apply_fields 保留给 RuntimeContext.override_server_args(测试 launch 替身)这一个调用方。同时新增对 published record 的拒绝:若 get_context().server_args 就是当前对象,直接 raise ValueError,因为 publish 之后 stash 不会再被投影,追加声明是静默 no-op,post-publish 变更应走 get_context().override(...)

  2. 修正 registry 语义POST_PROCESS_PASSES 注释从“end-state execution order”改为“registry, not an execution order”——实测 _hisparse_validation 注册第 16 位但总是最后执行,因为 check_server_args 阶段晚于 __post_init__。PR body 说明 _a2a_fusion_adjustments_hrm_text_attention_force 已补上 @register_post_process(diff 摘要窗口未完整展示该 hunk)。

  3. 新增契约测试test/registered/unit/server_args/test_record_holds_the_raw_input.py(新增)用 15 种 launch shape 断言 resolve_once() 后每个字段仍与 _raw_input 快照一致,覆盖两个独立方向——字段不得被重新绑定(rebind)、调用方传入的可变对象不得被原地修改(in-place edit,快照引用同一对象因此逐字段比较不可见),并通过 setattr / cuda_graph_config.setdefault 注入验证两个方向独立失败。

  4. 注册覆盖扫描与配套测试/文档test/registered/unit/test_chain_read_ratchet.py 新增 _passes_named_at_call_sites() AST 扫描(强制 run_post_process_pass 第二参数为裸名字,否则硬失败)与 TestEveryInvokedPassIsRegistered,以调用点为 ground truth 双向校验 registry;test_model_overrides.pytest_post_materialize_pass_writes_through 反转为 test_a_pass_after_resolution_declares_without_writing,断言 pass 后字段保持原始值;.claude/skills/sglang-runtime-context/SKILL.md 修正 publish 描述为“从声明投影,而非快照 resolved values”。

配套验证:24 种 launch shape × 478 个共享字段的 resolution dump 与基线 f775db03aaa 逐字段比较 0 差异(唯一新增字段 grpc_worker_threads 两侧值均为 4);所有 guard 在系列每个 commit 上单独运行通过。

文件 模块 状态 重要度
python/sglang/srt/arg_groups/overrides.py 配置解析 modified 6.89
test/registered/unit/server_args/test_record_holds_the_raw_input.py 契约测试 added 7.33
test/registered/unit/test_chain_read_ratchet.py 链读约束 modified 6.62
test/registered/unit/test_model_overrides.py 模型覆盖 modified 5.79
.claude/skills/sglang-runtime-context/SKILL.md 技能文档 modified 2.55

关键符号

run_post_process_pass _apply_fields _passes_named_at_call_sites TestEveryInvokedPassIsRegistered.test_the_registry_covers_every_call_site TestRecordHoldsTheRawInput.test_no_field_moves_from_what_the_caller_passed TestRecordHoldsTheRawInput.test_the_snapshot_is_the_value_the_caller_passed test_a_pass_after_resolution_declares_without_writing

关键源码片段

python/sglang/srt/arg_groups/overrides.py core-logic

唯一生产代码变更:删除 run_post_process_pass 的字段写穿、新增 published record 拒绝、修正 POST_PROCESS_PASSES 的 registry 语义,是系列契约的承重墙。

def run_post_process_pass(server_args: Any, fn: Callable[..., dict]) -> None:
    """在遗留 handler 槽位上调用一个 post-process pass。    pass 在解析态视图(叠加了 stash 中已累积声明)上求值,并把它的声明
    追加进 stash —— config bags 正是从 stash 投影出来的。字段本身保持不动。
    """
    from sglang.srt.runtime_context import get_context
​
    # 拒绝已发布(published)的 record:publish 之后 stash 不会再被投影,
    # 此时追加声明会成为静默 no-op,因此直接报错,引导代码改走
    # get_context().override(...) 更新 bags。
    try:
        published = get_context().server_args
    except ValueError:
        published = None
    if published is server_args:
        raise ValueError(
            f"run_post_process_pass({fn.__qualname__!r}) called on the published "
            "config; the stash is projected at publish and never again, so a "
            "declaration made here would be a silent no-op -- post-publish "
            "changes go to the bags via get_context().override(...)"
        )
​
    # 在叠加了声明 overlay 的只读视图上求值,pass 只返回声明 dict,不许改字段。
    declared = fn(ResolvedView(server_args, overlay=_declaration_overlay(server_args)))
    if not isinstance(declared, dict):
        raise TypeError(
            f"post-process pass {fn.__qualname__} must return a dict, "
            f"got {type(declared).__name__}"
        )
    if declared:
        entry = (fn.__qualname__, dict(declared))
        stash = getattr(server_args, "_resolved_overrides", None)
        if stash is None:
            # 直接作用于 fixture 的 pass 槽位可能从未经过 monolith dispatch
            # (dispatch 负责初始化 stash),这里惰性创建;真实 publish 一定
            # 先过 dispatch,所以 pass 槽位必须位于 __post_init__ 中
            # dispatch 之后。
            stash = server_args._resolved_overrides = []
        stash.append(entry)
        validate_declarations(server_args, [entry])
test/registered/unit/server_args/test_record_holds_the_raw_input.py test-coverage

新增的核心契约测试:15 种 launch shape 断言 resolve_once() 后每个字段仍等于调用方传入的 _raw_input 快照,双方向验证(rebind 与 in-place edit)。

def _moved(current, original):
    """判断字段是否不再等于调用方传入的原始值。    对可变对象(list / dict / set / bytearray)要求同一对象(is 判定):
    等值拷贝不再与调用方共享内存,record 的原地修改会污染调用方数据,
    因此也算 moved;对 int / str 等不可变类型退化为值比较
    (相等的 int / str 不一定是同一对象)。
    """
    if current is original:
        return False
    if isinstance(original, (list, dict, set, bytearray)) or isinstance(
        current, (list, dict, set, bytearray)
    ):
        # 可变对象只有“同一个对象”才算未移动:等值拷贝已不再与调用方共享。
        return True
    # 不可变类型的相等即视为未移动。
    return current != original
​
​
def test_no_field_moves_from_what_the_caller_passed(self):
    # 遍历 15 种 handler 家族对应的 launch shape,逐字段比对解析后的 record
    # 与解析前快照 _raw_input;任何字段被改写都意味着 record 不再回答
    # operator 的输入,决策者读它会与 config bags 不一致。
    for name, supplied in _SHAPES.items():
        with self.subTest(shape=name):
            server_args = self._resolve(**supplied)
            raw = server_args._raw_input
            moved = {
                field.name: (raw[field.name], getattr(server_args, field.name))
                for field in dataclasses.fields(server_args)
                if _moved(getattr(server_args, field.name), raw[field.name])
            }
            self.assertEqual(
                {},
                moved,
                f"resolution moved these fields on the {name} shape, so the "
                "record no longer answers with the operator's input and a "
                "reader that takes a decision off it disagrees with the bags: "
                f"{moved}",
            )
test/registered/unit/test_model_overrides.py test-coverage

将写穿断言反转为声明不写字段断言,验证 resolution 后 pass 只进 stash 不碰字段,是核心行为变更的直接测试佐证。

def test_a_pass_after_resolution_declares_without_writing(self):
    from sglang.srt.arg_groups.overrides import run_post_process_pass
​
    sa = self._construct("LlamaForCausalLM", "llama")
    raw_before = sa.attention_backend
​
    def _force_triton(view):
        # pass 只返回声明 dict,不再负责写字段。
        return {"attention_backend": "triton"}
​
    run_post_process_pass(sa, _force_triton)
​
    # 解析视图读到新值、publish 投影出的 leaf 也是新值……
    self.assertEqual("triton", self._resolved(sa, "attention_backend"))
    self.assertEqual(
        (self._publish(sa), self._leaf("attention_backend"))[1], "triton"
    )
    # ……但 record 字段保持调用方原始输入,这是本 PR 的核心契约。
    self.assertEqual(
        raw_before,
        sa.attention_backend,
        "the pass wrote the field, so the record stopped answering with the "
        "operator's input",
    )

评论区精华

P2: 等值替换的 mutable 未被判定为 moved 正确性

Codex 指出 `_moved` 对 `self.lora_paths = list(self.lora_paths)` 这类等值拷贝会漏检:`is not` 为真但相等性为假,guard 放行,record 不再引用 operator 原对象,违反声明的 no-rebinding 契约。

结论:未解决,无后续回复;测试契约对 identity 替换存在盲区。 · 待处理

P2: 顺序启动(Engine 重建)会误触发 published guard 正确性

Codex 指出 `Engine.shutdown()` 不重置 runtime context,同实例 `ServerArgs` 重建时 `_launch_subprocesses()` 在 republish 前调用 `check_server_args()`,`_hisparse_validation` 走到 published guard 直接 raise,回归 sequential launch。

结论:未解决,无后续回复;启动路径存在直接失败风险。 · 待处理

风险与影响

  1. 顺序启动回归(未解决):review 指出的 Engine 关停后同实例重建会误触发 run_post_process_pass 的 published 拒绝,属于启动路径直接失败,影响面大。
  2. mutable 等值替换盲区(未解决):契约测试的 _moved 判定漏检 list() / dict() 拷贝替换,违反本 PR 核心契约,后续系列重写可能依赖该护栏。
  3. 模型族特定分支覆盖不足_hrm_text_attention_force 只对单一模型族运行,runtime spy 用 Llama fixture 无法触达,只能靠静态扫描补位;一旦有未注册 pass 落入该分支仍有绕过风险。
  4. _hisparse_validation 行为变化:从写穿改为纯声明,依赖字段读值的代码若未被 bags 投影覆盖会读到原始输入;PR 已用 24 shape 验证,但真实进程组(process group)路径 CPU 测试覆盖不到。

对开发者:确立"字段永不写"的声明式解析契约,是后续 4 个 PR(118 文件机械重写、301 处调用点改造)的前置地基;所有 handler 必须通过 @register_post_process + 声明返回,读配置走 config bags。对系统:ServerArgs 保持 operator 原始输入,解析结果只存于 stash,publish 为唯一投影点。对测试体系:新增契约测试与 AST 扫描作为防回归护栏,后续每个 commit 都必须绿。

顺序启动回归风险 等值替换检测盲区 模型族特定分支测试盲区 核心配置路径变更

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论