Files
PolyGateway/research-wiki/designs/2026-07-20-m1-core-design.md
T
iomgaa b568b61a34 docs: pin register_provider as pure function per plan review
Also note that the seven architecture amendments predate the plan,
tighten T12 to independent implementation layers, and add fallback
guidance for read-only reference protocol imports.
2026-07-20 06:26:46 -04:00

277 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M1 核心里程碑设计:公共签名冻结与治理栈落地
> **状态**: 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 集成测试用实验室远程实例。
> **本文档冻结的内容**: `types.py` / `errors.py` / `ports.py` 公共签名、配置键名、五个开放设计轴的取舍。与 ARCHITECTURE.md 冲突处在 §13 列为反哺修订,经人类批准后先改 ARCHITECTURE 再实施。
## 1. 目标与非目标
M1 出口:GovDoc 与 Video-Tree 用 `GatewayClient.from_env()` + `chat()` 替代各自 `GovernedLLMClient` 跑通一次真实治理调用;`make ci` 绿、覆盖 ≥80%。非目标:Redis 限流/熔断后端、背压 stall、Postgres 遥测、pricing、OCR、音频(M2/M3)。
## 2. 核心类型冻结(`types.py`)
### 2.1 `LLMResponse`
前 11 个字段与两参考项目**逐字一致**(VT `core/types.py:12-29` ≡ GovDoc `docagent_core/types.py:8-25`),顺序不变、无默认值——三项目测试中按位置构造 fake 的代码零改动。新增字段全部带默认值(ARCHITECTURE §5.1 稳定性约定①):
```python
@dataclass(frozen=True)
class LLMResponse:
content: str; thinking: str; model: str; provider: str
prompt_tokens: int; completion_tokens: int; latency_ms: int
ttft_ms: float | None; max_inter_token_ms: float | None
cache_hit: bool; call_id: str
# —— 库新增(只增不删,必带默认值)——
source_name: str = ""
cost: float | None = None
usage_source: str = "measured" # "measured" | "estimated"
structured_data: Any | None = None # D14 阶梯通过后的解析产物
```
- `structured_data` 不参与缓存序列化(pydantic 实例不可 JSON 往返);缓存命中且调用方传了 `structured` 时,由 **CacheMW 复用装配时注入 StructuredMW 的同一 strategy 实例**对缓存 `content` 重跑阶梯②③(零网络)再填充——schema 变更后旧缓存自动重校验,失败按未命中处理并记 warning(命中路径不经过 StructuredMW,该职责必须显式落在 CacheMW)。
- 缓存反序列化防御:未知字段过滤、缺失新字段吃默认值(版本偏移不炸)。
### 2.2 `chat()` 签名与 `ChatRequest`
`chat()` 按 ARCHITECTURE §5.2 定稿,唯一细化是 `structured` 的类型:
```python
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,
) -> LLMResponse: ...
```
洋葱内部流转 `ChatRequest`(frozen dataclass):上述全部参数 + `overlay: dict`(StructuredMW/注册表写入的请求体叠加项,如 response_format、thinking 参数)。中间件用 `dataclasses.replace()` 派生新请求,不做原地修改(见 §4.1 方案 A1)。
### 2.3 `SourceConfig`(CHS `config.py:43-83` 超集)与辅助类型
| 字段 | 类型/默认 | 说明 |
|---|---|---|
| name / provider / base_url / api_key / model | str,必填 | provider 必须是注册表键(§7) |
| max_concurrency / rpm / tpm | int = 0 | **0 = 该闸不启用**(VT/GovDoc 迁移无 TPM 配置) |
| est_tokens | int = 0 | TPM 预扣 + usage 缺失兜底;`tpm > 0` 时必填 > 0(装配期校验)。默认 0 时 usage 缺失兜底落 0 并标 `estimated`——与 §9 行 10 不矛盾:放弃的是 VT"缺失填 0 且**不标注**"的静默行为 |
| 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`) |
| 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 |
| 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)`
**`max_attempts` 语义统一**:= 总尝试次数(含首次)。VT `range(max_retries)` 同义;CHS `fails > max_attempts` 为失败数上限,迁移映射时 +1(记入 CHS 迁移文档)。
## 3. 错误模型冻结(`errors.py`)+ §6.1 勘误
```python
class PolyGatewayError(Exception):
def __init__(self, message: str, *, source_name: str | None = None,
status_code: int | None = None, operation: str | None = None): ...
class TransientError(PolyGatewayError): # + retry_after_s: float | None = None
class SourceDeadError(PolyGatewayError): ...
class RequestRejectedError(PolyGatewayError): ...
class ResultInvalidError(PolyGatewayError):
# + raw_text: str, repair_error: str | None, validation_errors: tuple[str, ...]
class GatewayUnavailableError(PolyGatewayError):
# + scope: str, reason: str, retry_after_s: float, per_source_reasons: dict[str, str]
class CircuitOpenError(GatewayUnavailableError): ... # reason 恒 "circuit_open"
class AllSourcesExhausted(GatewayUnavailableError): ...
class GovernanceBackendError(PolyGatewayError): ... # 限流/熔断后端故障:报错不放行
```
基类构造形态承 CHS `ProviderError`(`errors.py:82`);`GatewayUnavailableError.retry_after_s` 为**非可选 float**(CHS `ProviderUnavailableError:155-176` 同),业务侧 arq 延期重投(`workers/tracking.py:406-428`)只需 catch 基类。全部类自 `polygateway` 顶层导出(§5.1 约定②)。
**勘误(反哺 ARCHITECTURE §6.1)**:该节称 reason 承接"CHS 的 7 种:circuit_open/retry_exhausted/stalled/quota_exhausted/no_sources/backpressure_timeout/probe_pending",但 CHS `errors.py:143-153` 实际值域是 `{network_error, timeout, rate_limited, source_dead, circuit_open, retry_exhausted, stalled}`——前者混入了未曾存在的值。重组为两层值域:
| 层 | 值域 |
|---|---|
| scope 级 `reason` | circuit_open / retry_exhausted / stalled(M2 启用)/ quota_exhausted(fail-fast 限流满)/ no_sources |
| `per_source_reasons` 值 | network_error / timeout / rate_limited / source_dead / circuit_open / cooldown |
## 4. 端口冻结(`ports.py`,全部 `@runtime_checkable Protocol`)
### 4.1 设计轴 A:中间件上下文形态
`Middleware` 协议:`async def __call__(self, request: ChatRequest, call_next: CallNext) -> LLMResponse`,`CallNext = Callable[[ChatRequest], Awaitable[LLMResponse]]`
| 方案 | 权衡 |
|---|---|
| **A1 不可变 ChatRequest + 派生传递(推荐)** | 中间件互不污染;衍生请求(结构化反馈重问、overlay 注入)显式 `replace()`;观测数据由 LLMResponse/异常携带,逐次遥测在 RetryMW 内部(它本就拥有尝试编排的全部信息) |
| A2 可变 CallContext 贯穿 | 遥测取数方便,但任何层都能偷改状态,重问循环下请求突变不可追踪;三项目 God-method 的病根之一 |
| A3 contextvars 传递 | 隐式全局,违反纯 asyncio 中立铁律;直接否决 |
遥测调用点收敛:内部组件 `TelemetryEmitter`(包装 `TelemetryRecorder`,从 request+结果/异常组装 18 字段)注入 TelemetryMW 与 RetryMW,是全库**唯一**调用 `record_llm_call` 的地方(铁律执行点)。
### 4.2 设计轴 B:限流端口粒度
| 方案 | 权衡 |
|---|---|
| **B1 端口在 limiter 层,契约 = CHS 形态(推荐)** | `try_acquire/acquire/source_stats/mark_progress/progress_age_s` + `Permit(release, settle)`(CHS `ports.py:494,507`);算法在各后端内实现,**契约测试一套两后端共跑**(CHS `tests/contracts_limiter.py` 5 项扩充) |
| B2 端口在存储原语层(incr/zadd 抽象),算法一份在中间件 | Redis 六道闸必须单条 Lua 原子执行(§10 原子性),原语端口无法原子组合;否决 |
| B3 无端口,duck-type 两个类 | 失去 import-linter 可执法的接缝;否决 |
D3"算法只有一份"在限流处的准确含义 = **语义契约与契约测试只有一份**(ARCHITECTURE §7.3 已如此表述);熔断/重试/退避则是字面的算法一份。`settle()/release()` 幂等、permit 带 TTL 租约、TPM 预扣 `est_tokens` 结算多退少补,全部进契约测试。
### 4.3 设计轴 C:熔断端口
| 方案 | 权衡 |
|---|---|
| **C1 采 CHS gate 契约(推荐)**:`try_enter(source, owner) -> Decision``record_success(entry)``record_failure(entry, reason, force_open)``release_probe(entry)``retry_after_s(sources)`(`provider_gate.py:105-169`) | 半开探针租约(TTL 防探针死锁)与 epoch fencing(防旧世代写回)在**契约内**;M2 的 Redis 版零适配移植;内存版以 VT `breaker.py` 状态机为蓝本按此契约重写(时钟构造注入) |
| C2 采 VT 简单 API(`is_open/record_failure/force_open/record_success`) | 内存版零改动,但探针 owner 与 epoch 无处安放,M2 Redis 版被迫另立 API → 中间件写两套分支,违反算法一份 |
| C3 两套 API 并存各配各的 | 同上,直接否决 |
`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 其余端口(无争议,直接冻结)
| 端口 | 签名要点 | 出处/理由 |
|---|---|---|
| `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 场景 |
| `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` 逐字 |
| `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 可测试性 |
## 5. 设计轴 D:结构化输出阶梯落位(D14 细化)
| 方案 | 权衡 |
|---|---|
| **D1 独立 `StructuredMW`,层序 遥测→缓存→结构化→重试→transport(推荐)** | 重问 = 再次调用 `call_next`(天然照过限流/熔断门、逐次遥测);缓存在其外 → 只缓存阶梯通过的最终响应(D14"不固化坏结果"自动成立);反馈改 messages 经 `replace()` 显式派生 |
| D2 循环写在 client.chat() 里(洋葱之上) | 重问绕过遥测/缓存中间件,需手工补记账;循环与洋葱两处编排,回到 God-method |
| D3 策略塞进 transport | transport 管协议不管语义;重问跨 attempt,与"transport 无重试"的 D2 职责划分冲突 |
细则(D14 留给本设计的三件事):
1. **反馈模板**(库内常量,英文、零业务词):重问 = 原 messages + `assistant(原始坏输出)` + `user("Your previous reply was not valid JSON matching the required schema. Errors: {errors}. Reply with ONLY the corrected JSON object.")``{errors}` 取修复/校验错误前 3 条、每条截断 200 字符(防 prompt 膨胀)。
2. **策略升级**:首次尝试用装配时策略(注册表声明 `supports_native_schema` 则 NativeSchema,否则 JsonRepair);重问一律叠加 NativeSchema(若支持)——修复失败说明 prompt 约定不够,升级到协议约束。
3. **归一化钩子**:VT/GovDoc 的 `_normalize_action`(`loop.py:379-399`,DeepSeek 平铺参数收拢)是**面向特定业务 schema 的**归一化,入库即违反零业务假设。库提供 `JsonRepairStrategy(normalize: Callable[[Any], Any] | None = None)` 注入点,业务侧自带归一化函数;`ResultInvalidError` 携带的 `raw_text` 保证兜底可行。
`structured="json"` 档 = 仅阶梯②(围栏剥离 → json_repair → `json.loads`),产物为 dict/list,失败抛 `ResultInvalidError` 不重问——恰好继任 CHS 被跨模块偷 import 的 `_extract_json_object`(`extractors.py:32-51`,其"失败即抛、不修复"语义由 `max_structured_retries=0` 默认覆盖)。`max_structured_retries` 默认 1,装配级配置;CHS 迁移设 0。
## 6. 设计轴 E:SSE 截断语义
现状分歧:VT 缺 `[DONE]` 一律 `_SseAnomaly("truncated_no_done")` 重试(`llm.py:585-586`);CHS 区分 early_eof(零内容,retry)/ missing_done(有内容,**打捞**)(`invokers.py:307-313`);GovDoc 无检查(缺陷)。
| 方案 | 权衡 |
|---|---|
| E1 一律 strict 重试 | 语义最干净,但 CHS 迁移丢打捞行为(迁移文档常驻约束:旧版行为不得隐式丢弃) |
| E2 一律打捞 | 截断响应可能进缓存被固化,违反缓存防毒化精神 |
| **E3 per-source `missing_done: "retry" \| "salvage"`,默认 retry(推荐)** | 默认严格(防缓存固化截断);CHS 迁移配 salvage 保留原行为;early_eof(零内容)恒 retry 不可配 |
打捞路径强制 `usage_source="estimated"`(usage 帧通常随 [DONE] 前的末帧丢失)。
## 7. provider 注册表(`providers.py`)
```python
@dataclass(frozen=True)
class ProviderProfile:
name: str
thinking_on: dict # enable_thinking=True 时并入请求体
thinking_off: dict # enable_thinking=False 时并入
strip_think_tags: bool # 响应 content 中 <think> 剥离(qwen)
supports_native_schema: bool = False
```
首发注册:`qwen`(`{"enable_thinking": True}` / `{"enable_thinking": False}`、剥 think 标签)、`deepseek`(`{"thinking": {"type": "enabled"}}` / `{"thinking": {"type": "disabled"}}`)、`openai`(全空,基线)。`reasoning_content` 增量提取是 OpenAI 兼容 SSE 的通用行为,归 transport 不进 profile。查找按 `SourceConfig.provider` **精确匹配**,未注册即装配期报错(消灭子串猜测);`register_provider` 公开,新 provider 一个条目零核心改动(D11)。**形态定稿(2026-07-20 计划审查修订)**: 纯函数 `register_provider(profile, *, base: Mapping | None = None) -> dict[str, ProviderProfile]`,返回 base(缺省模块级不可变 `DEFAULT_PROFILES`)+ 新条目的新表;client/from_env 收 `registry: Mapping | None = None`——无可变全局状态(铁律)。
## 8. 配置面定稿(from_env 键名全集)
| 组 | 键 | 说明 |
|---|---|---|
| 多源 | `{SCOPE}__{PROVIDER}__{N}__{FIELD}`,FIELD ∈ CHS 全集(`config.py:95-104`)+ `ENABLE_THINKING / TTFT_TIMEOUT_S / INTER_TOKEN_TIMEOUT_S / MISSING_DONE / TRUST_ENV` | 聚合为 `list[SourceConfig]` |
| scope 全局闸 | `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | CHS 同名 |
| per-scope 韧性 | `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S`;`{SCOPE}__BREAKER__FAIL_THRESHOLD / COOLDOWN_S / PROBE_TTL_S`;`{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S`;`{SCOPE}__SELECTOR`;`{SCOPE}__QUOTA_FULL`(wait/fail_fast) | CHS 键名 + 新增 PROBE_TTL_S、QUOTA_FULL |
| 平铺简写(单 scope) | `LLM_TIMEOUT / LLM_MAX_RETRIES / LLM_RETRY_BASE_DELAY / LLM_RETRY_MAX_DELAY / LLM_CIRCUIT_BREAKER_THRESHOLD / LLM_CIRCUIT_BREAKER_COOLDOWN / LLM_TTFT_TIMEOUT / LLM_INTER_TOKEN_TIMEOUT` | VT/GovDoc 现有键零改名迁移;与 scope 键并存时 scope 键优先(ARCHITECTURE §9) |
| 装配选择 | `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 同名 |
`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 治理循环)
| # | 旧行为(出处) | 处置 |
|---|---|---|
| 1 | chat() `cache_salt`(仅 VT,`llm.py:274`;调用点 `loop.py:336`) | 保留(GovDoc 无此参 = 超集兼容) |
| 2 | 熔断半开单探针(仅 VT `breaker.py:45-48` 内存 flag;GovDoc 每调用放行) | 替换为带 TTL 租约探针(C1 契约);GovDoc 无探针行为**有意放弃**(缺陷) |
| 3 | 熔断时钟逐调用传 `now`(VT/GovDoc) | 替换为构造注入 `now()`(等价可测,签名更稳) |
| 4 | 有效阈值 = max(threshold, concurrency*2)(.env 注释约定) | 保留,装配层自动算 |
| 5 | 遥测:VT 持久连接+Lock+close() / GovDoc 每写新建连接 | 采 VT 形态 + `asyncio.to_thread` 桥接;GovDoc 形态放弃 |
| 6 | 遥测初始化/写入失败降级不冒泡、`INSERT OR IGNORE` 幂等、表 `llm_calls` 15 列 | 全保留;列只增(`source_name/usage_source/cost`) |
| 7 | 缓存:sha256(model+messages[+salt])、TTL>0 校验、读写失败静默降级(VT `redis_cache.py`) | 语义保留;**key 公式替换**(必填 namespace + 多模态 part 先摘要)→ 迁移后旧缓存一次性冷启动(迁移文档已记);GovDoc 无 salt/无隔离放弃(缺陷) |
| 8 | 瞬时判定:VT 宽(`TimeoutException+TransportError`)/ GovDoc 窄 | 采宽集(GovDoc 窄集放弃——ConnectError 漏网即 bug) |
| 9 | 429 body `insufficient_quota` → SourceDead;Retry-After 仅秒数(CHS `invokers.py:144-166`) | 保留 |
| 10 | usage 缺失按 `est_tokens` 估算 + `usage_source` 标注(CHS `invokers.py:241`) | 保留;VT"缺失填 0"放弃 |
| 11 | thinking:VT/GovDoc 子串猜测+注入开启;CHS 配置驱动+注入关闭 | 替换为注册表 profile + `enable_thinking` 三态(§2.3/§7) |
| 12 | qwen `<think>` 剥离(`llm.py:147-164`)、`reasoning_content` 增量提取(三家同款) | 保留(前者 registry 声明,后者 transport 通用) |
| 13 | SSE:ping/空行跳过、usage 帧旁路、畸形 JSON → 瞬时、强制 `include_usage` | 保留(三家同源纯函数移植) |
| 14 | 缺 [DONE]:VT strict / CHS salvage / GovDoc 无检查 | E3 可配默认 strict;GovDoc 无检查放弃(缺陷) |
| 15 | 重试:退避公式 `min(base*2^n, max)*uniform(0.5,1.5)` 与 Retry-After 取大;每 attempt 新 call_id;致命 `force_open` 即抛 | 保留(D13);两家 jitter 系数差异按 VT 收敛 |
| 16 | CHS 循环:源冷却备忘、`gate_rejections==len(sources)` 判 circuit_open、`ResultInvalid` 记成功、finally 嵌套 settle→release、Cancel 时 release_probe(`governance.py:107-266`) | 全保留(RetryMW 蓝本) |
| 17 | 限流契约:预扣/结算/退款、release 幂等、mark_progress(CHS limiter + 契约测试 5 项) | 保留;M1 内存实现同契约 |
| 18 | 缓存命中独立 `cache_call_id` + 记遥测(VT `llm.py` 缓存分支) | 保留(cache_hit=True, latency_ms=0) |
| 19 | JSON 解析:VT 内 ≥5 套并存、失败语义不一 | 替换为 `JsonRepairStrategy` 单实现;业务归一化经 normalize 钩子外置(§5 细则 3) |
| 20 | 非流式路径:三家均无(硬编码 stream=True) | 新增 `stream=False` 快路径(单 JSON 响应,仅 total 超时) |
| 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. 非功能维度(逐条)
| 维度 | 回答 |
|---|---|
| 并发与取消 | 同一 client 被 N 协程并发调用为标准形态:所有中间件无实例级可变请求状态(A1);内存 limiter 用 asyncio 原语,断言单事件循环。`CancelledError`:退避 sleep / 限流等待 / `__anext__` 全可取消;取消路径 finally 依次 `release_probe`(若探针)→ `permit.settle(0)+release()`(TPM 退款,承 CHS `governance.py` finally 语义)→ 尽力遥测(error="cancelled",写失败静默)→ 重抛;库内零 `except BaseException` |
| 降级方向 | 缓存 get/set 失败、遥测写失败 → warning 静默;limiter/breaker 后端操作失败 → `GovernanceBackendError` 上抛**不放行**(M1 内存后端仅内部 bug 会触发,契约照立,M2 Redis 直接继承) |
| 幂等与重复 | `settle/release/release_probe` 幂等(契约测试);遥测 call_id 主键 OR IGNORE;缓存同 key 重写同值无害 |
| 持久化与原子性 | SQLite WAL + busy_timeout,单持久连接 + to_thread,崩溃最多丢当次记录;缓存写在阶梯通过后(部分写入 = Redis 单 SET,原子);内存后端无持久化(进程死 = 状态清零,单进程语义下正确) |
## 11. 测试策略
| 层 | 内容 |
|---|---|
| unit(纯函数) | 看门狗(fake 迭代器+注入时钟,三层超时逐个触发)、SSE 解析(用三项目录制的真实帧样本二次构造,含畸形帧/usage 帧/缺 DONE)、退避公式、缓存 key 公式(namespace 隔离、salt、多模态摘要稳定性)、注册表、json_repair 链、错误翻译表逐行 |
| 契约(参数化后端) | limiter:CHS 5 项 + settle 幂等 + 装配守卫;breaker:状态机全迁移 + 探针租约过期回收 + force_open;M1 跑 memory,M2 同套跑 Redis |
| integration | 远程 Redis 缓存(命名空间 `pgw:test:{uuid}`,teardown 清理,断连模拟降级方向)、SQLite 并发写、RetryMW×限流×熔断组合(换源/冷却/circuit_open/取消穿透——`asyncio.Task.cancel` 注入到退避与流式中途) |
| e2e | 真实网关冒烟(流式/非流式/structured 三档),输出落 `tests/outputs/`;GovDoc 与 Video-Tree 双最小接入冒烟 |
每步先写失败测试再实现(测试结果门);行为变更的证据 = 本会话内 pytest 先红后绿输出。
## 12. 纸面兼容验证(调用点反推)
| 调用点 | 形态 | 兼容性 |
|---|---|---|
| VT `loop.py:336` | `chat(messages, session_id=, cache_salt=)` | ✅ 逐字兼容 |
| VT `summarizer.py:236` | `chat(messages, session_id=, parent_call_id=)` | ✅ |
| VT `evolve.py:566` | `chat(messages)` 纯位置 | ✅ |
| GovDoc `loop.py:377` | `chat(messages, session_id=, parent_call_id=)` | ✅;其 `LLMProvider` Protocol(`protocols.py:15-25`)为我方签名子集,结构性满足 |
| CHS `tracking.py:406-428` | catch `ProviderUnavailableError``exc.retry_after_s/scope/reason` | ✅ `GatewayUnavailableError` 字段同名(M2 接入) |
| CHS `classifiers.py:12` 偷 import `_extract_json_object` | → `structured="json"` 档 | ✅ 语义覆盖(不修复→修复是增强,失败仍抛异常) |
## 13. 对 ARCHITECTURE.md 的反哺修订(经人类批准后执行)
1. §6.1 reason 枚举勘误(本文档 §3):scope 级 5 值 + per_source 6 值,替换现文不实的 7 值清单。
2. §5.1 新增字段清单补 `structured_data`;§5.2 `chat()``structured` 参数类型定稿为 `type[BaseModel] | Literal["json"] | None`
3. §7.1 补 SSE 缺 [DONE] 的 per-source `missing_done` 语义(本文档 §6,承 CHS salvage 迁移约束)。
4. §7.9 阶梯④补反馈模板与"重问叠加 NativeSchema"升级细则;归一化钩子外置声明。
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 表示"可立即重试")。