Eleven verifiable tasks covering decisions A-G and the 14 test items, with the reviewer-found execution traps written into the tasks.
24 KiB
实现计划: 采样参数透传(issue #4)
- 设计:
research-wiki/designs/2026-07-31-sampling-params-design.md(2026-07-31 人类批准) - 分支:
feat/issue-4-sampling-params - 目标: 让下游能固定解码参数(
temperature/seed/max_tokens),且不破坏缓存隔离与遥测诚实性。 - 方案概述:
chat()增 keyword-onlyoverlay参数(调用级),SourceConfig增extra_body字段(配置级)。ChatRequest增sampling快照字段作为跨洋葱层恒定读取点,供缓存 key 与遥测消费。遥测端口 20 → 21 字段。 - 技术: Python 3.11+,frozen dataclass,
MappingProxyType,sqlite3 / asyncpg DDL 幂等补列。
保真校验: 本计划不涉及 reference/ 参考实现迁移,保真校验不适用。
1. 文件结构
| 文件 | 职责变更 |
|---|---|
src/polygateway/types.py |
新增 validate_request_overlay() 与 merge_sampling() 两个纯函数;ChatRequest.sampling 字段;SourceConfig.extra_body 字段与构造期校验 |
src/polygateway/client.py |
chat() 增 overlay 参数;model_fingerprint 计算纳入 extra_body |
src/polygateway/middleware/cache.py |
build_cache_key() 增 sampling 入参并纳入 key |
src/polygateway/transports/openai_compat.py |
_build_payload 在 thinking profile 之后、overlay 之前应用 source.extra_body |
src/polygateway/config.py |
_SOURCE_FIELDS 增 EXTRA_BODY;_cast 增 json 分支 |
src/polygateway/ports.py |
TelemetryRecorder.record_llm_call 增第 21 参 sampling |
src/polygateway/middleware/telemetry.py |
三个 emit 入口按设计表格产出 sampling;_record 透传 |
src/polygateway/telemetry/sqlite.py |
DDL / _BACKFILL_COLUMNS / _COLUMNS 增 sampling |
src/polygateway/telemetry/postgres.py |
DDL / _BACKFILL / _COLUMNS 增 sampling |
src/polygateway/ocr.py / embedding.py |
构造期剥离 extra_body + warning(决策 G) |
src/polygateway/providers.py |
minimax/openai 空 thinking profile 补后果注释(决策 F) |
.env.example / README.md / CHANGELOG.md / research-wiki/ARCHITECTURE.md |
文档同步(设计 §6) |
各任务需新增的 import(现状核实,不加即 NameError):
| 文件 | 需新增 |
|---|---|
types.py |
from collections.abc import Mapping、from types import MappingProxyType、import json。该文件无 from __future__ import annotations,注解在类体求值,Mapping 必须真导入 |
client.py |
import json、import hashlib |
config.py |
import json |
ocr.py / embedding.py |
import dataclasses(现只有 from dataclasses import dataclass)、from loguru import logger(若未导入) |
middleware/telemetry.py |
merge_sampling/canonical_sampling_json 需运行时导入(现对 polygateway.types 只在 TYPE_CHECKING 下导入) |
关键接口(跨任务消费,此处定死):
# types.py —— 两个纯函数 + 两个字段
_PROTECTED_OVERLAY_KEYS = frozenset({"model", "messages", "stream", "stream_options"})
def validate_request_overlay(overlay: Mapping[str, Any], *, origin: str) -> dict[str, Any]:
"""校验采样参数覆盖层并返回浅拷贝;origin 用于错误信息定位来源。
保护键会击穿治理(model→成本算错、messages→缓存与遥测口径失真、
stream/stream_options→绕过看门狗与 usage 帧);值必须 JSON 可序列化,
否则会在 CacheMW 的降级 try 之外抛裸 TypeError(设计 §决策 B)。
"""
def merge_sampling(extra_body: Mapping[str, Any], sampling: Mapping[str, Any]) -> dict[str, Any]:
"""合并配置级与调用级采样参数(调用级优先);两者皆空返回空 dict。"""
def canonical_sampling_json(merged: Mapping[str, Any]) -> str | None:
"""遥测列与缓存 key 共用的序列化口径;空 mapping → None。"""
@dataclass(frozen=True)
class ChatRequest:
...
overlay: dict[str, Any] = field(default_factory=dict)
sampling: Mapping[str, Any] = field(default_factory=dict) # 新增
@dataclass(frozen=True)
class SourceConfig:
...
extra_body: Mapping[str, Any] = field(default_factory=dict) # 新增,__post_init__ 转 MappingProxyType
# middleware/cache.py —— 签名扩展(sampling 为 keyword-only)
# 默认值用 None 而非 {}: dict 字面量作默认参数会被 ruff B006 拦下
def build_cache_key(
model_fingerprint: str,
messages: list[dict[str, Any]],
namespace: str,
salt: str | None,
*,
sampling: Mapping[str, Any] | None = None,
) -> str: ...
# client.py —— chat() 新签名
async def chat(
self, messages: list[dict[str, Any]], *,
session_id: str | None = None, parent_call_id: str | None = None,
cache_salt: str | None = None, cache_namespace: str | None = None,
structured: type[BaseModel] | Literal["json"] | None = None,
stream: bool = True,
overlay: Mapping[str, Any] | None = None, # 新增
) -> LLMResponse: ...
2. 任务清单
任务按依赖排序;每个任务一次提交、独立可验证。每个任务合并前必须出示先失败后通过的测试证据(先写测试跑红,再实现跑绿)。
统一验证命令前缀:conda run -n PolyGateway --no-capture-output pytest。
共享后端纪律: 涉及 Redis/Postgres 的 integration 测试严禁与其他会话并跑(含 git 钩子触发的测试)。Task 7、Task 11 受此约束。
- [ ] Task 1: types.py 内核 —— 校验与合并纯函数 + 两个新字段
文件: 改 src/polygateway/types.py;测试 tests/unit/test_types.py
实现行为:
validate_request_overlay(overlay, *, origin),校验顺序即下列顺序:- 键必须是
str,否则ValueError(canonical JSON 要求)。必须排在序列化试探之前——{1: "a", "b": 2}在sort_keys=True下抛的是TypeError: '<' not supported between 'str' and 'int',若先试序列化会被误报成"值不可 JSON 序列化",指错方向; - 命中
_PROTECTED_OVERLAY_KEYS任一键 →ValueError,信息含 origin、违规键名、以及为什么(如stream会绕过流式看门狗); - 对整个 mapping 做
json.dumps(..., sort_keys=True)试序列化,TypeError→ 转ValueError并指出该值不可 JSON 序列化(信息提示改用float(x)等原生类型); - 返回
dict(overlay)浅拷贝。
- 键必须是
merge_sampling(extra_body, sampling)→{**extra_body, **sampling}(调用级优先)。canonical_sampling_json(merged)→ 空则None,否则json.dumps(merged, sort_keys=True, ensure_ascii=False)。ChatRequest增sampling字段(见 §1 关键接口)。SourceConfig增extra_body字段;__post_init__新增_validate_extra_body():调validate_request_overlay(self.extra_body, origin=f"SourceConfig({self.name}).extra_body"),再object.__setattr__(self, "extra_body", MappingProxyType(dict(...)))(frozen dataclass 需用object.__setattr__)。
已知后果(必须显式接受,不是疏漏): SourceConfig 加 mapping 字段后不再 hashable(hash() → TypeError),且因 MappingProxyType 不可 pickle,dataclasses.asdict() / copy.deepcopy() 也会失败。
- 不可 hash 是加任何 mapping 字段的固有代价,与是否用
MappingProxyType无关(裸dict同样不可 hash),无法规避; - 库内当前无调用点会踩:
asdict只用于LLMResponse/EmbeddingResponse(cache.py:140),全库无set(sources)或以源作 dict key 的写法; - 保留
MappingProxyType而非裸 dict,是因为决策 E 的只读约束值得这个代价;下游要可变副本用dict(source.extra_body),要改字段用dataclasses.replace(source, ...)(已验证可行,会重跑__post_init__重新包 proxy,不递归)。
验收标准: 四个保护键各自触发 ValueError 且信息含原因;非 str 键报的是"键必须是 str"而非"不可序列化";{"temperature": object()} 类不可序列化值报 ValueError 而非 TypeError;合法 {"temperature": 0, "seed": 42} 通过并返回独立副本(改原 dict 不影响返回值);SourceConfig.extra_body 构造后为 MappingProxyType 且不可改。
测试要求: 新增 tests/unit/test_types.py::TestSamplingValidation,覆盖上述每条。不可序列化值用 object() 实例即可,不引入 numpy 依赖。另加一条锁定测试:pytest.raises(TypeError): hash(source_config),把"不再 hashable"钉成有意行为——否则将来有人踩到时会以为是 bug 并"修"回去。
验证: pytest tests/unit/test_types.py -v → 全 PASS
- [ ] Task 2: config.py —— EXTRA_BODY env 解析
文件: 改 src/polygateway/config.py;测试 tests/unit/test_config.py
实现行为:
_SOURCE_FIELDS增"EXTRA_BODY": ("extra_body", "json");_cast增json分支:json.loads失败 →ValueError(沿用既有配置 {key} 解析失败: {exc}包装);解析结果非 dict →ValueError,信息说明必须是 JSON 对象(而非数组/标量)。
验收标准: LLM__QWEN__1__EXTRA_BODY={"temperature":0} → SourceConfig.extra_body == {"temperature": 0};{invalid → ValueError;[1,2] → ValueError;{"model":"x"} → ValueError(经 Task 1 的 SourceConfig.__post_init__ 保护键校验)。
测试要求: 新增 4 个 case 覆盖上述。注意: 这里同时验证了 Task 1 的校验确实挂在装配路径上。
验证: pytest tests/unit/test_config.py -v → 全 PASS
- [ ] Task 3: chat() 入口 + transport 应用 + fingerprint
文件: 改 src/polygateway/client.py、src/polygateway/transports/openai_compat.py;测试 tests/unit/test_client.py、tests/unit/test_openai_compat.py
实现行为:
chat()增overlay参数(见 §1 签名)。进洋葱之前:同一份validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")validated对象同时填ChatRequest.overlay与.sampling(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。_build_payload:在 thinking profile 之后、payload.update(overlay)之前插入payload.update(source.extra_body)。顺序即优先级,不可调换。model_fingerprint(client.py:117)改为:全源fingerprint = ",".join(sorted({s.model for s in sources})) marks = sorted({json.dumps([s.model, dict(s.extra_body)], sort_keys=True, ensure_ascii=False) for s in sources if s.extra_body}) if marks: fingerprint += "|" + hashlib.sha256("".join(marks).encode()).hexdigest()extra_body皆空时字面量与旧实现逐字相同。dict(...)是因为MappingProxyType不能直接进json.dumps。
验收标准: 配置 temperature=0 + 调用级 temperature=1 → payload 中为 1;结构化注入的 response_format 覆盖调用级同名键;保护键在 chat() 入口即 ValueError(未进洋葱,可用 mock handler 断言未被调用);全源无 extra_body 时 fingerprint 与旧值逐字相同;有 extra_body 时不同;改源 name 不改变 fingerprint。
测试要求: 覆盖设计 §5 测试 #3、#4(chat 侧)、#5、#6(拷贝语义:调用方在 chat() 返回后修改自己的 dict,不影响已构造的 request)、#8(不可 JSON 序列化的值在 chat() 入口即 ValueError,断言洋葱 handler 未被调用)。
验证: pytest tests/unit/test_client.py tests/unit/test_openai_compat.py -v → 全 PASS
- [ ] Task 4: 缓存 key 纳入 sampling
文件: 改 src/polygateway/middleware/cache.py;测试 tests/unit/test_cache.py
实现行为:
build_cache_key增 keyword-onlysampling参数(见 §1 签名),非空时以"sampling"键并入key_obj(仅非空参与,与salt的"仅非 None"不同——见设计决策 A 末段);CacheMW.__call__传sampling=request.sampling(不是request.overlay——后者在此层虽尚未被结构化注入污染,但读sampling才是语义正确且不依赖层序巧合的写法)。
验收标准:
- 同 messages、不同
seed→ 两个不同 key,第二次 miss(issue 场景的直接回归); - 空
sampling时 key 与旧实现逐字相同——测试须先把旧实现的 key 值固化为常量再比对(现有tests/unit/test_cache.py:39-54只有相等/不等断言,无 golden hash 可依); - 同
sampling不同键序 → 同一 key(canonical 序列化)。
测试要求: 覆盖设计 §5 测试 #1、#2。golden hash 的取法:在改动前先运行一次现有 build_cache_key 打印结果,写死进测试。
验证: pytest tests/unit/test_cache.py -v → 全 PASS
- [ ] Task 5: 地基不变式回归(承重)
文件: 测试 tests/unit/test_structured.py(或就近的洋葱集成测试文件)
实现行为: 纯测试任务,不改产品代码。
落点:tests/unit/test_structured.py 里既有的 ScriptedTerminal 恰好站在 RetryMW 的位置(client.py:91 的 terminal = RetryMW(...),StructuredMW 是最内中间件),扩写它即可,无需搭全洋葱。
断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后——
- terminal 每次收到的
request.sampling与构造ChatRequest时传入的sampling逐字相同; - 同一时刻
request.overlay含response_format(证明两者确实分叉,sampling不是冗余字段)。
为什么单列一个任务: 决策 C 与 D 都建立在"sampling 跨层恒定"之上,而这条目前只靠"dataclasses.replace 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。
验收标准: 该测试在故意把 structured.py 的 replace 改成重建 ChatRequest(丢掉 sampling)时必须变红——实施时须实际验证这一点,否则测试是空的。
验证: pytest tests/unit/test_structured.py -v → 全 PASS,且上述"故意破坏"实验红过一次
- [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径
文件: 改 src/polygateway/ports.py、src/polygateway/middleware/telemetry.py;测试 tests/unit/test_telemetry.py
实现行为:
-
ports.TelemetryRecorder.record_llm_call增第 21 参sampling: str | None(排在model_reported之后)。 -
TelemetryEmitter._record增同名参数并透传给 recorder。 -
三个入口按设计决策 D 的表格产出(不含结构化注入的
response_format):入口 sampling取值emit_attemptcanonical_sampling_json(merge_sampling(source.extra_body, request.sampling))emit_cache_hitcanonical_sampling_json(request.sampling)emit_terminal_failurecanonical_sampling_json(request.sampling)后两者无
source可言(由最外层 TelemetryMW 调用),与model/provider/source_name在终态行置空是同一先例。
关键约束: sampling 必须由 emitter 内部推导,不得作为新必填参数由调用者传入——否则 ocr.py:418 与 embedding.py:372 立刻 TypeError。
验收标准: 三个入口各自的 sampling 值符合上表;response_format 三行都不出现;request.sampling 与 source.extra_body 皆空时为 None。
测试要求: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。
验证: pytest tests/unit/test_telemetry.py -v → 全 PASS
- [ ] Task 7: 两个遥测后端落列 + 幂等补列
文件: 改 src/polygateway/telemetry/sqlite.py、src/polygateway/telemetry/postgres.py;测试 tests/unit/test_telemetry.py、tests/integration/test_postgres_telemetry.py
实现行为(逐字沿用 issue #3 建立的套路):
- sqlite.py: DDL 在
model_reported之后加sampling TEXT;_BACKFILL_COLUMNS追加("sampling", "TEXT");_COLUMNS末尾追加"sampling"。 - postgres.py: DDL 同位置加
sampling TEXT;_BACKFILL追加("sampling", "ALTER TABLE llm_calls ADD COLUMN sampling TEXT");_COLUMNS末尾追加。 - 两处
record_llm_call(**fields)按_COLUMNS取值,无需改动。
硬约束: 新列必须排在 created_at 之后(两文件既有注释已说明理由:旧表只能 ALTER 追加到末尾,新建库若插在前面,两条路径物理列序分叉)。补列一律先探测缺列再 ALTER;失败只逐行降级,绝不置结构性失能标志(postgres 的 _failed)。
连带必改(不改则直接红):
| 位置 | 改什么 | 不改的后果 |
|---|---|---|
tests/unit/test_telemetry.py:78-102 的 _record_minimal() |
fields dict 加 "sampling": None |
两侧所有落库测试全红:sqlite.py:126 / postgres.py:161 的 row = tuple(fields[col] for col in _COLUMNS) 在 try 之外,_COLUMNS 加列后抛裸 KeyError: 'sampling' 冒泡出 record_llm_call |
tests/integration/test_postgres_telemetry.py:88-109 的 _record_minimal() |
同上 | 同上 |
tests/unit/test_telemetry.py:18 的 _EXPECTED_COLUMNS |
追加 "sampling" |
列序断言红 |
tests/integration/test_postgres_telemetry.py:22 的 _EXPECTED_COLUMNS |
追加(另见 :210,231 引用点) |
列序断言红 |
tests/unit/test_ports.py:95-119 的 _DummyRecorder.record_llm_call |
显式 20 参签名同步为 21 | 不会红(runtime_checkable 的 isinstance 只查方法存在不查签名),但会与端口脱节,顺带同步 |
sqlite.py:123 docstring、test_telemetry.py:1 文案 |
"20 字段" → "21 字段" | 无功能影响,文案与事实脱节 |
验收标准: 新建库列序正确;对已存在的 20 列旧表能幂等补列且补后列序与新建库一致;重复初始化不报错;补列失败(模拟只有 INSERT 权限)时仅 warning、后续写入不被禁用。
测试要求: 覆盖设计 §5 测试 #11、#12。Postgres 部分是 integration,须独占 PG polygateway 库时序,严禁并跑。
验证:
pytest tests/unit/test_telemetry.py -v→ 全 PASSpytest tests/integration/test_postgres_telemetry.py -v→ 全 PASS(确认无其他会话在用 PG)
- [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning
文件: 改 src/polygateway/ocr.py、src/polygateway/embedding.py;测试 tests/unit/test_ocr_client.py、tests/unit/test_embedding.py
实现行为: 两个 __init__ 在既有校验块(quota_full 域校验附近)之后、self._sources = list(sources) 之前:
stripped = []
for src in sources:
if src.extra_body:
logger.warning(
"{} 路径暂不支持 extra_body,源 {} 的该配置已被忽略"
"(需要 dimensions 等参数请提 issue): {}",
<"embedding"|"OCR">, src.name, dict(src.extra_body),
)
src = dataclasses.replace(src, extra_body={})
stripped.append(src)
self._sources = stripped
剥离不是顺手清理,是承重的: 不剥离则 Task 6 的 merge_sampling(source.extra_body, ...) 会让遥测记录一个从未发出的参数——monkey_ocr.py:225,247 只发 multipart files=(根本没有 JSON body),openai_compat.py:343 的 embed payload 硬编码 {"model","input"}。那是数据造假而非参数失效。替代方案(emitter 内特判调用方身份)违「遥测调用点收敛单一 helper」铁律,已否决。
验收标准: 带 extra_body 的源 → 装配成功(不抛异常)、记一条 warning、client._sources 上 extra_body 为空;该路径遥测 sampling 列为 None;不带 extra_body 时无 warning。
测试要求: 覆盖设计 §5 测试 #10。后半段(遥测 sampling 为 None)是防遥测造假的真正断言,不可省——只断言"装配成功 + 有 warning"是不够的。用 caplog/loguru 捕获断言 warning 存在。
验证: pytest tests/unit/test_ocr_client.py tests/unit/test_embedding.py -v → 全 PASS
- [ ] Task 9: 决策 F —— 空 thinking profile 的后果注释
文件: 改 src/polygateway/providers.py
实现行为: 给 openai(:46-51)与 minimax(:53-58)两个 profile 各补一句后果说明:enable_thinking=False 对本 provider 不产生任何效果,需要关闭推理请用 SourceConfig.extra_body。
注意: :52 那条既有注释(「OpenAI 兼容基线,无已知注入差异」)在词法上属于紧随其后的 minimax 条目,openai 条目没有任何注释。补的是"后果"而非重复"为何为空"——不要写出与既有注释重复或矛盾的内容。
验收标准: 两个 profile 都能让读者明白 enable_thinking=False 对它们无效。纯注释变更,无行为变化。
测试要求: 无(纯注释)。此任务不单独提交,与 Task 10 合并提交。
验证: make lint → PASS
- [ ] Task 10: 文档同步(设计 §6 清单)
文件: 改 .env.example、README.md、CHANGELOG.md、research-wiki/ARCHITECTURE.md
| 目标 | 具体改动 |
|---|---|
.env.example |
在 LLM__QWEN__1__TRUST_ENV 注释行(:20)后加 # LLM__QWEN__1__EXTRA_BODY={"temperature":0} 及说明(JSON 对象串;保护键会报错;OCR/EMBED scope 会被忽略并 warning)。client.py:251 docstring 声明本文件是键名清单事实源,漏写等于新键无处可查 |
README.md:83 |
该行逐一列举 chat() 关键字参数,补 overlay 及一句用途 |
| ARCH §5.2 | chat() 签名定稿段追加 overlay 要点(带默认值的 keyword-only,不破坏"调用点零改动"承诺) |
| ARCH §7.5 | key 公式补 sampling 项 + 两条已知副作用(seed 进 key 导致该路径必 miss;model_fingerprint 是集合级指纹,同 scope 各源 extra_body 不同时仍可能跨源命中) |
ARCH §7.7(:451) |
该节逐字段枚举 SourceConfig 构成(name/provider/.../enable_thinking),补 extra_body |
ARCH §7.8(:463) |
必录字段 20 → 21,补 sampling 及其列语义(不含 response_format) |
ARCH §9(:519-527) |
配置面键族事实源,登记 {SCOPE}__{PROVIDER}__{N}__EXTRA_BODY |
CHANGELOG.md |
公共 API 新增(chat(overlay=)、SourceConfig.extra_body)+ 遥测端口扩列 |
Gitea Wiki 同步(docs-convention.md §2,CLAUDE.md §6 标为硬门)。本次同时命中该表两行:
| 命中行 | 必同步页 |
|---|---|
| 新公共 API / 新能力 | 对应指南页(新增「固定解码参数」内容,落在 指南-遥测与成本 或新页)+ 参考-公共API(chat() 签名、SourceConfig.extra_body)+ _Sidebar.md + CHANGELOG |
| 新增配置键 | 参考-配置键(登记 {SCOPE}__{PROVIDER}__{N}__EXTRA_BODY)+ 相关指南页的配置片段 + .env.example |
指南页必须写明三条坑:① seed 逐次变化时该路径缓存必 miss;② model_fingerprint 是集合级指纹,同 scope 各源 extra_body 不同时仍可能跨源命中(要逐源可复现需每源独享 scope 或 namespace);③ OCR/EMBED scope 的 EXTRA_BODY 会被忽略并 warning。
验收标准: 每条都能在文件中指到具体位置;ARCH 的改动与设计文档不矛盾;wiki 两行清单逐页落实。
测试要求: 无(纯文档)。与 Task 9 合并提交。
验证: make lint → PASS
- [ ] Task 11: 全链路集成验证与合并前检查
文件: 测试 tests/integration/(就近文件或新增)
实现行为: 端到端断言采样参数经 chat() → 选源 → transport payload 到达请求体(设计 §5 测试 #13),用 fake HTTP 层捕获实际 payload。
合并前门(逐条出示证据):
make ci→ 全绿(make lint+make test+ 覆盖率)- import-linter 契约无新违规(校验函数落最内层
types.py,分层关系不变) - 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名
- 派全新上下文 verifier subagent 独立验证(CLAUDE.md §3.2 里程碑级/跨多文件硬门)
验证:
make ci→ 全 PASS(不要在外面套conda run:Makefile每条 target 内部已是conda run -n PolyGateway ...,嵌套会让内层输出被缓冲)- verifier 报告无 blocking 问题
3. 提交节奏
| 提交 | 内容 |
|---|---|
| 1 | Task 1(types 内核) |
| 2 | Task 2(env 解析) |
| 3 | Task 3(chat 入口 + transport + fingerprint) |
| 4 | Task 4(缓存 key) |
| 5 | Task 5(地基不变式测试) |
| 6 | Task 6(遥测三入口) |
| 7 | Task 7(两后端落列) |
| 8 | Task 8(决策 G) |
| 9 | Task 9 + 10(注释与文档) |
| 10 | Task 11(集成验证,如有修补) |
每次提交调 commit skill。Task 1-4 是 issue 诉求的最小闭环;Task 5-8 是设计中"issue 未提但必须处理"的部分,不可跳过。