diff --git a/research-wiki/designs/2026-08-06-governance-backend-error-design.md b/research-wiki/designs/2026-08-06-governance-backend-error-design.md index 4a8885a..46c6d98 100644 --- a/research-wiki/designs/2026-08-06-governance-backend-error-design.md +++ b/research-wiki/designs/2026-08-06-governance-backend-error-design.md @@ -68,6 +68,8 @@ Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重 **选定**:`errors.py` 模块级常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,作为构造默认值,docstring 写明理由——后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻),取一个保守固定值;下游若有自己的退避策略可忽略此值。本库对 scope 级异常硬编码语义值已有先例(`retry.py:206` 的 `no_sources` 取 `0.0`)。 +它**不是环境配置项**,故不落 CLAUDE.md §4.5「严禁硬编码默认值」的论域——§4.5 约束的是 `pydantic-settings` + `.env` 管辖的工程配置(超时、并发、限额),而本常量是异常自身携带的语义默认值,与 `no_sources` 取 `0.0` 同性质。docstring 需显式写明这一点,避免后来者误加环境键。 + ### 3.3 `scope` 的三层来源 | 层 | 构造点数 | scope 来源 | 改动 | @@ -122,7 +124,9 @@ Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重 ## 7. 错误处理与测试策略 -新失败面只有一个:`SourceNotConfiguredError`,落在四分类之外(它是装配缺陷而非调用失败),这是有意的——四分类描述"一次调用如何失败",装配缺陷不属于该论域。 +新失败面只有一个:`SourceNotConfiguredError`,它落在四分类之外。这**不违反** CLAUDE.md §4.2「一切失败必须落入四分类」——该铁律的论域是 **transport 层翻译的调用失败**(`ARCHITECTURE.md` §6.2 的翻译规则表逐条对应 HTTP 状态码与解析失败),而本库已有一整族异常合法地处在四分类之外:`GatewayUnavailableError` / `CircuitOpenError` / `AllSourcesExhausted` 都不是四分类之一,`ARCHITECTURE.md` §6.1 把它们单列一行,因为它们回答的是另一个问题——"整个 scope 还能不能用",而非"这一次调用怎么失败的"。 + +`SourceNotConfiguredError` 属于第三个论域:**装配缺陷**(配置与治理循环不一致,正常不可达)。四分类决定重试/换源/熔断,而装配缺陷根本不该进入治理循环去被"决定",它应当立刻失败并让人看见。将其塞进四分类中的任何一类都会赋予它一份不该有的治理语义(如 `RequestRejectedError` 会让下游以为请求本身有问题、去修请求)。§9 Q1 保留了"复用 `RequestRejectedError`"作为备选供人类权衡。 | 测试 | 位置 | 先失败后通过的证据 | |---|---|---| @@ -142,18 +146,34 @@ Issue 建议取 0。**否决**:下游 `schedule_retry(after_s=0)` 会立刻重 | **版本** | **1.1.0**。有行为变更(下游对后端故障的处置路线改变)但无 API 破坏(加父类是扩大),按语义化版本走 minor | | **下游** | CHSAnalyzer3 当前在 1.0.1。升级后 `except GatewayUnavailableError` 即覆盖后端故障,其现有 `except GovernanceBackendError`(若有)继续有效,无需改代码即可获得修复 | +### 8.1 执行顺序(单一事实源纪律) + +`ARCHITECTURE.md` 是架构单一事实源,`SCOPE_REASONS` 新增值域与 `GovernanceBackendError` 的归位都与其 §6.1 现状冲突。因此 **§6.1 的修订必须先于或同批于代码实现落地**,不得"先改代码、事后补文档"。具体为:人类批准本设计后,`writing-plans` 的第一项任务即为修订 `ARCHITECTURE.md` §6.1(补 `GovernanceBackendError` 与 `SourceNotConfiguredError` 行、scope 级 reason 值域增 `governance_backend_down`、记录本次归位的理由与日期),与实现同一分支、同批提交。 + ## 9. 待人类确认的决策点 -| # | 决策 | 本文的选择 | 若你倾向不同 | +(编号用 Q 前缀,避免与 `ARCHITECTURE.md` 的架构决策 D1–D14 混淆) + +| # | 决策 | 本文的选择 | 备选 | |---|---|---|---| -| D1 | "未知源"归到哪 | 拆为 `SourceNotConfiguredError`,**不**在 `GatewayUnavailableError` 之下(§3.4) | 若认为它该沿用 `GovernanceBackendError`,则 §3.4 整节作废,但需接受"配置写错永不进死信" | -| D2 | `retry_after_s` 取值 | 常量 `5.0`(§3.2) | 可改为配置项或其他常量值;不建议取 0 | -| D3 | 新类是否公共导出 | 是(进 `__init__.py`) | 若只作内部诊断可不导出 | +| Q1 | "未知源"归到哪 | 拆为 `SourceNotConfiguredError`,**不**在 `GatewayUnavailableError` 之下(§3.4) | ① 沿用 `GovernanceBackendError`——需接受"配置写错永不进死信";② 复用 `RequestRejectedError`——留在四分类内、治理行为(不重试不换源快速失败)恰好正确,但语义错位会引下游去修请求 | +| Q2 | `retry_after_s` 取值 | 常量 `5.0`(§3.2) | 可改为配置项或其他常量值;不建议取 0 | +| Q3 | 新类是否公共导出 | 是(进 `__init__.py`) | 若只作内部诊断可不导出 | ## 10. 审批记录 | 阶段 | 状态 | |---|---| -| Claude 自审 | 已完成(全部结论对应本会话内 grep/read 输出) | -| Codex 独立审 | 待执行 | +| Claude 自审 | 已完成(全部结论对应本会话内 grep/read 输出;§3.5 的 `self.args` 保全机制经 conda 环境实跑验证) | +| Codex 独立审 | 已完成(2026-08-06),4 条意见逐条核验见下 | | 人类审批 | **待执行**(强制档:变更 `errors.py` 公共错误类型树) | + +### 10.1 Codex 意见的核验结果 + +| 意见 | 判定 | 处置 | +|---|---|---| +| ARCHITECTURE §6.1 未同步前实施违反单一事实源(判为阻塞) | **实质成立**,但性质是执行顺序而非设计缺陷——§8 本已把 §6.1 回补列入影响面 | 新增 §8.1 明确"架构文档修订先于/同批于实现" | +| §6.1 错误分类表未承认 `GovernanceBackendError`(判为阻塞) | **与上条同源**,且 §1.1 已自陈此为根因 | 同上,由 §8.1 覆盖 | +| `SourceNotConfiguredError` 落在四分类外违反 §4.2 铁律(判为阻塞) | **部分成立**:铁律论域被误读——`GatewayUnavailableError` 族本就合法处在四分类之外(§6.1 单列一行)。但原文表述确会引起该疑虑 | §7 补写三个论域的划分论证;§9 Q1 增列"复用 `RequestRejectedError`"备选交人类权衡 | +| 硬编码常量与 §4.5 存在张力(建议性) | **成立** | §3.2 补写"非环境配置项"及 docstring 要求 | +| Q 编号与架构 D1–D14 混淆(建议性) | **成立** | §9 决策点编号由 `D` 改为 `Q` | diff --git a/research-wiki/designs/governance-backend-error.md b/research-wiki/designs/governance-backend-error.md new file mode 100644 index 0000000..5970891 --- /dev/null +++ b/research-wiki/designs/governance-backend-error.md @@ -0,0 +1,44 @@ +--- +type: design +node_id: design:governance-backend-error +title: "治理后端故障归位为 scope 级不可用(Issue #7)" +date: 2026-08-06 +--- + +# 治理后端故障归位为 scope 级不可用(Issue #7) + +全文见 `2026-08-06-governance-backend-error-design.md`。来源: Gitea Issue #7(下游 CHSAnalyzer3 按异常类型分流失败)。**状态: 待人类审批。** + +问题: 限流/熔断状态后端故障时库 fail-closed,一个请求都发不出去——语义上就是 scope 级不可用,但 `GovernanceBackendError` 是 `PolyGatewayError` 的**直接子类**,只写 `except GatewayUnavailableError` 的调用方接不住,于是 Redis 抖一下,积压任务一批批消耗业务失败预算进死信,而那是运维重启就好的故障。 + +## 选定方案 + +| 决策 | 选定 | 关键理由 | +|---|---|---| +| A 类型树 | `GovernanceBackendError` 改继承 `GatewayUnavailableError`,`SCOPE_REASONS` 增 `governance_backend_down`,`reason` 恒为该值 | 加父类是**扩大**不是破坏(既有 `except GovernanceBackendError` 照旧命中);库内仅 `telemetry.py:210` 一处捕父类且已并列写两者,**零回归** | +| B `retry_after_s` | 模块常量 `GOVERNANCE_BACKEND_RETRY_AFTER_S = 5.0`,非环境配置项 | 后端恢复时间物理上不可知(不同于熔断冷却有确定到期时刻);取 0 会让积压任务零延迟批量重投,把一次故障放大成风暴 | +| C scope 来源 | 后端层用 `self._scope`;`QuotaGate`/`BreakerGate` 构造函数注入,三处装配(`retry.py`/`ocr.py`/`embedding.py`)各传一行 | 两个包装器是后端异常的唯一入口,注入点收敛;三处装配本就持有 `self._scope` | +| D 未知源拆分 | `_cfg()` 的 2 处改抛新增的 `SourceNotConfiguredError`,**有意不放在** `GatewayUnavailableError` 之下 | 那是装配缺陷不是后端故障;随整类归入"可重投"会让配置写错的任务永远重投、永不进死信——本 issue 要修的 bug 的镜像 | +| E message 保全 | `super().__init__()` 后覆写 `self.args = (message,)` | 父类会把 message 覆盖为 `f"{scope} 网关暂时不可用: {reason}"`,而 22 处构造点的诊断串是排障主线索。机制已实跑验证 | + +## 被否决的备选 + +| 备选 | 否决原因 | +|---|---| +| B(issue 原议): 只补文档,类型树不动 | 正确性依赖每个下游都读到那句话;本 issue 本身就是"文档读不出来"引发的,同一失效模式不能用同一种药治 | +| C: 在 RetryMW 边界包成 `AllSourcesExhausted` | 比选定方案更具破坏性——下游现有 `except GovernanceBackendError` 直接失效 | +| D: 后端层不再构造该异常,原始异常穿透由包装器统一翻译 | 初评时倾向。`redis/limiter.py:133,151` 的 `RedisPermit.release/settle` 依赖 `except GovernanceBackendError` 实现**释放侧降级**,穿透后接不住会破坏该既有行为;改 `except Exception` 则违反 P5 | +| `retry_after_s` 复用 `BackpressureConfig.poll_interval_s` | 该值只有三个装配点持有,为此给后端加构造参数等于让状态存储层持有重投策略,违反 P7 | +| 新增配置项 `PGW_GOVERNANCE_BACKEND_RETRY_AFTER_S` | YAGNI;无下游表达过需要,真需要时下游可忽略该字段用自有退避 | + +## 对 issue 前提的四处修正 + +泄漏路径是**三条**不是两条(`retry.py:216` 的 `progress_age_s()` 同样在 catch 之外);构造点 **22 处**;其中 2 处语义完全不同(未知源);`retry_after_s=0` 语义通但工程不通。 + +根因记录: `ARCHITECTURE.md` §6.1 错误分类表里 `GovernanceBackendError` **一次都没出现**——它是 M2 引入分布式后端时新增的,当时未回补架构表,于是它在"调用方视角的分类学"中从来没有位置,README 的遗漏是这个遗漏的下游后果。 + +## 独立审查修正(2026-08-06, Codex) + +4 条意见逐条核验: 两条"架构文档未同步"实质成立但性质是执行顺序 → 新增 §8.1 钉死"`ARCHITECTURE.md` §6.1 修订先于/同批于实现";"新错误类违反四分类铁律"**部分成立**——铁律论域被误读(`GatewayUnavailableError` 族本就合法处在四分类之外),但原表述确会引起疑虑 → §7 补写三论域划分论证,并把"复用 `RequestRejectedError`"增列为待人类权衡的备选;两条建议性意见(常量非配置项的说明、决策编号 `D`→`Q` 防与架构 D1–D14 混淆)已采纳。 + +相关: [[m2-distributed]]、[[m1-core-design]]、[[m25-resilience]] diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 56449c8..b74a331 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -130,6 +130,11 @@ "id": "plan:sampling-params-plan", "label": "采样参数透传实现计划(issue #4)", "type": "plan" + }, + { + "id": "design:governance-backend-error", + "label": "治理后端故障归位为 scope 级不可用(Issue #7)", + "type": "design" } ], "links": [ diff --git a/research-wiki/index.md b/research-wiki/index.md index 3bbb1e8..1aa8252 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,8 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-08-02 10:55 UTC +> 自动生成,更新时间:2026-08-06 06:38 UTC -## design (21) +## design (23) - [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` - [2026-07-21-m25-resilience-design](designs/2026-07-21-m25-resilience-design.md) `design:2026-07-21-m25-resilience-design` @@ -13,6 +13,7 @@ - [2026-07-30-settings-invariants-round-2-design](designs/2026-07-30-settings-invariants-round-2-design.md) `design:2026-07-30-settings-invariants-round-2-design` - [2026-07-31-response-observability-fields-design](designs/2026-07-31-response-observability-fields-design.md) `design:2026-07-31-response-observability-fields-design` - [2026-07-31-sampling-params-design](designs/2026-07-31-sampling-params-design.md) `design:2026-07-31-sampling-params-design` +- [2026-08-06-governance-backend-error-design](designs/2026-08-06-governance-backend-error-design.md) `design:2026-08-06-governance-backend-error-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` @@ -23,6 +24,7 @@ - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` - [推理开关能力建模与 reasoning_tokens 采集(issue #5 + #6)](designs/2026-08-02-thinking-capability-design.md) `design:2026-08-02-thinking-capability-design` +- [治理后端故障归位为 scope 级不可用(Issue #7)](designs/governance-backend-error.md) `design:governance-backend-error` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` ## finding (12) diff --git a/research-wiki/log.md b/research-wiki/log.md index 0618779..4752357 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -84,3 +84,5 @@ - [2026-08-02 09:49 UTC] 重建索引: 53 篇页面 - [2026-08-02 10:55 UTC] 重建索引: 53 篇页面 - [2026-08-02 10:55 UTC] 更新 finding: 补 §2.5 输出长度不是有效判别量(e2e 各 15 轮实测) +- [2026-08-06 06:37 UTC] 新增 design: 治理后端故障归位为 scope 级不可用(Issue #7) (design:governance-backend-error) +- [2026-08-06 06:38 UTC] 重建索引: 55 篇页面 diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index 66b7cfb..491ee9e 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -12,6 +12,7 @@ import asyncio import json import time import uuid +from dataclasses import dataclass from typing import TYPE_CHECKING from loguru import logger @@ -28,6 +29,44 @@ if TYPE_CHECKING: from polygateway.types import ChatRequest, LLMResponse, SourceConfig +@dataclass(frozen=True) +class _AttemptUsage: + """一次尝试的用量视图;默认值即"失败尝试"档(无用量可言,记 0 并标 unavailable)。 + + 存在的理由是把 `emit_attempt` 里逐字段重复的 `X if response else Y` 收敛为 + 一处判定——十处三元把该方法推到圈复杂度 C,而它们表达的是同一件事。 + """ + + response_text: str = "" + thinking: str = "" + prompt_tokens: int = 0 + completion_tokens: int = 0 + usage_source: str = "unavailable" + ttft_ms: float | None = None + max_inter_token_ms: float | None = None + cached_prompt_tokens: int | None = None + model_reported: str | None = None + reasoning_tokens: int | None = None + + @classmethod + def of(cls, response: LLMResponse | None) -> _AttemptUsage: + """从响应取用量;`None`(失败尝试)返回全默认视图。""" + if response is None: + return cls() + return cls( + response_text=response.content, + thinking=response.thinking, + prompt_tokens=response.prompt_tokens, + completion_tokens=response.completion_tokens, + usage_source=response.usage_source, + ttft_ms=response.ttft_ms, + max_inter_token_ms=response.max_inter_token_ms, + cached_prompt_tokens=response.cached_prompt_tokens, + model_reported=response.model_reported, + reasoning_tokens=response.reasoning_tokens, + ) + + class TelemetryEmitter: """从请求与结果组装 21 字段并写入 recorder;一切写失败降级 warning。""" @@ -46,25 +85,26 @@ class TelemetryEmitter: error: str | None, ) -> None: """逐次尝试记录(RetryMW 调用);失败尝试无用量可言,记 0 并标 unavailable。""" + usage = _AttemptUsage.of(response) await self._record( request=request, call_id=call_id, model=source.model, provider=source.provider, source_name=source.name, - response_text=response.content if response else "", - thinking=response.thinking if response else "", - prompt_tokens=response.prompt_tokens if response else 0, - completion_tokens=response.completion_tokens if response else 0, - usage_source=response.usage_source if response else "unavailable", + response_text=usage.response_text, + thinking=usage.thinking, + prompt_tokens=usage.prompt_tokens, + completion_tokens=usage.completion_tokens, + usage_source=usage.usage_source, latency_ms=latency_ms, - ttft_ms=response.ttft_ms if response else None, - max_inter_token_ms=response.max_inter_token_ms if response else None, + ttft_ms=usage.ttft_ms, + max_inter_token_ms=usage.max_inter_token_ms, cache_hit=False, error=error, - cached_prompt_tokens=response.cached_prompt_tokens if response else None, - model_reported=response.model_reported if response else None, - reasoning_tokens=response.reasoning_tokens if response else None, + cached_prompt_tokens=usage.cached_prompt_tokens, + model_reported=usage.model_reported, + reasoning_tokens=usage.reasoning_tokens, # 唯一有"生效源"的入口,故是唯一能并上 extra_body 的(设计决策 D) sampling=canonical_sampling_json(merge_sampling(source.extra_body, request.sampling)), )