diff --git a/Home.md b/Home.md index d153e7f..9c0153d 100644 --- a/Home.md +++ b/Home.md @@ -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` | diff --git a/参考-公共API.md b/参考-公共API.md index c9a24ad..857db36 100644 --- a/参考-公共API.md +++ b/参考-公共API.md @@ -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 键见 [[参考-配置键]]。 diff --git a/参考-异常.md b/参考-异常.md index 3fa6a7a..3293bdf 100644 --- a/参考-异常.md +++ b/参考-异常.md @@ -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`。 diff --git a/参考-配置键.md b/参考-配置键.md index 63e1189..79b89e1 100644 --- a/参考-配置键.md +++ b/参考-配置键.md @@ -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`)是真可省 | diff --git a/指南-OCR.md b/指南-OCR.md index 0655d24..6c3b3b6 100644 --- a/指南-OCR.md +++ b/指南-OCR.md @@ -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` 会漏接。 diff --git a/指南-响应缓存.md b/指南-响应缓存.md index fc9863f..18f50ab 100644 --- a/指南-响应缓存.md +++ b/指南-响应缓存.md @@ -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 后端。 ## 降级方向 diff --git a/指南-多源与选源.md b/指南-多源与选源.md index abf5a2c..d018a0d 100644 --- a/指南-多源与选源.md +++ b/指南-多源与选源.md @@ -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) diff --git a/指南-结构化输出.md b/指南-结构化输出.md index 1a5ca27..7083938 100644 --- a/指南-结构化输出.md +++ b/指南-结构化输出.md @@ -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` 对它不起作用,因此也不会产生额外调用与额外遥测行。 diff --git a/指南-遥测与成本.md b/指南-遥测与成本.md index 2d42ed5..36e7066 100644 --- a/指南-遥测与成本.md +++ b/指南-遥测与成本.md @@ -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` 里配的别名可能不是一个东西——供应商把别名指向新权重时,只有它认得出当时真正跑的版本。要做可复现的实验快照,记这一列。 diff --git a/指南-采样参数.md b/指南-采样参数.md index 3f36563..d1aaea5 100644 --- a/指南-采样参数.md +++ b/指南-采样参数.md @@ -16,7 +16,7 @@ for seed in range(5): # 请求体最终是 {"temperature":0, "top_p":1, "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` 会照发(它不在保护键里,库不拦)。详见 [[指南-结构化输出]]。 ## 三个坑 diff --git a/指南-限流与熔断.md b/指南-限流与熔断.md index ea053bb..a39182a 100644 --- a/指南-限流与熔断.md +++ b/指南-限流与熔断.md @@ -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 # 单源每分钟请求 diff --git a/教程-十分钟接入.md b/教程-十分钟接入.md index 1effea5..a3a90b0 100644 --- a/教程-十分钟接入.md +++ b/教程-十分钟接入.md @@ -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. 看见治理在工作 diff --git a/解释-错误四分类.md b/解释-错误四分类.md index 1b0a0e5..683bbb9 100644 --- a/解释-错误四分类.md +++ b/解释-错误四分类.md @@ -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 供任务队列延期)。