Files
PolyGateway/research-wiki/plans/2026-07-31-sampling-params.md
iomgaa b12bf6ce79 docs: plan the sampling parameter implementation (issue #4)
Eleven verifiable tasks covering decisions A-G and the 14 test items,
with the reviewer-found execution traps written into the tasks.
2026-07-31 13:01:53 -04:00

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-only overlay 参数(调用级),SourceConfigextra_body 字段(配置级)。ChatRequestsampling 快照字段作为跨洋葱层恒定读取点,供缓存 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_FIELDSEXTRA_BODY;_castjson 分支
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 / _COLUMNSsampling
src/polygateway/telemetry/postgres.py DDL / _BACKFILL / _COLUMNSsampling
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 Mappingfrom types import MappingProxyTypeimport json该文件无 from __future__ import annotations,注解在类体求值,Mapping 必须真导入
client.py import jsonimport 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

实现行为:

  1. 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) 浅拷贝。
  2. merge_sampling(extra_body, sampling){**extra_body, **sampling}(调用级优先)。
  3. canonical_sampling_json(merged) → 空则 None,否则 json.dumps(merged, sort_keys=True, ensure_ascii=False)
  4. ChatRequestsampling 字段(见 §1 关键接口)。
  5. SourceConfigextra_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");
  • _castjson 分支:json.loads 失败 → ValueError(沿用既有 配置 {key} 解析失败: {exc} 包装);解析结果非 dictValueError,信息说明必须是 JSON 对象(而非数组/标量)。

验收标准: LLM__QWEN__1__EXTRA_BODY={"temperature":0}SourceConfig.extra_body == {"temperature": 0};{invalidValueError;[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.pysrc/polygateway/transports/openai_compat.py;测试 tests/unit/test_client.pytests/unit/test_openai_compat.py

实现行为:

  1. chat()overlay 参数(见 §1 签名)。进洋葱之前:
    validated = validate_request_overlay(overlay or {}, origin="chat(overlay=...)")
    
    同一份 validated 对象同时填 ChatRequest.overlay.sampling(设计决策 E:一次拷贝、两个字段指向同一快照,不做两份独立拷贝)。
  2. _build_payload:在 thinking profile 之后、payload.update(overlay) 之前插入 payload.update(source.extra_body)顺序即优先级,不可调换
  3. 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-only sampling 参数(见 §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:91terminal = RetryMW(...),StructuredMW 是最内中间件),扩写它即可,无需搭全洋葱

断言:走结构化重问阶梯(强制至少重问一次,用先返回坏 JSON 再返回好 JSON 的 scripted terminal)后——

  1. terminal 每次收到的 request.sampling构造 ChatRequest 时传入的 sampling 逐字相同;
  2. 同一时刻 request.overlay response_format(证明两者确实分叉,sampling 不是冗余字段)。

为什么单列一个任务: 决策 C 与 D 都建立在"sampling 跨层恒定"之上,而这条目前只靠"dataclasses.replace 恰好保留未提及字段"的约定成立,无任何机械执法。这条测试同时钉死决策 A 的"库内中间件永不修改"与决策 E 的只读约束。缺它则约束被破坏时无人发现。

验收标准: 该测试在故意把 structured.pyreplace 改成重建 ChatRequest(丢掉 sampling)时必须变红——实施时须实际验证这一点,否则测试是空的。

验证: pytest tests/unit/test_structured.py -v → 全 PASS,且上述"故意破坏"实验红过一次


- [ ] Task 6: 遥测端口扩至 21 字段 + 三入口口径

文件: 改 src/polygateway/ports.pysrc/polygateway/middleware/telemetry.py;测试 tests/unit/test_telemetry.py

实现行为:

  1. ports.TelemetryRecorder.record_llm_call 增第 21 参 sampling: str | None(排在 model_reported 之后)。

  2. TelemetryEmitter._record 增同名参数并透传给 recorder。

  3. 三个入口按设计决策 D 的表格产出(不含结构化注入的 response_format):

    入口 sampling 取值
    emit_attempt canonical_sampling_json(merge_sampling(source.extra_body, request.sampling))
    emit_cache_hit canonical_sampling_json(request.sampling)
    emit_terminal_failure canonical_sampling_json(request.sampling)

    后两者无 source 可言(由最外层 TelemetryMW 调用),与 model/provider/source_name 在终态行置空是同一先例。

关键约束: sampling 必须由 emitter 内部推导,不得作为新必填参数由调用者传入——否则 ocr.py:418embedding.py:372 立刻 TypeError。

验收标准: 三个入口各自的 sampling 值符合上表;response_format 三行都不出现;request.samplingsource.extra_body 皆空时为 None

测试要求: 覆盖设计 §5 测试 #9。用 fake recorder 捕获 kwargs 断言。

验证: pytest tests/unit/test_telemetry.py -v → 全 PASS


- [ ] Task 7: 两个遥测后端落列 + 幂等补列

文件: 改 src/polygateway/telemetry/sqlite.pysrc/polygateway/telemetry/postgres.py;测试 tests/unit/test_telemetry.pytests/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:161row = 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 → 全 PASS
  • pytest tests/integration/test_postgres_telemetry.py -v → 全 PASS(确认无其他会话在用 PG)

- [ ] Task 8: 决策 G —— OCR/embedding 构造期剥离 + warning

文件: 改 src/polygateway/ocr.pysrc/polygateway/embedding.py;测试 tests/unit/test_ocr_client.pytests/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._sourcesextra_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.exampleREADME.mdCHANGELOG.mdresearch-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。

合并前门(逐条出示证据):

  1. make ci → 全绿(make lint + make test + 覆盖率)
  2. import-linter 契约无新违规(校验函数落最内层 types.py,分层关系不变)
  3. 设计 §5 的 14 条测试全部有对应实现,逐条对应到具体测试函数名
  4. 全新上下文 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 未提但必须处理"的部分,不可跳过