docs: 修正第二轮审查发现的 22 处 wiki 与代码不一致
含两条上一轮我照搬建议引入的错误: 缓存页的"未装 strategy 静默降级" (实为 chat() 入口硬抛 ImportError)、采样页的"provider 支持时自动选用 原生 schema"(内置四个 profile 的 supports_native_schema 全为 False)。 其余: LLMResponse 字段表把 source_name 排进前 11 位(迁移项目按位置构造 fake 会静默错位)、aclose 不释放限流/熔断后端、register_provider 是纯函数、 retry_after_s 取值随 reason 而异、TransientError 有三种出口、429 记的是 rate_limited、OCR 的 cache 键仍必填、六个可选键缺默认值、RPM/TPM 是分钟 固定窗口、退避 jitter 上界 1.5 倍、memory 缓存无上限、依赖实为五项必装。
+2
-2
@@ -9,7 +9,7 @@
|
||||
| 你想 | 去 |
|
||||
|---|---|
|
||||
| 第一次用,想被领着走通一遍 | [[教程-十分钟接入]] |
|
||||
| 有明确任务,查怎么配 | 指南区: [[指南-多源与选源]] / [[指南-限流与熔断]] / [[指南-响应缓存]] / [[指南-遥测与成本]] / [[指南-结构化输出]] / [[指南-OCR]] / [[指南-Embedding]] / [[指南-迁移既有项目]] |
|
||||
| 有明确任务,查怎么配 | 指南区: [[指南-多源与选源]] / [[指南-限流与熔断]] / [[指南-响应缓存]] / [[指南-遥测与成本]] / [[指南-结构化输出]] / [[指南-采样参数]] / [[指南-OCR]] / [[指南-Embedding]] / [[指南-迁移既有项目]] |
|
||||
| 查签名、字段、配置键 | 参考区: [[参考-公共API]] / [[参考-配置键]] / [[参考-异常]] |
|
||||
| 理解设计与行为逻辑 | 解释区: [[解释-架构]] / [[解释-错误四分类]] / [[解释-治理行为]] / [[解释-降级与取消]] |
|
||||
|
||||
@@ -19,6 +19,6 @@
|
||||
|---|---|
|
||||
| 安装 | `pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ "polygateway[redis,postgres,structured]==1.0.*"` |
|
||||
| Python | ≥ 3.11,纯 asyncio |
|
||||
| 核心依赖 | 仅 httpx + pydantic(redis/asyncpg/json-repair 走 extras) |
|
||||
| 核心依赖 | 五项必装: httpx、pydantic、pydantic-settings、python-dotenv、loguru(redis / asyncpg / json-repair / openai 走 extras) |
|
||||
| 架构事实源 | 主仓库 `research-wiki/ARCHITECTURE.md`(含 D1-D14 全部决策论证) |
|
||||
| 变更记录 | 主仓库 `CHANGELOG.md` |
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# 参考:公共 API
|
||||
|
||||
顶层导出全集(`from polygateway import ...`);公共类型承诺**字段只增不删不改名**,新增字段必带默认值。
|
||||
顶层导出全集(`from polygateway import ...`);公共类型承诺 `LLMResponse` **前 11 个字段逐字保序**(迁移项目按位置构造 fake 依赖这一点),此后新增字段只增不删不改名且必带默认值。
|
||||
|
||||
## GatewayClient
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
| `from_env` | `(scope="LLM", *, limiter=None, breaker=None, cache=None, telemetry=None, registry=None, env=None) -> GatewayClient` | 从 .env/环境变量装配;关键字参数可注入自定义后端(测试/共享状态) |
|
||||
| `from_settings` | `(settings: GatewaySettings, ...) -> GatewayClient` | 从已解析配置装配 |
|
||||
| `chat` | `(messages, *, session_id=None, parent_call_id=None, cache_salt=None, cache_namespace=None, structured=None, stream=True, overlay=None) -> LLMResponse` | 一次治理调用;messages 为 OpenAI 形态(原生支持多模态 content 数组);`structured` 传 pydantic 模型类或 `"json"`;`overlay` 传采样参数(v1.0.5新增,见 [[指南-采样参数]]) |
|
||||
| `aclose` | `() -> None`(亦支持 `async with`) | 幂等释放连接与后端资源。三个 client 均实现 `__aenter__`/`__aexit__`,推荐 `async with GatewayClient.from_env("LLM") as client:` 让退出时自动 aclose |
|
||||
| `aclose` | `() -> None`(`async with` 等价) | 幂等释放 **transport 连接池、遥测连接、缓存客户端**三项。**不释放限流/熔断后端**——`PGW_LIMITER_BACKEND`/`PGW_BREAKER_BACKEND=redis` 且由工厂自建时,其 Redis 客户端只靠 GC 回收;要确定性释放请自建 `RedisLimiter.from_url`/`RedisGate.from_url` 经 `limiter=`/`breaker=` 注入后自行 aclose |
|
||||
| `gather_bounded`(模块级函数) | `(aws: Iterable[Awaitable[T]], *, concurrency: int) -> list[T]` | 有界并发跑一批协程,顶层导出。`concurrency` 是 **keyword-only**,位置传参会 `TypeError`;`< 1` 抛 `ValueError`。语义同 `asyncio.gather`(结果保序、首个异常上抛),只多一道并发上限。用法 `await gather_bounded((client.chat(m) for m in batch), concurrency=8)` |
|
||||
|
||||
## LLMResponse(frozen dataclass)
|
||||
@@ -17,12 +17,13 @@
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| content / thinking | str | 正文与思考流(thinking 模型) |
|
||||
| model / provider / source_name | str | 溯源 |
|
||||
| model / provider | str | 溯源(model 是配置里的别名) |
|
||||
| prompt_tokens / completion_tokens | int | 用量 |
|
||||
| latency_ms | int | 总耗时 |
|
||||
| ttft_ms / max_inter_token_ms | float\|None | 流式时延指标 |
|
||||
| cache_hit | bool | **PolyGateway 自身响应缓存**是否命中(未产生网关调用);与供应商 prompt cache 无关,后者见 cached_prompt_tokens |
|
||||
| call_id | str | 遥测主键 |
|
||||
| source_name | str | 命中的源名。**注意它排在 call_id 之后**——库新增字段一律追加在末尾 |
|
||||
| cost | float\|None | 按价格表折算(缺表为 None) |
|
||||
| usage_source | str | measured / estimated / unavailable(v1.0.3 起三态,口径见 [[指南-遥测与成本]]) |
|
||||
| structured_data | Any\|None | `structured=` 时的校验结果 |
|
||||
@@ -57,7 +58,7 @@
|
||||
|---|---|
|
||||
| `GatewaySettings` / `EmbeddingSettings` / `OcrSettings` | `from_env` 的解析产物;高级场景可自行构造后走 `from_settings` |
|
||||
| `SourceConfig` | 单源完整配置(构造期校验不变式)。方法 `effective_est_tokens() -> int`:TPM 入场预扣量,显式 `est_tokens > 0` 优先,否则按 `max(1, tpm // 60)` 派生,`tpm=0` 时为 0(v1.0.3 新增)。字段 `extra_body`(v1.0.5新增):本源恒定的采样参数,构造后是只读视图——**该字段令 `SourceConfig` 不再 hashable**,`asdict()`/`deepcopy()` 亦不再适用(加任何 mapping 字段的固有代价);要可变副本用 `dict(source.extra_body)`,要改字段用 `dataclasses.replace` |
|
||||
| `ProviderProfile` / `DEFAULT_PROFILES` / `register_provider` | provider 方言注册表(thinking 注入方式、思考流字段等);自定义 provider 经 `register_provider()` 注册或 `registry=` 传入 |
|
||||
| `ProviderProfile` / `DEFAULT_PROFILES` / `register_provider` | provider 方言注册表。`register_provider(profile, base=None)` 是**纯函数**——返回 `DEFAULT_PROFILES` + 新条目的**新表**,不改全局状态(`DEFAULT_PROFILES` 是 MappingProxyType,改不动)。新表必须经 `from_env(registry=...)` / `from_settings(registry=...)` 传入才生效;**只调 `register_provider()` 不传 `registry=`,装配期必抛 `ValueError: 未注册的 provider`**。内置四个 profile 的 `supports_native_schema` 均为 `False` |
|
||||
| `PricingTable` / `ModelPrice` | 价格表(成本折算) |
|
||||
|
||||
异常层级见 [[参考-异常]];全部 env 键见 [[参考-配置键]]。
|
||||
|
||||
+16
-4
@@ -8,7 +8,7 @@
|
||||
|---|---|---|
|
||||
| `TransientError` | 超时 / 5xx / 网络抖动 / SSE 截断 / 429 / **空补全**(200 且流程完整但 content 空白)/ 响应体非法 JSON 或缺 choices | 换源重试 + 退避。429 pushback(按 Retry-After 等待、不耗重试预算)**只在 chat 路径**;`EmbeddingClient`/`OcrClient` 的 429 照常计入 `MAX_ATTEMPTS` |
|
||||
| `SourceDeadError` | 401 / 403 / 429+insufficient_quota(欠费) | 立即熔断该源 + 换源 |
|
||||
| `RequestRejectedError` | **400 及其余未特判的非 200 状态码**(402/404/408/409/422…) / 内容拒绝 / 本地格式拒绝 | 不重试不换源,直接抛给业务。base_url 配错导致的 404、上游用 402 表达欠费都走这里立即终态,`exc.status_code` 携原状态码 |
|
||||
| `RequestRejectedError` | **400 及其余未特判的非 200 状态码**(402/404/408/409/422…) / 内容拒绝 / **OCR 的 200 但 `success≠true`**(`status_code=200`) | 不重试不换源,直接抛给业务。base_url 配错导致的 404、上游用 402 表达欠费都走这里立即终态,`exc.status_code` 携原状态码 |
|
||||
| `ResultInvalidError` | 调用成功但结果不合格(坏 JSON / 维度不符 / 退化 bbox) | **不熔断**;结构化场景先有界重问,仍失败才抛 |
|
||||
|
||||
### `ResultInvalidError` 的诊断属性
|
||||
@@ -29,8 +29,8 @@
|
||||
|---|---|
|
||||
| `scope` | 哪个 scope(小写) |
|
||||
| `reason` | **scope 级**受控词表(越界值构造期报错): `circuit_open` / `retry_exhausted` / `stalled` / `quota_exhausted`(配额满且 `QUOTA_FULL=fail_fast`)/ `no_sources` |
|
||||
| `retry_after_s` | 最早值得重试的秒数(读熔断后端;0=可立即);任务队列按它延期重投 |
|
||||
| `per_source_reasons` | 逐源失败原因字典,诊断用。**值域与 `reason` 是两张表**: `network_error` / `timeout` / `rate_limited` / `source_dead` / `circuit_open` / `cooldown` / `adaptive_paced` |
|
||||
| `retry_after_s` | 最早值得重试的秒数,**取值来源随 reason 而异**:`circuit_open` 与「无源可跑的 stalled」读熔断后端;`retry_exhausted` = `BACKOFF_BASE_S`;`quota_exhausted` = `POLL_INTERVAL_S`(缺省 0.05s);`no_sources` = 0.0。这是**最短建议间隔而非源恢复时间**,队列重投请自加下限 |
|
||||
| `per_source_reasons` | 逐源失败原因字典,诊断用。**值域与 `reason` 是两张表**: `network_error` / `timeout` / `rate_limited` / `source_dead` / `circuit_open` / `cooldown` / `adaptive_paced`。注意**上游 429 与本地限流闸拒绝都记 `rate_limited`,二者不可区分**;`adaptive_paced` 只由 AIMD pacer 拦截产生 |
|
||||
|
||||
## 基建故障
|
||||
|
||||
@@ -38,8 +38,20 @@
|
||||
|
||||
**冒泡只覆盖准入侧**(`source_stats` / `try_acquire` / `try_enter` / `retry_after_s`)——闸没问上就绝不放行。调用已真实发出后的**记账写回**(`record_success` / `record_failure` / `mark_progress` / `release_probe`、permit 的 settle/release)遇到后端故障是 **warning 降级不冒泡**:不能因为写回失败就丢掉已经拿到的响应,或掩盖原始的尝试异常。代价是这期间并发租约靠 TTL 回收、TPM 差额不结算,限额短期漂移——Redis 抖动时请盯 warning 日志,而不是只盯异常率。
|
||||
|
||||
## 本地校验抛的是裸异常
|
||||
|
||||
`chat(overlay=)` 的保护键校验、`embed()` 的 `texts` 类型检查、OCR 的 `image` 检查抛的是裸 `ValueError` / `TypeError`,**不继承 `PolyGatewayError`**——它们发生在洋葱之外,属调用方编程错误,不入四分类、无遥测行、不触发任何治理。详见 [[指南-采样参数]]。
|
||||
|
||||
## 业务侧建议写法
|
||||
|
||||
捕 `GatewayUnavailableError` 做延期重投,捕 `RequestRejectedError`/`ResultInvalidError` 做确定性失败处理。
|
||||
|
||||
**`TransientError` 不会穿出 `chat()` / `embed()` / OCR**——无论 `MAX_ATTEMPTS` 配多少(哪怕 1),它一律被包成 `AllSourcesExhausted(reason='retry_exhausted')`,原异常留在 `exc.__cause__`。所以 `except TransientError:` 的兜底分支永远不会命中;要拿单次尝试的细节请读 `exc.__cause__` 与 `exc.per_source_reasons`。
|
||||
**`TransientError` 不会穿出 `chat()` / `embed()` / OCR**——无论 `MAX_ATTEMPTS` 配多少(哪怕 1),`except TransientError:` 的兜底分支都不会命中。它会变成 `GatewayUnavailableError` 族的三种出口之一:
|
||||
|
||||
| 出口 | 何时 | `__cause__` |
|
||||
|---|---|---|
|
||||
| `AllSourcesExhausted(reason='retry_exhausted')` | 重试预算耗尽 | 原 `TransientError` |
|
||||
| `CircuitOpenError(reason='circuit_open')` | 源已被熔断(故障稳态) | `None` |
|
||||
| `AllSourcesExhausted(reason='stalled')` | 429 持续 pushback 直到判死 | `None` |
|
||||
|
||||
所以 `__cause__` 只在 `retry_exhausted` 这一条路径上有值;通用的诊断入口是 `exc.per_source_reasons`。
|
||||
|
||||
+6
-6
@@ -39,12 +39,12 @@
|
||||
|
||||
| 键 | 说明 |
|
||||
|---|---|
|
||||
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸 |
|
||||
| `{SCOPE}__GLOBAL__MAX_CONCURRENCY / RPM / TPM` | 跨源合计闸。**全局 TPM 要成为准入闸有前置条件**:入场预扣量取自**源级**(`EST_TOKENS` 或 `max(1, 源TPM//60)` 派生)——只配 `{SCOPE}__GLOBAL__TPM` 而每个源上既无 TPM 也无 EST_TOKENS 时,预扣恒为 0、入场永远放行,该闸退化成事后计数 |
|
||||
| `{SCOPE}__SELECTOR` | health_aware(默认)/ round_robin / least_inflight |
|
||||
| `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S` | **必填**(或用下方平铺简写);MAX_ATTEMPTS 含首次 |
|
||||
| `{SCOPE}__BREAKER__FAIL_THRESHOLD / COOLDOWN_S / PROBE_TTL_S` | FAIL_THRESHOLD 与 COOLDOWN_S **必填**(或用平铺简写);PROBE_TTL_S 可省 |
|
||||
| `{SCOPE}__BREAKER__MIN_CALLS / FAIL_RATE / WINDOW_S / MAX_COOLDOWN_S` | 失败率通道与开路退避封顶 |
|
||||
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT) |
|
||||
| `{SCOPE}__RETRY__MAX_ATTEMPTS / BACKOFF_BASE_S / BACKOFF_MAX_S` | **必填**(或用下方平铺简写);MAX_ATTEMPTS 含首次。退避公式 `min(BASE × 2^(n-1), MAX) × jitter`,**jitter ∈ [0.5, 1.5)**——`BACKOFF_MAX_S` 封顶的是抖动**前**的基数,单次实际等待最大是它的 1.5 倍,按它估算任务软超时请乘 1.5 |
|
||||
| `{SCOPE}__BREAKER__FAIL_THRESHOLD / COOLDOWN_S / PROBE_TTL_S` | FAIL_THRESHOLD 与 COOLDOWN_S **必填**(或用平铺简写);PROBE_TTL_S 可省,缺省由最慢源 `timeout_s + 5` 派生 |
|
||||
| `{SCOPE}__BREAKER__MIN_CALLS / FAIL_RATE / WINDOW_S / MAX_COOLDOWN_S` | 失败率通道与开路退避封顶。**四个都可省且有默认值**:`10` / `0.6` / `60.0` / `max(300, COOLDOWN_S)`。注意它们**静默生效、缺失不报错**——不显式配就是在用这套缺省 |
|
||||
| `{SCOPE}__BACKPRESSURE__STALL_WINDOW_S / POLL_INTERVAL_S` | 配额满等待的判死窗口(须 ≥ 最大源 TTFT);均可省,缺省 `300.0` / `0.05` |
|
||||
| `{SCOPE}__QUOTA_FULL` | wait(默认)/ fail_fast |
|
||||
|
||||
平铺简写(单 scope 项目习惯,scope 键优先):`LLM_MAX_RETRIES` / `LLM_RETRY_BASE_DELAY` / `LLM_RETRY_MAX_DELAY` / `LLM_CIRCUIT_BREAKER_THRESHOLD` / `LLM_CIRCUIT_BREAKER_COOLDOWN` / `LLM_TIMEOUT` / `LLM_TTFT_TIMEOUT` / `LLM_INTER_TOKEN_TIMEOUT`。
|
||||
@@ -71,4 +71,4 @@
|
||||
| `EMBED__BATCH_SIZE` | 必填,每批条数 |
|
||||
| `EMBED__NORMALIZE` | 可选,true = L2 归一化 |
|
||||
| `EMBED__EXPECTED_DIM` | 可选,维度校验(不符抛 ResultInvalid) |
|
||||
| OCR scope | 无专用键;cache/structured 键对 OCR 无意义被忽略,api_key 惯例填 `none` |
|
||||
| OCR scope | 无专用键,api_key 惯例填 `none`。**cache 键仍是装配必填**——`PGW_CACHE_BACKEND` 缺失即启动 `ValueError`,取非 `none` 还会连带索要 `PGW_CACHE_NAMESPACE` / `PGW_CACHE_TTL_S`(>0)/(redis 时)`REDIS_URL`,尽管 OCR 路径根本不构造缓存组件、取值对运行时零影响。**OCR-only 部署请填 `PGW_CACHE_BACKEND=none`**。只有 structured 键(`PGW_STRUCTURED_MAX_RETRIES`)是真可省 |
|
||||
|
||||
+2
@@ -42,3 +42,5 @@ await ocr.aclose()
|
||||
- CHSAnalyzer 的 `table_locator` 与两项目迁移写法见主仓库 `research-wiki/migrations/`。
|
||||
|
||||
> **别忘了 scope 级韧性键**:`OCR__RETRY__MAX_ATTEMPTS/BACKOFF_BASE_S/BACKOFF_MAX_S` 与 `OCR__BREAKER__FAIL_THRESHOLD/COOLDOWN_S` 不配的话会回落到 `LLM_*` 平铺键(见 [[参考-配置键]])——OCR 的超时与重试特性和 LLM 差别很大,建议显式配全。
|
||||
|
||||
> **`/parse` 的第三类终态**:MonkeyOCR 返回 **HTTP 200 但 `success=false`** 时抛的是 `RequestRejectedError`(`status_code=200`),不是 `ResultInvalidError`——只写 `except ResultInvalidError` 会漏接。
|
||||
|
||||
+3
-1
@@ -37,7 +37,9 @@ resp = await client.chat(messages, cache_namespace=tenant_id) # 多租户:
|
||||
|
||||
- `structured_data` **不进缓存存储**——命中时按**本次调用的 schema** 现场重跑 parse + 校验;
|
||||
- **schema 不进缓存 key**。改了 pydantic 模型后,旧缓存会被自动重校验,校验不过即按未命中回源(伴一条 warning「缓存命中重建失败」)——**无需手工清 Redis 或换 salt**;
|
||||
- 未装配 structured strategy 时,structured 调用的命中会被静默降级为回源。
|
||||
- **未装 `polygateway[structured]` 时 `structured=` 是硬失败**:`chat()` 在进洋葱之前就抛 `ImportError`,不存在"降级回源"这回事。真实的降级路径只有一条——命中条目重建失败(schema 变更、条目损坏),它带 warning「缓存命中重建失败」后回源,不静默。
|
||||
|
||||
> **`memory` 后端的限制**:纯进程内 dict,**无容量上限、无后台清扫**,过期条目只在同一个 key 被再次读到时才删除——长跑进程会单调增长。它只适合单进程与测试;多进程或长驻服务请用 redis。下面「降级方向」一节描述的是 redis 后端。
|
||||
|
||||
## 降级方向
|
||||
|
||||
|
||||
+2
-2
@@ -35,9 +35,9 @@ LLM__QWEN__2__TIMEOUT_S=120
|
||||
|
||||
- 被**熔断开路**拒绝 → 写本地冷却备忘,冷却期内直接跳过(`per_source_reasons` 记 `cooldown`),不再白烧它的 RPM 去探测;
|
||||
- 被**限流闸**拒绝 → **不写冷却**(记 `rate_limited`),拒绝本身零副作用不消耗配额,下一轮照常参与选源并重试 `try_acquire`;
|
||||
- 上游返回 429 → 走 AIMD 并发削减(记 `adaptive_paced`),也不是冷却。
|
||||
- 上游返回 429 → 也记 `rate_limited`(**与本地限流闸拒绝同值,二者不可区分**)、不写冷却,同时触发 AIMD 并发削减;`adaptive_paced` 是 AIMD pacer 主动拦截时才记的值。
|
||||
|
||||
重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。
|
||||
重试预算是**调用级跨源累计**的(`LLM_MAX_RETRIES` 含首次),不是每源各自一份。退避公式 `min(BASE × 2^(n-1), MAX) × jitter`,jitter ∈ [0.5, 1.5)——封顶的是抖动前的基数,单次实际最长等待是 `BACKOFF_MAX_S` 的 1.5 倍。
|
||||
|
||||
## 多逻辑角色(SCOPE)
|
||||
|
||||
|
||||
+4
@@ -18,6 +18,10 @@ raw = await client.chat(messages, structured="json") # 只要合法 JSON,不
|
||||
|
||||
需要安装 `polygateway[structured]`(json-repair);未装时传 `structured=` 会显式报错。
|
||||
|
||||
## 策略如何选定
|
||||
|
||||
库有两个策略:`JsonRepairStrategy`(prompt 约定 + 事后修复,不改请求体)与 `NativeSchemaStrategy`(下发 `response_format`)。选原生的条件是 **scope 内全部源的 `ProviderProfile.supports_native_schema` 都为 `True`**——而**内置四个 profile(qwen / deepseek / openai / minimax)全是 `False`**。所以经 `from_env` / `from_settings` 装出来的**恒是 json_repair 策略,请求体从不带 `response_format`**;要用原生 schema 必须自定义 profile 并经 `registry=` 传入(见 [[参考-公共API]])。
|
||||
|
||||
## 阶梯行为
|
||||
|
||||
以下三步阶梯**只适用于传 pydantic 模型这一档**。`structured="json"` 只做第 1 步修复:调一次上游、json_repair 解析,失败即抛 `ResultInvalidError`——不重问,`PGW_STRUCTURED_MAX_RETRIES` 对它不起作用,因此也不会产生额外调用与额外遥测行。
|
||||
|
||||
+4
-3
@@ -26,7 +26,7 @@ PGW_TELEMETRY_SQLITE_PATH=logs/telemetry.db # sqlite 时必填
|
||||
| 结果 | cache_hit / error / cost。**error 的格式两条路径不同**:chat 与 embedding 记异常消息原文、**无类名前缀**(如 `s1 瞬时错误: 500`、`llm 网关暂时不可用: retry_exhausted`);仅 **OCR** 带类名前缀(`TransientError: ...`)。按四分类聚合请勿依赖 error 前缀 |
|
||||
| 可观测(v1.0.4) | cached_prompt_tokens / model_reported(见下) |
|
||||
| 复现(v1.0.5) | sampling —— 本次调用的采样参数(见下) |
|
||||
| 落库时刻 | created_at(库自动填,不由调用方传;做时间窗聚合直接用它) |
|
||||
| 落库时刻 | created_at(库自动填,不由调用方传)。**两后端类型与时区不同**:SQLite 是 `TEXT` + `datetime('now')`,存的是 **UTC 且无时区标记**;Postgres 是 `TIMESTAMPTZ` + `now()`。按本地时间窗查 SQLite 需写 `datetime(created_at,'localtime')` |
|
||||
|
||||
表是 22 列,但 `TelemetryRecorder` 端口是 21 个参数——差的正是 `created_at`(由数据库默认值生成)。自定义遥测后端实现该端口时按 21 个关键字参数接。
|
||||
|
||||
@@ -88,12 +88,13 @@ WHERE usage_source = 'unavailable' AND cache_hit = false;
|
||||
**统计命中率时 `WHERE cache_hit = false` 不可省**:
|
||||
|
||||
```sql
|
||||
SELECT SUM(cached_prompt_tokens)::float / NULLIF(SUM(prompt_tokens), 0)
|
||||
-- 注: 下面用的是通用写法; ::float 是 Postgres 语法,sqlite 请用 1.0 * SUM(...)
|
||||
SELECT 1.0 * SUM(cached_prompt_tokens) / NULLIF(SUM(prompt_tokens), 0)
|
||||
FROM llm_calls
|
||||
WHERE cache_hit = false AND cached_prompt_tokens IS NOT NULL;
|
||||
```
|
||||
|
||||
原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值,计进去就是重复计数。命中行被覆写的只有:**换新 `call_id`**(主键幂等要求,与被复用的原始行没有任何关联,不能用于溯源 join)、`cache_hit=true`、时延三件套清零、`cost` 重算为 `0.0`;其余字段(`model` / `model_reported` / `prompt_tokens` / `cached_prompt_tokens` 等)全是回放值。
|
||||
原因和上面 cost 缺口的口径一样:缓存命中行里这两个字段是**原样回放**的历史值,计进去就是重复计数。命中行被覆写的只有:**换新 `call_id`**(主键幂等要求,与被复用的原始行没有任何关联,不能用于溯源 join)、`cache_hit=true`、时延三件套清零、`cost` 重算为 `0.0`;其余**响应侧**字段(`model` / `model_reported` / `prompt_tokens` / `cached_prompt_tokens` 等)是回放值。但 `session_id` / `parent_call_id` / `messages` 取自**本次调用**(`LLMResponse` 根本没有这两个字段)——所以命中行的溯源列归属当次会话,可以按 session 聚合,只是 `call_id` 是新 uuid、无法 join 回被复用的原始行。
|
||||
|
||||
`model_reported` 是 API 响应体里实际返回的 model,和 `.env` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ for seed in range(5):
|
||||
# 请求体最终是 {"temperature":0, "top_p":1, "seed":<seed>, ...}
|
||||
```
|
||||
|
||||
优先级 **结构化输出注入 > 调用级 `overlay` > 源级 `EXTRA_BODY`**。结构化输出排最高是因为它关系到响应能否被解析:用**原生 schema 策略**(provider 支持 `response_format` 时自动选用)的话,`structured=` 时你传的 `overlay={"response_format": ...}` 会被它覆盖。缺省的 json_repair 策略不改请求体,此时你传的 `response_format` 会照发——它不在保护键里,库不拦。
|
||||
优先级 **结构化输出注入 > 调用级 `overlay` > 源级 `EXTRA_BODY`**。结构化输出排最高是因为它关系到响应能否被解析:用**原生 schema 策略**时,`structured=` 会覆盖你传的 `overlay={"response_format": ...}`。但注意:该策略要求 scope 内全部源的 `supports_native_schema` 为真,而**内置 profile 全为 `False`**——经 `from_env` 装配的恒是 json_repair 策略,它不改请求体,所以实践中你传的 `response_format` 会照发(它不在保护键里,库不拦)。详见 [[指南-结构化输出]]。
|
||||
|
||||
## 三个坑
|
||||
|
||||
|
||||
+4
@@ -4,6 +4,10 @@
|
||||
|
||||
并发 / RPM / TPM × 单源 / 全局,共六道,全过才放行;拒绝零副作用(不部分计数)。0 或缺省 = 该闸不启用。
|
||||
|
||||
> **RPM/TPM 是分钟固定窗口**(按 `int(now/60)` 分桶,整分钟到点归零),不是滑动窗口。照抄供应商的滑动窗口配额会在**整分钟交界处出现约 2 倍瞬时速率**(上一分钟末尾打满 + 新分钟开头再打满),建议配成配额的一半左右。
|
||||
>
|
||||
> **全局 TPM 的预扣量取自源级**:入场预扣是每个源的 `EST_TOKENS`(或由源 `TPM // 60` 派生)。只配 `LLM__GLOBAL__TPM` 而源上既无 `TPM` 也无 `EST_TOKENS` 时,预扣恒为 0、入场永远放行,全局 TPM 闸退化成事后计数。
|
||||
|
||||
```bash
|
||||
LLM__QWEN__1__MAX_CONCURRENCY=8 # 单源并发
|
||||
LLM__QWEN__1__RPM=60 # 单源每分钟请求
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ async def main() -> None:
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
`from_env("LLM")` 一行装配了整套治理栈:限流闸、熔断门、重试循环、看门狗、遥测。`aclose()` 归还连接与后端资源(FastAPI 放 lifespan、arq 放 shutdown)。
|
||||
`from_env("LLM")` 一行装配了整套治理栈:限流闸、熔断门、重试循环、看门狗、遥测。`aclose()` 归还 transport 连接池、遥测连接与缓存客户端(限流/熔断后端不在其中,见 [[参考-公共API]])(FastAPI 放 lifespan、arq 放 shutdown)。
|
||||
|
||||
## 4. 看见治理在工作
|
||||
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
- **429 归 Transient 但特殊**:它是上游的"慢点"信号(pushback),不计入熔断失败率(三条路径通用)——否则高峰期会把健康源全熔掉。但"**不消耗重试预算、按 Retry-After 等待、靠调用级 stall 判死**"这套**只在 chat 路径生效**;`EmbeddingClient` / `OcrClient` 的治理循环对 429 照常 `fails += 1` 计入 `MAX_ATTEMPTS`,耗尽后的失败原因是 `retry_exhausted` 而非 `stalled`。
|
||||
- **429 + insufficient_quota 归 SourceDead**:欠费不是限流,等多久都没用。
|
||||
- **HTTP 响应本身证明服务活着**:即使是业务层面的失败响应(如 OCR 返回 success=false),熔断记账也算成功——熔断度量的是"服务是否可达",不是"结果是否满意"。
|
||||
- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。**这条只在默认 `MISSING_DONE=retry` 下成立**:配成 `salvage` 时,有内容的截断会被打捞成正常响应返回(`usage_source=estimated`)**并照常写入缓存**,整个 TTL 内被复用——正是这句声称已防住的那起事故。零内容断流(early_eof)无论怎么配都是 Transient。
|
||||
- **SSE 截断(收到内容但缺 [DONE])归 Transient** 且不写缓存——把半截响应当成功缓存住是前身项目的真实事故。**这条只在默认 `MISSING_DONE=retry` 下成立**:配成 `salvage` 时,有内容的截断会被打捞成正常响应返回**并照常写入缓存**——只有在截断前已收到 usage 帧时才标 `usage_source=estimated`,而库强制 `include_usage`、usage 帧紧邻 `[DONE]`,所以**常态是标 `unavailable`、tokens=0、cost=NULL**,打捞响应无法只靠 `usage_source` 识别,整个 TTL 内被复用——正是这句声称已防住的那起事故。零内容断流(early_eof)无论怎么配都是 Transient。
|
||||
- **空补全(200、流程完整但 content 空白)归 Transient**:按服务抖动处理,退避重试/换源,绝不缓存。库不会返回 `content=''` 的成功响应;模型合法返回空串的场景需业务侧改 prompt。
|
||||
|
||||
scope 级"无源可用"是另一层:四分类描述单次尝试,`GatewayUnavailableError` 族描述整个 scope 的暂时不可用(带 retry_after_s 供任务队列延期)。
|
||||
|
||||
Reference in New Issue
Block a user