docs: revise M1 design per independent review findings

Fix Decision/Update field freeze (retry_after_s in seconds, source_name
included), freeze the 18 telemetry fields with model_name->model rename,
add thinking-token watchdog liveness semantics (CHS R1), add client
lifecycle/from_settings/gather_bounded/shared-backend injection
signatures, and register two more ARCHITECTURE feedback amendments.
This commit is contained in:
2026-07-20 05:54:12 -04:00
parent 6442461201
commit 2daeb4ae36
@@ -1,6 +1,6 @@
# M1 核心里程碑设计:公共签名冻结与治理栈落地 # M1 核心里程碑设计:公共签名冻结与治理栈落地
> **状态**: 待 Codex 审 → 人类门。**依据**: ARCHITECTURE.md D1D14 / ROADMAP §2 / migrations/ 三份;签名证据来自 2026-07-20 对 reference/ 三项目的逐字提取(文中 `文件:行号` 均指 `reference/` 下路径)。 > **状态**: Claude 自审 ✅ → 独立审查 ✅(全新上下文 subagent 顶替 Codex——本机 codex CLI 损坏;6 Issues + 4 建议已全部核验修订)→ **待人类门**。**依据**: ARCHITECTURE.md D1D14 / ROADMAP §2 / migrations/ 三份;签名证据来自 2026-07-20 对 reference/ 三项目的逐字提取(文中 `文件:行号` 均指 `reference/` 下路径)。
> **范围**: M1 全部交付物(ROADMAP §2 七步),含 2026-07-20 人类拍板:多源完整行为进 M1;GovDoc 与 Video-Tree 双冒烟;Redis 集成测试用实验室远程实例。 > **范围**: M1 全部交付物(ROADMAP §2 七步),含 2026-07-20 人类拍板:多源完整行为进 M1;GovDoc 与 Video-Tree 双冒烟;Redis 集成测试用实验室远程实例。
> **本文档冻结的内容**: `types.py` / `errors.py` / `ports.py` 公共签名、配置键名、五个开放设计轴的取舍。与 ARCHITECTURE.md 冲突处在 §13 列为反哺修订,经人类批准后先改 ARCHITECTURE 再实施。 > **本文档冻结的内容**: `types.py` / `errors.py` / `ports.py` 公共签名、配置键名、五个开放设计轴的取舍。与 ARCHITECTURE.md 冲突处在 §13 列为反哺修订,经人类批准后先改 ARCHITECTURE 再实施。
@@ -28,7 +28,7 @@ class LLMResponse:
structured_data: Any | None = None # D14 阶梯通过后的解析产物 structured_data: Any | None = None # D14 阶梯通过后的解析产物
``` ```
- `structured_data` 不参与缓存序列化(pydantic 实例不可 JSON 往返);缓存命中且调用方传了 `structured` 时,对缓存 `content` 重跑阶梯②③(零网络)再填充——schema 变更后旧缓存自动重校验,失败按未命中处理并记 warning。 - `structured_data` 不参与缓存序列化(pydantic 实例不可 JSON 往返);缓存命中且调用方传了 `structured` 时,由 **CacheMW 复用装配时注入 StructuredMW 的同一 strategy 实例**对缓存 `content` 重跑阶梯②③(零网络)再填充——schema 变更后旧缓存自动重校验,失败按未命中处理并记 warning(命中路径不经过 StructuredMW,该职责必须显式落在 CacheMW)
- 缓存反序列化防御:未知字段过滤、缺失新字段吃默认值(版本偏移不炸)。 - 缓存反序列化防御:未知字段过滤、缺失新字段吃默认值(版本偏移不炸)。
### 2.2 `chat()` 签名与 `ChatRequest` ### 2.2 `chat()` 签名与 `ChatRequest`
@@ -55,13 +55,23 @@ async def chat(
|---|---|---| |---|---|---|
| name / provider / base_url / api_key / model | str,必填 | provider 必须是注册表键(§7) | | name / provider / base_url / api_key / model | str,必填 | provider 必须是注册表键(§7) |
| max_concurrency / rpm / tpm | int = 0 | **0 = 该闸不启用**(VT/GovDoc 迁移无 TPM 配置) | | max_concurrency / rpm / tpm | int = 0 | **0 = 该闸不启用**(VT/GovDoc 迁移无 TPM 配置) |
| est_tokens | int = 0 | TPM 预扣 + usage 缺失兜底;`tpm > 0` 时必填 > 0(装配期校验) | | est_tokens | int = 0 | TPM 预扣 + usage 缺失兜底;`tpm > 0` 时必填 > 0(装配期校验)。默认 0 时 usage 缺失兜底落 0 并标 `estimated`——与 §9 行 10 不矛盾:放弃的是 VT"缺失填 0 且**不标注**"的静默行为 |
| timeout_s | float,必填 | 须 ≤ permit 租约 TTL(§7.3 装配守卫) | | timeout_s | float,必填 | 须 ≤ permit 租约 TTL(ARCH §7.3 装配守卫) |
| ttft_timeout_s / inter_token_timeout_s | float \| None = None | 不变式 `0 < inter < ttft < timeout_s`(CHS `config.py:66-82`) | | ttft_timeout_s / inter_token_timeout_s | float \| None = None | 不变式 `0 < inter < ttft < timeout_s`(CHS `config.py:66-82`) |
| enable_thinking | bool \| None = None | **三态**:None=不注入(模型默认)/ True=注入开启 / False=注入关闭——同时覆盖 VT"注入开启"(`llm.py:130-144`)与 CHS"注入关闭"(`invokers.py:230-238`)两种现状 | | enable_thinking | bool \| None = None | **三态**:None=不注入(模型默认)/ True=注入开启 / False=注入关闭——同时覆盖 VT"注入开启"(`llm.py:130-144`)与 CHS"注入关闭"(`invokers.py:230-238`)两种现状 |
| missing_done | str = "retry" | SSE 缺 [DONE] 语义,见 §6 | | missing_done | str = "retry" | SSE 缺 [DONE] 语义,见 §6 |
| trust_env | bool = True | 代理绕行(VT `ocr.py:46` 教训,M3 OCR 复用) | | trust_env | bool = True | 代理绕行(VT `ocr.py:46` 教训,M3 OCR 复用) |
### 2.4 client 公共面与装配签名
| 签名 | 说明 |
|---|---|
| `async def aclose(self) -> None` + `__aenter__/__aexit__` | 生命周期 API(ARCH §5.1 约定③);aclose 幂等,关闭各源 httpx client 与遥测连接 |
| `from_env(scope: str = "LLM", *, limiter: RateLimiter \| None = None, breaker: ProviderGate \| None = None, cache: CacheBackend \| None = None, telemetry: TelemetryRecorder \| None = None) -> GatewayClient` | classmethod;None 项按 env 构建私有实例,**显式传入即共享**——多逻辑角色共享全局并发/RPM/TPM 闸(VT `TREE_BUILD_API_CONCURRENCY` 语义,VT 迁移缺口 R5)= 业务侧自建一个 InMemoryLimiter 传给多次 `from_env`;全局闸计数在 limiter 实例内,单源闸按 source name 键 |
| `from_settings(settings: GatewaySettings, *, limiter=..., breaker=..., cache=..., telemetry=...) -> GatewayClient` | 同上,配置对象替代环境读取(`GatewaySettings` 为 pydantic-settings 模型,聚合 §8 全部键) |
| 构造函数 | 全量依赖注入(middlewares/transport/sources/registry/后端全显式),测试与高级用户路径 |
| `async def gather_bounded(aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]` | 模块级便利函数;语义 = `asyncio.gather` 默认行为(结果保序、首个异常上抛)+ semaphore 并发上限;替代 VT 手搓样板 |
其余:`Usage(prompt_tokens, completion_tokens, usage_source)`;`SourceStats(inflight, rpm_used, tpm_used)`(CHS `ports.py:396`);`TransportResult`(transport 返回的内部类型:content/thinking/两 token 数/usage_source/ttft_ms/max_inter_token_ms/raw)。韧性配置四件套沿用 CHS 字段:`RetryPolicy(max_attempts, backoff_base_s, backoff_max_s)``BreakerConfig(fail_threshold, cooldown_s, probe_ttl_s)``BackpressurePolicy(stall_window_s, poll_interval_s)`(M1 仅 poll 用,stall 判定 M2)、`GlobalLimits(max_concurrency, rpm, tpm)` 其余:`Usage(prompt_tokens, completion_tokens, usage_source)`;`SourceStats(inflight, rpm_used, tpm_used)`(CHS `ports.py:396`);`TransportResult`(transport 返回的内部类型:content/thinking/两 token 数/usage_source/ttft_ms/max_inter_token_ms/raw)。韧性配置四件套沿用 CHS 字段:`RetryPolicy(max_attempts, backoff_base_s, backoff_max_s)``BreakerConfig(fail_threshold, cooldown_s, probe_ttl_s)``BackpressurePolicy(stall_window_s, poll_interval_s)`(M1 仅 poll 用,stall 判定 M2)、`GlobalLimits(max_concurrency, rpm, tpm)`
**`max_attempts` 语义统一**:= 总尝试次数(含首次)。VT `range(max_retries)` 同义;CHS `fails > max_attempts` 为失败数上限,迁移映射时 +1(记入 CHS 迁移文档)。 **`max_attempts` 语义统一**:= 总尝试次数(含首次)。VT `range(max_retries)` 同义;CHS `fails > max_attempts` 为失败数上限,迁移映射时 +1(记入 CHS 迁移文档)。
@@ -127,15 +137,15 @@ D3"算法只有一份"在限流处的准确含义 = **语义契约与契约测
| C2 采 VT 简单 API(`is_open/record_failure/force_open/record_success`) | 内存版零改动,但探针 owner 与 epoch 无处安放,M2 Redis 版被迫另立 API → 中间件写两套分支,违反算法一份 | | C2 采 VT 简单 API(`is_open/record_failure/force_open/record_success`) | 内存版零改动,但探针 owner 与 epoch 无处安放,M2 Redis 版被迫另立 API → 中间件写两套分支,违反算法一份 |
| C3 两套 API 并存各配各的 | 同上,直接否决 | | C3 两套 API 并存各配各的 | 同上,直接否决 |
`Decision/Update` 数据类(allowed/state/epoch/is_probe/probe_owner/retry_after_ms;applied/state/epoch/failure_count/retry_after_ms)照 CHS `ports.py:405,442` 冻结。有效阈值 `max(configured, concurrency*2)` 由装配层自动计算(三项目 .env 注释的手动约定入库)。 `Decision/Update` 数据类照 CHS `ports.py:405-455` 冻结(含 `__post_init__` 一致性校验):`Decision(source_name, allowed, state, epoch, is_probe, probe_owner, retry_after_s)``Update(applied, state, epoch, failure_count, retry_after_s)`;时间单位一律**秒**(与 `cooldown_s``GatewayUnavailableError.retry_after_s` 一致;CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。有效阈值 `max(configured, concurrency*2)` 由装配层自动计算(三项目 .env 注释的手动约定入库)。
### 4.4 其余端口(无争议,直接冻结) ### 4.4 其余端口(无争议,直接冻结)
| 端口 | 签名要点 | 出处/理由 | | 端口 | 签名要点 | 出处/理由 |
|---|---|---| |---|---|---|
| `Transport` | `async def complete(*, messages, source: SourceConfig, stream: bool, overlay: dict, call_id: str) -> TransportResult`;内部含看门狗、SSE 解析、§6.2 错误翻译 | D2;overlay 承接注册表 thinking 参数与 NativeSchema 的 response_format | | `Transport` | `async def complete(*, messages, source: SourceConfig, stream: bool, overlay: dict, call_id: str) -> TransportResult`;内部含看门狗、SSE 解析、ARCH §6.2 错误翻译。**看门狗活性口径**:任何增量(content **或** reasoning_content)都算 token——ttft = 首个任意 token、思考流刷新 inter_token 计时(CHS `invokers.py:55-79`;CHS 迁移约束 R1,防 thinking 模型被看门狗误杀) | D2;overlay 承接注册表 thinking 参数与 NativeSchema 的 response_format |
| `CacheBackend` | `async get(key) -> str \| None` / `async set(key, value, ttl_s) -> None`;**key 公式上移至 CacheMW**(算法一份),后端是笨 KV | VT `redis_cache.py` 的 key 构造从类中析出;内存版 dict+TTL 供测试与无 Redis 场景 | | `CacheBackend` | `async get(key) -> str \| None` / `async set(key, value, ttl_s) -> None`;**key 公式上移至 CacheMW**(算法一份),后端是笨 KV | VT `redis_cache.py` 的 key 构造从类中析出;内存版 dict+TTL 供测试与无 Redis 场景 |
| `TelemetryRecorder` | `async record_llm_call(*, <18 字段>)`,keyword-only;15 旧字段名照 VT/GovDoc Protocol 逐字保留 + `source_name, usage_source, cost` | 表名 `llm_calls`、列只增不删 → VT 既有分析脚本继续可用 | | `TelemetryRecorder` | `async record_llm_call(*, <18 字段>)`,keyword-only。字段冻结:`call_id, parent_call_id, session_id, model, provider, source_name, messages, response, thinking, prompt_tokens, completion_tokens, usage_source, latency_ms, ttft_ms, max_inter_token_ms, cache_hit, error, cost`(ARCH §7.8) | 唯一更名:旧 Protocol 的 `model_name``model`(VT 迁移文档 §8 已记录,grep 实测两项目均**只写不读** telemetry.db,更名零风险);表名 `llm_calls` 保留,旧 db 留档 |
| `SourceSelector` | `def order(sources, stats) -> list[SourceConfig]`;首发 round_robin / least_inflight | CHS `selector.py:12` 逐字 | | `SourceSelector` | `def order(sources, stats) -> list[SourceConfig]`;首发 round_robin / least_inflight | CHS `selector.py:12` 逐字 |
| `StructuredOutputStrategy` | `def request_overlay(schema: dict \| None) -> dict` + `def parse(text: str) -> Any`(失败抛 `ResultInvalidError`) | 双实现见 §5 | | `StructuredOutputStrategy` | `def request_overlay(schema: dict \| None) -> dict` + `def parse(text: str) -> Any`(失败抛 `ResultInvalidError`) | 双实现见 §5 |
| 时钟/随机注入 | 有状态组件构造收 `now: Callable[[], float]``sleep``rng`(CHS `governance.py:85-89` 形态) | P6 可测试性 | | 时钟/随机注入 | 有状态组件构造收 `now: Callable[[], float]``sleep``rng`(CHS `governance.py:85-89` 形态) | P6 可测试性 |
@@ -193,7 +203,7 @@ class ProviderProfile:
| 装配选择 | `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND`(memory\|redis,M1 仅 memory)、`PGW_CACHE_BACKEND`(redis\|memory\|none)、`PGW_TELEMETRY_BACKEND`(sqlite\|none)、`PGW_TELEMETRY_SQLITE_PATH``PGW_CACHE_NAMESPACE`(缓存启用时**必填**)、`PGW_CACHE_TTL_S`(必填 >0)、`PGW_STRUCTURED_MAX_RETRIES` | 缺关键键直接报错,严禁默认值兜底 | | 装配选择 | `PGW_LIMITER_BACKEND` / `PGW_BREAKER_BACKEND`(memory\|redis,M1 仅 memory)、`PGW_CACHE_BACKEND`(redis\|memory\|none)、`PGW_TELEMETRY_BACKEND`(sqlite\|none)、`PGW_TELEMETRY_SQLITE_PATH``PGW_CACHE_NAMESPACE`(缓存启用时**必填**)、`PGW_CACHE_TTL_S`(必填 >0)、`PGW_STRUCTURED_MAX_RETRIES` | 缺关键键直接报错,严禁默认值兜底 |
| 复用 | `REDIS_URL` | VT 同名 | | 复用 | `REDIS_URL` | VT 同名 |
`from_env(scope: str = "LLM")` 按 scope 装配单 client;多逻辑角色 = 业务侧对每个角色调一次 `from_env(scope=...)`,共享状态后端须显式传入(§7.7 R5)。VT 的 `SEARCH_LLM_MODEL` 旧式键由其迁移文档映射为 `SEARCH__...`,库不做旧键兼容层。 `from_env(scope)` 按 scope 装配单 client;多逻辑角色 = 业务侧对每个角色调一次 `from_env(scope=...)`,共享状态后端经 §2.4 的显式注入参数传入(ARCH §7.7,VT 迁移 R5)。VT 的 `SEARCH_LLM_MODEL` 旧式键由其迁移文档映射为 `SEARCH__...`,库不做旧键兼容层。
## 9. 旧版行为审计(替换 VT/GovDoc `GovernedLLMClient` 栈 + CHS 治理循环) ## 9. 旧版行为审计(替换 VT/GovDoc `GovernedLLMClient` 栈 + CHS 治理循环)
@@ -219,7 +229,10 @@ class ProviderProfile:
| 18 | 缓存命中独立 `cache_call_id` + 记遥测(VT `llm.py` 缓存分支) | 保留(cache_hit=True, latency_ms=0) | | 18 | 缓存命中独立 `cache_call_id` + 记遥测(VT `llm.py` 缓存分支) | 保留(cache_hit=True, latency_ms=0) |
| 19 | JSON 解析:VT 内 ≥5 套并存、失败语义不一 | 替换为 `JsonRepairStrategy` 单实现;业务归一化经 normalize 钩子外置(§5 细则 3) | | 19 | JSON 解析:VT 内 ≥5 套并存、失败语义不一 | 替换为 `JsonRepairStrategy` 单实现;业务归一化经 normalize 钩子外置(§5 细则 3) |
| 20 | 非流式路径:三家均无(硬编码 stream=True) | 新增 `stream=False` 快路径(单 JSON 响应,仅 total 超时) | | 20 | 非流式路径:三家均无(硬编码 stream=True) | 新增 `stream=False` 快路径(单 JSON 响应,仅 total 超时) |
| 21 | `evolve_llm = llm` 隐式共享实例(VT `main.py`) | 放弃隐式;共享 = 显式注入同一状态后端(§7.7 R5) | | 21 | `evolve_llm = llm` 隐式共享实例(VT `main.py`) | 放弃隐式;共享 = 显式注入同一状态后端(§2.4) |
| 22 | 看门狗活性:thinking 增量刷新计时、ttft=首个任意 token(CHS `invokers.py:55-79`) | 保留(§4.4 Transport 行;CHS 迁移 R1) |
M2 备忘(不入 M1):CHS 图片 magic bytes 探测 mime(`invokers.py:116-124`)随 VLM 多模态迁移移入库 transport。
## 10. 非功能维度(逐条) ## 10. 非功能维度(逐条)
@@ -259,3 +272,5 @@ class ProviderProfile:
3. §7.1 补 SSE 缺 [DONE] 的 per-source `missing_done` 语义(本文档 §6,承 CHS salvage 迁移约束)。 3. §7.1 补 SSE 缺 [DONE] 的 per-source `missing_done` 语义(本文档 §6,承 CHS salvage 迁移约束)。
4. §7.9 阶梯④补反馈模板与"重问叠加 NativeSchema"升级细则;归一化钩子外置声明。 4. §7.9 阶梯④补反馈模板与"重问叠加 NativeSchema"升级细则;归一化钩子外置声明。
5. §8 模块图 `middleware/` 清单补 `structured.py`(StructuredMW);`sources.py` 职责注明含选源策略(selector 不单设目录)。 5. §8 模块图 `middleware/` 清单补 `structured.py`(StructuredMW);`sources.py` 职责注明含选源策略(selector 不单设目录)。
6. §4.3 默认层序更新为 遥测→缓存→结构化→重试循环(StructuredMW 插入位,本文档 §5)。
7. §6.1 `AllSourcesExhausted`/`CircuitOpenError``retry_after_s` 定稿为**非可选 float**(替换现文 `float | None`;CHS 同款,0 表示"可立即重试")。