docs: document reasoning ownership and explicit cache migration

This commit is contained in:
2026-09-09 02:41:25 -04:00
parent 73008ad7d5
commit d332287b28
12 changed files with 252 additions and 98 deletions
+42 -24
View File
@@ -38,7 +38,7 @@ resp = await client.chat(messages) # resp: LLMResponse
### 1.2 能力对比矩阵
| 能力 | Video-Tree-TRM5 | GovDoc-SaaS | CHSAnalyzer |
|---|---|---|---|
| --- | --- | --- | --- |
| 治理网关(重试/退避/超时) | ✅ `GovernedLLMClient` | ✅ 同款移植 | ✅ Invoker/Governance 分层(结构最好) |
| 错误分类 | ⚠️ 二分类(瞬时/致命) | ⚠️ 同款 | ✅ 三分类 + Retry-After 解析 + 工件级失败 |
| 限流 | ❌ 仅 `asyncio.Semaphore` | ❌ 完全没有 | ✅ Redis+Lua 六道闸(并发/RPM/TPM × 全局/单源) |
@@ -65,7 +65,7 @@ resp = await client.chat(messages) # resp: LLMResponse
### 1.4 各项目关键资产索引(移植蓝本)
| 资产 | 来源 | 移植去向(§7) |
|---|---|---|
| --- | --- | --- |
| 治理网关主循环(参考结构,需重构掉遥测复制) | `Video-Tree/adapters/llm.py``GovDoc/packages/docagent-core/src/docagent_core/llm/client.py` | client + middleware |
| 三层流式活性看门狗(纯函数,近乎原样复用) | 三项目同款 `streaming.py` | `streaming.py` |
| 进程内熔断器(时钟注入、单探针) | `Video-Tree/adapters/breaker.py` | `backends/memory/` |
@@ -108,7 +108,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
### 2.3 非目标(已确认,含理由)
| 不做 | 理由(讨论结论) | 归属 |
|---|---|---|
| --- | --- | --- |
| 任务队列(arq)/任务编排 | 队列单位是业务任务,库单位是单次调用,高度不同;强行进库会迫使批处理项目部署队列、并把"任务"业务概念污染进零业务假设的库。Video-Tree 声明了 arq 依赖却从未使用(死依赖)是现实佐证 | 业务侧 |
| 视频抽帧(ffmpeg)、图像裁剪/拼接/增强等预处理 | 纯业务先验(超声图表格在左上角、每 5 帧一批等),且会拖入 ffmpeg/PIL/numpy 重依赖;库只收就绪的 content 数组/图像字节 | 业务侧 |
| OCR 结果的几何映射(坐标换算/归一化/marker 推算) | 同上,业务先验;库只返回 OCR 服务的原生 bbox + page_size | 业务侧 |
@@ -128,6 +128,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**决策**: 借鉴 Clean Architecture 的三条原则——依赖规则(核心不依赖具体技术)、端口与适配器(Protocol 定义接缝)、组装点(所有构造集中注入);**不照搬**其面向应用的四层分层(Entities/Use Cases/Interface Adapters/Frameworks)。库内部的组织模式采用**中间件洋葱**(同 ASGI middleware / gRPC interceptor / Rust tower):重试、限流、熔断、缓存、遥测各为一层,层与层正交,顺序与取舍是配置。
**背景与讨论**: 人类提问"是否借鉴《Clean Architecture》,是否有更好的指导思想"。结论:那本书为应用程序而写,库没有"用例层",硬套四层会造出空转抽象。对库更适配的思想来源:
- **Hexagonal / Ports & Adapters**(Cockburn):三项目已在实践的本质。
- **《A Philosophy of Software Design》(Ousterhout)的"深模块、窄接口"**:接口复杂度是用户付的成本。落地为——90% 用户三行起步(`from_env()``chat()`),全部可配置性经构造函数暴露给需要的人,但绝不强迫简单用户理解。
- **中间件洋葱**:与治理栈天然同构。反面证据:三项目的 `GovernedLLMClient.chat()` 是约 500 行的方法,五层治理手工内联在一个重试循环里,横切关注点没有被切开,遥测调用因此被迫复制 4 次。洋葱模型下遥测就是一层,只写一次。
@@ -143,7 +144,7 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**背景与讨论**: 人类要求完整阐述官方 SDK 与手写的差异优劣。核心对比:
| 维度 | 手写 httpx | 官方 SDK(openai) |
|---|---|---|
| --- | --- | --- |
| SSE 协议解析(帧格式、畸形帧、usage 帧、[DONE]) | 自己写自己修(约 200 行),但全可控 | SDK 维护,跟随协议演进 |
| 错误分类 | 状态码 + body 字符串匹配,自己写 | 类型化异常层级(RateLimitError 等),映射干净 |
| 非标字段(qwen `enable_thinking`、deepseek `reasoning_content`) | 天然支持 | `extra_body` 写入 + `model_extra` 读出,**够用** |
@@ -221,6 +222,10 @@ HTTP API → arq 队列 → worker 协程 脚本 → asyncio.gather 协
**职责拆分(2026-08-25,issue #16/#17)**: 上面这条决策里的**推理**部分已从 `providers.py` 移出,落进新模块 `thinking.py`。起因是推理这件事从「请求侧注入什么参数」长成了「请求侧注入 + 响应侧裁定 + 两者对账」三件事,留在注册表里会让 `providers.py` 变成「推理的一切」,一句话说不清职责(P3)。拆后 `providers.py` 只回答**provider 是什么**(`ProviderProfile``DEFAULT_PROFILES``get_provider`/`register_provider`),`thinking.py` 承载**推理这件事的全部决策**(`ThinkingCapability``DEFAULT_CAPABILITIES``get_capability`/`register_capability``resolve_thinking``observe_thinking``reconcile_thinking``ThinkingUnsupportedError`);纯值类型 `ThinkingObservation` 归最内层 `types.py`(§5.1)。六个公共符号同批提升到包根导出——此前只能深路径 import,而深路径引用正是模块重组会打断下游的原因。
**1.3.4 受管推理契约(2026-09-09 已批准)**:AUTO=要求开启、不指定强度;空 on_base 仅是协议无需开启字节,不是任意模型默认推理。已登记模型必须含 AUTO 才接受 TrueAUTOnearest 不将 AUTO 代选强度;未知模型按已知 wire 尽力+warning,不保证开启。MiniMax on_base 改空,M3 仍不含 AUTOM2.5M2.7 空 wire 真实复验待完成,不新增能力条目。
有受管意图(含 NONE/糖/未知模型)时,source.extra_body 或 request.overlay 任一层出现标准控制根或当前 wire 两向控制根/effort_key 均拒绝,同值和后层遮蔽也不豁免;无意图保留 raw-only,不推断 applied。on_base 不得含自己的 effort_key 或标准 reasoning_effortoutput_config.effort,点号仍是字面顶层键,不新增私有方言解释器。工厂源级校验在装配期;chat 仅前置校验显式请求档与已知 raw;默认 transport 对选中源完整校验并在 HTTP 前 RequestRejected,可能已经准入,finally 结算不变。自定义 transport 由端口实现方履约,不新增 preflight。细则与 M1M9 见[批准设计 §45](designs/2026-09-09-134-thinking-contracts-design.md)。
### D12 零业务假设 + 单向依赖(继承 GovDoc 铁律)
**决策**: 库内禁止出现任何下游业务领域词汇(视频/文书/超声等)与业务 fixtures;扩展点一律 Protocol;import-linter 契约机械化执法(§8)。GovDoc 已证明这套纪律可执行(`pyproject.toml [tool.importlinter]`)。
@@ -307,7 +312,7 @@ flowchart TB
> 2026-07-20 修订(CHS 迁移文档缺口 G3): 初版把熔断/限流画在重试循环外,与"每次重试重新过限流闸"的理由自相矛盾,且熔断/限流是 **per-source** 的——源在循环内才被选出,准入只能发生在循环内。修订后与 CHSAnalyzer 实践(`governance.py:120-167` 逐次尝试执行选源→熔断→permit)一致。
| 相对顺序 | 理由 |
|---|---|
| --- | --- |
| 遥测最外 | 观测一切,包括缓存命中与各类失败;任何路径都留痕 |
| 缓存在重试循环外 | 缓存命中不打网关:不消耗限流配额、不受熔断状态影响 |
| 结构化在缓存内、重试外(2026-07-20 M1 设计) | 带反馈重问 = 再次调用内层,天然照过限流/熔断门、逐次遥测;缓存只固化阶梯通过的最终结果 |
@@ -332,7 +337,7 @@ flowchart TB
这是**跨子系统的通用纪律**,不是遥测的局部约定。它被写下来的直接原因是: 库对"谁建的、谁负责关"从来没有统一说法,于是同一个根因在三个地方长出三种形态——
| 形态 | 位置(修复前) | 性质 |
|---|---|---|
| --- | --- | --- |
| `GatewayClient.aclose()` 无条件关掉**注入的** telemetry,共享 recorder 被第一个关闭的 client 弄死(`embedding.py`/`ocr.py` 各有一份逐字复制) | `client.py:271-273` | 越权 |
| `RedisCache.aclose()` 无条件关掉**注入的** redis 客户端 | `redis_cache.py:43` | 越权 |
| `_build_limiter`/`_build_breaker` **自建**的 redis 客户端从来没人关(`aclose` 压根不持有 limiter/breaker 的引用) | `client.py:263-280` | 泄漏 |
@@ -341,7 +346,7 @@ flowchart TB
纪律把已有的那个正确先例推广为全库唯一说法,分两层落地:
| 层 | 所有权归属 | 落法 |
|---|---|---|
| --- | --- | --- |
| 组件**内部**自建的连接(limiter/breaker/cache 的 redis 客户端) | 组件自己 | 组件的 `aclose` 自查 `_owns_client`;调用方无条件调用即安全 |
| client **自建**的整个组件(transport / recorder / limiter / breaker / cache) | client | 工厂构造后置 `_owns_*` 私有属性,`aclose` 只关自建的;三处复制的 `getattr(..., "aclose")` 鸭子探测收敛为一个内部 helper(同时探测 `aclose`/`close`,SQLite recorder 只有同步 `close()`) |
@@ -362,7 +367,7 @@ flowchart TB
**兼容约束(硬)**: 以下字段为三项目现有消费面,只增不删不改名:
| 字段 | 类型 | 说明 |
|---|---|---|
| --- | --- | --- |
| `content` | str | 正式输出文本 |
| `thinking` | str | 思考流内容(reasoning_content / think 标签,按 provider 注册表提取) |
| `model` / `provider` | str | 溯源 |
@@ -377,7 +382,7 @@ flowchart TB
**可观测字段(2026-07-31,issue #3;下游 dissect 的调用审计需求)**:
| 字段 | 含义 | 生产者 |
|---|---|---|
| --- | --- | --- |
| `cached_prompt_tokens` | **供应商侧** prompt cache 命中的输入 token 数(OpenAI 兼容格式的 `usage.prompt_tokens_details.cached_tokens`)。`None` = 该源未上报;`0` = 上报了一次真实零命中——两者对下游处置不同(前者不可做缓存成本校正),故不可混同 | `openai_compat` 两条路径解析后经 `TransportResult` 上浮 |
| `model_reported` | API 响应体里的 `model` 字段;`None` = 未上报。与 `model`(`.env` 配置别名)可能分叉——供应商把别名指向新权重时,实验复现必须认这个串 | 流式取首个含 `model` 的 chunk(首次写入即固定),非流式取 body 顶层 |
@@ -386,11 +391,13 @@ flowchart TB
**推理观测三态 `thinking_observation`(2026-08-25,issue #16/#17)**: 类型 `ThinkingObservation`(`StrEnum`),缺省 `UNKNOWN`。回答的问题是「这次调用到底推理没推理」,由多信号裁定:
| 值 | 含义 | 判据(按证据硬度排序) |
|---|---|---|
| --- | --- | --- |
| `observed` | 确证本次推理发生 | 推理正文 `thinking.strip()` 非空(**事实本身**),或 `reasoning_tokens > 0`(上游对事实的转述) |
| `absent` | 上游明确上报本次未推理 | `reasoning_tokens == 0`(正面证据) |
| `unknown` | 本次无任何信号,判不出来 | 两个信号双缺 |
**测试证据边界(1.3.4**:运行时 UNKNOWN 不告警不等于关闭测试成功。关闭须完整合格轮次全 ABSENT;不可关闭命题在完整合格轮次有 OBSERVED 可支持本条件下未关闭,全 ABSENT 证伪,无 OBSERVED 但 UNKNOWN 仅未覆盖。开启保留完整计划分母与多数 OBSERVED,不丢失败轮。身份缺失只有独立原始 JSON 证据才可归上游;公共身份丢失且无取证 FAIL,成功 SSE 不新增捕获器。默认 FAIL,仅完整请求/唯一尝试/完整无重复键 JSON404 精确 error.type=model_not_found 可自动 UNCOVERED;一般400、429、5xx、解析与治理异常不整类 skip,预期拒绝另按预声明机器字段断言。逐轮安全报告在 tests/outputs,写失败 FAIL,不增加生产数据面。
三态**不可折叠为布尔**: `unknown`(判不出)与 `absent`(确证没有)语义不同,把前者读作后者正是 `reasoning_tokens=None` 制造的那个歧义——MiniMax-M3 非流式开启推理时,推理内容已计费却不回传正文(2026-08-25 实测 completion 53 vs 关闭档 3),该档只能判 `unknown`,宣称「没推理」即撒谎。缺省取 `UNKNOWN` 使任何不填该字段的路径(非 OpenAI 兼容 transport、失败尝试、终态失败行)天然诚实——**默认值本身不撒谎**,这是 P5 在字段设计上的落法。
判据取 `thinking.strip()` 而非 `bool(thinking)`: transport 收集 `reasoning_content` 时只判 truthy,上游返回纯空白串会被计成「观测到推理」(网关响应是外部输入,校验后使用)。裁定纯函数 `observe_thinking` 定义在 `thinking.py`,由 `openai_compat` 的流式与非流式**两条**组装路径各调一次(只填一条即分叉);`CacheMW._rehydrate` 回放时显式转回枚举实例(JSON 复活的是裸 `str`),域外取值降级为 `unknown` 并单独告警、内容照常复活——纯可观测性字段不该有能力作废内容完好的缓存(多项目共用同一 Redis 时,先升级者写入的新态会让未升级者每次判未命中、覆写回旧值,两版互打缓存);「整条作废」只留给真正破坏内容完整性的失败。该字段**不进缓存 key**——它是结果不是请求。
@@ -404,7 +411,7 @@ flowchart TB
**`usage_source` 三态值域(2026-07-30,est_tokens 解耦设计;此前为 measured/estimated 两态)**:
| 值 | 含义 | 生产者 | cost |
|---|---|---|---|
| --- | --- | --- | --- |
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
@@ -440,7 +447,7 @@ flowchart TB
### 6.1 统一四分类 + 熔断信号(融合 CHSAnalyzer 三分类与 GovDoc 二分类)
| 错误类 | 触发 | 重试 | 换源 | 熔断计数 |
|---|---|---|---|---|
| --- | --- | --- | --- | --- |
| `TransientError` | 超时/5xx/429/网络抖动/SSE 异常(畸形帧、断流无 [DONE])/看门狗超时 | ✅ 退避后 | ✅ | ✅ |
| `SourceDeadError` | 401/403/欠费/insufficient_quota(429 body 细分) | ❌ | ✅ 立即 | ✅ force_open |
| `RequestRejectedError` | 400/请求格式错/坏输入(如不支持的图像格式) | ❌ | ❌ | ❌ |
@@ -458,7 +465,7 @@ flowchart TB
### 6.2 翻译规则(transport 层职责)
| 输入 | 翻译为 |
|---|---|
| --- | --- |
| httpx Timeout/Transport 错误、`StreamLivenessTimeout`、SSE 异常 | `TransientError` |
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
@@ -524,6 +531,8 @@ flowchart TB
### 7.5 响应缓存
**1.3.4 显式迁移前置(D3**:key 与源指纹不新增包版本、语义 revision、fallback、能力表或 wire 版本。AUTOrawMiniMax 语义变更及同版本 fallback/能力表/自定义 wire 改变时,受影响调用集合必须在首次读写前切到从未承载旧语义的 namespace 或 salt。保留租户前缀与 epoch;覆盖工厂默认、全量注入和 per-call(只改默认对覆盖路径无效)。同一共享缓存身份只要一源受影响,整个调用集合须隔离或由下游显式拆分;不强制未受影响 chat 冷启动。新旧版本不共享新身份,回滚旧身份会重见旧值。**未迁移仍可能回放旧响应、绕过新拒绝**,库不会自动检查新可满足性;操作说明不能当自动防护。
**key 公式**: `sha256(canonical_json({model, messages_digest, namespace, salt, sampling, reasoning_effort}))`,前缀 `pgw:cache:`
- `messages_digest`: 文本部分原文参与;多模态 content part(base64 图像等)先各自 sha256 摘要再参与——修正 Video-Tree 把整段 base64 进 hash 的开销问题,且 key 稳定性不变。
@@ -561,7 +570,16 @@ flowchart TB
**`tenant_id`/`meta` 两列(2026-08-17,issue #11,端口 22 → 24)**: 见 §5.2 的调用方维度追加。两列都是 `TEXT NOT NULL DEFAULT ''`(`meta` 在 PG 是 `JSONB DEFAULT '{}'`),**缺省落哨兵而非 NULL**——PG 的 RLS `USING` 表达式对返回 false **或 NULL** 的行一律隐藏且不报错,故 NULL 的 `tenant_id` 不是"未归属",是对所有人永久不可见的黑洞;哨兵空串可被 `COUNT(*) WHERE tenant_id = ''` 一条 SQL 审计出历史欠账。PG 11+ 加带非易失默认值的列不重写全表,SQLite 加列是元数据操作且硬性要求 `NOT NULL` 列有非 NULL 常量默认值——三条约束在这个写法上同时满足。补列走既有 `_BACKFILL` 路径,失败仍只逐行降级、不判死。
**`reasoning_effort`(2026-09-05,issue #20,端口 25 → 26)**: 记本次调用**生效的推理档位**,`TEXT` 可空——`NULL`(不表态,或档位取值不在本版词汇内而降级)与 `'none'`(明确要求不推理)是两回事,折叠成任一档等于替上游声称一件它没说过的事。加这一列的理由是分组能力: 此前 25 列里没有任何一列能回答「这一行跑在哪档」,「不同档位是不是真有用」的压测在数据侧无从下手。**三个 emit 入口的口径必须各自定死**(与 `sampling` 列同一先例): `emit_attempt` 成功行读 `response.applied_effort`(即 `nearest` 映射后**真正发出去**的那一档)且**绝不重算**——重算 `effective_effort` 必然算成请求档,于是整行被挂在一个从未发出过的分组下,而这两个值在没开映射的源上恒等,该错误在本地跑不出来;失败尝试没有响应,退回请求档(`effective_effort` 三层优先级,不是裸读字段——`enable_thinking` 也是一次表态)。故**开了 `nearest` 的源上,成功行与失败行不是同一把尺子**,`GROUP BY reasoning_effort` 须带 `error IS NULL``emit_cache_hit` / `emit_terminal_failure` 手上没有选中源,只记请求档。embedding / OCR 路径由 `reasoning_applies=False` 显式声明「本路径无推理语义」,该列恒 NULL——这个布尔**不设默认值也不由 emitter 推断**: 三条路径共用同一个 `SourceConfig` 类型,一个误配了 `ENABLE_THINKING` 的 embedding 源会让回落算出 `auto`,给一次从来不带推理参数的调用挂上一个从未发出过的档
**`reasoning_effort`(1.3.4 四种行来源澄清,不改 schema)**:TEXT 可空NULL 与明确要求不推理的 `'none'` 不同。只有真实成功尝试读 transport 的 response.applied_effortnearest 后,不重算);AUTO 是编码选择,不是服务端内部强度,未知 AUTO 不构成能力验证,raw-only 为 NULL
| 行类型 | 来源/限制 |
| --- | --- |
| 真实成功尝试 | response.applied_effort;未知/raw-only 限制如上 |
| 失败尝试 | effective_effort(请求>源>糖)的意图,可能零 HTTP,不能称实际发出 |
| cache_hit | 本次请求级 reasoning_effort,不读历史 applied、不推源级;观测回放历史,不是本次实测 |
| scope 终态失败 | 本次请求级 reasoning_effort,可能尚未选源,不补逐次根因 |
实际档分析须 `cache_hit=false AND error IS NULL`。embeddingOCR textlayout 的成功与失败尝试由 reasoning_applies=False 保证 NULL,真实 client→emitter→临时 SQLite 与 chat 阳性共同守卫,不能用空行集合证明。生产 emitter 单一出口、端口字段数和 DDL 不变。
**`thinking_observation` 列(2026-08-25,issue #16/#17,端口 24 → 25)**: 落 `LLMResponse.thinking_observation` 的裸取值(`observed` / `absent` / `unknown`,两端均为可空 `TEXT`),语义见 §5.1。它补的是 `reasoning_tokens` 补不上的那一格: 后者为 NULL 时「没推理」与「没上报」不可区分,而供应商停报 `completion_tokens_details` 是会真实发生的事(MiniMax 这一路 2026-08-25 实测已停报,qwen 与 deepseek 在同一网关同一 key 上照常返回),届时按 `reasoning_tokens IS NULL OR = 0` 统计「未推理」会把推理了的调用一并算进去。有了本列,口径改为按本列取值分组,`unknown` 独立成一档而不再被并进「未推理」。
@@ -580,7 +598,7 @@ flowchart TB
**遥测池的资源语义(2026-08-24,issue #15)**: `PostgresRecorder` 此前 `create_pool(dsn, timeout=10)` 继承 asyncpg 默认的 `min_size=max_size=10`,而 asyncpg 的 `min_size` 语义是"**预连接**"不是"下限"(`pool.py:457``if self._minsize:`)——建池是一次全有全无的重资源动作: 拿不到 10 条就抛异常。这让遥测成为全库唯一预占资源的组件(httpx transport 与三个 redis 后端全是按需建连),也就成了共享实例余量紧张时**必然第一个倒下**的一环,而它承担的恰恰是最不该悄悄失败的职责。改为 `create_pool(dsn, min_size=0, max_size=<PGW_TELEMETRY_PG_POOL_MAX>, timeout=<预算>, command_timeout=<预算>)`,三条随之确立:
| 语义 | 内容 |
|---|---|
| --- | --- |
| 建池零成本 | `min_size=0``_initialize` 只造 holder 对象、**一条连接都不连**(实测 0.000s,指向不可达端口也照样成功)。稳态占用由"每 client 常驻 10 条"变为"实际并发,闲时 0";真实 PG 实测: 建 recorder 后 0 → 一次写入后 1 → 20 行并发后 4(= `pool_max`)→ `aclose` 后 0 |
| 只暴露 `max_size` | `min_size` **有意不给配置项**: 它唯一的作用是把上面那个脆点装回来,换取的只是首次写入省下 ≈390ms 建连。库没有理由提供一个只会伤人的旋钮(P1+P5)。`max_size` 则必须暴露——继承第三方默认值等于库对自己的资源占用不表态(P4) |
| 写入有硬预算 | 整次写入(准备 + acquire + execute)由 `asyncio.timeout(PGW_TELEMETRY_PG_WRITE_TIMEOUT_S)` 包一层,超时按行级丢弃。把"遥测绝不拖垮业务"从"靠各处 timeout 参数凑"升级为一条可陈述、可测试的保证 |
@@ -593,7 +611,7 @@ flowchart TB
2. **行级 vs 环境级看"失败与这一行的数据有没有关系"**: 只与本行数据有关(换一行可能成功)= 行级;与数据无关、每一行都会同样失败 = 环境级。
| 档 | 覆盖(按 SQLSTATE 分类而非异常类白名单——SQLSTATE 是 PG 标准,不随 asyncpg 版本漂移) | 处置 |
|---|---|---|
| --- | --- | --- |
| 配置级致命 | `ClientConfigurationError`(DSN 不可解析);`create_pool` 抛的 `ValueError`/`TypeError` | 永久 no-op + 一条 **error**(人配错了,不是 warning) |
| 环境级不可用 | SQLSTATE 类 `08`/`53`(含 53300 too many connections)/`57`/`28`/`3D`,具体码 `42501`(无权限)/`42P01`(表不存在);`OSError`/`ConnectionError`/其余 `InterfaceError`;`TimeoutError`(**仅在准备期路径可达**: 它是 `OSError` 子类,但写入期的超时先被 `record_llm_call``except TimeoutError` 接住并按行级丢弃,压根到不了本分类函数——见下方第 ④ 点);表确定不存在且建不出来 | **冷却降级**(内部常量 60s,不给配置项——无部署差异理由),到期放行**一次**重新准备,成功即恢复 |
| 行级拒绝 | 其余 `PostgresError`(`22`/`23` 等数据与约束类),以及**具名例外 `42703`(缺列)** | 逐条 warning 丢弃,不降级,接入节流复述 |
@@ -613,7 +631,7 @@ flowchart TB
### 7.9 结构化输出阶梯(D14)
| 级 | 内容 | 成本 |
|---|---|---|
| --- | --- | --- |
| ① 预防 | provider 注册表声明支持时,用 response_format / function calling 直接约束(`NativeSchemaStrategy`) | 无额外 |
| ② 修复 | 围栏剥离 → json_repair → provider 变体归一化(DeepSeek 参数平铺等)(`JsonRepairStrategy`) | 零网络 |
| ③ 校验 | 调用方传 pydantic 模型时库内做**形态**校验;语义校验留业务层 | 零网络 |
@@ -625,7 +643,7 @@ flowchart TB
### 7.10 OCR 端口族
| 端口 | 对应 MonkeyOCR 端点 | 协议 | 输出 |
|---|---|---|---|
| --- | --- | --- | --- |
| `OcrTextPort.recognize_text(image: bytes)` | `POST /ocr/text` | multipart 上传 → JSON `{content}` | `OcrTextResult`(多行纯文本) |
| `OcrLayoutPort.parse_layout(image: bytes)` | `POST /parse` | multipart → JSON(download_url) → GET ZIP → 解包 `*_middle.json` | `OcrLayoutResult`(elements 含 bbox + page_size) |
@@ -680,7 +698,7 @@ src/polygateway/
## 10. 非功能性需求(强制覆盖,继承 Video-Tree CLAUDE.md §4.2.1 条款)
| 维度 | 回答 |
|---|---|
| --- | --- |
| 持久化策略 | 遥测逐调用追加写(WAL);缓存写在响应成功后;崩溃最多丢当次调用的遥测记录 |
| 幂等性 | 遥测 `INSERT OR IGNORE`(call_id 主键);缓存写幂等(同 key 同值);限流 permit 带 TTL 租约,进程死亡后自动过期回收 |
| 断点续跑 | 库无长任务状态,天然无断点问题;响应缓存本身即业务侧重跑的加速器 |
@@ -699,7 +717,7 @@ src/polygateway/
### 11.1 GovDoc-SaaS(难度低,首个迁移)
| 项目侧 | 处置 |
|---|---|
| --- | --- |
| `docagent-core/llm/client.py``breaker.py``redis_cache.py``streaming.py``telemetry_sqlite.py` | 删除,由库继任 |
| `protocols.py``LLMProvider.chat(messages, *, session_id, parent_call_id)` 签名 | 库保持兼容(或一行 shim) |
| 倒推的库需求 | `from_env` 工厂(GovDoc 装配层本就缺失,库直接补上)、Postgres 遥测、缓存 key namespace 含租户 |
@@ -709,7 +727,7 @@ src/polygateway/
> 下表保留作历史记录与能力倒推依据(VT 倒推的库能力——多逻辑角色、cache salt、多模态摘要进 hash、OcrTextPort 等——均已交付且被其他消费方使用,不回收);v1.0 验收标准相应改为 §11.1 + §11.3 两项目。
| 项目侧 | 处置 |
|---|---|
| --- | --- |
| `adapters/llm.py``breaker.py``streaming.py``redis_cache.py``telemetry.py` | 删除,由库继任 |
| `main.py:_build_adapters()` | 改为按角色调用 `from_env`(SEARCH/JUDGE/VL/EVOLVE;共享实例显式声明) |
| `adapters/vlm.py`(base64 编码与注入)、抽帧、OCR 文本拼接与注入前缀 | 留在项目(业务侧),组装好 content 数组后调库 |
@@ -719,7 +737,7 @@ src/polygateway/
### 11.3 CHSAnalyzer(难度高,能力对标项)
| 项目侧 | 处置 |
|---|---|
| --- | --- |
| `app/providers/governance.py``app/coordination/limiter.py` + `scripts.py``provider_gate.py` | 删除,由库继任(库必须先达到能力对等,这是 M2 的验收内容) |
| `app/providers/invokers.py` 的 VLM invoker / `MonkeyOcrParseInvoker` | 由库 transport / `OcrLayoutPort` 继任 |
| `app/providers/table_locator.py`(几何映射)、`marker_imaging.py`(拼图/增强)、`position_scheduler.py`(公平调度) | 留在项目(业务侧) |
@@ -731,7 +749,7 @@ src/polygateway/
## 12. 里程碑
| 阶段 | 交付 | 可接入 |
|---|---|---|
| --- | --- | --- |
| M1 核心 | types/errors/ports、OpenAICompat transport(含非流式)、看门狗、RetryMW、**多源多账号+选源+源冷却备忘(2026-07-20 人类拍板,自 M2 提前——理由: RetryMW 循环与端口签名 M1 冻结,多源行为一并钉死避免 M2 返工)**、内存版限流/熔断、缓存(Redis+内存)、SQLite 遥测、结构化输出双策略、provider 注册表、from_env | GovDoc、Video-Tree |
| M2 分布式 | Redis 限流(六道闸+契约测试)/熔断后端、多源 × Redis 后端联合验证(全局限额跨 worker)、背压 stall、Postgres 遥测、pricing 成本 | CHSAnalyzer(治理部分) |
| M3 OCR | OcrText/OcrLayout 端口 + MonkeyOCR transport,走同一治理栈 | CHSAnalyzer(全量)、Video-Tree(OCR 升级) |
@@ -744,7 +762,7 @@ src/polygateway/
## 13. 开放问题(待人类拍板)
| # | 问题 | 建议 |
|---|---|---|
| --- | --- | --- |
| Q1 | 打包与分发 | **已拍板(2026-07-22 用户)**: Gitea PyPI 包注册(gitea.iomgaa.online,内置 registry;twine 上传、项目侧 `pip install --index-url .../api/packages/iomgaa/pypi/simple/`);git+https 留作退路 |
| Q2 | Python 最低版本 | **3.12(已拍板,2026-08-24 人类确认)**: "我们现在的项目至少都是 3.12 的了,3.11 都有点老"——原记载的依据"覆盖三项目: 3.11×2 + 3.13×1"**已过时**,三个迁移目标均已 ≥3.12,故抬版本不再让任何迁移目标装不上。落点: `requires-python = ">=3.12"`、ruff `target-version = "py312"`、CLAUDE.md 与 README 同步。收益是 `asyncio.timeout` 可直接用于遥测写入预算(3.11.0/3.11.1 的 `uncancel` 缺陷不再在支持范围内,省掉一整块 `wait_for` 绕行补丁)与 PEP 695 泛型语法;代价是仍在 3.11 的部署 `pip install` 会被 pip 直接拒绝(issue #15,见 CHANGELOG"请先读这一条(一)") |
| Q3 | Embedding 客户端是否纳入。**勘误(2026-07-20,VT 迁移文档 R11)**: 初版称"各有一套独立重试实现"不实——GovDoc 的 `OpenAICompatEmbedding` 有自研退避,但 Video-Tree 的 `RemoteEmbeddingProvider` 是**同步 SDK 裸调、无任何重试**;纳入库还需异步化其端口 | **已拍板(2026-07-20 人类)**: 纳入 M2(消灭无治理的裸调 + 统一重试),含端口异步化;Embedding 端口为公共 API,随 M2 设计文档过人类门 |
@@ -1,5 +1,7 @@
# 推理档位一等化设计(issue #20 及其一般形式)
> **替代指针(2026-09-09**:§36812 的 AUTO 无条件放行、MiniMax 内置 medium、受管 raw 覆盖与缓存迁移/遥测总括语义,以[1.3.4 已批准设计](2026-09-09-134-thinking-contracts-design.md) §4–8 为准。历史调研与实验事实保留,不倒改为新语义已验证。
- **日期**: 2026-09-04
- **状态**: **2026-09-04 人类已批准**(经 Claude 自审 → Codex 独立审 → 人类审批门)
- **触发**: issue #20 —— 智谱无 profile,下游只能手写 `extra_body`,本库为推理准备的三道机制被**静默**绕过
@@ -16,7 +18,7 @@ issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解
2026-09-04 调研,四份独立注册表(cherry-studio 客户端注册表、OpenRouter `/models``reasoning` 字段、LiteLLM 模型元数据、我们自己的网关 new-api `relaykit/relayconvert/reasoning/`)与六家官方文档,三条结论直接推翻 issue #20 的建议:
| # | 结论 | 证据 |
|---|---|---|
| --- | --- | --- |
| 1 | **GLM-5.3 官方强制推理**,`thinking.type` 只接受 `enabled`;官方档位 `low/high/max`,`none` **不是**它的档位 | 智谱官方文档;cherry `toggle:false`;OpenRouter `mandatory:true` 三源一致 |
| 2 | **`medium` 只在 GPT-5.x / Claude 5 / Gemini 3 三家存在** | 见 §8 档位表 |
| 3 | 不可关闭不是孤例: GLM-5.3 系、Gemini 3 Pro / 3.1 Pro 为 mandatory;MiniMax M2.x **接受 `disabled` 但不生效** | 官方文档;与本库 2026-08-02 实测一致 |
@@ -32,7 +34,7 @@ issue #20 的字面诉求是补一条 `zhipu` profile。补上它**不能**解
替换 `thinking.py` 的请求侧决策,响应侧与对账基本保留。逐条声明:
| # | 现有行为 | 处置 |
|---|---|---|
| --- | --- | --- |
| 1 | `enable_thinking` 三态: None 不注入 / True 注入 on / False 注入 off | **保留**语义,降为 `reasoning_effort` 的语法糖(§4.2) |
| 2 | `ProviderProfile.thinking_on/off` 两个固定片段,`None`=形态未知 | **替换**为 `ThinkingWire`(§3.3);`None`=未知的语义**保留**。**判据须按请求档位取相关字段**(旧版 `slot = thinking_on if enable_thinking else thinking_off` 即如此)——初稿 §4.1 Phase 2 写成「只看 `on_base`」是错的: 那会让「关闭形态已知、开启形态未知」的自定义 provider 在请求 `none` 时被误拒,且指路指向它已经做过的 `register_provider`,比不指更糟(2026-09-05 独立验证查出) |
| 3 | `ThinkingCapability.can_disable: bool` | **替换**为 `supported_efforts`;`can_disable` 成为 `'none' in supported_efforts` 的派生(§3.2) |
@@ -82,7 +84,7 @@ class ThinkingCapability:
三个派生量,不单独存字段(存了就会漂移):
| 派生 | 定义 | 用途 |
|---|---|---|
| --- | --- | --- |
| `can_disable` | `Effort.NONE in supported_efforts` | 兼容旧语义 |
| `cheapest_effort` | 除 `none` 外的第一档 | 不可关闭时的可执行替代(§4.1 Phase 5) |
| 是否档位型 | 除 `none`/`auto` 外仍有 ≥1 档 | 决定告警文案(纯开关型不该说「可选档位」) |
@@ -104,7 +106,7 @@ class ThinkingWire:
四个形态样例(经 new-api 中转的口径):
| provider | off | on_base | effort_key |
|---|---|---|---|
| --- | --- | --- | --- |
| zhipu | `{"thinking":{"type":"disabled"}}` | `{"thinking":{"type":"enabled"}}` | `reasoning_effort` |
| qwen | `{"enable_thinking": False}` | `{"enable_thinking": True}` | `None`(无档位,只有 toggle) |
| openai / anthropic / google | `{"reasoning_effort":"none"}` | `{}` | `reasoning_effort` |
@@ -126,7 +128,7 @@ cherry 有两层我们**明确不做**:
判定顺序即语义。前三关是既有的,判据从 bool 换成档位;**Phase 4「可执行替代」是新增的**,Phase 5 是既有第 4 关的档位化推广。
| Phase | 条件 | 结果 |
|---|---|---|
| --- | --- | --- |
| 1 | 生效档位为 `None`(调用方不表态) | 返回 `{}`,不注入 |
| 2 | **该请求档所需的**形态未知(请求 `none` 看 `wire.off`,其余档看 `wire.on_base`;`none` 方向须 `off` 与 `on_base` **皆为 `None`** 才算「整体形态未知」——单 `off is None` 是「该 provider 关不掉」,归 `_inject` 说清缺的是哪半边,2026-09-05 实现时补正) | `ThinkingUnsupportedError`,指路 `register_provider`/`extra_body` |
| 3 | 能力未登记 | warning 后按 wire 尽力注入,**不校验档位** |
@@ -154,14 +156,14 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
`enable_thinking` **保留不删**(它已被三项目消费,迁移兼容约束见 ARCH §5.1),降级为语法糖:
| 旧写法 | 等价于 |
|---|---|
| --- | --- |
| `enable_thinking=False` | `reasoning_effort=Effort.NONE` |
| `enable_thinking=True` | `reasoning_effort=Effort.AUTO`(注入 `on_base`,不附档位),不依赖能力表 |
**`True` 的等价性分两种**(2026-09-04 实现时发现,更正初稿「与旧行为逐字节等价」的说法):
| provider 类型 | 旧 `thinking_on` | 新 `AUTO` 注入 | 是否等价 |
|---|---|---|---|
| --- | --- | --- | --- |
| `on_base` 完整表达「开」(qwen/deepseek/zhipu/moonshot) | `{"enable_thinking": True}` 等 | 同左 | **逐字节等价** |
| 靠档位表达「开」(openai/anthropic/google) | `{"reasoning_effort": "medium"}` | `{}`(不注入) | **行为变更** |
| 同上但**默认不推理**(minimax) | `{"reasoning_effort": "medium"}` | 同左(2026-09-05 回退) | **逐字节等价** |
@@ -189,7 +191,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
已知三条入口,缺一即漏:
| # | 入口 | 归一点 |
|---|---|---|
| --- | --- | --- |
| 1 | `.env` / `from_env()` / `from_settings()` | `config._cast` 委托 `coerce_effort` |
| 2 | 构造函数全量注入 `SourceConfig(...)``chat(reasoning_effort=...)` | `SourceConfig.__post_init__` / `chat()` 入口 |
| 3 | **缓存命中回放** `LLMResponse.applied_effort` | `CacheMW._coerce_applied_effort` |
@@ -211,7 +213,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
真正需要处置的是两处,均因请求级档位而新增:
| 层 | 处置 | 理由 |
|---|---|---|
| --- | --- | --- |
| 源级 `reasoning_effort` | 并入 `_fingerprint_mark`,与 `enable_thinking` 同规则(**仅表态时**追加) | 与既有一致;全源不表态时指纹字面量不变,存量缓存不冷启动 |
| 请求级 `reasoning_effort` | 进 `build_cache_key`,仅非 `None` 时参与 | `model_fingerprint` 是**装配期**算的集合级指纹,覆盖不到逐调用变化的值。不进 key 则同 messages 跑 low 与 max 会互相命中——issue #4「5 个 seed 全命中同一响应」的逐字翻版 |
@@ -240,7 +242,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
## 7. 备选方案对比
| | 方案 | 改动面 | 权衡 |
|---|---|---|---|
| --- | --- | --- | --- |
| **A** | **最小补丁**: 只补 `zhipu` profile,`thinking_on` 填一个档,维持 bool | `providers.py` 一条 + `thinking.py` 两条 | issue #20 字面满足。但 §1 三条结论全部无解: GLM-5.3 填什么档都是错(`medium` 是空档、`none` 是未定义值);`can_disable` 只能在「让下游跑不起来」与「登记一个官方否认的能力」之间二选一。**治标** |
| **B** | **能力表档位化 + 源级/请求级双入口**(本设计) | `types.py``Effort`、两个公共类型重构、`resolve_thinking` 加两关、缓存 key、遥测加列、`.env` 加键 | 表达力对齐现实;下游不必再走 `extra_body`;压测可按档位分组。代价是公共类型破坏性变更 + 一次缓存冷启动 |
| **C** | **照抄 cherry 的完整 wire DSL**: closed operation 集合、`effortMap``budgetWire`、endpoint-keyed contract | B 的全部 + 一套 wire 解释器 + per-model wire 覆盖表 | 能表达 budget 型(qwen `thinking_budget`)与原生协议代际差异。但本库只有一个 OpenAI 兼容 transport(§3.4),这层复杂度当前无消费者——**违 P1 YAGNI** |
@@ -254,7 +256,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
**落库规则**(Codex 审查补): 本表是**调研素材**,不是可直接转代码的表。只有 `supported_efforts` 能写成合法 `Effort` 元组的条目才进 `DEFAULT_CAPABILITIES`。分三档处置:
| 情形 | 处置 |
|---|---|
| --- | --- |
| 档位清单与「能否关闭」皆无冲突 | 直接登记 |
| **档位清单三源一致,仅「能否关闭」存疑**(如 kimi-k3: 官方档位无 `none`,OpenRouter 却标 `mandatory:false`) | 按**保守方向**登记(不含 `none`),evidence 注明存疑点。理由: 不登记会退回 Phase 3 的「尽力注入」,下游配 `none` 时静默失效——**那正是 issue #20 的病**;保守登记则报错并给出最低档,明确且有出路 |
| 档位清单本身无该型号直接证据(`未查到`,或仅由**同系**推定如 `推定同上`) | 不登记,走 Phase 3 |
@@ -262,7 +264,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
第二档与第三档的分界是**有没有该型号自己的档位证据**,不是「关不关得掉存不存疑」: `kimi-k3` 进第二档,因为月之暗面官方文档直接写明它的三档是 `low/high/max`,只有「能否关」两源分歧;而 `gemini-3-flash``claude-haiku-5` 的档位清单是从同系型号(3.1-pro / opus-5)推来的,**没有该型号自己的文档**,故进第三档。Phase 3 并非静默——它会 warning 指路「实测后用 `register_capability` 登记」,且未登记模型的运行期对账文案也专门写了这一句;登记一个纯推定值反而会让下游以为库确认过。T10 实测时这三个型号优先补。`default` 列只是调研记录,按 §3.2 并入 `evidence` 文本,不进字段。
| 模型 | supported_efforts(推定) | 厂商默认(入 evidence) | 关? |
|---|---|---|---|
| --- | --- | --- | --- |
| glm-5.3, glm-5.3-flash | low, high, max | max | ✗ |
| glm-5.2 | none, high, max | max | ✓ |
| kimi-k3 | low, high, max | max | ?(OR 标可关,但官方档位无 `none`——**待实测**) |
@@ -283,7 +285,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
## 9. 非功能维度
| 维度 | 回答 |
|---|---|
| --- | --- |
| **并发** | 两张表仍是 `MappingProxyType` + 纯函数查找,无共享可变状态。transport 的 `_warned_models`/`_warned_mismatches` 是实例级 `set`,读写之间无 `await`,单事件循环内原子。节流键加入生效档位后基数上升(源×模型×档位),仍为有界小集合 |
| **取消** | 档位解析全部是同步纯函数,不含 `await`,不改变 `CancelledError` 的穿透路径。既有保证不受影响 |
| **降级方向** | 推理档位属**请求正确性**而非资源闸,故一律**报错不放行**(Phase 2/4/5(下同)),与「限流/熔断后端不可用须报错」同向。能力**未登记**是唯一例外——warning 后尽力注入,理由是新模型上线不该被库挡住(既有决策,保留) |
@@ -299,7 +301,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
**测试策略**(先失败后通过,每条对应一个行为):
| 层 | 用例 |
|---|---|
| --- | --- |
| unit | 五道关卡各自的触发与不触发;`enable_thinking` 语法糖的三种等价;矛盾配置构造期报错;`nearest` 映射的取档方向;派生量(`can_disable`/`cheapest_effort`)与 `supported_efforts` 一致 |
| unit | Phase 4 文案**含** `cheapest_effort` 与 env 键名(这是交付物,要断言内容而非只断言抛错) |
| unit | 缓存 key: 同 messages 不同档位 → key 不同;不表态时 key 与存量形状一致(回归) |
@@ -319,7 +321,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
**破坏性变更五处**(初稿只列了第 1 条,其余四条为 2026-09-05 独立验证实测补全——照初稿写 CHANGELOG 会让下游撞上没有预告的 `TypeError`):
| # | 位置 | 变更 | 谁会断 |
|---|---|---|---|
| --- | --- | --- | --- |
| 1 | `ThinkingCapability` | 构造签名 `can_disable``supported_efforts` | 自建能力表的调用方 |
| 2 | `ports.Transport.complete()` | 新增**无默认值**参数 `reasoning_effort` | 任何自建 transport 实现 |
| 3 | `ports.TelemetryRecorder.record_llm_call()` | 新增无默认值参数 `reasoning_effort` | 任何自建 recorder 实现 |
@@ -333,7 +335,7 @@ request.reasoning_effort > source.reasoning_effort > source.enable_thinking(语
真实的库内调用点(可复验):
| 位置 | 用法 | 处置 |
|---|---|---|
| --- | --- | --- |
| `thinking.py:169` | 读 `capability.can_disable` | 改读派生属性,行为不变 |
| `tests/unit/test_thinking.py:128` | `ThinkingCapability(True, "实测")` **位置参数构造** | 随实现同步改——这是不可兼容的部分 |
| `tests/e2e/test_thinking_live.py:455` | 读 `can_disable` | 派生属性覆盖 |
@@ -7,7 +7,7 @@ date: 2026-09-09
# 1.3.4 T0T4T7 实施验证
> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。T5T6T8T9 尚未执行。所有原始输出在 `tests/outputs/134/`,不提交。
> 状态:本轮限定的生产契约与确定性测试已实现;不是整个版本验收。原T0T4/T7记录保留;T5/T6T8文档续作见文末,独立验证/live/发布未执行。所有原始输出在 `tests/outputs/134/`,不提交。
## 基线与修改边界
@@ -59,3 +59,56 @@ date: 2026-09-09
| 独立 verifier/集成/slow/下游迁移 | 由父会话后续执行,本轮不声明通过;设计所列真实缺测和下游缺失仍有效 |
日志方案沿已批设计:未知能力沿既有 loguru warning,实际调用仍经 TelemetryEmitter 单点出口,四类行来源和 NULL 契约用既有 schema 验证,不新增运行时数据面。
## T5T6 与 T8 文档续作(起点 16fa0ca
本续作禁止发布/slow/付费调用,未改任何生产文件。已读完整批准设计、计划及 TDDstructured-loggingcommit 技能。独立验证与全量集成/live 仍由父会话负责,本节不表示整个版本验收完成。
| 门/节点 | 本会话实际结果/原始日志(tests/outputs/134/ |
| --- | --- |
| 受影响基线 | `t56-baseline.log`client/config/openai_compat **338 passed** |
| T5 新模块首次 | `t5-first.log`:98 passed;首次无行为红不计TDD,红证据来自下述隔离变异 |
| 当前受影响 | `t56-current-diagnostics-proof.log`client/live_evidence/config **325 passed**;旧异步2/131通知已被当前结果取代 |
| 日常全单元 | `t56-accepted-unit.log/.exit`**1357 passedexit 0**(其后仅取证关联/报告字段收尾,受影响325再通过,最终门见提交日志) |
| 静态 | `t56-accepted-check.log`make check 通过;compileall 测试支持模块通过;生产43模块123依赖、1契约通过 |
| live 采集 | `t56-accepted-collect.log/.exit`e2e **90 tests collectedexit 0**;仅采集,不是真实通过 |
### 隔离语义红→还原绿
仓库外临时副本只复制 src/tests/必要工程文件,不复制 `.env`、reference、`.pi`PYTHONPATH及 cwd 指向副本,import来源见 `t56-mutation-import.log``t56-consumer-mutation-import.log`。每个变异均目标 AssertionError、退出1,恢复散列一致后节点退出0,不靠 import error 当红。
| 变异 | 目标节点(tests/unit/test_live_evidence.py | 红/还原 |
| --- | --- | --- |
| 整类 skip | test_whole_exception_class_skip_is_forbidden | 10 |
| 正文子串 model_not_found | test_incomplete_or_ambiguous_error_body_fails | 10 |
| UNKNOWN 安静→PASS | test_coverage_is_proposition_specific | 10 |
| 缺轮缩分母 | test_missing_round_never_reduces_denominator | 10 |
| 身份无证据→skip | test_identity_requires_independent_raw_evidence | 10 |
| 丢第一轮 | test_round_consumer_keeps_first_success_when_second_assertion_fails | 10 |
| 部分档未覆盖→模型PASS | test_partial_uncovered_and_failed_rounds_never_become_model_pass | 10 |
| 不交付独立raw快照 | test_raw_identity_snapshot_reaches_round_consumer | 10 |
汇总与逐例日志:`t56-mutation-summary.json``t56-consumer-mutation-summary.json``t56-mutation-*-{red,restored}.log`;脚本 `mutate-live.py``mutate-live-consumer.py`。主工作区从未放回假绿策略。
### 实现与矩阵边界
取证按 session/parent→attempt→HTTP;零HTTP和多HTTP分开,404严格唯一完整证据,成功非流式原始JSON独立解析并拒重复键。成功SSE不预读、不捕获。真实RetryMW两并发逻辑轮次各503→成功验证精确成功call_id;取消ContextVar复位、资源关闭与逐轮报告写失败显式失败均有离线节点。报告不保存任何原始正文/异常,白名单字段含校验布尔、状态、身份资格与固定安全原因;假凭据/提示词sentinel逐文件无泄漏。
四live文件均迁入窄通道,装配拒绝/平铺键/合成Protocol移入日常;外部Protocol缺包单列未覆盖。M3真实开启改medium,M2 AUTO复用既有T10档;L4迁为退出受管的raw-only高档,双来源本地拒绝由已实现单测守卫。L8不可关闭装配拒绝不计live,未知wire仍显式全None离线验证。UNKNOWN不靠completion长度或prompt锚点升格;L2b仅保留指定历史prompt锚点命题,不是关闭能力。
默认轮数/并发未增加,删除额外UNKNOWN长度锚点调用;T10保留既有一次重试设置,去除60s stall缩小值。静态矩阵:L1–L7合计93逻辑调用,L8可关闭19型号×5=95T10 NONE 26×5=130及条件长复核≤78,开启57档×5=285,默认基线15,其他chat6+embed1=7;总上界703,不含既有治理重试/结构化重问。没有执行这些调用。
### 调试与未验证项
| 项 | 实际处置 |
| --- | --- |
| 新RetryPolicy测试参数误写base_delay_s | 当前工具实报TypeError,查源码后改backoff_base_s/backoff_max_s239及后续242/325通过;该失败不计目标红 |
| make check初报SIM117/B017 | 合并测试上下文,按真实解析异常指定类型,不加ignore;最终静态门通过 |
| pi-lens解释器/StrEnum噪音 | 记录 `t56-diagnostics.txt`,conda内真实导入与ruff为门,不改任务外枚举;pytest wrapper generator的return report是协议必需,独立next/send/StopIteration.value测试通过 |
| T10型号→400机器字段基线不存在 | 父会话明确确认:不编造白名单,实际400默认FAIL并逐轮留证。纯负向契约精确类型/状态/type单独测试;具体live预期拒绝未验证、需人工基线 |
| 结构化反馈重问 | 请求摘要预期固定,发生反馈重问更改messages时保守FAIL,不从待测payload补齐预期;未改生产结构化行为,不声称该取证分支已取得live覆盖 |
| 发布/集成/live/下游 | 本任务未执行,M2空wire、M3非流式UNKNOWN、身份不足、三项目实际配置缺失仍保留为证据门 |
文档已同步README M1M9、CHANGELOG未发布段、env注释、ARCH D11/5.1/7.5/7.8、旧设计替代指针及既有schema/metric;无版本bump、无新生产字段/DDL。Wiki站已下线,不虚报线上页更新。
续作提交:`73008ad test: apply evidence-based live checks without hiding regressions`。最终提交前实际门:`t56-precommit-unit.log/.exit` **1357 passed/0**`t56-precommit-affected.log` **331 passed**(包含生产默认factory节点),`t56-precommit-check.log/.exit` **make check通过/0**`t56-precommit-collect.log` **90 collected**`t56-precommit-compile.log`通过;`git diff --check`通过,`git diff --quiet -- src`确认生产零差异。T8仅文档部分完成,不勾选完整验收门。
+7
View File
@@ -458,6 +458,13 @@
"relation": "tested_by",
"evidence": "T0T4/T71241单测与11隔离变异;未覆盖live/集成/发布",
"added": "2026-09-09T05:39:12.170598+00:00"
},
{
"source": "schema:llm-calls",
"target": "design:2026-09-09-134-thinking-contracts-design",
"relation": "implements",
"evidence": "复用既有26字段,四种行来源与无推理成败NULL;不增加生产数据面",
"added": "2026-09-09T06:32:06.527808+00:00"
}
]
}
+7 -1
View File
@@ -1,8 +1,9 @@
# Research Wiki 索引
> 自动生成,更新时间:2026-09-09 05:39 UTC
> 自动生成,更新时间:2026-09-09 06:32 UTC
## design (42)
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
- [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design`
@@ -47,6 +48,7 @@
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
## finding (15)
- [1.3.4 T0T4 与 T7 确定性验证](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
@@ -64,6 +66,7 @@
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
## plan (37)
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
@@ -103,11 +106,14 @@
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
## review (1)
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
## schema (1)
- [表结构: llm_calls(遥测 26 字段)](schemas/llm-calls.md) `schema:llm-calls`
## metric (2)
- [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success`
- [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage`
+2
View File
@@ -154,3 +154,5 @@
- [2026-09-09 04:48 UTC] 重建索引: 97 篇页面
- [2026-09-09 05:39 UTC] 新增边: plan:2026-09-09-134-thinking-contracts --tested_by--> finding:2026-09-09-134-thinking-contracts-validation
- [2026-09-09 05:39 UTC] 重建索引: 98 篇页面
- [2026-09-09 06:32 UTC] 新增边: schema:llm-calls --implements--> design:2026-09-09-134-thinking-contracts-design
- [2026-09-09 06:32 UTC] 重建索引: 98 篇页面
@@ -7,3 +7,13 @@ date: 2026-07-20
# 每次调用必录覆盖率(含缓存命中/失败/取消)
## 1.3.4 契约与基线
| 指标 | 确定性阈值/证据 | 实际 live 基线 |
| --- | --- | --- |
| 三个无推理入口成败行档位 NULL | embed、recognize_text、parse_layout 真实 client→emitter→临时 SQLite,非空失败/成功计数与 NULL 全部成立,契约要求100% | 待首次实际运行,不填伪百分比 |
| chat 阳性 | 糖失败 auto、显式意图失败、nearest 成功实际档均精确匹配,要求100%;防恒 NULL 假绿 | 待首次实际运行 |
| 四种行来源 | 真实成功=applied;失败=effectivecache_hitscope终态=本次请求级 | 排除缓存/失败后才可做实际档分析 |
| live 覆盖 | 逐轮 PASSFAILUNCOVERED、计划轮数与缺轮分别统计;必需单元不因 pytest exit 0 自动放行 | 未执行;UNKNOWN/缺轮/skip 不能记PASS |
复用 schema:llm-calls(无新字段/DDL),证据索引见 `findings/2026-09-09-134-thinking-contracts-validation.md`。独立错误取证只在 tests 内存,Markdown 只记录白名单安全摘要与布尔校验,不使用生产遥测旁路补失踪尝试。生产埋点仍是 TelemetryEmitter 单点出口。
@@ -7,7 +7,7 @@ date: 2026-09-09
# 1.3.4 推理契约与测试证据实施计划
> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0T4、T7 已实现并通过确定性验证;T5/T6/T8/T9 待执行**。
> 日期:2026-09-09。状态:**自审及 Codex 独立计划审查通过(复审 run ea38c3a7-12ef-4bf0-bb04-257ce37eb96f),T0T7 已实现并通过确定性验证;T8 文档已同步,独立验证/集成/live 与 T9 待执行**。
> 设计:`research-wiki/designs/2026-09-09-134-thinking-contracts-design.md`,用户已正式批准。
> 目标:解决 #21 的受管推理语义漏洞、#25 的测试归因漏洞、#26 的客户端遥测守卫缺口,不扩展生产端口或遥测 schema。
> 方案:在既有推理决策层添加窄校验并接入工厂/默认 transport;测试侧独立保留请求与响应证据,按明确命题判定覆盖。缓存仍由下游显式迁移,生产治理循环不重写。
@@ -263,7 +263,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc
**验证**`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py -q`。安全测试使用假的唯一 sentinel 凭据/私有提示词,逐文件检查不出现 sentinel,不能拿真实密钥做输出搜索。
- [ ] 提交点:`test: distinguish unsupported live coverage from library failures`
- [x] 实现及离线证据完成:窄分类/hooks/独立身份/逐轮安全报告;与 T6 合并提交
### T6:迁移四个 live 文件并离线化装配断言
@@ -286,7 +286,7 @@ T5 按 `(session_id, parent_call_id) → AttemptEvidence.call_id → HttpEvidenc
**验证**`conda run -n PolyGateway pytest tests/unit/test_live_evidence.py tests/unit/test_client.py tests/unit/test_config.py tests/unit/test_openai_compat.py::TestDefaultClientFactory -q``conda run -n PolyGateway pytest tests/e2e/ -m slow --collect-only -q`(只采集,不视作真实通过)。在离线注入旧整类 skip、丢第一轮、UNKNOWN→PASS、identity 丢失→skip 变异,分别红;新实现恢复绿。
- [ ] 提交点:`test: apply evidence-based live checks without hiding regressions`
- [x] 实现及日常离线验证/90节点collect-only完成;未执行live,不代表能力覆盖通过
### T7:无推理路径真链路与四类变异
@@ -378,4 +378,10 @@ chat 阳性走真实 RetryMWemitterTrue 糖失败 auto、显式请求失
## 本轮实施证据
T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`。未执行 live、集成和发布,T5/T6 的测试支持文件未创建
T0–T4/T7 的命令、实际失败与修复、11 个隔离变异及 1241 项单测通过,见 `findings/2026-09-09-134-thinking-contracts-validation.md`T5/T6 已续作:1357单元通过、8个隔离假绿变异exit1/还原0、e2e 90节点仅采集;T8文档同步完成。未执行live、集成、独立verifier和发布。具体节点及残余见同一finding续作节
### T5/T6续作决策记录
父会话确认无已批准型号→400机器type白名单:不编造,缺机器证据400默认FAIL;精确预期拒绝契约离线守卫,具体live负向缺基线记录未验证。不可关闭命题完整合格轮次有OBSERVED支持本条件下未关闭,全ABSENT证伪,无OBSERVED但UNKNOWN未覆盖。T8复选框保持未勾选,因为独立verifier与全量/live证据门未执行;本轮仅其文档同步部分完成,禁止发布。
T5/T6实现提交:`73008ad`。最终日常单元1357、受影响含factory331、make check、compileall、e2e collect-only90通过;完整T8/T9仍未执行。日志路径及8项红→还原绿详见同一finding。
+13 -9
View File
@@ -7,11 +7,10 @@ date: 2026-07-20
# 表结构: llm_calls(遥测 26 字段)
## 列定义(冻结,M1 设计 §4.4 / ARCH §7.8)
| 列 | 类型 | 说明 |
|---|---|---|
| --- | --- | --- |
| call_id | TEXT PRIMARY KEY | 每次尝试独立 UUID;INSERT OR IGNORE 幂等 |
| parent_call_id / session_id | TEXT | 调用链路(agent step → LLM call) |
| model / provider / source_name | TEXT NOT NULL | 溯源;model 由旧 Protocol 的 model_name 更名(VT 迁移 §8) |
@@ -31,12 +30,12 @@ date: 2026-07-20
| tenant_id | TEXT NOT NULL DEFAULT '' | 调用方租户(2026-08-17,issue #11);**缺省落哨兵空串而非 NULL**——PG 的 RLS `USING` 对返回 NULL 的行一律隐藏且不报错,NULL 的租户不是「未归属」而是对所有人永久不可见 |
| meta | TEXT / JSONB NOT NULL DEFAULT '' / '{}' | 调用方自定义维度(同批,≤16 个 KV);SQLite 存 canonical JSON 串,PG 存 JSONB |
| thinking_observation | TEXT | 本次推理是否真的发生的三态裁定(2026-08-25,issue #16/#17);`observed` / `absent` / `unknown`。见下方口径 |
| reasoning_effort | TEXT | 本次调用**实际发出**的推理档位(2026-09-04,issue #20);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
| reasoning_effort | TEXT | 按四种行来源记录的推理意图/实际编码档位(2026-09-09 澄清,issue #20/#26);八档 `Effort` 字面量之一,NULL = 调用方未表态(与 `none`「明确要求不推理」不可混同)。见下方口径 |
## usage/成本口径(2026-07-30,est_tokens 解耦)
| usage_source | 含义 | 生产者 | cost |
|---|---|---|---|
| --- | --- | --- | --- |
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是事实) | 按 token 换算 |
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断) | 按 token 换算 |
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | NULL |
@@ -67,7 +66,7 @@ FROM llm_calls WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
三个 emit 入口的取值必须各自定死,否则同一列在不同行含义不同:
| 入口 | 调用者 | 有生效源? | 记什么 |
|---|---|---|---|
| --- | --- | --- | --- |
| `emit_attempt` | RetryMW(最内) | 有 | `merge(source.extra_body, request.sampling)` |
| `emit_cache_hit` | TelemetryMW(最外) | 无 | 仅 `request.sampling` |
| `emit_terminal_failure` | TelemetryMW | 无 | 仅 `request.sampling` |
@@ -107,17 +106,18 @@ ORDER BY model, calls DESC;
## 推理档位口径(2026-09-04,issue #20)
`reasoning_effort` 回答的是「这一行跑在哪一档」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
`reasoning_effort` 的来源取决于行类型,不能总括为「实际发出」——补列之前,25 列里没有任何一列答得出,于是「不同档位是不是真有用」在数据侧无从分组。NULL 有两个来源(调用方未表态 / 档位名读不懂),两者都**不可**折叠进 `none`:`none` 是一次「要求不推理」的表态。
三个 emit 入口的取值同样各自定死,与 `sampling` 同构:
| 入口 | 有生效源? | 记什么 |
|---|---|---|
| --- | --- | --- |
| `emit_attempt`(成功) | 有 | `response.applied_effort`——transport 裁定的**实发档** |
| `emit_attempt`(失败) | 有 | `effective_effort(请求级 > 源级 > enable_thinking)` 的**请求档** |
| `emit_cache_hit` / `emit_terminal_failure` | 无 | `request.reasoning_effort` |
| `emit_cache_hit` | 无 | 本次 `request.reasoning_effort`,不取历史 applied、不推源级 |
| `emit_terminal_failure` | 无 | 本次 `request.reasoning_effort`,可能尚未选源 |
成功行必须读实档而非重算: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
真实成功尝试必须读实际编码档而非重算(分析须同时排除 cache_hit 和 error;未知 AUTO 仅尽力,不证明上游推理,raw-only=NULL: 源上开了 `EFFORT_FALLBACK=nearest` 时请求 `medium` 而模型只有 low/high/max,实发的是 `low`,重算会把整行挂在一个从未发出过的档下。失败尝试没有响应,实发档无从得知,故退回请求档——于是开了映射的源上**成功行与失败行不是同一把尺子**,跨 `error IS NULL` 混合统计前必须显式分开。仍然记而不留空,是因为档位错误(`resolve_thinking` 的 Phase 2/4/5)根本没发 HTTP 就被拒,这类行记的正是**被拒绝的那一档**,而「哪一档配错了」正是排障要的信号。
OCR / embedding 路径的该列**恒为 NULL**(`emit_attempt(reasoning_applies=False)`),理由与 `sampling` 逐字相同: 两条路径的 payload 不带推理参数,源上即便误配了 `ENABLE_THINKING`,记一个档也是记录一个从未发出的参数。
@@ -142,3 +142,7 @@ ORDER BY model, calls DESC;
## 评估基线
首版无历史基线,标"待首次运行后建立";验收断言: 单测覆盖成功/失败/缓存命中/取消四路径各产生恰一行;并发 50 协程写全落库。
## 1.3.4 测试侧证据(不新增 schema)
`tests/live_evidence.py`e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。