执行摘要
- 一句话:重构配置解析:resolution 只声明不写字段
- 推荐动作:值得精读。重点看三点:
run_post_process_pass 的 published 拒绝设计(含与 declare_late_resolution 的对称性)、契约测试的双方向验证 + 注入法(先证明两个方向独立失败再断言)、test_chain_read_ratchet.py 用 AST 强制调用形状以保证扫描完备性的思路。注意 2 条未解决的 P2 review 评论,尤其是顺序启动回归,在后续 PR 中确认是否修复。
功能与动机
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 漏洞。
实现拆解
实现分四步:
-
删除写穿逻辑(核心):python/sglang/srt/arg_groups/overrides.py 中 run_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(...)。
-
修正 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)。
-
新增契约测试: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 注入验证两个方向独立失败。
-
注册覆盖扫描与配套测试/文档: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.py 将 test_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(模块 配置解析;类别 source;类型 core-logic;符号 run_post_process_pass, _apply_fields, POST_PROCESS_PASSES, register_post_process): 唯一生产代码变更:删除 run_post_process_pass 的字段写穿、新增 published record 拒绝、修正 POST_PROCESS_PASSES 的 registry 语义,是系列契约的承重墙。
test/registered/unit/server_args/test_record_holds_the_raw_input.py(模块 契约测试;类别 test;类型 test-coverage;符号 TestRecordHoldsTheRawInput, setUp, restore, _model_path): 新增的核心契约测试:15 种 launch shape 断言 resolve_once() 后每个字段仍等于调用方传入的 _raw_input 快照,双方向验证(rebind 与 in-place edit)。
test/registered/unit/test_chain_read_ratchet.py(模块 链读约束;类别 test;类型 test-coverage;符号 _passes_named_at_call_sites, TestEveryInvokedPassIsRegistered, test_the_registry_covers_every_call_site): 新增 registry 覆盖扫描:以 run_post_process_pass 调用点为 ground truth,AST 强制调用形状并双向校验 POST_PROCESS_PASSES,堵住未注册 pass 绕过检查的漏洞。
test/registered/unit/test_model_overrides.py(模块 模型覆盖;类别 test;类型 test-coverage;符号 test_post_materialize_pass_writes_through, test_a_pass_after_resolution_declares_without_writing): 将写穿断言反转为声明不写字段断言,验证 resolution 后 pass 只进 stash 不碰字段,是核心行为变更的直接测试佐证。
.claude/skills/sglang-runtime-context/SKILL.md(模块 技能文档;类别 docs;类型 documentation): 修正 publish 语义描述:从声明投影而非快照 resolved values,避免开发者按旧描述在 record 上找解析结果。
关键符号: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
唯一生产代码变更:删除 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
新增的核心契约测试: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
将写穿断言反转为声明不写字段断言,验证 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",
)
评论区精华
两轮 Codex 自动 review 均未人工回复,各提出 1 个未解决的 P2 问题:
- 等值替换检测盲区:
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。
- 顺序启动回归:
overrides.py:241 的 published guard 在 Engine.shutdown() 后重建同一 ServerArgs 实例时误触发——runtime context 未重置,_launch_subprocesses() 在 republish 前调用 check_server_args(),_hisparse_validation 走到该 guard 直接 raise,破坏 sequential launch。
- P2: 等值替换的 mutable 未被判定为 moved (correctness): 未解决,无后续回复;测试契约对 identity 替换存在盲区。
- P2: 顺序启动(Engine 重建)会误触发 published guard (correctness): 未解决,无后续回复;启动路径存在直接失败风险。
风险与影响
- 风险:
- 顺序启动回归(未解决):review 指出的
Engine 关停后同实例重建会误触发 run_post_process_pass 的 published 拒绝,属于启动路径直接失败,影响面大。
- mutable 等值替换盲区(未解决):契约测试的
_moved 判定漏检 list() / dict() 拷贝替换,违反本 PR 核心契约,后续系列重写可能依赖该护栏。
- 模型族特定分支覆盖不足:
_hrm_text_attention_force 只对单一模型族运行,runtime spy 用 Llama fixture 无法触达,只能靠静态扫描补位;一旦有未注册 pass 落入该分支仍有绕过风险。
_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 都必须绿。
- 风险标记:顺序启动回归风险, 等值替换检测盲区, 模型族特定分支测试盲区, 核心配置路径变更
关联脉络
- PR #36725 config: every handler declares its cuda-graph decisions: 同 5-PR 系列第 2 个,cuda-graph 决策改由各 handler 显式声明,依赖本 PR 确立的"只声明不写字段"契约。
- PR #36620 config: a parallel leaf with no live counterpart is read bare: 同 5-PR 系列第 3 个,118 文件机械重写的前提是本 PR 的字段不变式。
- PR #36621 config: a parallel size has one spelling; a patched scope declares its own: 同 5-PR 系列第 4 个,由第 3 个 PR 的重写所支撑的设计变更。
- PR #36622 config: the record is not an object that gets passed around: 同 5-PR 系列第 5 个,清理 ServerArgs 传递链;PR body 明确提及它解释为何 _apply_fields 最后一个调用方需单独处理。
参与讨论