merge: release 1.3.8 net generation time and mid-stream stall hedging

This commit is contained in:
2026-09-14 03:04:04 -04:00
39 changed files with 3027 additions and 138 deletions
+7
View File
@@ -80,6 +80,13 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
# ── 「挂起 vs 慢生成」物理不可分,建议取源 p50 的数倍)。对冲向异源并发再发一次,走完整限流/熔断准入, # ── 「挂起 vs 慢生成」物理不可分,建议取源 p50 的数倍)。对冲向异源并发再发一次,走完整限流/熔断准入,
# ── 拿不到配额静默放弃。成本含义: 开启即用配额换延迟——触发窗口内 in-flight 翻倍,输家被取消后按 est # ── 拿不到配额静默放弃。成本含义: 开启即用配额换延迟——触发窗口内 in-flight 翻倍,输家被取消后按 est
# ── 保留预扣(不喂熔断),且输家可能已被上游计费、取消止不住。须与 CALL_DEADLINE_S 组合时强制阈值 < 期限 ── # ── 保留预扣(不喂熔断),且输家可能已被上游计费、取消止不住。须与 CALL_DEADLINE_S 组合时强制阈值 < 期限 ──
# LLM__STREAM__STALL_THRESHOLD_MS=1000 # 中段沉默计量阈值(毫秒,缺省 1000):相邻增量 chunk 间隔严格大于它才计入
# ── stall_ms/stall_count 并从 generation_ms 净口径中扣除;仅改计量口径,不改任何行为 ──
# LLM__HEDGE__STALL_AFTER_S= # 中段沉默对冲触发阈值(秒);缺省不设 = 关闭(仅流式 chat 生效)
# ── 语义: 主路首 token 之后相邻增量 chunk 沉默超此阈值 → 向异源并发再发一路;独立于 HEDGE__AFTER_S 生效,
# ── 仅设它且请求非流式时逐字走单路。成本: 下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲——
# ── 开启即用配额换延迟,输家可能已被上游计费;无业界公开先例(超业界现状能力)。守卫: ≥ 最小源有效
# ── inter-token 超时(对冲窗永不开)或 ≥ CALL_DEADLINE_S → 装配期 ValueError;单源 scope 仅 warning ──
# ══ 装配选择(PGW_*)══ # ══ 装配选择(PGW_*)══
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
+47
View File
@@ -1,5 +1,52 @@
# Changelog # Changelog
## 1.3.8(2026-09-14)
流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。
### 行为变更: `generation_ms` 口径重定义(1.3.7 墙钟 → 本版净生成时间)
> [!WARNING]
> **同一字段,两版口径不同。** 1.3.7 的 `generation_ms` 是赢家那次 transport 调用的完整**墙钟**(中段沉默全部计入);本版起是**净生成时间** = 墙钟 − Σ(所有 > `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 的相邻增量 chunk 沉默),**仍含 TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择)。还原公式: **墙钟 = `generation_ms + stall_ms`**(`stall_ms` 为 None 时墙钟 = `generation_ms`)。
语义突变在一般情形下违反「字段只增不删不改名」精神,本次由人类以「窗口期最小代价」裁决放行(2026-09-11),依据三行: 该字段 1.3.7(2026-09-10)才引入、**发布仅 2 天**;**消费者唯一**(提出本需求的下游 Terminal-Bench,三参考项目均未消费);**遥测 DB 从未存过** `generation_ms`,无历史账本纵向断裂。字段名「裸生成时间」原本承诺的就是净口径,本次重定义是兑现名字而非推翻承诺。`max_inter_token_ms`(单次最大相邻间隔)原语义一字不动——它与 `generation_ms` 是两个口径,不能互算。
| 项 | 口径 |
| --- | --- |
| 计入沉默的间隔 | 相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗/首 token 活性口径一致)间隔**严格大于**阈值(恰好等于不减);空 chunk / usage 帧 / SSE keepalive 不计时;chunk 边界是传输/解析缓冲产物而非 token 边界,本口径不假装是 ITL |
| `stall_ms: int \| None` | 赢家路超阈值中段沉默总额(毫秒);与 `stall_count` **成对报告**(只报净数无法行动) |
| `stall_count: int \| None` | 超阈值相邻间隔个数 |
| None 条款(照 vLLM 排除条款) | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**(= 原墙钟);None = 「无沉默观测」而非 0,下游预算公式以 `stall_ms is None` 为分支条件 |
| 对冲赢家 | 三值全部取**赢家路**;已完成但落选的对冲输家其沉默在其 attempt 遥测行(可按 `logical_call_id` join 还原);被取消的输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None |
| 与业界基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
### 遥测两列(纯增量,可空)
遥测表 `llm_calls` 末尾追加 `stall_ms` / `stall_count` 两**可空**列(INSERT 字段 36 → 38,物理列 37 → 39),落库值与 `CallStats` 快照同源(retry 层已取整的 int)。旧行 NULL 不回填(NULL 表达「补列之前根本没记过这件事」);auto 档幂等补列(先探测后 ALTER),manual 档按现有列裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取。`generation_ms` **仍不进遥测列**——要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原。`TelemetryRecorder.record_llm_call` 协议同步加两个**无默认值** keyword-only 参数: 自建 recorder 未跟进会在**装配期**被形状闸当场拒绝(不是运行期静默丢行),`**fields` 形态不受影响;`emit_cache_hit` / `emit_terminal_failure` 与失败/取消 attempt 行两列恒 NULL。
### 中段沉默对冲(默认关闭;无业界公开先例)
新键 `{SCOPE}__HEDGE__STALL_AFTER_S`: 主路**首 token 之后**相邻增量 chunk 间隔超此阈值 → 向异源并发再发一路,先回者赢、输家取消。编排/记账/遥测全部复用 1.3.7 对冲语义(异源排除、完整准入、拿不到配额静默放弃、`hedge_cancelled` 遥测行、两败只计一次重试预算)。**诚实标注: 业界对中段沉默的落地形态收敛为「看门狗掐流 → 重试」,「中段沉默触发并发副本」在任何主流网关/库中均无公开先例**——本能力属超业界现状能力,启用前请理解其语义。
**请先读这三句**:
| # | 承诺 | 展开 |
| --- | --- | --- |
| 1 | **默认关闭,开启即用配额换延迟** | 不设该键时行为逐字等于 1.3.7。下游实测(Terminal-Bench 4407 次成功调用)沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft >20s 仅 3/4407),开启后约两成调用会在触发窗口内 in-flight 翻倍 |
| 2 | **输家可能已被上游计费,取消止不住** | 与 1.3.7 同一性质: 输家按 est 保留预扣(闸内保守记账,不是上游真实计费的计量);取消止得住等待,止不住上游已经烧掉的钱。对账靠 `hedge_cancelled` 遥测行按 `logical_call_id` join |
| 3 | **独立于 `HEDGE__AFTER_S` 生效,仅流式 chat** | 两阈值各自独立,可独设可组合;仅设 `STALL_AFTER_S` 时首 token 前的沉默**不触发**(窗在首 token 到达后才开);仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念,等价未配置);Embedding/OCR scope 配了任一对冲键 → 装配期 warning 后静默退化 |
**与看门狗的交互及不对称声明(运维「挂起率」统计须知)**: 主路沉默超 `INTER_TOKEN_TIMEOUT_S` 被看门狗掐死 → 照常进 Transient 重试链并**喂熔断失败计数**(1.3.6 起既有语义),对冲路已在跑时本轮不因主路之死而败(等副路裁定,主路 attempt 记账照常);对冲输家被取消 → **不喂**熔断(1.3.7 已批准语义)。同为源沉默事件、两种处置,本版不改任一。
### 配置面
| 键 | 值域 | 缺省 | 守卫 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;调整接口三层同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`),无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 装配守卫: `≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,对冲窗永不开)→ **ValueError**;`≥ call_deadline_s` → ValueError;单源 scope 设阈值 → warning 允许 |
`GatewayClient(...)` 直传 `hedge_stall_after_s` / `stall_threshold_ms` 两 keyword-only 参数走同一份装配守卫;`from_settings`/`from_env` 照常透传。
## 1.3.7(2026-09-10) ## 1.3.7(2026-09-10)
给 chat 链路加了一条**默认关闭**的长尾对冲(issue #24):一次尝试挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢、输家取消。另给 `CallStats` 追加三个字段,其中 `generation_ms`(裸生成时间)对四条链路全部生效,与是否开启对冲无关。 给 chat 链路加了一条**默认关闭**的长尾对冲(issue #24):一次尝试挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢、输家取消。另给 `CallStats` 追加三个字段,其中 `generation_ms`(裸生成时间)对四条链路全部生效,与是否开启对冲无关。
+31 -3
View File
@@ -18,12 +18,13 @@
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 | | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** | | 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限) | 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限)
| 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 | | 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 | | 遥测与成本 | 每次调用(含缓存命中与失败)必录 38 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及裸生成时间 `generation_ms`——赢家那次 transport 调用的墙钟,排除准入排队/退避/触发前等待,与 `total_latency_ms` 的差值即「在等不在生成」——与对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) | | 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 | | 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** | | 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` | | 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
@@ -111,13 +112,40 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。 自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
## 1.3.8 生成时间口径与中段沉默对冲
> [!WARNING]
> **`generation_ms` 自 1.3.8 起是净生成时间,不再是墙钟。** 同一字段两版口径不同(放行依据:字段发布仅 2 天、消费者唯一、遥测无账本,人类「窗口期最小代价」裁决);1.3.7 的墙钟值 = 本版的 `generation_ms + stall_ms`。两段口径的存量快照不可直接纵比。
### `generation_ms` 口径表
| 项 | 口径 |
| --- | --- |
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk / usage 帧 / SSE keepalive 不计时 |
| `stall_ms is None` 分支条件 | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**;None = 「无沉默观测」而非 0,下游预算公式以此为分支条件 |
| 与基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
`max_inter_token_ms`(单次最大相邻间隔)原语义不变,与 `generation_ms` 是两个口径、不可互算。沉默归因同批进遥测表 `stall_ms`/`stall_count` 两**可空**列(INSERT 字段 36 → 38,旧行 NULL 不回填);`generation_ms` 本身仍不落库,墙钟用上式还原。对冲场景三值全部取**赢家路**:落选输家的沉默在其 attempt 遥测行(可按 `logical_call_id` join),被取消输家的 `hedge_cancelled` 行 stall 恒 NULL。
### 配置表(两键,均仅 chat 生效)
| 键 | 值域 | 缺省 | 守卫与调整接口 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;三层调整接口同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`);无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S` 生效;守卫: `≥ min(各源 inter_token_timeout_s 或 timeout_s)`(对冲窗永不开)或 `≥ call_deadline_s` → 装配期 **ValueError**,单源 scope → warning;三层调整接口同权(env 键 / `GatewaySettings.hedge_stall_after_s` / `GatewayClient(hedge_stall_after_s=...)`);Embedding/OCR scope 配任一对冲键 → warning 后静默退化 |
对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 未发布段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。
## 安装 ## 安装
发布在实验室 Gitea PyPI(公开包,匿名可装): 发布在实验室 Gitea PyPI(公开包,匿名可装):
```bash ```bash
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
"polygateway[redis,postgres,structured]>=1.3.7,<2" "polygateway[redis,postgres,structured]>=1.3.8,<2"
``` ```
核心仅依赖 `httpx` + `pydantic`;按需选 extras: 核心仅依赖 `httpx` + `pydantic`;按需选 extras:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "polygateway" name = "polygateway"
version = "1.3.7" version = "1.3.8"
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
+7 -1
View File
@@ -501,6 +501,10 @@ flowchart TB
指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。 指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。
**生成时间净口径与沉默归因(2026-09-11,1.3.8,issue #27;设计 `designs/2026-09-11-streaming-stall-design.md` §4)**: `CallStats.generation_ms` 自 1.3.8 起为**净生成时间** = 赢家那次 transport 调用墙钟 − Σ(所有 > `stall_threshold_ms`(默认 1000,`SourceConfig` 字段,env 键 / `GatewaySettings` 字段 / 构造 kwarg 三层可调,无 per-call 覆盖)的相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗活性口径一致;空 chunk/usage 帧/SSE keepalive 不计时)沉默,**仍含 TTFT 与尾部**(末 chunk→流结束归活跃侧,墙钟基底口径的有意选择);1.3.7 的墙钟口径由 `generation_ms + stall_ms` 还原,语义突变经人类"窗口期最小代价"裁决放行(字段发布仅 2 天/消费者唯一/遥测无账本)。归因两字段 `stall_ms`/`stall_count`(沉默总额/超阈值相邻间隔个数,成对报告——Gregg off-CPU 原则);非流式/增量 chunk <2 → None 且不退减(None = 无沉默观测,下游以 `stall_ms is None` 分支);对冲输家的沉默不进赢家快照(落选者在其 attempt 遥测行,被取消者 `hedge_cancelled` 行恒 None)。**换算唯一处纪律(N8)**: TransportResult 携带未取整 `float` stall 值,取整与净额计算(`net = max(0, wall_ms − int(stall_ms or 0))`,clamp 防御)全库只在 retry 层一处,各实现不得另取整;钟同源(N4)——沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)同钟,FakeClock 测试配自控 stall 值的假 transport。观测原语: transport 热路径只做"记时间戳 + set `content_event`",一切计量与触发判定在热路径外从该序列派生。扣沉默是库自造口径,NVIDIA/vLLM 基准口径均不扣沉默,不得混写。
**中段沉默对冲(2026-09-11,1.3.8,issue #28;设计同上 §5)**: `{SCOPE}__HEDGE__STALL_AFTER_S`(默认关闭)与 1.3.7 `{SCOPE}__HEDGE__AFTER_S` **两阈值独立生效**(可独设可组合);`_past_hedge_window` 为**两阶段触发窗**——阶段 1 首 token 前: AFTER_S 已设才计时触发(仅设 stall 时此阶段不触发,但仍须等首 token 到达才进阶段 2,否则 pre-TTFT 沉默会被 stall 窗误吞);阶段 2 首 token 后: STALL_AFTER_S 循环等待 `content_event`,超时到期复检 `is_set()` 防 chunk 与超时同 pass 误触发、check→clear 之间无 await、每轮自建 waiter 并逐字保留 waiter 收口 + `cancelling()` 穿透纪律;仅设 stall 且请求非流式 → `__call__` 入口门逐字走单路(中段沉默无非流式概念)。触发后编排/记账/遥测全复用 1.3.7(异源、完整准入、准入失败静默放弃、输家取消 est 保留不喂熔断、`hedge_cancelled` 行、两败计一次预算);对冲路 `content_event` 恒 None(v1 不梯次)。**与看门狗的交互**: 主路沉默超 `inter_token_timeout_s` 被掐死时,对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路裁定),attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常。**不对称声明(N9)**: 看门狗掐死**喂**熔断失败(1.3.6 起既有语义)vs 对冲输家取消**不喂**熔断(1.3.7 已批准语义)——同为源沉默事件不同处置,本版不改任一,运维"挂起率"统计须知此不对称。装配硬守卫: `stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,对冲窗永不开)或 `≥ call_deadline_s` → ValueError;单源 scope → warning 允许;Embedding/OCR scope 配任一对冲键 → `__post_init__` warning(B4,装配期可证明纯非流式;chat 的流式性是 per-call 参数,装配期无信号,故 chat 侧无此 warning)。**诚实标注: 中段沉默触发并发副本无业界公开先例**(主流收敛为看门狗掐流 → 重试),属超业界现状能力,人类已明确接受。
### 7.3 限流 ### 7.3 限流
**语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。 **语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。
@@ -562,7 +566,9 @@ flowchart TB
### 7.8 遥测与成本 ### 7.8 遥测与成本
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**。 **必录字段**(继承三项目 15 字段规范;当前 38 个 INSERT 字段,物理表列 39 = 38 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**、**stall_ms**、**stall_count**。
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。 **`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
+2
View File
@@ -70,6 +70,8 @@
**已交付增补(2026-09-10)**: 长尾对冲(issue #24,1.3.7)——chat 单路并发对冲,触发=流式首 token 未至/非流式纯时长阈值,异源走完整准入,默认关闭;`CallStats` 增 `generation_ms`(裸生成时间)/`hedges`/`hedge_won`。设计 designs/2026-09-10-24-hedged-requests-design.md(人类已批准 H1-H8),实施 plans/2026-09-10-24-hedged-requests.md。后续储备:梯次多路对冲(H5 预留)、分位数触发、embedding/OCR 对冲(现不做)。 **已交付增补(2026-09-10)**: 长尾对冲(issue #24,1.3.7)——chat 单路并发对冲,触发=流式首 token 未至/非流式纯时长阈值,异源走完整准入,默认关闭;`CallStats` 增 `generation_ms`(裸生成时间)/`hedges`/`hedge_won`。设计 designs/2026-09-10-24-hedged-requests-design.md(人类已批准 H1-H8),实施 plans/2026-09-10-24-hedged-requests.md。后续储备:梯次多路对冲(H5 预留)、分位数触发、embedding/OCR 对冲(现不做)。
**已交付增补(2026-09-14)**: 流式中段沉默计量与对冲(issue #27/#28,1.3.8)——`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部;1.3.7 墙钟口径由 `generation_ms + stall_ms` 还原,披露见 CHANGELOG 未发布段),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加同两列(INSERT 字段 36→38);`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立于 `HEDGE__AFTER_S` 生效、默认关闭;无业界公开先例,超业界现状能力),`{SCOPE}__STREAM__STALL_THRESHOLD_MS` 计量阈值(默认 1000,三层可调)。设计 designs/2026-09-11-streaming-stall-design.md(人类已批准 + 独立审查补遗 B1-B4/N1-N9),实施 plans/2026-09-11-streaming-stall.md,验证 findings/2026-09-11-streaming-stall-validation.md。后续储备:自适应分位阈值(防反馈环,现不做)、流式透传下的 commit 点重审、embedding/OCR 对冲(现不做)。
## 7. 开放决策依赖 ## 7. 开放决策依赖
| 决策 | 阻塞点 | 需拍板时间 | | 决策 | 阻塞点 | 需拍板时间 |
@@ -0,0 +1,232 @@
# 流式中段沉默:计量口径与对冲(已批准设计)
- 状态: **已批准 + 独立审查补遗已并入(2026-09-11,`reviews/2026-09-11-streaming-stall-design-review.md`,B1-B3/N1-N9 全处置,见 §11)**;本文件只做设计,不含实现
- 基线: main `a28d944` / 1.3.7(已发布,含单路并发对冲与 `CallStats.generation_ms/hedges/hedge_won`)
- 输入: `findings/2026-09-11-streaming-stall-hedging-research.md`(业界范式与来源,下称"调研",引用处标注 §x.y);下游 Terminal-Bench 实测 4407 次成功调用遥测(需求来源);1.3.7 设计 `designs/2026-09-10-24-hedged-requests-design.md`;远端 issue #27(P1)/#28(P2)
- 关联: ARCH §6.4 取消语义、§7.2 重试;既有流式看门狗 `streaming.py`;1.3.7 对冲机制(本设计直接站在其编排与记账语义上)
## 1. 背景与问题陈述
两个问题都是**口径/能力缺口,不是实现 bug**:1.3.7 行为与其设计文档(§4.5 H8、对冲触发判据)完全一致,但下游实测暴露了设计时未覆盖的病态形态。
### P1:中段沉默计入 `generation_ms`,"裸生成时间"名不副实(issue #27)
`CallStats.generation_ms` 的口径是赢家 transport 调用的完整墙钟:`middleware/retry.py:332` `gen_started` 紧贴 `transport.complete()` 调用前,`:346` sink 在返回后立即记录。对流式,`complete()` 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。
下游实测(Terminal-Bench,4407 次成功调用):
| 指标 | 数值 | 含义 |
| --- | --- | --- |
| ttft 中位 | 1.63s(仅 3 次 >20s) | 首 token 几乎从不慢 |
| 慢调用 `generation_ms` 中位 | 69.4s | 墙钟长尾严重 |
| 其中 `max_inter_token_ms` 中位 | 54.9s | 长尾的主体是**一次中段沉默** |
| 沉默占全部模型时间 | 71.3% | "在等不在生成"是主要成本 |
| 沉默与输出/输入量相关性 | r = 0.05 / −0.06 | 沉默与生成量无关 |
| reasoning_tokens 上报次数 | 129 / 4407 | 沉默**不是**模型在思考,是渠道/服务端排队(调研 §A4:prefill/decode 互踩是结构性现象) |
结论:下游需要一个"扣除沉默段的活跃生成时间"口径,且带归因(沉默几次、共多长),不是只给一个净数(Gregg off-CPU 分解原则,调研 §B2)。
### P2:中段沉默无对冲,现有两道闸门都接不住(issue #28)
| 既有机制 | 证据 | 为何接不住中段沉默 |
| --- | --- | --- |
| 首 token 对冲 `HEDGE__AFTER_S` | `middleware/retry.py:542-562` `_past_hedge_window` 返回 `not primary.done() and not first_token.is_set()`;非对冲模式 `__call__` 传 `first_token_event=None` 根本不观测 | 首 token 到达后对冲窗**永不打开**;实测 ttft 中位 1.63s,`AFTER_S=20` 对流式调用恒不触发 |
| 看门狗 `INTER_TOKEN_TIMEOUT_S` | `streaming.py`(budget = ttft_s 首 chunk / inter_token_s 后续);`openai_compat.py:573` `inter_token_s = source.inter_token_timeout_s or source.timeout_s`;配置 `config.py:46` | 能掐中段沉默,但**掐掉即丢弃全部已生成内容**、从头重试;重试可能再卡;每步成本 = 一次完整重新生成 |
调研结论(§A3):业界对中段沉默的落地形态收敛为"间隔看门狗 → 抛错 → 重试",**"中段沉默触发并发副本"在任何主流网关/库中均无公开先例**——本设计的 P2 方案 2B 属超业界现状能力,须诚实标注。
## 2. 业界范式摘要(压缩自调研,不重查网络)
| 范式 | 出处(调研节) | 对本设计的对应 |
| --- | --- | --- |
| 中段沉默对冲**无公开先例**;主流 = 看门狗 → Transient → 重试 | 调研 §A3(LangChain `stream_chunk_timeout` 默认 120s 已合并;Codex CLI 静默 300s 整路重试;LiteLLM 提案默认关) | P2 的 2A = 业界基线;2B 若做须自证语义 |
| 沉默扣除唯一先例 pi-tps:gap ≥500ms 判 stall,**减所有超阈值段**,连续停顿合并一次,样本不足返回 null | 调研 §B3 | P1 的 `stall_ms/stall_count` 口径直接照此;"只减最大一段"无任何先例,否决 |
| Gregg off-CPU:墙钟分解须**成对报告 + 归因**,只报净数无法行动 | 调研 §B2 | 1B' 下由 `generation_ms`(净)+ `stall_ms`/`stall_count` 成对报告满足,墙钟 = `generation_ms + stall_ms` 可还原 |
| NVIDIA TTFT/ITL/E2EL:排队计入 TTFT,基准口径**不扣除**沉默 | 调研 §B1 | 扣沉默是库自造口径,必须显式声明、不与基准口径混写 |
| vLLM `generation_time_ms` 排除条款:token 数 <2 时间隔类字段 null;`n>1` 无法归因即抑制 | 调研 §B3 | 三新字段的 None 条款照抄:增量 chunk <2 / 非流式 → None |
| Envoy/gRPC:判赢在**响应头**即 commit 不回头;per_try_timeout 只在响应开始前有效,中段停顿不重新对冲 | 调研 §A2 | P2 赢家裁定与"不回头"语义的参照;约束未来流式透传(§5.2) |
另:配置命名先例对照见调研 §3.3,本设计键名与其对齐。
## 3. 共享观测原语:增量 chunk 单调时间戳序列
P1(计量)与 P2(中段触发)共用同一份底层观测,避免两套计时逻辑漂移:
| 项 | 设计 |
| --- | --- |
| 记录点 | transport 层(`openai_compat.py:580-590` 现算 `max_gap` 的循环处已逐 chunk 迭代,时间戳序列在此追加记录,增量成本近零;:605 是 `max_inter_token_ms` 装配处) |
| 记录内容 | **chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致**(`openai_compat.py:585-588` 注释"思考增量同样是存活证据"同款);**仅空 chunk / usage 帧 / SSE keepalive 不计时**(keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,本序列天然免疫,LangChain 语义见调研 §A3) |
| 派生原则 | 一切计量(`stall_ms` 等)与中段触发信号(§5.2)都从此序列/其增量事件派生,**不在热路径做判定**(调研 §3.2-1);热路径只做"记一个时间戳 + set 一个 Event" |
| chunk ≠ token | chunk 边界是传输/解析缓冲产物,非 token 边界(vLLM #27485 陷阱,调研 §B1);一切间隔类口径按 chunk 声明,不假装是 ITL |
P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk 即刷新一个 `asyncio.Event`(语义 = "自上一增量 chunk 以来未再沉默"),编排侧以"等该事件超时"实现 stall 计时器——与 1.3.7 首 token 事件同构,零新机制类别。
## 4. P1 方案:沉默计量口径(1B' 已批准)
### 4.1 方案 1A:不改库,下游自算 —— 已否决
| 项 | 内容 |
| --- | --- |
| 语义 | 下游用既有 `LLMResponse.max_inter_token_ms`(`types.py:406`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 |
| 改动面 | README/CHANGELOG 口径澄清,零代码 |
| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py` COLUMNS),存量数据即可分析(本次实测正是用它定位的病) |
| 缺点 | 多段沉默场景**系统性高估**活跃时间(调研 §B3:只减最大一段无任何先例);口径责任散落每个下游,各家算法漂移后跨项目不可比;无 `stall_count`,违反 Gregg 归因原则 |
**否决理由(人类裁决,2026-09-11)**:口径必须库内收敛,不得散落各下游自算。
### 4.2 方案 1B'(已批准):`generation_ms` 口径重定义
人类原话:"如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"。PM 澄清两字段本质不同(完整墙钟 vs 单次最大沉默段)后,人类意图收敛为:原草案 1B 的 `active_generation_ms` 净口径**直接写进 `generation_ms` 本身**,不新增派生字段。
| 项 | 内容 |
| --- | --- |
| 语义 | `generation_ms` 重定义为**净生成时间** = 赢家 transport 墙钟 − Σ(所有 > 阈值的相邻**增量 chunk** 沉默,pi-tps 式口径);`CallStats` 新增两归因字段(默认 None):`stall_ms: int \| None`(沉默总额)、`stall_count: int \| None`(= **超阈值相邻间隔个数**;pi-tps"连续停顿合并一次"子句不移植——gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句是 pi-tps 事件流语义、在本口径下无操作定义,§11-N6);**不新增 `active_generation_ms`**——`generation_ms` 本身就是净口径 |
| 边界与换算(§11-N1/N4/N8 钉死) | 净值 = 墙钟 − 中段沉默,**仍含 TTFT 与尾部**(末 chunk→流结束);尾部归活跃侧是有意选择(墙钟基底口径,尾部通常≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步。钟同源纪律:沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`。换算只此一处:TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——各实现不得另取整。FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 |
| 归因与还原 | 墙钟 = `generation_ms + stall_ms`;净数与沉默总额/次数成对报告,Gregg 成对报告原则(调研 §B2)在净口径下依然满足 |
| 排除条款(照 vLLM,与原案一致) | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` **不退减**(= 原墙钟);增量口径与看门狗一致(`content`/`reasoning_content` 均计)——reasoning 模型长 thinking 阶段只流 reasoning_content,若只计 content 会把正常推理误计为沉默(P1 虚高、P2 误触发);非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件;对冲输家的沉默**不进**赢家快照——已完成但竞速落选的输家其沉默在其 attempt 遥测行(可 join 还原);被取消的输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,其存在性由该行表达 |
| 阈值 | 新配置 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`,**默认 1000ms**(§9-③ 拍板);人类附加要求"需要是一个可以调整的参数,对外提供调整接口"——调整接口 = 既有装配路径(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层均可调),**不提供 per-call 覆盖**(§8 非目标 F);**不做自适应**(防与对冲阈值耦出反馈环,调研 §3.2-3) |
| 遥测 | 遥测表新增 `stall_ms`/`stall_count` 两列(§9-⑤ 拍板):`telemetry/schema.py` PG/SQLite 各一次可空 DDL + recorder/`middleware/telemetry.py` 透传;`generation_ms` **仍不进遥测列**(YAGNI——下游要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原) |
| 改动面 | `types.py:296-324`(`CallStats` 增两字段);`ports.py` `TransportResult` 增 `stall_ms`/`stall_count: float \| None` 默认 None;`openai_compat.py:580-590`(max_gap 循环邻域;:605 是装配处)从 chunk 时间戳序列计算两值(沿用 §3 原语);`middleware/retry.py:332/346` 邻域(赢家记账改为 `record_generation`(净值) + context 存 stall 两值进快照,口径与 H8 同款);遥测透传链路(PM 裁决选 (b):LLMResponse 不增顶层字段——CallStats 已承载赢家口径,避免同数据两处平铺漂移):`middleware/telemetry.py` `_emit` 显式从 TransportResult 传 stall 两值 + emitter `_AttemptUsage` 增两字段 + `ports.py` `TelemetryRecorder.record_llm_call` 签名加两参(**第三次端口签名变更**,§7 端口行补录;emitter 装配闸 `signature.bind` 以该协议签名为判据,漏改协议即装配期炸)+ 两 recorder 实现(PG/SQLite)透传;`telemetry/schema.py` 编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;配置走 `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段;`client.py` 构造透传 |
#### 契约变更的正当性与披露
语义突变在一般情形下违反"只增不删不改名"精神(§4.3 拒绝理由),本次由人类以"窗口期最小代价"裁决放行,依据:
| 依据 | 事实 |
| --- | --- |
| 字段极新 | `generation_ms` 是 1.3.7(2026-09-10)引入的新字段,发布仅 2 天 |
| 消费者唯一 | 唯一消费者是提出本需求的下游 Terminal-Bench;三参考项目均未消费该字段 |
| 名实自相矛盾 | 字段名"裸生成时间"原本承诺的就是净口径,现实现(墙钟)与名字矛盾,本次重定义是**兑现名字**而非推翻承诺 |
| 无账本断裂 | 遥测 DB 从未存过 `generation_ms`(已验证 `telemetry/schema.py` 与 `middleware/telemetry.py` 均无此字段),库内无历史账本纵向比较断裂 |
披露义务:CHANGELOG 设独立"行为变更"段显著标注 1.3.7→1.3.8 口径变化 + README 口径表更新 + 本设计存档。
### 4.3 方案 1C:改 `generation_ms` 口径为扣沉默 —— 原拒绝理由被人类裁决覆盖(留档)
| 理由 | 依据 |
| --- | --- |
| 已发布契约突变:1.3.7 设计 §4.5 H8 明写墙钟口径并已被下游消费 | 铁律"字段只增不删不改名";语义突变等同改名 |
| 历史账本不可比:遥测 DB 存量 `generation_ms` 全是墙钟,口径切换后纵向比较断裂 | 遥测必录铁律的对账前提 |
| NVIDIA/vLLM 基准口径均不扣沉默(调研 §B1),库把墙钟改掉会与业界定义脱钩 | 口径分层应靠新增字段,不是靠改旧字段 |
**人类裁决(2026-09-11)**:上述理由在一般情形下成立,但人类以"窗口期最小代价"覆盖之(字段发布仅 2 天、消费者唯一、遥测无账本,见 §4.2 正当性表)——净口径以 1B' 形式落地;原 1B 三字段并存案随之被取代。
### 4.4 P1 对比与决策
| 维度 | 1A 下游自算(已否决) | 1B 原三字段案(已被取代) | **1B' 口径重定义(已批准)** | 1C 改原口径 |
| --- | --- | --- | --- | --- |
| 多段沉默准确性 | 差(系统性高估) | 好(减所有超阈段) | 好(减所有超阈段) | 好 |
| 归因能力 | 无(只有最大段) | 有(总量 + 次数) | 有(`stall_ms`/`stall_count` 成对) | 无(只有净数) |
| 口径唯一性 | 散落各下游 | 库内收敛 | 库内收敛 | 库内但破坏存量 |
| 契约风险 | 零 | 只增字段 + 遥测 DDL | 语义突变,经人类以"窗口期最小代价"裁决放行(§4.2 正当性) | **违反只增不删精神,拒绝** |
| 实现量 | 零 | 中(五文件邻域) | 中(六文件邻域,含端口) | 小但不可接受 |
**决策:1B'(§9-① 拍板)**;1A 因"口径必须库内收敛"被人类否决;1B 原案被 1B' 取代;1C 拒绝理由被人类裁决覆盖(留档 §4.3)。
## 5. P2 备选方案:中段沉默的应对
### 5.1 方案 2A:不做中段对冲,看门狗链 + 文档
| 项 | 内容 |
| --- | --- |
| 语义 | 维持业界主流:中段沉默超 `INTER_TOKEN_TIMEOUT_S` → 掐流 → Transient → 既有重试/换源链;库补文档与配置指引(各源默认值、与 `timeout_s` 的回落关系 `openai_compat.py:573`、建议按源沉默分布取值) |
| 改动面 | README/wiki 配置指引,零代码 |
| 优点 | 零实现零契约变更;有 LangChain(默认 120s)与 Codex CLI(300s)生产先例(调研 §A3);掐死即 Transient,四分类不新增语义 |
| 缺点 | 掐掉即**丢弃全部已生成内容**、从头重试;重试可能再卡同一类排队;每步成本 = 一次完整重新生成;实测沉默 >20s 占 19.0%,这条链会频繁点火 |
### 5.2 方案 2B(已批准):中段沉默对冲
| 项 | 内容 |
| --- | --- |
| 触发 | 新配置 `{SCOPE}__HEDGE__STALL_AFTER_S`(**独立于 `HEDGE__AFTER_S` 生效**,§9-④ 拍板"否,独立生效";调研 §3.3 命名);语义:主路**相邻增量 chunk 间隔**超此阈值 → 触发。机制 = §3 原语派生的"增量 chunk Event 等待超时",与首 token 事件同构;`_past_hedge_window`(`retry.py:542-562`)扩为"首 token 未至走 AFTER_S,首 token 已至走 STALL_AFTER_S"。并发纪律(Event + 复检方案,§11-N3):①超时到期后触发对冲前必须复检 `is_set()`(chunk 与超时同一事件循环 pass 到达时防误触发);②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸声明);④stall 等待每轮迭代自建 waiter,逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义 |
| 对冲执行 | 触发后主路**不取消**;并发异源副路走**完整准入 + 熔断**,无配额静默放弃(1.3.7 设计 §4.3 语义原样复用);`FIRST_COMPLETED` 赛跑;输家取消,est 保留不喂熔断、遥测 `hedge_cancelled`(1.3.7 设计 §4.4/§4.5 全部复用,零新记账语义) |
| 赢家计量 | 赢家路为准:`generation_ms`(1B' 净口径)/`stall_ms`/`stall_count`(1B' 同批落地,§9-①)全部取赢家路;`hedge_won` 语义不变(赢家是否对冲路) |
| 无拼接污染 | `chat()` 是**库内聚合流**——调用方拿完整 `LLMResponse`,流在库内消费完毕才返回;中段切换赢家时两路内容从未交付调用方,**无拼接问题**。这与调研 §3.1 基于"流透传"假设的 commit 语义不同。**约束明写**:未来若做流式透传,commit 点须重审——一旦任一路首 chunk 已透传下游,即按 Envoy"判赢不回头"锁定该路,中段对冲对该次调用永久关闭(§8 非目标:本版不做透传) |
| 与看门狗的交互 | 两参数语义独立:若主路沉默超 `inter_token_timeout_s` 被看门狗掐死,**对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常**;副路未起 → 照旧 Transient 重试。装配守卫升级为硬守卫(§6,§11-N5):`stall_after_s ≥ 有效最小值` → ValueError,对冲窗永不开的情形在装配期直接拒绝。不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 |
| 成本披露 | 实测沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft 仅 3/4407 超 20s);每触发一次最多 +1 并发请求 + 一份 est 预扣滞留(1.3.7 §4.4 口径);**默认必须关闭**,文档明写"对冲 = 用配额换延迟" |
| 改动面 | `retry.py`:`__call__` 入口门改为"两阈值(`hedge_after_s`/`stall_after_s`)任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置,中段沉默无非流式概念)"(§9-④ 独立生效的编排落点);`_past_hedge_window`(`retry.py:542-562`)从一次性等待重构为两阶段(首 token 前:`AFTER_S` 已设才等;首 token 后:`STALL_AFTER_S` 循环等待)+ 与看门狗掐死的汇合规则;`ports.py` + `openai_compat.py:569-575/580-590` 邻域(`complete` 加增量 chunk Event 参数——公共端口签名第二次变更,§9 整体批准覆盖);`config.py` 邻域(新键 + 交叉守卫);`client.py` 透传;遥测复用 `hedge_cancelled`,零新列 |
**方案 2C 不单列,作为 2B 的裁定细节**:看门狗掐死瞬间原子地并发补一路(掐与对冲合一)。评估:省一个参数但把"观测阈值"与"掐死阈值"耦合,掐死时副路从零开始(TTFT 全付),总延迟 = 沉默阈值 + 完整生成,严格劣于 2B 的"沉默中提前并发";且与 2A 链叠加时行为难推理。否决,仅留档。
### 5.3 P2 对比与决策
| 维度 | 2A 看门狗链 + 文档 | **2B 中段沉默对冲(已批准)** |
| --- | --- | --- |
| 已生成内容 | 掐掉全丢 | 主路成果保留到最后一刻,输家才弃 |
| 长尾延迟 | 沉默阈值 + 完整重试耗时 | ≈ 沉默阈值 + min(两路剩余) |
| 业界先例 | 有(LangChain/Codex CLI) | **无公开先例**(调研 §A3),属超业界现状能力 |
| 成本 | 每次沉默 = 一次完整重新生成 | 触发率 19% 量级 × 一次额外请求 |
| 公共面变更 | 零 | 端口签名再变 + 新配置键 |
| 实现量 | 零(文档) | 大(但编排/记账/遥测全复用 1.3.7) |
**决策:2B(§9-② 拍板,独立于 `HEDGE__AFTER_S` 生效)**。诚实标注保留:它超出业界现状能力(调研 §A3),人类已明确接受;2A 链(看门狗 → Transient → 重试)作为不启用对冲时的默认行为继续存在,文档指引义务不变;P1(1B')同批落地后下游可直接量化沉默代价。
## 6. 配置汇总(新增两键,默认关闭/合理默认)
| 键 | 值域 | 缺省 | 守卫与既有键关系 |
| --- | --- | --- | --- |
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000**(§9-③ 拍板,可调) | 仅计量口径,不改任何行为;不设 = 用缺省;调整接口 = env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层,无 per-call 覆盖;命名对齐调研 §3.3(pi-tps 500ms 先例,但场景不同) |
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒(复用 `ensure_call_deadline` 同款校验) | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S`;仅对流式 chat 请求生效;chat 非流式请求按 §5.2 入口门运行期静默退化——装配期**无法**判定 chat scope 的流式性(`stream` 是 per-call 参数,`client.py:355`/`types.py:470`),故 chat 侧无装配 warning(§11-B4);Embedding/OCR scope(装配期可证明纯非流式)检测到内嵌 gateway 的对冲键(`AFTER_S`/`STALL_AFTER_S`)非 None → 装配 warning(§11-B4,含 1.3.7 `AFTER_S` 同病顺带补齐);`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,`openai_compat.py:573` 回落同款)→ **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫,config.py:820-824——stall 场景无非流式残留收益,warning 论证不足);`stall_after_s ≥ call_deadline_s` → ValueError,落进 `check_hedge_assembly`(config.py:772);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款) |
`STREAM` 段须加入 `_RESERVED_SEGMENTS`(`config.py:57` 邻域,1.3.7 为 `HEDGE` 做过同款);3 段键天然跳过 `_load_sources`(`config.py:427-431`)。装配路径与 1.3.7 对冲同款:`GatewaySettings` 字段 + `__post_init__` 守卫 + `GatewayClient.__init__` keyword-only 入口即校;不提供 per-call 覆盖(阈值是源/渠道特性,1.3.7 §5 同款判据)。
## 7. 与既有契约的关系
| 契约 | 处理 |
| --- | --- |
| `CallStats`/`LLMResponse` 只增不删不改名 | 两新字段(`stall_ms`/`stall_count`)全 `int \| None`、默认 None;1.3.7 及以前构造的位置调用不炸(1.3.7 §4.5 同款论证);`generation_ms` 语义重定义为净口径(**已批准例外**,§4.2 正当性与披露),`max_inter_token_ms` 原语义一字不动 |
| 遥测迁移 | `stall_ms`/`stall_count` 两列可空,PG/SQLite 各一次 DDL;旧行 null;迁移纪律照 issue #12/#13/#15 教训与 1.3.7 §4.5 备选调先例;**加列已拍板**(§9-⑤) |
| 公共端口签名 | `Transport.complete` 随 2B 第二次加参(增量 chunk Event,必填 kw),与 1.3.7 `first_token_event` 同款约定;`TransportResult` 随 1B' 增 `stall_ms`/`stall_count`(默认 None);`TelemetryRecorder.record_llm_call` 签名随 1B' 加 `stall_ms`/`stall_count` 两参(**第三次端口签名变更**,emitter 装配闸 `signature.bind` 以该协议签名为判据)——三次变更均 §9 整体批准覆盖 |
| 文档登记义务 | README 能力表 + CHANGELOG + ARCHITECTURE.md(口径段与对冲触发段)三处同步,发版按 CLAUDE.md §4.4.1 清单执行 |
| 版本归口 | **1.3.8 确认**(人类拍板 P1 + P2 同批发);P1 含 `generation_ms` 口径变更,披露义务照 §4.2;P2 纯增量(新键 + 默认关闭行为) |
## 8. 非目标
| # | 不做 | 理由 |
| --- | --- | --- |
| A | 不做墙钟/净双口径并存字段(如 `active_generation_ms`) | 1B' 拍板净口径直接写进 `generation_ms`;墙钟由 `generation_ms + stall_ms` 还原(§4.2),并存字段徒增口径漂移面 |
| B | 不做自适应分位对冲阈值(bhope/hedge DDSketch) | 列未来方向:per-source 滚动分布跨进程要进 Redis,且与沉默计量阈值耦合有反馈环风险(调研 §3.2-3);本版绝对阈值已够用 |
| C | 不做流式透传 | 透传引入 commit 点与"判赢不回头"约束(§5.2),是独立大设计 |
| D | 不做内容拼接/续写 | 增量文本不可拼接,缝合即结果污染(调研 §3.1) |
| E | embedding/OCR 不动 | 无流式中段概念,沿用 1.3.7 非目标 A |
| F | 不做 per-call 沉默阈值覆盖 | 人类拍板(§9-③):调整接口走装配层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层),per-call 覆盖超本版范围 |
## 9. 决策记录(2026-09-11 全部拍板)
| # | 决策点 | 拍板结果 | 人类原话 / 选项 |
| --- | --- | --- | --- |
| ① | P1 选型 | **1B'**:`generation_ms` 口径重定义为净时间(原 1B 的 `active_generation_ms` 口径直接写进 `generation_ms` 本身,不新增派生字段);新增 `stall_ms`/`stall_count` 归因字段 | "如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"(PM 澄清两字段本质不同后,意图收敛为口径重定义) |
| ② | P2 选型 | **2B**:中段沉默对冲 | 选定 2B(已明确接受"超业界现状能力"标注) |
| ③ | `STREAM__STALL_THRESHOLD_MS` 默认值 | **1000ms,可调** | "需要是一个可以调整的参数,对外提供调整接口"——接口 = 装配三层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg),不提供 per-call 覆盖 |
| ④ | 中段对冲是否以 `HEDGE__AFTER_S` 已启用为前提 | **否,独立生效** | 原话"否,独立生效" |
| ⑤ | 遥测是否加 `stall_ms`/`stall_count` 两列 | **加** | 选定"加"(归因进 DB,可对账) |
另(§9 之外拍板):P1 + P2 **同批发 1.3.8**(§7)。
## 10. 自查记录
- 行数 231 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。
- 方案改动面均列具体文件:行号(1B'、2B);行号以 2026-09-11 独立审查核实记录为准(全部实读源码,见 §11)。
- 调研结论引用处标注(调研 §x.y);未重查网络。
- 2026-09-11 批准确认:§9 五项 + 同批发 1.3.8 全部落地本文;1A/1C 保留为否决留档,1B 原案标注被 1B' 取代。
- 2026-09-11 独立审查补遗:`reviews/2026-09-11-streaming-stall-design-review.md` 的 B1-B3/N1-N9 经 PM 逐条裁决全处置,修正直接并入正文对应小节,§11 只做索引;§9 五项拍板不变。
## 11. 独立审查补遗(2026-09-11)
审查报告 `reviews/2026-09-11-streaming-stall-design-review.md` 裁决"有条件实施"(3 阻断 + 9 建议);PM 逐条裁决如下,全部修正已并入正文对应小节(**本节只做索引,正文为准**)。§9 五项拍板不变。
| 发现 | PM 裁决 / 处置 | 正文落点 |
| --- | --- | --- |
| B1 chunk 定义未钉死 | chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时;排除条款"内容 chunk <2"改"增量 chunk <2"(reasoning 模型长 thinking 只流 reasoning_content,只计 content 会误计沉默:P1 虚高、P2 误触发) | §3 记录内容行、§4.2 语义/排除条款、§2 vLLM 行 |
| B2 遥测透传链路遗漏 | PM 裁决选 (b):`_emit` 显式从 TransportResult 传 stall 两值,LLMResponse 不增顶层字段(CallStats 已承载赢家口径,避免两处平铺漂移);`ports.py` `record_llm_call` 签名加两参(第三次端口签名变更,补进 §7 端口行);emitter `_AttemptUsage` + 两 recorder 实现 + schema.py 五处编辑点全列;输家表述修正:被取消输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,存在性由该行表达 | §4.2 改动面/排除条款、§7 端口行 |
| B3 编排入口遗漏 | `__call__` 入口门改"两阈值任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置)";`_past_hedge_window` 从一次性等待重构为两阶段(首 token 前 AFTER_S 已设才等 / 首 token 后 STALL_AFTER_S 循环等待);纯非流式 scope 设置给装配 warning | §5.2 改动面、§6 配置表 |
| N1 计时边界未明写 | 净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归活跃侧是有意选择(墙钟基底,尾部≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步 | §4.2 边界与换算行 |
| N2 非流式双口径 | 非流式 net≈wall(无中段沉默概念);下游预算公式以 `stall_ms is None` 为分支条件 | §4.2 排除条款 |
| N3 Event 并发纪律 | 采 Event + 复检方案:①超时到期后触发前复检 `is_set()`;②check→clear 之间无 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮迭代自建 waiter,逐字套用 `_past_hedge_window` waiter 收口 + `cancelling()` 穿透;⑤等待者唯一(编排) | §5.2 触发行 |
| N4 钟同源与负值防御 | 沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`;`net = max(0, wall − stall)` clamp;FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 | §4.2 边界与换算行 |
| N5 守卫三缺口 | 守卫用有效值 `min over sources of (inter_token_timeout_s or timeout_s)`(回落同款);`stall_after_s ≥ 该有效最小值` → **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫——stall 场景无非流式残留收益,warning 论证不足);`≥ call_deadline_s` → ValueError,点名落进 `check_hedge_assembly` | §6 配置表、§5.2 与看门狗交互行 |
| N6 `stall_count` 定义 | = 超阈值相邻间隔个数;删"连续停顿合并一次"子句(gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句移植自 pi-tps 事件流语义、在此无操作定义) | §4.2 语义行 |
| N7 引用勘误 | CallStats 在 `types.py:296-324`(原"417-418 邻域"错位,417-418 是 LLMResponse 邻域,`max_inter_token_ms` 在 :406);`config.py:401-407` → `:427-431`;"`config.py:46` 邻域" → `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段(:46 是 per-source 四段键表 `_SOURCE_FIELDS`,与 §6 三段键矛盾);max_gap 循环在 `openai_compat.py:580-590`(:605 是装配处);遥测编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"改"§9 整体批准覆盖" | §3、§4.1/§4.2/§5.2 改动面、§6、§7 |
| N8 换算钉死 | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`;`CallStats.stall_ms = int(stall_ms)`(None 透传)——换算只此一处,各实现不得另取整 | §4.2 边界与换算行 |
| N9 看门狗措辞 | "主路之死不算失败"改"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常";补不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 | §5.2 与看门狗交互行 |
| B4(计划期补遗,2026-09-11 PM 裁决) | "纯非流式 scope 设置时装配 warning"(B3/§6 原文)字面不可实现:装配期无法判定 chat scope 流式性(`stream` 是 per-call 参数)。操作化:①装配 warning 只落在 Embedding/OCR scope(装配期可证明纯非流式)——`EmbeddingSettings`/`OcrSettings.__post_init__` 检测内嵌 gateway 的 `hedge_after_s`/`hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化";`AFTER_S` 一并纳入(1.3.7 同病顺带补齐,属缺陷修复不扩公共面);②chat 侧装配 warning 明确不做,非流式请求按 §5.2 入口门运行期静默退化;③不新增任何 scope 级 stream 配置键(扩公共面且超 §9 键清单) | §6 配置表 STALL_AFTER_S 行 |
@@ -0,0 +1,174 @@
# 流式中段卡顿对冲与"净生成时间"口径调研
> 调研日期: 2026-09-11。对应缺口: ①对冲触发判据只盯"首 token 未到",真实故障是"首 token 快(中位 1.6s)但流中段长时间沉默(中位 55s)";②`generation_ms` 把中段沉默包进墙钟,下游需要"净生成时间"。
## 〇、总览: 结论先行
### 主题 A: 流式中段卡顿(stream started but stalls mid-way)的对冲/恢复范式
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
| --- | --- | --- | --- |
| Hedged/Tied requests(原始定义) | Dean & Barroso, CACM 2013 | 论文语境是一元(unary)完整响应的 leaf 扇出,"first result"指整个响应,**未讨论增量/流式响应** | 延迟派发(等 p95 再发第二路)+ 首个结果到达即取消其余 + 额外负载可低至 ~2%;tied request 的跨副本取消协议 |
| 自适应对冲(TTFT 竞速) | llms.blog 生产架构文;bhope/hedge; InfoQ | LLM 网关语境把赢家裁定从"响应头"前移到"首 token";但该文断言"卡顿几乎只发生在排队/prefill"——**与我们实测(中段沉默中位 55s)相矛盾**,SGLang decode 饿死 issue 证明中段卡顿真实存在 | 按模型×渠道维护滚动 P90/P95 TTFT(DDSketch)作动态对冲阈值;token bucket 限制对冲放大率 |
| Envoy hedge policy | Envoy 官方文档 | 对冲赢家裁定在**响应头**("first response with acceptable headers"),一旦裁定即 commit;**响应体中段停顿不会重新对冲**,只能靠 stream idle timeout 兜底 | "判赢即 commit、不回头"的语义参考;`hedge_on_per_try_timeout` 命名;per_try_timeout 只在响应开始前有效(PR #10082) |
| gRPC hedging | gRPC A6 提案 + 官方指南 | hedgingDelay 以"收到非错误响应(头)"为节拍;收到 nonFatal 状态码立即补发下一路;server pushback(`grpc-retry-pushback-ms`)可推迟/禁止对冲;retryThrottling 用 token bucket 压对冲率 | `maxAttempts`/`hedgingDelay`/`nonFatalStatusCodes` 配置三元组;上限钳到 5;deadline 覆盖整个对冲链 |
| 流内不活跃看门狗(inactivity watchdog) | LiteLLM PR #29767、LangChain PR #36949(已合并) | **业界对中段沉默的主流应对是"看门狗→超时→抛错/重试",不是并发副本**;关键细节: 以**解析后的内容 chunk 间隔**计时,SSE keepalive 注释不重置计时器 | `stream_chunk_timeout`(LangChain, 默认 120s) / `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(LiteLLM 提案,默认关);首 chunk 不受看门狗约束(TTFT 由请求超时管) |
| 沉默检测→整路重试 | OpenAI Codex CLI issue #33051 | Responses 流静默 300s 后判定 `stream disconnected - retrying sampling request (1/5)`,是"中段沉默触发重试"的真实生产先例 | 沉默超阈值 → 归为 Transient → 走既有重试/换源链,而非保活等待 |
| serving 层 ITL 劣化成因 | vLLM disagg prefill 文档、vLLM PR #33743、SGLang #32549/#29879 | 中段卡顿的服务端根因: prefill 与 decode 同卡互踩、chunked prefill 调度饿死 decode(实测 decode 被压到 ~1 batch/24s) | 中段沉默是**结构性现象**而非异常个例,网关层必须有对策;观测指标参考 `sglang:decode_step_latency_seconds` |
### 主题 B: "净生成时间"(扣除沉默段)观测口径范式
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
| --- | --- | --- | --- |
| TTFT / ITL(=TPOT) / E2EL 标准定义 | NVIDIA NIM/AIPerf 指标文档、NVIDIA 技术博客 | E2EL = TTFT + Generation_time(首 token→末 token,剔除 `[DONE]`);ITL = (E2EL−TTFT)/(输出 token 数−1);**排队、prefill、网络全部计入,无任何沉默扣除** | 口径分层: 排队/prefill 归 TTFT,纯解码段单独计量;工具间定义不一致(TTFT 是否计入 ITL 均值)必须显式声明 |
| TPOT vs ITL 聚合权重 | Anyscale 指标文档 | 单请求 TPOT = ITL 均值;多请求聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权 | 库内聚合统计要声明权重口径,否则跨实现不可比 |
| vLLM 阶段化计时 | vLLM metrics 设计文档 + per-request metrics 特性 | `request_queue_time / prefill_time / decode_time` 三段直方图;响应体 `metrics` 对象给 `generation_time_ms`(= decode time,排除排队与 prefill/TTFT) | **generation_time_ms 命名先例**(注意: vLLM 的 decode time 仍含引擎侧中段停顿,沉默扣除在服务端的口径也没人做) |
| off-CPU / wall-time 分解 | Brendan Gregg off-CPU analysis | 墙钟 = on-CPU + off-CPU(阻塞);方法论要求给阻塞段**归因**(stack/原因),而非只减总量 | 沉默段扣除必须同时记录沉默总量与归属(哪段、多长、几次),否则下游无法审计 |
| 沉默扣除实战先例 | pi-tps(pi 编辑器扩展) | **唯一直接对应"从生成速率里扣除沉默段"的实现**: gap ≥500ms 判为 stall,累计 stallMs/stallCount,从 streaming window 中扣除后算 TPS;连续停顿算一次事件;burst 交付/样本不足时输出 `—` 不硬算 | 阈值式扣除(减所有超阈值的段,而非只减最大一段);三分支防御(主口径/保守兜底/不可辨识时返回 null) |
| OpenRouter 供应商口径 | OpenRouter 路由/延迟文档 | 供应商对比口径: latency = TTFT(含网络+排队+prefill),throughput = 解码 TPS;Total = TTFT + tokens/TPS | "延迟=TTFT、速率=解码段"的二元口径是用户侧最广泛认知的形态 |
## 一、主题 A 详细发现
### A1. Tail at Scale 原始定义与流式后续工作
- 原始定义: "客户端先向其认为最合适的副本发送请求,短暂延迟后再发第二路;首个结果返回后取消其余在途请求"。额外负载可控: 等 95 分位预期延迟再发对冲,只惠及少量请求;更激进的对冲需要更快的取消(跨服务器取消的 tied requests)。([CACM 原文](https://cacm.acm.org/research/the-tail-at-scale/)、[作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf))
- 论文场景是搜索 leaf 扇出的一元响应(各 leaf 返回 top-k 部分结果给聚合器),**"partial"在文中指分片结果,不是增量 token 流**;全文无 streaming/incremental 响应的对冲讨论。([semicolony 批注版](https://semicolony.dev/papers/tail-at-scale))
- 后续工作的共识补充: 高负载下对冲会级联放大(每个慢请求都对冲→负载↑→更慢),生产系统需要熔断/自适应阈值等反馈回路。([semicolony 批注版·批评节](https://semicolony.dev/papers/tail-at-scale))
- LLM 语境的适配文(llms.blog): 把对冲裁定前移到 TTFT 竞速,主张"首 token 一旦产出,prefill 已完成、decode 高度确定,卡顿几乎只发生在排队/prefill"。**此断言与我们实测不符**(首 token 中位 1.6s、中段沉默中位 55s),且被 serving 层证据推翻(见 A4),引用时须警惕。([llms.blog](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols))
- bhope/hedge(Go 库): 按 host 用 DDSketch 学延迟分布,主路超 p90 估计即发备路,token bucket 压对冲率(防故障期放大),p99 从 64ms→17ms(~74%),开销 ~9%;**明确支持流式负载(按 TTFT 而非响应头计量)**。([GitHub](https://github.com/bhope/hedge)、[InfoQ 解读](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/))
### A2. 基础设施层: Envoy / gRPC / NGINX
- **Envoy**: 启用 hedge policy 后"竞速多个并发上游请求,把**首个带可接受响应头的响应**返回下游;由 retry policy 判定响应是否可接受"。当前对冲**只能由 per-try timeout 触发**(`hedge_on_per_try_timeout`: per-try 超时不取消原请求,直接补发)。([HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html)、[HedgePolicy API](https://javadoc.io/static/io.envoyproxy.controlplane/api/1.0.37/io/envoyproxy/envoy/config/route/v3/HedgePolicy.html)、[PR #6228](https://github.com/envoyproxy/envoy/pull/6228))
- **Envoy 对"响应已开始但中途停顿"**: per_try_timeout "只在响应开始前有效"(PR #10082 明确允许全局 route timeout 禁用时配 per_try_timeout 给流式端点用)——即**判赢发生在响应头,中段停顿不会触发新的对冲**;流式响应的停顿只能靠 stream idle timeout 兜底(route timeout 默认 15s 与流式不兼容,须禁用改用 idle_timeout)。([PR #10082](https://github.com/envoyproxy/envoy/pull/10082)、[超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html))
- **gRPC(A6)**: `hedgingPolicy = {maxAttempts, hedgingDelay, nonFatalStatusCodes}`;原请求立即发,每过 hedgingDelay 未收到非错误响应就补发一路;收到 nonFatal 状态码立即补发下一路;任一请求收到非错误响应→取消全部其余并返回该响应;fatal 状态码→取消全部并报错;server pushback(`grpc-retry-pushback-ms`,负值=禁止再发)可推迟/终止对冲;`retryThrottling{maxTokens, tokenRatio}` 按失败/成功比用 token bucket 压制对冲与重试;maxAttempts 客户端钳到 5;deadline 覆盖整条对冲链。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md)、[官方指南](https://grpc.io/docs/guides/request-hedging/))
- gRPC 对冲对 server-streaming: listener 把 `onNext/onClose` 转发给所有在途对冲 RPC——流式场景赢家仍按"首个非错误响应(头)"裁定,**没有按消息流中段进度重新裁定的语义**。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md))
- **NGINX**: 无对冲原语;`ngx_http_mirror_module` 只做多副本镜像且**忽略镜像响应**(fire-and-forget),不构成 FIRST_COMPLETED 竞速。([NGINX mirror 模块文档](https://nginx.org/en/docs/http/ngx_http_mirror_module.html))
### A3. LLM 网关/库层: 中段 stall 的真实实现与讨论
| 项目 | 机制 | 关键语义 | 来源 |
| --- | --- | --- | --- |
| LiteLLM(提案, PR 已 stale 关闭) | `_StreamInactivityGuard` + `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(默认关) | 以 `asyncio.wait_for` 约束 chunk 间隔;**首 chunk 不受看门狗约束**(TTFT 归请求 timeout, reasoning 模型首 token 慢);触发抛 `litellm.Timeout`(408) | [PR #29767](https://github.com/BerriAI/litellm/pull/29767) |
| LiteLLM(已发) | `ttft_timeout` 配置;`stream_timeout` 修为"整流墙钟上限" | `ttft_timeout`: 无首 token 超时即 `litellm.Timeout`(非流式内部提升为流式实现);httpx read timeout 被 keepalive 字节重置,**对"有字节无内容"的卡顿完全失效** | [config 文档](https://docs.litellm.ai/docs/proxy/config_settings)、[PR #31238](https://github.com/BerriAI/litellm/pull/31238) |
| LiteLLM(bug 证据) | 中段超时在 200 已发后抛在生成器内 | 被误记为成功(遥测语义教训);另有反向机制: 上游沉默时主动注入 SSE ping 保活(PR #36024, Anthropic 每 ~30s ping, Claude Code 20s 看门狗) | [Issue #29602](https://github.com/BerriAI/litellm/issues/29602)、[PR #36024](https://github.com/BerriAI/litellm/pull/36024) |
| LangChain(已合并, 1.2.0) | `stream_chunk_timeout`(默认 **120s**) + `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` | 计**解析后内容 chunk 的间隔**, SSE keepalive 不重置;触发抛 `StreamChunkTimeoutError`(TimeoutError 子类,携带 timeout_s/model/chunks_received 结构化字段);配套 TCP keepalive/`TCP_USER_TIMEOUT` socket 选项 | [PR #36949](https://github.com/langchain-ai/langchain/pull/36949)、[API 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) |
| LangChain JS(诉求) | 用户要"不活跃超时"而非"总时长超时" | 总时长超时误杀长流;间隔计时才是正确语义 | [langchainjs #9088](https://github.com/langchain-ai/langchainjs/issues/9088) |
| OpenAI Codex CLI | 流静默检测 + 整路重试 | 静默 300s 判 `stream disconnected`,重试 `retrying sampling request (1/5)`;用户报告 5–10 分钟级卡顿 | [issue #33051](https://github.com/openai/codex/issues/33051) |
| Portkey | request_timeout 曾用 `AbortSignal.timeout()` | bug: 流已开始仍被总超时杀掉;修复为收到首个响应即清定时器——"总时长 vs 不活跃"同一教训 | [Issue #471](https://github.com/Portkey-AI/gateway/issues/471)、[streaming 文档](https://portkey-ai-gateway.mintlify.app/features/streaming) |
| Bifrost / Helicone | 重试+fallback+自适应 LB | **未见公开的中段沉默触发重试/对冲实现**;Bifrost 生态文章把 "parallel hedged requests" 列为路由模式之一但无流式细节 | [Bifrost retries](https://bifrost.mintlify.app/features/retries-and-fallbacks)、[Helicone retries](https://docs.helicone.ai/features/advanced-usage/retries) |
**结论**: 业界对"流中段沉默"的落地形态收敛为 **间隔式不活跃看门狗 → 抛错(Timeout 子类)→ 交给既有重试/换源链**;"中段沉默触发并发副本(hedging)"在任何主流网关/库中**均未找到公开实现**——我们的 FIRST_COMPLETED 异源对冲若覆盖中段沉默,属于超业界现状的能力,但语义上可组合"看门狗 + 已取消输家不可复活"来实现。
### A4. serving 层: inter-token stall 的观测与成因
- **成因**: 连续批处理引擎把新请求的 prefill(算力密集)与在途请求的 decode(带宽密集)交错;vLLM 官方文档承认未分离时"decode 中插入 prefill 会导致 tail ITL 尖刺",故推 P/D 分离分别调 TTFT 与 ITL。([vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/))
- **实测极端案例**: SGLang 严格 prefill-first 调度下,持续 prompt 负载使 decode 饿死到 **~1 batch/24s**——即中段沉默数十秒是调度器结构性产物,非异常。([SGLang #32549](https://github.com/sgl-project/sglang/issues/32549))
- **观测指标先例**: vLLM `vllm:inter_token_latency_seconds` 直方图(llm-d 运维文档建议用 `histogram_quantile` 盯分位,"ITL 高→输出断断续续");SGLang 新增 `forward_pass_duration_seconds`/`decode_step_latency_seconds`(按 prefill 桶×decode 请求数打标,专测 decode 被干扰)。([vLLM metrics](https://docs.vllm.ai/en/stable/design/metrics/)、[llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md)、[SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879))
- **缓解手段**: chunked prefill(vLLM 默认预算 2048 tokens/迭代,防长 prompt 冻结在途会话)、inter-prefill-budget(中位 TTFT 降 37%,防 HoL 阻塞)。([LeCompute 调度调优](https://lecompute.fr/en/runtimes/latency-against-latency/)、[vLLM PR #33743](https://github.com/vllm-project/vllm/pull/33743))
## 二、主题 B 详细发现
### B1. LLM 推理基准的标准时间口径
- **NVIDIA NIM/AIPerf 官方定义**: TTFT = 提交查询→收到首个 token(忽略空内容的首个响应;含排队+prefill+网络);E2E 请求延迟 = TTFT + Generation_time(首 token→末 token,剔除末尾 `[DONE]`/空响应);ITL =(E2E−TTFT)/(输出 token 数−1),**显式排除 TTFT**,分母减 1 使其只刻画 decode 段。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
- NVIDIA 技术博客同口径: "TTFT 含请求排队、prefill 与网络延迟";"E2E = TTFT + 生成时间;ITL 为相邻输出 token 间隔均值(不含首 token)"。([NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/))
- **排队与网络如何处理**: 基准口径**不扣除**——排队计入 TTFT/E2EL,网络计入 TTFT;跨工具可比的前提是定义对齐("Tool implementations vary, so compare results only when definitions align")。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
- **Anyscale/vLLM 口径**: 单请求 TPOT = ITL 均值 =(E2E−TTFT)/(token 数−1);聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权——两者回答不同问题(单请求体感 vs 系统稳态流率)。([Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md))
- **MLPerf Inference**: LLM server 场景收 TTFT + TPOT 两指标,TPOT 定义为"所有生成 token 间的平均间隔";延迟自 LoadGen 调度时刻起算(含排队)。([inference_rules.adoc](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc)、[MLPerf 5.1 说明](https://mlcommons.org/2025/09/small-llm-inference-5-1/))
- **陷阱先例**: vLLM bench serve 的 ITL 在 chunk 与 token 非 1:1 时(reasoning/tool-call parser 缓冲)算错——**chunk 边界 ≠ token 边界**,任何按 chunk 计时的口径都要声明这一点。([vLLM #27485](https://github.com/vllm-project/vllm/issues/27485));另有"ITL 低于 TPOT"的历史 bug(均值口径混淆)。([vLLM #6531](https://github.com/vllm-project/vllm/issues/6531))
### B2. wall-time vs on-CPU: 扣除外部阻塞时间的标准做法与陷阱
- **off-CPU analysis**(Brendan Gregg): CPU profiling 只看在 CPU 上执行的线程;off-CPU 分析测量线程被阻塞(off-CPU)的时间并带上下文(stack trace),两者互补以解释 100% 线程时间。off-CPU 时间 = 调度器 off-cpu→on-cpu 探针的时间戳差。([Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html)、[offcputime 工具](https://github.com/brendangregg/bpf-perf-tools-book/blob/master/originals/Ch06_CPUs/offcputime.bt))
- **方法论要点**: ①墙钟分解必须成对报告(on-CPU + off-CPU flame graph 同看),只报"净时间"会丢失归因;②阻塞时间若发生在请求的同步路径上,则直接等比影响延迟——即"扣除"是分析手段,不改事实;③归因需要上下文(stack/wakeup stack),单给一个总数无法行动。([Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html)、[Wakeup/Off-Wake](https://www.brendangregg.com/blog/2016-02-01/linux-wakeup-offwake-profiling.html))
- 映射到我们的场景: `generation_ms`(墙钟)= 活跃生成(on-CPU 类比)+ 中段沉默(off-CPU 类比);Gregg 范式要求的"带归因的分解"对应"沉默总量 + 沉默次数 + 各段位置",而非只给一个净数。
### B3. 从 token 到达序列扣沉默段的计量学先例与陷阱
- **唯一找到的成体系先例 — pi-tps**: 每个流式更新测量与上一更新的间隔;**gap ≥ 500ms 判为 stall**,整段累计为 `stallMs`、连续停顿算一次 `stallCount`;算 TPS 时把 stall 从 streaming window 中扣除;每个 message 边界重置 stall 时钟(工具调用间隙不误计);三分支防御: 主口径(≥5 次更新、平均间隔 ≥1ms、stall < 活跃时间)、保守兜底(用含 TTFT 的全窗口,宁可低估)、**结构性不可辨识时返回 null 显示 `—`**(burst 交付/样本不足)。([pi-tps 包页](https://pi.dev/packages/@logicouo/pi-tps?page=49))
- **vLLM 服务端口径**: `generation_time_ms` = 首 token→末 token 的 decode 时间,**已排除排队与 prefill/TTFT,但仍包含引擎侧中段停顿**(preempt/调度等待);`mean_itl_ms` 单 token 响应为 null;`n>1` 时整个 metrics 抑制(无法归因)——口径排除条款的范本。([vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/))
- **OpenRouter 用户侧口径**: latency = TTFT(网络+排队+prefill),throughput = 解码 TPS;`Total = TTFT + tokens/TPS`——二元口径,不做沉默扣除,但把"等"与"生成"分开呈现是行业用户心智。([OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance)、[供应商评估](https://openrouter.ai/blog/insights/evaluate-llm-provider-performance/))
- **三种扣除形态的判定**(综合以上先例):
| 形态 | 评价 |
| --- | --- |
| 只减最大一段 | 无任何先例;多段沉默场景系统性高估活跃时间,不推荐 |
| 减所有超阈值段(pi-tps 式) | 有唯一直接先例;阈值外小停顿计入活跃时间,偏差有界且方向保守,**推荐** |
| 减所有段(纯 token 间隔求和) | 等价于重定义 ITL;chunk≠token 时不可算(vLLM #27485),放弃 |
- **尾部归属**: AIPerf 显式剔除末尾 `[DONE]`/空响应(E2E 止于末 token);pi-tps 的 stream span 止于最后一次 message_update;**最后一个 token 的间隔属于生成段内,流终止信号之后的时间一律不计**。vLLM 单 token 响应 `mean_itl_ms = null` 提示: token 数 <2 时一切间隔类口径应输出 null 而非 0。
## 三、对库设计的具体建议
### 3.1 中段对冲的赢家裁定语义
1. **裁定事件分两级**: 现行"首 token 竞速"保留(命名对齐业界: TTFT race);新增**中段沉默竞速**——主路沉默超过 `stall_after_s` 触发异源副路,副路同样按"首个内容 chunk"裁定。
2. **commit 点**: 借鉴 Envoy/gRPC"首个可接受响应即 commit":一旦某一路的**首个内容 chunk 已向下游交付**,该路成为 committed winner,其余路取消、不得复活——chat completion 的增量内容**不可拼接**(两路文本分叉,缝合即结果污染),这是 Env名"判赢不回头"语义在 LLM 场景的必然推论。
3. **commit 前摇摆**: 若副路先出 chunk 而主路恰好恢复,以**先交付者**为准(FIRST_DELIVERED),不做内容比车。
4. **committed winner 再沉默**: 不再对冲(无副本可换语义),走看门狗 → `stall_timeout` 抛错 → 错误分类 Transient → 交既有重试/换源链(Codex CLI 先例);已在下游的增量内容如实保留并标记截断。
5. **成本与放大控制**: 借鉴 gRPC `retryThrottling`(token bucket 压对冲率)与 Tail at Scale 级联警告: 对冲预算独立计量,故障期自动收敛;输家 est 不喂熔断的现行设计保持。
### 3.2 沉默扣除口径的推荐形态
1. **记录原语**: 传输层记录每个内容 chunk 的单调时间戳序列(空 chunk/SSE keepalive 不计,LangChain 语义),遥测侧由时间戳派生,不在热路径做判定。
2. **派生字段**(CallStats 只增不删):
| 字段 | 口径 |
| --- | --- |
| `generation_ms`(已有) | 首内容 chunk→末内容 chunk 墙钟(vLLM `generation_time_ms` 同口径) |
| `stall_ms` | Σ 所有 > `stall_threshold_ms` 的相邻 chunk 间隔(pi-tps 式,非只减最大段) |
| `stall_count` | 超阈值间隔个数(连续停顿合并为一次) |
| `active_generation_ms` | `generation_ms − stall_ms`,**派生值、永不覆盖墙钟原值**(Gregg 分解原则) |
3. **阈值形态**: 固定默认值(pi-tps 用 500ms,Anthropic ping 间隔 ~30s 提示阈值需 ≫ keepalive 周期,建议默认 2–5s 量级、可配),不做自适应分位阈值(避免与对冲阈值耦合出反馈环)。
4. **排除条款照抄 vLLM**: chunk 数 <2 时间隔类字段为 null;非流式调用三个新字段全 null;对冲输家的沉默数据保留在输路记录但不进赢家 CallStats。
5. **对冲与沉默的关系**: `stall_after_s`(对冲触发)与 `stall_threshold_ms`(计量口径)是**两个独立参数**——前者触发动作、后者只做观测,命名与语义都必须分开。
### 3.3 配置命名先例对照
| 语义 | 业界先例 | 建议沿用风格 |
| --- | --- | --- |
| 首 token 超时 | LiteLLM `ttft_timeout`、`stream_timeout` | `HEDGE__AFTER_S` 已符合;看门狗类用 `STREAM__TTFT_TIMEOUT_S` |
| 中段不活跃看门狗 | LangChain `stream_chunk_timeout`(默认 120s)、LiteLLM 提案 `...STREAM_INACTIVITY_TIMEOUT_SECONDS` | `STREAM__STALL_TIMEOUT_S` |
| 中段对冲触发阈值 | 无直接先例(gRPC `hedgingDelay` 是按响应头的节拍) | `HEDGE__STALL_AFTER_S` |
| 沉默计量阈值 | pi-tps 固定 500ms(代码常量) | `STREAM__STALL_THRESHOLD_MS` |
| 对冲并发上限 | gRPC `maxAttempts`(钳 5) | 现有单副路设计已等价 maxAttempts=2 |
## 四、来源清单
**保留(关键证据)**
- [The Tail at Scale (CACM 2013)](https://cacm.acm.org/research/the-tail-at-scale/) / [作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf) — 对冲原始定义
- [semicolony 批注版 Tail at Scale](https://semicolony.dev/papers/tail-at-scale) — 论文未覆盖流式响应、级联放大批评
- [Envoy HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html) / [超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html) / [PR #10082](https://github.com/envoyproxy/envoy/pull/10082) — 判赢在响应头、流式停顿不重新对冲
- [gRPC A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md) / [官方 hedging 指南](https://grpc.io/docs/guides/request-hedging/) — hedgingDelay/非致命码/pushback/throttle 语义
- [LiteLLM PR #29767](https://github.com/BerriAI/litellm/pull/29767) / [config_settings](https://docs.litellm.ai/docs/proxy/config_settings) / [Issue #29602](https://github.com/BerriAI/litellm/issues/29602) / [PR #36024](https://github.com/BerriAI/litellm/pull/36024) — 中段看门狗形态、keepalive 重置 read timeout 失效、遥测误记教训
- [LangChain PR #36949](https://github.com/langchain-ai/langchain/pull/36949) / [stream_chunk_timeout 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) — 已合并的内容 chunk 间隔看门狗
- [OpenAI Codex issue #33051](https://github.com/openai/codex/issues/33051) — 沉默检测→整路重试生产先例
- [Portkey issue #471](https://github.com/Portkey-AI/gateway/issues/471) — 总时长超时误杀流式的反面教材
- [bhope/hedge](https://github.com/bhope/hedge) / [InfoQ 自适应对冲](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/) — DDSketch 动态阈值 + token bucket 压放大率
- [llms.blog 生产 LLM 对冲](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols) — TTFT 竞速架构(其"卡顿只在 prefill"断言须警惕)
- [vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/) / [SGLang #32549](https://github.com/sgl-project/sglang/issues/32549) / [SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879) / [llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md) — 中段卡顿的 serving 层成因与指标
- [NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html) / [NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/) — TTFT/ITL/E2EL 官方口径
- [Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md) — TPOT/ITL 聚合权重
- [MLPerf inference_rules](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc) — 基准延迟自调度时刻起算
- [vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/) / [metrics 设计](https://docs.vllm.ai/en/stable/design/metrics/) — generation_time_ms 命名与排除条款范本
- [vLLM #27485](https://github.com/vllm-project/vllm/issues/27485) — chunk≠token 口径陷阱
- [Brendan Gregg Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html) / [Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html) — 墙钟分解与归因原则
- [pi-tps](https://pi.dev/packages/@logicouo/pi-tps?page=49) — 沉默扣除唯一直接先例(500ms 阈值、stallMs/stallCount、三分支防御)
- [OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance) — 用户侧 TTFT/TPS 二元口径
**弃用(及原因)**
- learnaivisually、hivenet、clickhouse、themainthread 等科普稿 — 定义与 NVIDIA/Anyscale 一手文档重复且更浅
- macnica(代理商博客)、AWS Neuron/营销页、acecloud、saraswatmks 博客 — 营销/转载性质
- Portkey/Helicone/Bifrost 营销文档 — 只证实"无中段沉默对冲实现",不承载技术语义
- Solana/Rust hedged RPC crates — 与 LLM 流式无关的一元 RPC 竞速
- eunomia eBPF 教程 — Gregg 一手文已覆盖
## 五、未能确认的事项
1. **"中段沉默触发并发副本"是否有任何已投产实现**——未找到;不排除闭源网关(如各云厂 AI 网关)内部存在。建议以"看门狗→重试"为基线交付,把中段对冲作为可选增强。
2. pi-tps 的 500ms 阈值是否经生产校准(单个开源扩展,样本未知);我们的阈值应基于自有遥测分布定,不照抄数值。
3. Tail at Scale 之后是否有**学术**流式对冲后续工作(本次检索未见;如需可再查 IEEE/ACM 数据库)。
@@ -0,0 +1,98 @@
---
type: finding
node_id: finding:2026-09-11-streaming-stall-validation
title: "1.3.8 流式中段沉默:净口径与中段对冲验证报告"
date: 2026-09-14
---
# 1.3.8 流式中段沉默:净口径与中段对冲验证报告
> 计划:`research-wiki/plans/2026-09-11-streaming-stall.md`;设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`(人类已批准 + 独立审查补遗 B1-B4/N1-N9 全处置)。
> 分支 `feature/1.3.8-streaming-stall`;基线 main `a28d944`(1.3.7)+ 设计/计划提交 `97de410`/`6f3a620`;代码提交 `901a193`(T1)/`53bcf43`(T2)/`e0b0be0`(T3)/`2ac05a2`(T4),文档提交见本文件 git 历史。
> 所有命令在 `PolyGateway` conda 环境执行,未接管道(退出码不失真)。
## 1. 红绿证据索引
证据目录 `tests/outputs/138/{t1,t2,t3,t4}`(不提交,本地留存)。与 137 惯例不同,138 日志**未逐文件落 `EXIT_CODE=` 行**;退出码由日志内 `conda ... failed` ERROR 行(红)与 pytest 汇总行(绿)佐证,如实登记。
| 任务 | 相位 | 证据文件 | 结果与失败形态 |
| --- | --- | --- | --- |
| T1 端口事件 + stall 原语 | 红(批次 A) | `t1/red-batch-a.log` | exit≠0:收集期 `ImportError: cannot import name '_stall_from_chunk_times'`(纯函数不存在,非断言值不符) |
| T1 | 绿(批次 A) | `t1/green-batch-a.log` | 260 passed(test_openai_compat + test_types) |
| T1 | 绿(unit 全套) | `t1/green-unit.log` | 1602 passed,既有断言一行未改 |
| T2 净口径记账 | 红(批次 B) | `t2/red-batch-b.log` | exit≠0:6 failed——`CallStats`/`_CallContext` 字段缺失、`generation_ms` 仍得墙钟值、两败轮归因未置 None |
| T2 | 绿(批次 B) | `t2/green-batch-b.log` | 40 passed |
| T2 | 绿(unit / unit+contracts) | `t2/green-unit.log` / `t2/green-unit-contracts.log` | 1608 passed / 1669 passed, 17 skipped |
| T3 遥测两列 | 红(批次 C) | `t3/red-batch-c.log` | exit≠0:14 failed, 224 passed——24 处 AssertionError(冻结清单先扩即红:列数 36≠38、列序末两位、DDL 首现顺序、占位符数)+ 装配闸/落行断言未实现形态 |
| T3 | 绿(批次 C) | `t3/green-batch-c.log` | 238 passed |
| T3 | 绿(unit+contracts) | `t3/green-unit.log` | 1673 passed, 17 skipped |
| T4 中段对冲 + 配置守卫 | 红(批次 D) | `t4/red-batch-d.log` | exit≠0:16 failed, 153 passed——9×`TypeError: RetryMW.__init__() got an unexpected keyword argument 'stall_after_s'`、2×`AttributeError: 'GatewaySettings' object has no attribute ...`、键未识时 `STREAM` 段被当源(`['qwen_1','stream_1'] == ['qwen_1']`)、守卫未抛/默认值未生效断言,均为未实现形态 |
| T4 | 绿(批次 D,首轮) | `t4/green-batch-d-first.log` | 169 passed(与红相位 16+153 总量一致) |
| T4 | 绿(unit / contracts) | `t4/green-unit.log` / `t4/green-contracts.log` | 1688 passed, 17 skipped / 61 passed, 17 skipped(批次 E 默认关闭回归门:既有语义断言一行未改,例外仅为计划登记的签名/清单同步) |
| T4 | lint | `t4/lint.log` | `make lint` 通过,ruff 全绿,import-linter Contracts: 1 kept, 0 broken |
| T1 期 ruff 先红 | 红 | `t1/ruff.log` | 1 error(`strict=` 缺显式值),T2 起 `ruff.log` 转 All checks passed |
## 2. 批次与提交映射
| 提交 | 计划任务 | 覆盖批次(计划 §5) | 关键断言 |
| --- | --- | --- | --- |
| `901a193` | T1 观测原语与端口契约 | A | 流式每增量 chunk 置位 content_event;reasoning chunk 计活性;非流式永不置位;None 不观测行为不变;漏传必填 kw 即 TypeError;stall 纯函数(超阈累计/恰等不减/<2 返回 None/尾部不计/空帧不计时);`SourceConfig.stall_threshold_ms` 默认 1000 且 0/负/bool 拒 |
| `53bcf43` | T2 净口径记账 | B | 两字段默认 None 旧构造不炸;`generation_ms == wall − stall` 且还原公式成立;clamp 负值归零;stall None 不退减;两败轮归因 None |
| `e0b0be0` | T3 遥测两列 | C | 冻结清单 38 列与列序/DDL/占位符同源;SQLite 落行两列恰为传入值;auto_migrate 幂等补列(二次初始化);manual 档点名两条新 ALTER;取消输家行 stall 恒 None;装配闸三负例照旧绿 |
| `2ac05a2` | T4 中段对冲 + 守卫 | D、E | 首 token 后沉默触发对冲且赢家净口径;同 pass 复检防误触发;四格独立矩阵;仅 stall+非流式退单路;看门狗掐死主路对冲路存活且记账照常;两败计一次预算;stall 等待中外部取消穿透无残留;对冲路 content_event 恒 None;阈值三层可调;STREAM 保留段;四守卫(有效最小值/deadline 冲突 ValueError、单源 warning、Embedding/OCR B4 warning) |
## 3. 实测核对(文档承诺 vs 运行实测,T5 交付时本会话复核)
| 承诺 | 核对方式 | 实测结果 |
| --- | --- | --- |
| `CallStats` 两新字段默认值 | `CallStats(logical_call_id='x', attempts=1, total_latency_ms=5)` 仅旧参构造 | `generation_ms=0`、`hedges=0`、`hedge_won=False`、`stall_ms=None`、`stall_count=None`,构造不炸 |
| 协议字段数 | `inspect.signature(TelemetryRecorder.record_llm_call)` | 38 参(36 → 38,含 self 不计) |
| 遥测列 | `telemetry/schema.py::COLUMNS` | `len == 38`,末两位 `('stall_ms', 'stall_count')` |
| 两键缺省与三层 | `GatewaySettings.from_env(env=...)` 注入单源最小 env | 不设两键 → `stall_threshold_ms=1000`、`hedge_stall_after_s=None`,且已分发进 SourceConfig(1000);设 `LLM__STREAM__STALL_THRESHOLD_MS=250`/`LLM__HEDGE__STALL_AFTER_S=8` → 250/8.0 |
| 值域校验落点 | `dataclasses.replace(src, stall_threshold_ms=0)` | `ValueError: SourceConfig.stall_threshold_ms 必须是正整数`(replace 触发 `__post_init__` 重跑,校验只此一处) |
| 版本号不动 | `pyproject.toml` 与 `src/polygateway/__init__.py` | 两处均 1.3.7,本计划不 bump、不 tag、不发布 |
## 4. 验收命令与退出码(T1–T4 各卡)
| 任务 | 验收命令(均 `conda run -n PolyGateway`,禁接管道) | 结果 |
| --- | --- | --- |
| T1 | `pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q` | 260 passed → 1602 passed,exit 0 |
| T2 | `pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q` | 批次 40 passed → 1669 passed, 17 skipped,exit 0 |
| T3 | `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q` | 238 passed → 1673 passed, 17 skipped,exit 0 |
| T4 | `pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint` | 169 passed → 1688+61 passed,exit 0;lint 通过 |
合并前验收门(计划 §6:全套件 + slow 交集子集 + 整分支审查 + verifier)**不在 T5 范围**,随合并/发布流程执行;结果届时补记本节或另文。
## 5. 豁免索引(未跑项与去向)
| 未跑项 | 理由 | 去向 |
| --- | --- | --- |
| PG 实库遥测迁移用例(`tests/integration/test_postgres_telemetry.py`) | 执行期无沙箱可跑(计划 §7 阻塞矩阵已登记此情形);批次 C 的 SQLite 幂等用例(二次初始化)保留未删,PG 侧 `_EXPECTED_COLUMNS` 冻结清单已同步扩展 | **发布门 slow 交集补跑**(CLAUDE.md §4.4.1 第 4 步;本 diff 触及 telemetry schema,PG 实库形态在交集内) |
| `tests/e2e/` 与 Redis 时间语义变体(slow) | T1–T4 未动 `streaming.py`/限流 Lua/`Permit` 端口/`backends/**`;但 diff 触及 retry(结算/取消/编排)与公开入口 `complete` 签名 | 发布门按 diff 交集选子集:**Redis 时间语义变体 + 真实网关冒烟届时在交集内**(计划 §6 门 3 已登记) |
| `test_thinking_live.py` 全模型能力矩阵 | 本 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施 | 按 2026-09-10 生效的交集规则**豁免**,复用 1.3.7 已登记的有效矩阵证据,不外推到已变化路径 |
## 6. T4 worker 超时恢复实录(提交链完整性说明)
T4 由前台委派 worker 执行,worker 在收尾阶段(证据落盘后、提交前)会话超时失联。处置:父会话复核 `tests/outputs/138/t4/` 五份证据(red-batch-d 16 红形态均为未实现错误、green-batch-d-first 169 passed 与红相位总量一致、green-unit 1688 / green-contracts 61 全绿、lint 1 kept 0 broken)并核对工作区 diff 精确命中计划 §2.1 表定五文件(`client.py`/`config.py`/`retry.py`/`test_config.py`/`test_stall_hedge.py`,无越界改动)后,代为提交 `2ac05a2`。**教训沉淀**:委派任务的提交动作与证据落盘之间隔着一个会话生命周期,证据先行落盘(计划 §1 证据纪律)使本次恢复无需重跑任何测试。
## 7. 残余复述(设计/计划已批准口径)
| 项 | 口径 |
| --- | --- |
| 中段对冲无业界公开先例 | 调研 §A3 结论,CHANGELOG/README/ARCHITECTURE 三处诚实标注;2A 看门狗链仍是不启用对冲时的默认行为 |
| `generation_ms` 语义突变 | 人类「窗口期最小代价」裁决放行的例外(字段发布仅 2 天/消费者唯一/遥测无账本);披露义务已在 T5 三处文档落地 |
| 复检竞速窗口 | N3① 复检 `is_set()` 只能把误触发窗口收窄到同一事件循环 pass,不能归零——已是设计批准的最强形态 |
| 挂起率统计的不对称 | 看门狗掐死喂熔断 vs 对冲输家取消不喂(1.3.6/1.3.7 各自既有语义,本版不改任一);运维侧统计须知 |
## 8. 结论
T1–T4 全部行为变更具备先红后绿证据(§1),默认关闭回归门成立(批次 E:unit 1688 + contracts 61 全绿,既有语义断言一行未改),lint 与 import-linter 契约通过;文档承诺经运行实测核对(§3)。版本 bump、tag、发布与 slow 交集子集**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §5 豁免索引补跑交集项)。
## 5. 合并前独立验证补跑(2026-09-14,verifier P2-1/P2-2 缺口闭合)
| 项 | 证据 | 结果 |
| --- | --- | --- |
| 全套件(含 integration) | `tests/outputs/138/verify/full-suite2.log` | **1764 passed, 23 skipped, exit=0** |
| PG 遥测 30 用例 | `pg-integration-rerun2.log` | 30 passed exit=0(首轮 12 failed → 根因: `_minimal_fields` 测试 helper 未同步 1.3.8 两列,`postgres.py:450` 按物理列取值 KeyError;修复 helper + `test_pre_135` 期望字典,同类 B1 测试同步缺口,非生产缺陷——生产路径由装配闸强制全签名) |
| 关键用例重跑 | `key-reruns.log` | 10 passed exit=0(净口径精确值/复检防误触发/看门狗掐死汇合/遥测迁移幂等) |
| make lint | `verify/lint.log` | exit=0,Contracts: 1 kept, 0 broken |
@@ -0,0 +1,463 @@
---
type: plan
node_id: plan:2026-09-11-streaming-stall
title: "1.3.8 流式中段沉默:计量口径与对冲实施计划"
date: 2026-09-11
---
# 1.3.8 流式中段沉默:计量口径与对冲实施计划
> 设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`,**人类已批准 + 独立审查补遗 B1-B3/N1-N9 全处置并入,计划期补遗 B4(PM 2026-09-11 裁决)已并入 §6/§11**;本计划一切语义以该设计为准,不另行发明。
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
> 目标:① P1(issue #27)`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ 超阈值中段沉默),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加两列;② P2(issue #28)`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立生效,默认关闭);③ 缺省行为(两键均不设)逐字等于 1.3.7。
> 方案:P1 = 设计 §4.2 方案 1B'(净口径直接写进 `generation_ms`);P2 = 设计 §5.2 方案 2B(增量 chunk Event 等待超时触发,复用 1.3.7 对冲编排/记账/遥测全部语义)。
> 技术:Python 3.12+、asyncio 任务组编排(1.3.7 既有 `_attempt_hedged` 骨架扩两阶段触发窗)、frozen dataclass、pytest + 事件驱动假 transport + FakeClock/FakeSleep(净口径断言)与真实 loop 钟(对冲触发,4–10× 余量)、ruff、import-linter。
> 基线: main `a28d944`(1.3.7 已发布)+ 设计提交 `97de410`;分支 `feature/1.3.8-streaming-stall`。
**范围纪律**:不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口/看门狗 `streaming.py` 语义;不做流式透传、内容拼接、自适应阈值、per-call 沉默阈值覆盖、embedding/OCR 对冲(设计 §8 非目标 A–F);不加墙钟/净双口径并存字段(`active_generation_ms` 否决);`generation_ms` **不进**遥测列(YAGNI,墙钟 = `generation_ms + stall_ms` 还原);除设计 §7 已批准的三次端口变更外不动任何端口签名。
## 1. 边界、授权与执行纪律
| 项目 | 固定边界 |
| --- | --- |
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.8 合并/发布授权沿用 1.3.X 惯例;跨到 1.4 或设计 §9 之外的新公共面须停下确认 |
| 公共面 | 只做设计 §7/§9 已批准项:`Transport.complete` 加 `content_event` 必填 kw(第二次端口变更)、`TransportResult` 增 `stall_ms`/`stall_count`、`TelemetryRecorder.record_llm_call` 加两参(第三次端口变更)、`CallStats` 增 `stall_ms`/`stall_count`、两配置键 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(默认 1000,可调)/`{SCOPE}__HEDGE__STALL_AFTER_S`(未设=关闭)、`GatewayClient.__init__` 对应 keyword-only 参数、遥测表 `stall_ms`/`stall_count` 两列(可空)。`generation_ms` 语义重定义为净口径(已批准例外,披露义务见 T5)。**不新增其它键/端口方法/遥测列/异常类** |
| 三次端口变更 | ①`Transport.complete` 加 `content_event`(必填、无默认,与 `first_token_event` 同款约定);②`TransportResult` 增两字段(带默认 None);③`record_llm_call` 加 `stall_ms`/`stall_count`(无默认,装配闸判据即本签名)。全部同步点见 §2.2/§2.3 同步清单(真实 grep 结果,非凭记忆) |
| 口径钉死(N8) | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——**取整换算全库只此一处**(retry 层),各实现不得另取整 |
| 钟同源纪律(N4) | 沉默序列(transport 内 `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)生产同钟;**FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟**;对冲触发只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`(1.3.7 §4.1 同款) |
| 取消铁律 | 外部取消到达时对冲两路同消并穿透;不 shield、不留后台任务;`_past_hedge_window` 两阶段重构**逐字保留** waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568 既有范式);stall 等待每轮迭代自建 waiter |
| Event 并发纪律(N3) | ①超时到期后触发对冲前必须复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 `content_event` 恒 None(1.3.7 首 token 约定延伸,v1 不梯次);④等待者唯一(编排),Event 无需多等待者语义 |
| 排除条款 | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` 不退减(= 原墙钟);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk/usage 帧/SSE keepalive 不计时;对冲输家的沉默不进赢家快照(已完成落选者的沉默在其 attempt 遥测行;被取消输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None) |
| 降级方向 | 准入失败 → 静默放弃对冲(不抛错不硬等);限流/熔断后端不可用照常冒泡(fail-closed);遥测写失败 warning 降级;遥测两列对既有实例**幂等** ALTER(auto_migrate 先探测后补列,manual 档裁剪+点名告警) |
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/`、`tests/outputs/`;测试事件驱动,禁 sleep 撞窗口;红绿证据落 `tests/outputs/138/t<N>/`(pytest 输出文件,不提交) |
Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿证据落本会话工具输出 + `tests/outputs/138/t<N>/` 文件);每次提交执行 `commit`;T5 前执行 `requesting-code-review` 与 `verification-before-completion`(全新上下文 verifier);异常先 `systematic-debugging`。
**保真校验**:本计划不涉及 `reference/` 迁移(沉默计量与中段对冲为库内自研口径,蓝本即本库 1.3.7 编排与 `streaming.py` 看门狗),保真校验不适用。
## 2. 文件职责、同步清单与不变接缝
### 2.1 文件职责
| 动作 | 精确路径 | 职责 |
| --- | --- | --- |
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:56-66)加 `content_event` 必填 kw + docstring;`TelemetryRecorder.record_llm_call`(:306-344)加 `stall_ms`/`stall_count` 两参 + docstring("36 字段"→"38 字段");`TransportResult` 在 types.py(见下) |
| 修改 | `src/polygateway/types.py` | `TransportResult`(:560-586)增 `stall_ms: float \| None = None`/`stall_count: float \| None = None`(追加在 `applied_effort` 后);`CallStats`(:296-324)在 `hedge_won` 后增 `stall_ms: int \| None = None`/`stall_count: int \| None = None`;`_CallContext`(:328-388)`__slots__`/`__init__` 增两私有位、`record_generation` 增 keyword-only `stall_ms`/`stall_count`(默认 None)、`snapshot` 填两字段;`SourceConfig`(:590-700)在 `effort_fallback` 后追加 `stall_threshold_ms: int = 1000` + `__post_init__` 值域校验(正整数、拒 bool) |
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:427-473)加 `content_event` 透传两路径;`_complete_stream`(:553-616)chunk 循环(:580-590 邻域)记增量 chunk 时间戳 + 每 chunk `set` content_event(None 跳过),装配处(:597-616 邻域)由序列算 stall 两值;模块级纯函数 `_stall_from_chunk_times`;`_complete_once`(:660-705)接收 content_event 但**永不置位**(docstring 明写),stall 两值恒 None(默认) |
| 修改 | `src/polygateway/middleware/retry.py` | `__call__` 入口门(:273-283)改两阈值判定;`_attempt`(:304-400)加 `content_event` 私有 kw、sink 元素改三元组、成功分支取整换算 + `_emit` 传 stall;`_attempt_hedged`(:415-531)Phase 1/4 接线 content_event、Phase 5 汇合复用;`_past_hedge_window`(:533-562)两阶段重构;`_record_generation`/`_register_hedge` 邻域(净口径计算收敛一处);`__init__` 加 `stall_after_s` kw |
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"STREAM"`;`GatewaySettings`(:192-196 后)加 `stall_threshold_ms: int = 1000`、`hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传新参;`from_env`(:398-427)`**_load_stream(...)`;新增 `_load_stream`(:745 `_load_hedge` 邻域);`check_hedge_assembly`(:772-849)加 `stall_after_s` 参与三守卫;`EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`)加 B4 对冲键 warning |
| 修改 | `src/polygateway/client.py` | `__init__`(:240 邻域)加 `hedge_stall_after_s`/`stall_threshold_ms` keyword-only 参数,守卫(:252-257)扩传;`stall_threshold_ms` 经 `dataclasses.replace` 分发进各 SourceConfig;RetryMW 构造(:264-283)传 `stall_after_s`;`from_settings`(:485-526)透传 |
| 修改 | `src/polygateway/middleware/telemetry.py` | `_AttemptUsage`(:199-236)增两字段;`emit_attempt`(:396-447)加 `stall_ms`/`stall_count` kw(默认 None)+ 转发;`emit_cache_hit`/`emit_terminal_failure`(:449-540)显式传 None;`_record`(:560-665)加两参并在 :618 调用点透传 |
| 修改 | `src/polygateway/telemetry/schema.py` | 五编辑点:`SQLITE_DDL`/`PG_DDL` 末尾(`total_latency_ms` 后)加两列;`SQLITE_BACKFILL`(:113-141)追加两项;`_PG_BACKFILL_DECLS`(:144-166)追加两项(保序对齐);`COLUMNS`(:177-215)追加两名;`telemetry_schema_sql`(:323-372)由常量派生**同源再生成**(本函数零编辑,列数断言随动);模块头 "36/37" 计数 docstring 同步 |
| 修改 | `src/polygateway/telemetry/sqlite.py` / `postgres.py` | `record_llm_call`(:145/:411)为 `**fields` 形态,**零签名改动**自动拾取新列;docstring "36 字段冻结签名"→"38 字段";补列/裁剪逻辑由 schema 常量驱动,零代码改动 |
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(content_event 四用例 + stall 纯函数用例);`_complete` helper(:82-93)同步签名 |
| 新建 | `tests/unit/test_stall_hedge.py` | 批次 D 中段对冲编排用例(与 1.3.7 `test_hedge.py` 同设施范式,不动该文件既有断言) |
| 修改 | `tests/unit/test_types.py` `test_retry.py` `test_config.py` `test_client.py` `test_telemetry.py` `test_ports.py` | 批次 B/C/E 与 fake 同步(§2.2/§2.3);`test_telemetry.py` 冻结列清单按 T3 扩展(:53-99、:265、:295-297、:374 等断言点) |
| 修改 | `tests/unit/test_backpressure.py:213` `test_ports.py:75` `test_client.py:224/1827` `test_retry.py:82/112` `test_hedge.py:55` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:285` `tests/unit/test_live_evidence.py:280/1270` `tests/unit/test_usage_source_domain.py:142` | T1 端口签名同步(e2e 包装**转发** `content_event`;详见 §2.2 全量清单) |
| 修改 | `tests/integration/test_postgres_telemetry.py` | `_EXPECTED_COLUMNS`(:33-77 邻域)追加两列;`_CALL_OBSERVABILITY_COLUMNS`(:84)为派生式自动纳入,manual 档告警比对用例随动核对 |
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/ROADMAP.md` | T5 文档与登记(§4 T5) |
| 新建 | `research-wiki/findings/2026-09-11-streaming-stall-validation.md` | 红绿、命令、豁免索引,≤300 行 |
**不改**:`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`backends/**`/`embedding.py`/`ocr.py` 链路本体(仅其 emit_attempt 调用点经默认 None 自然兼容,零改动)/`tests/contracts/**`;`StallClock`/`backoff_delay`/`settle_and_release`/`_combine_failures` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
### 2.2 T1 同步清单:`Transport.complete` 加 `content_event`(真实 grep 结果)
执行于 2026-09-11,命令:`grep -rn "def complete\|first_token_event" tests/ src/ | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志后)。`content_event` 与 `first_token_event` 同签名形态(必填 kw、`asyncio.Event | None`),下列每一处都要同步。
**实现/协议(11 处 `def complete`)**:
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/ports.py:56`(Transport 协议) | 加 `content_event` 必填 kw + docstring(None = 不观测增量 chunk;非流式永不置位;对冲路恒 None) |
| 2 | `src/polygateway/transports/openai_compat.py:427`(真实实现) | 加参透传 `_complete_stream`(:469)/`_complete_once`(:473)两路径 |
| 3 | `src/polygateway/transports/openai_compat.py:560`(`_complete_stream`) | 加参;每增量 chunk 置位(:587-588 首 token 块邻域扩展为每 chunk) |
| 4 | `src/polygateway/transports/openai_compat.py:666`(`_complete_once`) | 接收但**永不置位**,docstring 明写 |
| 5 | `tests/unit/test_retry.py:82`(FakeTransport) | 签名加 `content_event`,不给默认值(test_retry.py:71-73 既有注释同款纪律) |
| 6 | `tests/unit/test_retry.py:112`(第二个 fake) | 同上;:123 调用处转发 |
| 7 | `tests/unit/test_hedge.py:55`(事件驱动假 transport) | 加参并**逐次记录身份**(:50/:59 同款,供"对冲路恒 None"断言) |
| 8 | `tests/unit/test_backpressure.py:213` | 加参忽略 |
| 9 | `tests/unit/test_ports.py:75` | 加参忽略 |
| 10 | `tests/unit/test_client.py:224` 与 `:1827` | 加参忽略 |
| 11 | `tests/integration/test_redis_cross_connection.py:78` / `tests/e2e/conftest.py:285` | e2e/conftest 包装必须**转发** `content_event`(:294/:306 同款);integration fake 加参忽略 |
**调用点(11 处)**:
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/middleware/retry.py:275`(单路路径) | T1 先传字面 `content_event=None`;T4 接线真事件 |
| 2 | `src/polygateway/middleware/retry.py:344`(`_attempt` 内) | 透传 `_attempt` 的 `content_event` kw |
| 3 | `src/polygateway/middleware/retry.py:436`(对冲原路) | T4 接线:流式且 `stall_after_s` 已设才给真事件,否则 None |
| 4 | `src/polygateway/middleware/retry.py:484`(对冲路) | 恒 `content_event=None`(N3③,v1 不梯次) |
| 5 | `src/polygateway/transports/openai_compat.py:469/:473` | 两路径透传(见实现 #2) |
| 6 | `tests/unit/test_openai_compat.py:84/:93`(`_complete` helper) | helper 加 `content_event=None` 默认转发(测试设施,与生产端口"必填无默认"约定不冲突) |
| 7 | `tests/unit/test_live_evidence.py:280` 与 `:1270` | 两直调传 `content_event=None`;不传则必填 kw 报 TypeError,unit 门必红 |
| 8 | `tests/unit/test_usage_source_domain.py:142` | 直调传 `content_event=None` |
### 2.3 T3 同步清单:`TelemetryRecorder.record_llm_call` 加两参(真实 grep 结果)
执行于 2026-09-11,命令:`grep -rn "record_llm_call" tests/ src/ --include="*.py" | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志)。
| # | 位置 | 处置 |
| --- | --- | --- |
| 1 | `src/polygateway/ports.py:306`(协议) | 末尾追加 `stall_ms: int \| None`、`stall_count: int \| None`(**无默认值**,与既有 36 参同款约定);docstring "36 字段"→"38 字段"。**本签名是装配闸唯一事实源**,漏改即全部 `**fields` recorder 在装配期 `signature.bind` 炸——这是有意的(1.3.5 先例) |
| 2 | `src/polygateway/middleware/telemetry.py:618`(唯一调用点 `_record`) | 显式传 `stall_ms=`/`stall_count=`;`_record` 签名加两参 |
| 3 | `src/polygateway/telemetry/sqlite.py:145` | `**fields` 形态,零签名改动;行取值按探测列裁剪,新列由 schema 常量驱动自动纳入;docstring 计数同步 |
| 4 | `src/polygateway/telemetry/postgres.py:411` | 同上 |
| 5 | `tests/unit/test_ports.py:102`(`_DummyRecorder`,**显式全签名**) | +2 参(它镜像协议全签名,不加则与本协议脱节) |
| 6 | `tests/unit/test_telemetry.py:3327/:3349/:3354`(装配闸**负例** fake,故意残缺签名) | **不改**——协议加参后它们仍不满足签名,负例断言照常通过;T3 绿态核对这三条仍绿 |
| 7 | `**fields` fake(13 处):`test_ocr_client.py:149`、`test_pricing.py:131`、`test_openai_compat.py:106`、`test_embedding.py:246`、`test_cache.py:562`、`test_client.py:528/955/968/1516/1842`、`test_usage_source_domain.py:71`、`test_telemetry.py:1191/2059` | **零改动**自动兼容;批次 C 断言落行时用其中一份捕获 fields |
| 8a | 发射器 fake:`tests/unit/test_backpressure.py:233`(`*args, **kwargs` 形态) | **零改动**(`emit_attempt` 新参默认 None,见 §3.4 取舍) |
| 8b | 发射器 fake:`tests/unit/test_hedge.py:122`(RecordingEmitter,显式全签名 8 kw,无 `**kwargs`) | **列入 T3 同步清单**(审查 B1):加 `stall_ms=None, stall_count=None` 两 kw——T3 后 `_emit` 显式转发两 kw,不加则 TypeError;"新参默认 None"只护调用方、护不住被调的显式签名 fake |
## 3. 跨任务接口(可执行定义,禁止占位)
### 3.1 T1:观测原语与端口契约(P1 底层 + P2 信号源)
**端口签名**(ports.py:56-66,顺序追加在 `first_token_event` 后,**必填、不设默认值**,同款约定):
```python
async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort,
first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ...
```
docstring 补:`None` = 调用方不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content` 或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk/usage 帧/SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 None(v1 不梯次,N3③)。
**`TransportResult`**(types.py:560-586,追加在 `applied_effort` 后):
```python
stall_ms: float | None = None
"""超阈值相邻增量 chunk 沉默总额(毫秒,未取整;换算唯一处在 retry 层,设计 §4.2 N8)。"""
stall_count: float | None = None
"""超阈值相邻间隔个数;非流式/增量 chunk <2 → None(排除条款照 vLLM,设计 §4.2)。"""
```
**`SourceConfig.stall_threshold_ms`**(types.py `effort_fallback` 后追加,`int = 1000`):**这是钉死的传入路径裁决**。任务给的两条候选中:走 `complete` 新 kw = 设计 §7 未批准的第四次端口变更(且 §2.2 清单 22 处全部二次翻动,计量口径被耦进端口契约),**否决**;走 SourceConfig 字段与 `inter_token_timeout_s`(types.py:612)同先例——transport 在 `_complete_stream` 直接读 `source.stall_threshold_ms`,端口零新增,watchdog 成对校验的 `_validate_watchdog`(:676-681)同款落点加值域校验(正整数、拒 bool)。scope 级键 → per-source 字段的分发由装配层 `dataclasses.replace` 完成(T4,§3.5),运行时零额外接线。符合"决策逻辑与状态存储分离":字段是纯配置不可变数据,计算逻辑在 transport,编排判定在 retry。
**`_complete_stream` 原语**(:580-590 max_gap 循环邻域):循环内已有逐 chunk 迭代,追加两件零判定动作——`chunk_times.append(now)`(或等价的增量累计,见下)与 `content_event.set()`(None 跳过);**热路径不做阈值判定**(设计 §3 派生原则)。stall 计算收敛为模块级纯函数(P6):
```python
def _stall_from_chunk_times(times: list[float], threshold_ms: int) -> tuple[float | None, float | None]:
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
"""
if len(times) < 2:
return None, None
total = 0.0
count = 0
for prev, cur in zip(times, times[1:]):
gap_ms = (cur - prev) * 1000
if gap_ms > threshold_ms:
total += gap_ms
count += 1
return (total, float(count)) if count else (0.0, 0.0)
```
注意:无超阈段但 chunk ≥2 时返回 `(0.0, 0.0)` 而非 None——"观测过且零沉默"与"无从观测"(None)是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。`_complete_stream` 装配处(:597-616 邻域)填 `stall_ms=`/`stall_count=`;`_complete_once` 走默认值 None。`max_inter_token_ms` 原语义一字不动。
### 3.2 T2:净口径记账(P1 落账)
`CallStats`(types.py:296-324,`hedge_won` 后追加):
```python
stall_ms: int | None = None
"""赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。墙钟 = generation_ms + stall_ms。"""
stall_count: int | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。"""
```
`_CallContext`(:328-388):`__slots__`/`__init__` 加 `_stall_ms`/`_stall_count`(初值 None);`record_generation` 增 keyword-only `stall_ms: int | None = None, stall_count: int | None = None`(chat 赢家路传入;embedding/ocr 既有调用零改动,恒 None 即"无沉默观测");`snapshot` 填两字段。
**retry 层换算唯一处**(N8):`_attempt` 成功分支在现有 sink append 处(retry.py:344-347 邻域)把 sink 元素从 `int` 升级为三元组:
```python
wall_ms = int((self._now() - gen_started) * 1000)
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms) # 取整只此一处
stall_count_i = None if result.stall_count is None else int(result.stall_count)
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
```
净口径计算收敛进共享守卫 `_record_generation`(单路)与 `_register_hedge`(对冲赢家)共同调用的一个内部换算:`net = max(0, wall_ms - (stall_ms_i or 0))`(clamp 防御,N4),然后 `context.record_generation(net, accumulate=False, stall_ms=stall_ms_i, stall_count=stall_count_i)`。三条消费路径:
| 路径 | 位置 | 口径 |
| --- | --- | --- |
| 单路成功 | `__call__` :283-288 邻域(`generation_sink` 非空分支) | net + stall 两值进快照 |
| 对冲赢家 | `_register_hedge`(winner_sink 非空) | 赢家路 net + 赢家路 stall 两值;**输家沉默不进赢家快照**(设计 §4.2 排除条款) |
| 两败(无赢家) | `_register_hedge(hedge_won=False, winner_sink=None)` | 不记 generation、**stall 保持 None**(无归属);两路各自的沉默在各自 attempt 遥测行 |
非流式/chunk<2:TransportResult stall 恒 None → `net = wall`(不退减),快照 stall 两值 None(设计 §4.2 排除条款原样落地,无需分支——None 透传天然达成)。
### 3.3 T2:FakeClock 纪律下的测试设施
净口径断言用 `test_retry.py:120-127` 既有 FakeSleep/FakeClock 范式 + **自控 stall 假 transport**:fake 委托现有 FakeTransport,返回前 `clock.advance(wall)` 并在 TransportResult 上填**固定** `stall_ms`/`stall_count`(假 transport 自己报数,不经过真实计时)——真实 transport(`openai_compat` 内部 `time.monotonic`)**不注入假钟**(N4)。精确值断言只允许在这类注入钟 + 自控 stall 的用例里;真实 loop 钟用例只断下界与相对比较(1.3.7 §6 同款纪律)。
### 3.4 T3:遥测两列(P1 归因进库)
**schema.py 五编辑点**(顺序与 1.3.5 十列同款纪律;新列全部可空、无默认值、追加在末尾——旧行 NULL 表达"补列之前根本没记过这件事",不回填):
| 编辑点 | 内容 |
| --- | --- |
| ① `SQLITE_DDL`/`PG_DDL` 末尾 | `total_latency_ms` 后加 `stall_ms INTEGER` / `stall_count INTEGER`(PG 同名 `INTEGER`;**不用 REAL**——落库值是 retry 层已取整的 int,口径与 N8 一致) |
| ② `SQLITE_BACKFILL`(:113-141) | 追加 `("stall_ms", "INTEGER")`、`("stall_count", "INTEGER")` |
| ③ `_PG_BACKFILL_DECLS`(:144-166) | 追加同名两项,与 ② 逐项保序对齐(两路径物理列序不许分叉) |
| ④ `COLUMNS`(:177-215) | 末尾追加 `"stall_ms"`, `"stall_count"` |
| ⑤ `telemetry_schema_sql`(:323-372) | **零编辑**——由 ②③ 常量派生,同源再生成天然达成;列数断言测试随动(38) |
**协议与发射器**:ports.py:306 协议 +2 参(§2.3 #1);`_record` +2 参并在 :618 透传;`_AttemptUsage` +2 字段(默认 None;`of()` **不从 response 取**——LLMResponse 不增顶层字段,设计 §11-B2 裁决 (b));`emit_attempt` 加 keyword-only `stall_ms: int | None = None, stall_count: int | None = None` 显式转发。**默认值取舍**:与 `reasoning_applies` 不同——stall 漏传落 None 是**合法事实**("非流式/无沉默观测"),不是"没表态",默认 None 语义安全且保住约 50 处既有 `emit_attempt` 关键字调用点零改动(反 gold-plating);chat 成功路径漏传会被批次 C 落行断言当场抓住。`emit_cache_hit`/`emit_terminal_failure` 显式传 None(缓存命中与终态行无 transport 观测)。
**retry 侧 `_emit`**(:665-700):加 `stall_ms`/`stall_count` kw(默认 None);`_attempt` 成功分支把 §3.2 已算好的 `stall_ms_i`/`stall_count_i` 传入;失败/取消分支不传(恒 None——被取消输家无 TransportResult,`hedge_cancelled` 行 stall 恒 None,其存在性由该行表达,设计 §11-B2)。embedding.py:532/ocr.py:611 调用点零改动(默认 None 即正确语义)。
### 3.5 T4:配置两键 + 守卫 + 装配分发
**`STREAM__STALL_THRESHOLD_MS`**(计量阈值,设计 §6 第一行):
| 项 | 精确定义 |
| --- | --- |
| 保留段 | :57 `_RESERVED_SEGMENTS` 加 `"STREAM"`(防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,1.3.7 `HEDGE` 同款);3 段键天然跳过 `_load_sources`(:427-431 段数判据) |
| loader | 新增 `_load_stream(scope, env)`(:745 `_load_hedge` 邻域):`_first` + `_cast(..., "int", ...)`,origin 传实际命中键名;值域(正整数)归 `SourceConfig.__post_init__` 一处(字段在 §3.1 已钉死);返回 `{"stall_threshold_ms": ...}`,未设 = 1000 |
| 字段 | `GatewaySettings` :196 后加 `stall_threshold_ms: int = 1000` |
| 分发 | 装配层把 scope 值经 `dataclasses.replace(source, stall_threshold_ms=...)` 写进每个 SourceConfig:`from_settings`(client.py:485-526)恒分发(settings 值权威);`GatewayClient.__init__` 加 keyword-only `stall_threshold_ms: int | None = None`,**None = 保留各 SourceConfig 自带值**(不 stomp 直构造调用方的 per-source 设置),非 None 才分发;replace 触发`**post_init**` 重跑,值域校验复用同一处 |
**`HEDGE__STALL_AFTER_S`**(对冲触发,设计 §6 第二行 + §11-N5/B4):
| 项 | 精确定义 |
| --- | --- |
| loader | `_load_hedge`(:745-769)扩读 `STALL_AFTER_S`:`_first` + `_cast` + `ensure_call_deadline`(origin 传实际命中键名),返回字典加 `"hedge_stall_after_s"` 键;未设 = None(关闭) |
| 字段 | `GatewaySettings` 加 `hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传 |
| 守卫(全部落进 `check_hedge_assembly` :772-849,签名加 keyword-only `stall_after_s` 参;**返回契约钉死(审查 B2)**:返回 `tuple[float | None, float | None]` = (归一化 after_s, 归一化 stall_after_s),`config.py:384` 与 `client.py:252` 两处调用点随之解构,`test_config.py:1174/:1184` 列入 T4 同步清单(`is None` 断言按新返回形态改写);**两阈值皆 None 才早退**——"同 `after is None` 早退形态"字面执行会让仅 stall 配置绕过守卫②③④,禁止) | ①值域 = `ensure_call_deadline` 同款(有限正数);②`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,与 openai_compat.py:573 回落同款)→ **ValueError**(对冲窗永不开,装配期直接拒绝;stall 场景无非流式残留收益,warning 论证不足,N5);③`stall_after_s ≥ call_deadline_s`(两者皆设)→ ValueError(与 `after_s` 同款,config.py:825-828 邻域);④单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);⑤**B4**:Embedding/OCR scope 装配 warning 落 `EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`):内嵌 gateway 的 `hedge_after_s` **或** `hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化"(1.3.7 `AFTER_S` 同病顺带补齐,缺陷修复不扩公共面);chat 侧装配 warning 明确不做(per-call stream,装配期无信号,B4 裁决) |
| client | `GatewayClient.__init__` 加 `hedge_stall_after_s: float \| None = None` keyword-only,:252-257 守卫调用扩传;RetryMW 构造(:264-283)传 `stall_after_s=` 归一化值(取守卫返回二元组第二元,B2);`from_settings`(:525 邻域)透传 |
### 3.6 T4:retry 编排改动(P2 唯一实现形态)
`RetryMW.__init__` 加 keyword-only `stall_after_s: float | None = None`(存 `self._stall_after_s`;守卫在装配层,本类不重复校验——同 `hedge_after_s` 先例)。
**`__call__` 入口门**(:273-283 重构,设计 §11-B3):
```python
if self._hedge_after_s is None and self._stall_after_s is None:
# 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立)
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
elif self._hedge_after_s is None and not request.stream:
# 仅 stall 已设且请求非流式: 中段沉默无非流式概念,逐字走单路(等价未配置)
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
else:
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
```
**`_attempt_hedged` 接线**:Phase 1 原路事件的构造改为——`first_token` 恒建(AFTER_S 已设时要等它;仅 stall 时阶段 2 入口也要先等它,见下);`content_event = asyncio.Event() if (request.stream and self._stall_after_s is not None) else None`,随 `content_event=` 传入 `_attempt`。Phase 4 对冲路恒 `first_token_event=None, content_event=None`(N3③)。Phase 5 赢家裁定/收口/**"先了结的是失败则等另一路"**/`_combine_failures`/两败一次预算**逐字复用**——主路被看门狗掐死(Transient)时落入既有"先了结的是失败"分支等副路,attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常(设计 §11-N9;不对称声明:掐死喂熔断 vs 输家取消不喂,本版不改任一)。
**`_past_hedge_window` 两阶段重构**(:533-562;waiter 收口 + `cancelling()` 穿透纪律逐字保留,签名加 `content_event`):
```python
async def _past_hedge_window(self, primary, first_token, content_event) -> bool:
# 阶段 1 首 token 前: AFTER_S 已设才等(设计 §5.2;仅 stall 时此阶段不触发,
# 但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall 窗误吞)
if self._hedge_after_s is not None:
<既有 waiter 形态: asyncio.wait({primary, waiter(first_token)},
timeout=self._hedge_after_s, FIRST_COMPLETED),finally 收口 + cancelling 穿透>
if not primary.done() and not first_token.is_set():
return True # 首 token 超时未至 → AFTER_S 触发
else:
<同形态 waiter 但无 timeout: 只等 first_token 或 primary 了结>
if primary.done():
return False
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
if self._stall_after_s is None or content_event is None:
return False
while not primary.done():
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
try:
await asyncio.wait({primary, waiter}, timeout=self._stall_after_s,
return_when=asyncio.FIRST_COMPLETED)
finally:
<waiter.cancel() + await 收口 + cancelling() 穿透,逐字套用既有范式>
if primary.done():
return False
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
content_event.clear() # N3② check→clear 之间无 await
continue
return True # 复检仍未置位 = 真沉默 → 触发对冲
return False
```
红线:阶段 2 的 `clear()` 只许出现在 `is_set()` 为真且两者间无 await 的紧邻位置;每轮 waiter 必须进 finally 收口;外部取消在任一阶段穿透两路(`_attempt_hedged` 既有 `except BaseException` 收口块不动)。
## 4. 任务与提交点(T1→T2→T3→T4→T5;T3 可并 T2 后)
> 依赖顺序:T1(端口+原语)→ T2(记账,消费 T1 的 TransportResult 字段)→ T3(遥测,消费 T1/T2 的口径;可在 T2 后并行,因只碰 telemetry/ports/retry._emit 邻域)→ T4(编排+配置,消费 T1 的 content_event 与 T2 的快照口径)→ T5(文档,需前四者行为冻结)。每任务卡:目标 / 改动文件:行号邻域 / 先失败测试清单 / 证据出口 / 验收命令 / 回滚点。所有命令 `conda run -n PolyGateway`,**禁接管道**(退出码失真教训);证据文件不提交。
### T0:设计 B4 补遗与本计划(本任务,无代码)
产出:设计 §6 配置表 STALL_AFTER_S 行修正 + §11 补 B4 行(**已完成**,2026-09-11 PM 裁决)+ 本计划。不提交代码。
### T1 → 提交 1 `feat: add a content event and stall accounting to the transport port`
- **目标**:观测原语(增量 chunk 时间戳 + content_event)+ 端口契约(`complete` 加 kw、`TransportResult`/`SourceConfig` 增字段)+ stall 纯函数;§2.2 同步清单全量落地。
- **改动**:`ports.py:56-66`;`types.py:560-586/590-700`;`openai_compat.py:427-473/553-616/660-705`;`retry.py:275/314/344/436/484`(T1 阶段恒传 None);§2.2 表 11 实现 + 11 调用点全量同步。
- **先失败测试清单**(批次 A;先红证据 = TypeError/ImportError/属性缺失,非断言值不符):
| 测试名 | 断言点 |
| --- | --- |
| `test_openai_compat.py::TestContentEvent::test_stream_sets_content_event_per_chunk` | 流式 N 个增量 chunk → event 被置位(每 chunk 一次;计数/逐次 is_set 观测) |
| `…::test_reasoning_chunk_counts_as_liveness` | 只流 `reasoning_content` 的流也置位 content_event 且计入 chunk 序列(B1:思考增量同样是存活证据) |
| `…::test_non_stream_never_sets_content_event` | 非流式永不置位 |
| `…::test_none_content_event_keeps_behavior` | None = 不观测,行为逐字不变 |
| `…::test_content_event_is_required_keyword` | 漏传 → TypeError(钉住必填) |
| `…::TestStallFromChunkTimes::test_gap_above_threshold_accumulates` | 构造时间戳序列 [0, 0.3, 2.0, 2.1] 阈值 1000ms → stall_ms≈1700、stall_count==1(多段序列 → 总额/次数) |
| `…::test_gap_exactly_at_threshold_not_deducted` | 间隔恰好 == 阈值 → 不减(严格大于) |
| `…::test_fewer_than_two_chunks_returns_none` | 0/1 个 chunk → (None, None)(vLLM 排除条款) |
| `…::test_tail_not_counted` | 末 chunk 之后的流结束间隔不进 stall(尾部归活跃侧,N1) |
| `…::test_empty_or_usage_frames_not_timed` | 空 chunk/usage 帧/keepalive 不产生时间戳记录点 |
| `test_types.py::test_transport_result_stall_fields_default_none` | 两字段默认 None,旧位置构造不炸 |
| `test_types.py::test_source_config_stall_threshold_validation` | 默认 1000;0/负/bool → ValueError |
- **证据出口**:`tests/outputs/138/t1/red-batch-a.log`(先红)+ `green-unit.log`(后绿)。
- **验收命令**:`pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q`(全绿且不改一行既有断言,§2.2 同步遗漏会在此暴露为 TypeError)。
- **回滚点**:`git revert <提交1>`(同步清单全量在同一提交,revert 即全回)。
### T2 → 提交 2 `feat: redefine generation_ms as net generation time with stall attribution`
- **目标**:`generation_ms` 净口径 + `CallStats`/`_CallContext` 两归因字段 + retry 三路径赢家记账(换算唯一处)。
- **改动**:`types.py:296-324/328-388`(§3.2);`retry.py:283-288/332-360` 邻域 + `_record_generation`/`_register_hedge` 共享换算(§3.2 表)。
- **先失败测试清单**(批次 B;先红 = 字段不存在 TypeError / 断言仍得墙钟值):
| 测试名 | 断言点 |
| --- | --- |
| `test_types.py::test_callstats_stall_fields_default_none` | 两字段默认 None;仅旧参数构造 `CallStats(...)` 不炸(1.3.7 及以前兼容) |
| `test_types.py::test_callcontext_record_generation_with_stall_and_snapshot` | stall 两值经 record_generation 进 snapshot;不传恒 None |
| `test_retry.py::test_generation_ms_net_of_stall` | FakeClock + 自控 stall 假 transport:wall 推进 5.0s、stall_ms=3200 → `generation_ms == 1800` 且 `stall_ms == 3200`、`stall_count` 透传;`generation_ms + stall_ms == wall`(还原公式) |
| `test_retry.py::test_generation_ms_clamps_negative_to_zero` | stall > wall(假 transport 报 6.0s stall、wall 5.0s)→ `generation_ms == 0`(clamp,N4) |
| `test_retry.py::test_non_stream_stall_none_no_deduction` | stall None → `generation_ms == wall`(不退减),快照两值 None(排除条款) |
| `test_retry.py::test_stall_attribution_none_when_no_winner` | 两败轮次(复用 1.3.7 两败设施)→ snapshot stall 两值 None、generation_ms 不记 |
- **证据出口**:`tests/outputs/138/t2/red-batch-b.log` + `green-unit.log`。
- **验收命令**:`pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q`(全绿;对冲赢家口径断言在 T4 编排落地后补,见批次 D 末行)。
- **回滚点**:`git revert <提交2>`。
### T3 → 提交 3 `feat: add stall_ms and stall_count telemetry columns`
- **目标**:遥测两列全链路(schema 五编辑点 + 协议两参 + emitter 透传 + 两 recorder 自动拾取);§2.3 同步清单落地。
- **改动**:`telemetry/schema.py` 五编辑点(§3.4 表)+ 模块头计数 docstring;`ports.py:306-344`;`middleware/telemetry.py:199-236/396-447/449-540/560-665` + `:368` docstring 计数("组装 36 字段"→38,审查 N4);`retry.py:_emit`(§3.4);`sqlite.py:145`/`postgres.py:411` docstring 计数;`test_ports.py:102` +2 参;`test_telemetry.py` 冻结清单扩展;`tests/integration/test_postgres_telemetry.py:33-77` 冻结清单扩展。
- **先失败测试清单**(批次 C;先红 = 冻结清单已扩展而 schema 未改 → 列数/列序断言失败;协议未加参 → 装配闸 ValueError):
| 测试名 | 断言点 |
| --- | --- |
| `test_telemetry.py::TestSchemaFreeze::test_columns_and_ddl_are_frozen`(扩) | `_EXPECTED_COLUMNS` 末尾追加两列后:`len(COLUMNS) == 38`;两端 DDL 首现顺序含新列;`PG_BACKFILL[-1]` 变 stall_count 项;占位符 `$38)`/`?`×38 |
| `test_ports.py:313` 与 `test_telemetry.py:748` 硬编码计数(扩,审查 N1) | `len(params)-1 == 36` → 38;`len(physical) == 37` → 39;连带 :650/:743/:756 注释文本同步——两处列入批次 C 清单,防被实施者"发现"而非计划登记 |
| `test_telemetry.py::TestBackendColumnParity::test_new_columns_are_appended_last`(扩) | `COLUMNS[-2:] == ("stall_ms", "stall_count")` |
| `test_telemetry.py::<SQLite 落行新用例>` | emit attempt(stall_ms=3200, stall_count=2)→ SQLite 行两列恰为该值;success 非流式行两列 NULL |
| `test_telemetry.py::<迁移幂等新用例>` | 既有 1.3.5 形态表(37 物理列)开 auto_migrate recorder → ALTER 补两列不炸;**重复初始化第二次**幂等;manual 档缺列告警逐字含两条新 ALTER |
| `test_hedge.py::<取消输家行新用例>` | 既有 AFTER_S 对冲触发,输家 `hedge_cancelled` 行 `stall_ms`/`stall_count` 恒 None(被取消输家无 TransportResult,B2) |
| `test_telemetry.py` 装配闸负例三条(:3327/:3349/:3354) | **不改**,协议加参后仍绿(负例仍旧不满足签名) |
- **证据出口**:`tests/outputs/138/t3/red-batch-c.log` + `green-unit.log`;PG 实库形态由 `tests/integration/test_postgres_telemetry.py` 覆盖(slow,发布门交集见 §6)。
- **验收命令**:`pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q`。
- **回滚点**:`git revert <提交3>`(DDL 为可空加列,旧代码读新表兼容;回滚后新列闲置 NULL)。
### T4 → 提交 4 `feat: add opt-in mid-stream stall hedging for chat`
- **目标**:`HEDGE__STALL_AFTER_S` 独立生效的中段对冲 + `STREAM__STALL_THRESHOLD_MS` 装配三层可调 + 全部守卫(B4 含 1.3.7 AFTER_S 同病补齐)。
- **改动**:`config.py:57/192-196/382-392/398-427/745-769/772-849/865-869/OcrSettings 新 __post_init__`(§3.5);`client.py:240/252-257/264-283/485-526`(§3.5);`retry.py:__init__/:273-283/:415-531/:533-562`(§3.6)。
- **先失败测试清单**(批次 D;先红 = 键未识时 3 段键被 `config.py:431` 静默跳过、实际红因 AttributeError/TypeError(审查 N3 措辞修正,红仍成立)、None 断言失败、对冲未触发 `hedges==0`、挂起用例超时):
| 测试名(新建 `tests/unit/test_stall_hedge.py`,设施复用 test_hedge.py 范式:两源、event-driven 假 transport、真实 loop 钟、阈值 0.05s、容差 4–10×) | 断言点 |
| --- | --- |
| `test_stall_triggers_hedge_after_first_token` | 假 transport 首 chunk 后沉默:s1 挂起、s2 即时成功 → 对冲触发,`hedges==1`、`hedge_won is True`;`generation_ms` 为赢家路净口径(T2 批次 B 顺延的对冲赢家断言落这里) |
| `test_stall_recheck_prevents_false_trigger` | chunk 在超时到期的同一事件循环 pass 到达(0.049s 置位,阈值 0.05s)→ **不触发**,`attempts==1`(N3① 复检) |
| `test_stall_and_after_s_independent_matrix` | 参数化四格:仅 AFTER_S / 仅 STALL_AFTER_S / 两者 / 皆无 → 各格触发语义与设计 §5.2 逐字一致(仅 stall 时 pre-TTFT 沉默**不**触发——阶段 1 不开窗) |
| `test_stall_only_non_stream_degrades_to_single_leg` | 仅 stall 已设 + `stream=False` → 逐字单路,`attempts==1`、`hedges==0`(入口门) |
| `test_watchdog_kills_primary_hedge_leg_survives` | 主路首 chunk 后沉默超 `inter_token_timeout_s` 被看门狗掐死、副路已在跑 → 本轮不因主路之死而败,副路结果裁定;主路源 `failure_count + 1`(attempt 记账照常,N9)、遥测主路 error 行 + 副路正常行 |
| `test_both_fail_counts_budget_once` | 主路看门狗掐死 + 副路 Transient → `fails` 只计一次(`_combine_failures` 复用),两败轮 stall 快照 None |
| `test_external_cancel_during_stall_wait_penetrates` | stall 等待中(阶段 2 循环内)外部 `task.cancel()` → CancelledError 穿透、waiter 收口无残留任务、两路(若已发)同消;1.3.7 `test_external_cancel_cancels_both_legs` 既有断言**一字不改**仍绿 |
| `test_hedge_leg_content_event_always_none` | 对冲路收到的 content_event 恒 None(N3③,test_hedge.py:50/:59 同款逐次身份记录) |
| `test_config.py::test_stall_threshold_key_loads_three_layers` | env 键 / GatewaySettings 字段 / GatewayClient kwarg 三层均可调(§9-③ 人类要求);SourceConfig 分发经 replace 生效;未设恒 1000 |
| `test_config.py::test_stream_segment_reserved` | `STREAM` 在保留段,`LLM__STREAM__1__X` 不造源;3 段键跳过 `_load_sources` |
| `test_config.py::test_stall_after_s_guard_effective_minimum_raises` | `stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError(有效值回落同款;配 watchdog 的源用 inter_token,未配的用 timeout_s) |
| `test_config.py::test_stall_after_s_guard_deadline_conflict_raises` | `≥ call_deadline_s` → ValueError |
| `test_config.py::test_stall_after_s_single_source_warns` | 单源 warning 允许(loguru 捕获 fixture,test_config.py:31-33 既有) |
| `test_config.py::test_embedding_ocr_scope_hedge_keys_warn` | Embedding/OCR settings 内嵌 gateway 的 AFTER_S **或** STALL_AFTER_S 非 None → warning(B4,含 1.3.7 同病补齐);chat 侧无此 warning(B4 裁决) |
| 批次 E 默认关闭回归门 | 两键均不设 → `pytest tests/unit tests/contracts -q` 全绿且**不改一行既有语义断言**(例外登记——审查 B1/B2/N1:fake 签名同步、`test_config.py:1174/:1184` 返回形态断言、两处硬编码计数,均属签名/清单同步,非语义断言改动) |
- **证据出口**:`tests/outputs/138/t4/red-batch-d.log` + `green-unit.log` + `green-contracts.log` + `lint.log`。
- **验收命令**:`pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint`。
- **回滚点**:`git revert <提交4>`(两键默认关闭,revert 后配置键失效由 `_load_*` 缺省自然达成)。
### T5 → 提交 5 `docs: document net generation time and mid-stream stall hedging`
- **目标**:披露义务与登记(设计 §4.2/§7);无代码行为变更。
- **改动清单**:
1. `CHANGELOG.md` 未发布段:设**独立"行为变更"段**显著标注——`generation_ms` 1.3.7(墙钟)→ 1.3.8(**净生成时间** = 墙钟 − Σ 超阈值中段沉默,仍含 TTFT 与尾部),还原公式 **墙钟 = `generation_ms + stall_ms`**;正当性三行(字段发布仅 2 天/消费者唯一/遥测无账本,设计 §4.2);`max_inter_token_ms` 原语义不变;新两字段/两键/遥测两列;对冲强制措辞三句(默认关闭;开启即用配额换延迟——实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲;输家可能已被上游计费);**"中段沉默对冲无业界公开先例"诚实标注**(调研 §A3);看门狗掐死喂熔断 vs 对冲输家取消不喂的不对称声明(N9)。
2. `README.md`:口径表(`generation_ms` 净口径定义/边界含 TTFT+尾部/还原公式/`stall_ms is None` 分支条件/与 NVIDIA-vLLM 基准口径不扣除沉默的显式区隔)+ 配置表两键(值域/缺省/守卫/三层调整接口)+ 能力表"中段沉默对冲(可选,超业界现状能力)"行。
3. `.env.example`:对冲两行注释后加 `# LLM__STREAM__STALL_THRESHOLD_MS=1000` 与 `# LLM__HEDGE__STALL_AFTER_S=`(缺省关闭,语义与成本说明)。
4. `research-wiki/ARCHITECTURE.md`:口径段(`generation_ms` 净口径 + 归因两字段 + 换算唯一处纪律)与对冲触发段(两阈值独立生效/两阶段触发窗/与看门狗交互)登记。
5. `research-wiki/ROADMAP.md`:1.3.8 里程碑登记。
6. `research-wiki/findings/2026-09-11-streaming-stall-validation.md`:红绿证据、命令与退出码、豁免索引(≤300 行);wiki 注册本计划与 findings。
- **证据出口**:`tests/outputs/138/t5/lint.log` + 审查/验证纪要不进 outputs(research-wiki/reviews/ 按既有惯例)。
- **验收命令**:`make lint` + `make ci`(只读验证);版本 bump/tag/构建/上传 registry **不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
- **回滚点**:文档提交独立 revert 即可。
## 5. 测试矩阵 → 任务映射(汇总)
| 批次 | 内容 | 任务 | 落点 |
| --- | --- | --- | --- |
| A | 端口加参 + 原语 + stall 纯函数(12 用例) | T1 | test_openai_compat.py / test_types.py |
| B | 净口径记账(6 用例) | T2 | test_types.py / test_retry.py |
| C | 遥测两列(冻结扩 + 4 新用例 + 3 负例核对) | T3 | test_telemetry.py / test_hedge.py / test_ports.py |
| D | 中段对冲编排(8 用例)+ 配置守卫(6 用例) | T4 | test_stall_hedge.py(新)/ test_config.py |
| E | 默认关闭回归门(不改一行既有断言) | T4 门 | tests/unit + tests/contracts 全套件 |
**设施复用核对(动手前必读)**:`test_retry.py:120-127` FakeSleep 只记录不推进时钟(推进须用例自带闭包);`test_hedge.py:41-69` 事件驱动假 transport(content_event 身份逐次记录,同款扩展);`test_config.py:31-33` loguru WARNING 捕获 fixture(caplog 抓不到 loguru);取消窗口用 `entered` Event 范式(`test_retry.py:79-89`);真实 loop 钟阈值 0.05s、容差 4–10×(`test_streaming.py` 既有范式,不标 slow);**命名区隔**(审查 N5):`test_hedge.py:150` 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s`(对冲触发阈值)是不同概念,`test_stall_hedge.py` 复用设施时注释明写;`test_telemetry.py:53-99` 冻结清单的扩展属"先红"手段(先改清单 → schema 未改即红)。
## 6. 风险节与验收门
| 风险 | 纪律 |
| --- | --- |
| **钟混用**:测试注入 FakeClock 时真实 transport 用了假钟,或沉默序列与墙钟异钟 | N4 钉死:沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产 = `time.monotonic`)生产同钟;**FakeClock 用例一律配自控 stall 值的假 transport**,真实 transport 测试不注入假钟;对冲触发只用 loop 相对时长,绝不读注入 `now`(1.3.7 §4.1) |
| **取消穿透回归**:两阶段重构动 `_past_hedge_window`,1.3.6/1.3.7 既有取消语义被破坏 | 既有取消/对冲/看门狗测试**不得改语义只许扩展**;waiter 收口 + `cancelling()` 穿透逐字保留;批次 D 外部取消用例 + 批次 E 全绿为双门 |
| **遥测 DDL 对既有实例不幂等** | 新列可空无默认追加末尾;auto_migrate 先探测后 ALTER(SQLite duplicate column 竞态已有处理);批次 C 迁移幂等用例(二次初始化)为门;manual 档裁剪 + 点名告警同源(`missing_columns_warning` 由常量派生,零编辑) |
| **端口签名同步遗漏**(1.3.7 T1 教训) | §2.2/§2.3 两表为真实 grep 全量结果;T1/T3 全套件绿为门(遗漏 = TypeError/装配闸 ValueError 必红);**不许凭记忆补点**,执行时先重跑同一条 grep 复核 |
| **换算漂移**:多处各自取整/各自减沉默 | N8:取整与净额计算全库只 retry 层一处;`_record_generation`/`_register_hedge` 共用同一内部换算;遥测列取 INTEGER 与之一致 |
| **误触发**:chunk 与超时同一 pass 到达 | N3① 复检 `is_set()`;批次 D `test_stall_recheck_prevents_false_trigger` 为门 |
| **触发率成本**:实测沉默 >20s 占 19.0%,远高于首 token 对冲 | 默认必须关闭;CHANGELOG/README 强制措辞"对冲 = 用配额换延迟";守卫②把"对冲窗永不开"的配置在装配期直接拒掉 |
| **反馈环**:计量阈值与对冲阈值自适应耦合 | 不做自适应(设计 §8-B);两阈值皆绝对值,文档明写 |
| **emit_attempt 默认 None 掩盖 chat 路径漏传** | 批次 C 落行断言(chat 成功行两列恰为传入值)为门;取舍理由已录 §3.4 |
**验收门(合并前,全部 `conda run -n PolyGateway` 禁接管道)**:
1. `make lint`(ruff --fix + import-linter;ports/types/errors 不 import 实现的铁律由契约执法)。
2. 全套件:`pytest tests/unit tests/contracts tests/integration -q`(integration 中 PG/Redis 用例按其既有标记;默认不含 slow)。
3. **slow 交集子集**(CLAUDE.md §4.4.1 第 4 步,按 diff 交集):本 diff 触及 retry(结算/取消/编排)与 telemetry → **Redis 时间语义变体**(`tests/integration/test_redis_governance_time.py` 等与取消/结算交集的 slow 变体)+ **网关冒烟**(`tests/e2e/test_smoke_gateway.py`,公开入口 `complete` 签名变更属交集)。`test_thinking_live.py` 全模型矩阵**不跑**(本 diff 未动 thinking.py/能力注册表;复用 1.3.7 有效矩阵证据并如实登记)。
4. T5 前:`requesting-code-review`(整分支)+ `verification-before-completion`(全新上下文 verifier subagent,前台)。
## 7. 阻塞矩阵与交接
| 触发条件 | 处置 |
| --- | --- |
| 需要新增设计 §9 之外的公共键/端口方法/遥测列/异常类 | **停下上报**(边界之外即未批准) |
| `asyncio.current_task().cancelling()` 语义在目标版本不符 | 3.12 语义同 1.3.6/1.3.7 已验证路径;若实测不符,改"取消标志位置于 `_attempt_hedged` 局部"方案并记 findings,**不得吞取消** |
| 复检防误触发用例在 CI 抖动 | 只断"不触发 + attempts==1"结果,不断精确时序;仍不稳则调宽阈值比(0.45×阈值置位)并记 findings,不得删复检断言 |
| 想顺手让对冲路携带 content_event(梯次) | **不做**(N3③:v1 单路;对冲路恒 None) |
| 想把看门狗掐死改成不喂熔断 / 把输家取消改成喂熔断 | **不改任一**(N9 不对称声明:前者 1.3.6 起既有语义,后者 1.3.7 已批准语义);运维"挂起率"统计须知此不对称,落 README |
| 想给 LLMResponse 加顶层 stall 字段 | **不加**(B2 裁决 (b):CallStats 已承载赢家口径,两处平铺必漂移) |
| 发现 embedding/OCR 也想用中段对冲 | **不加**(非目标 E);B4 warning 已覆盖误配场景;登记为后续 issue |
| PG 实库迁移用例无沙箱可跑 | 记 findings 豁免索引,发布门 slow 交集时补跑;不得删批次 C 的 SQLite 幂等用例顶替 |
交接物:5 个提交(T0 文档并入首个提交或独立,由 commit skill 裁定)、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §6 验收门 3 补 slow 交集)。
## 8. 自审
| 检查 | 结论 |
| --- | --- |
| 路径/行号/签名是否可执行无 TBD | 是——全部接入点本会话现读:`ports.py:56-66/306-344`、`types.py:296-324/328-388/560-586/590-700`、`openai_compat.py:427-473/553-616/660-705`、`retry.py:239-300/304-400/371-562/665-700`、`config.py:57/192-196/382-392/398-427/745-769/772-849/854-910`、`client.py:217-310/485-526`、`middleware/telemetry.py:199-236/396-665`、`telemetry/schema.py:22-215/323-372`、§2.2/§2.3 全部测试落点 |
| 同步清单是否真实 grep | 是——§2.2(11 实现 + 11 调用点)与 §2.3(8 类落点)均为 2026-09-11 本会话实跑结果,非凭记忆;执行时重跑复核已写进 §6 |
| 先失败后通过证据点 | 是——T1 批次 A(TypeError/ImportError)、T2 批次 B(字段缺失/仍得墙钟)、T3 批次 C(冻结清单先扩即红/装配闸)、T4 批次 D(键未识/hedges==0/超时)均先红;证据出口 `tests/outputs/138/t<N>/` 逐卡列出 |
| 设计保真 | 是——净口径/排除条款/换算唯一处/两阶段触发窗/N3 并发纪律/N9 不对称/B2 裁决 (b)/B4 操作化均逐字落自设计;无另行发明语义 |
| 取消与降级铁律 | 取消穿透路径逐字保留既有收口;准入失败静默;后端不可用冒泡;遥测写失败降级;DDL 幂等;无 shield/后台任务 |
| 反 gold-plating | 四分类/熔断/429/Lua/streaming.py/embedding-OCR 链路/LLMResponse 顶层字段/梯次对冲/自适应阈值一律不碰;recorder `**fields` 零改动;约 50 处 emit_attempt 调用点零改动 |
| 跨任务签名一致 | `content_event`(T1 端口 → T4 接线)、stall 三元组 sink(T2 定义 → T4 赢家断言)、`stall_after_s`(config 定义 → client 透传 → RetryMW 消费)、`stall_threshold_ms`(SourceConfig 字段 → 装配 replace 分发 → transport 读)四处接缝均在 §3 写出实际代码 |
| 行数 | 约 400 行 ≤1000;表格/伪代码优先;无超 15 行代码块 |
| 残余诚实标注 | 中段对冲无业界公开先例(调研 §A3,CHANGELOG 必写);`generation_ms` 语义突变为人类"窗口期最小代价"裁决放行的例外(披露义务在 T5);复检竞速窗口只能收窄不能归零(N3① 已是设计批准的最强形态) |
@@ -0,0 +1,43 @@
九个审查点已全部核对完毕。以下为完整审查报告。
---
# 1.3.8 流式中段沉默分支:整分支合并前审查
- **结论先行:可合并(OK with notes)**。无阻断项;2 条建议级(P2)发现,均不阻塞合并。
- 范围:`main a28d944...HEAD ca8743b`(代码提交 901a193/53bcf43/e0b0be0/2ac05a2 + 文档提交)。审查方式:只读,未运行 git/pytest(审查纪律),一切裁决以当前工作区源码 + 测试 + 文档实读为证据;绿态证据引自 `findings/2026-09-11-streaming-stall-validation.md` 登记的命令输出。
## 逐点裁决表
| # | 审查点 | 裁决 | 关键证据 |
| --- | --- | --- | --- |
| 1 | 取消穿透 | **通过** | `retry.py:596-645` `_past_hedge_window` 两阶段均逐字套用"waiter.cancel() → await waiter → CancelledError 时 `cancelling()` 判外部取消穿透"既有范式;阶段 1(`:608-622`)、阶段 2 循环内(`:630-642`)、对冲在途(`_attempt_hedged` Phase 5 `:540-556` + `except BaseException` 收口 `:557-567` 逐字保留)三时点全部穿透。测试:`test_stall_hedge.py::test_external_cancel_during_stall_wait_penetrates`(阶段 2 内取消,无残留任务、permit 释放、`asyncio.all_tasks()` 前后相等)+ `test_hedge.py:501` 既有取消用例未动 |
| 2 | Event 并发纪律 | **通过** | 与计划 §3.6 伪代码逐点比对:①复检 `is_set()` 在 `primary.done()` 之后、触发之前(`retry.py:638`);②`is_set()`→`clear()` 紧邻无 await(`:638-639`);③对冲路恒 `content_event=None`(`:513` Phase 4);④每轮自建 waiter(`:630`);⑤等待者唯一(编排单点等待,waiter 进 finally 收口后才进下一轮)。唯一偏差:阶段 1 的 waiter 创建被提升到 `if` 分支外(`:608`),功能等价且更简洁,非问题 |
| 3 | 净口径换算唯一处 | **通过** | 全库 `int(.*stall` 仅 `retry.py:377-378` 两处相邻行(同一次换算);`net = max(0, wall_ms − (stall_ms or 0))` clamp 收敛在 `_record_winner_sample`(`:575-590`),单路(`_record_generation`)与对冲赢家(`_register_hedge`)共用。None 透传五路径逐一核实:非流式(`_complete_once` 永不置位、TransportResult 默认 None)、chunk<2(`_stall_from_chunk_times` 返回 `(None,None)`,`openai_compat.py:98-99`)、两败(`_register_hedge(winner_sink=None)` 不记 generation、stall 保持 None)、embedding/ocr(`record_generation` 不带 stall kw,embedding.py/ocr.py 零改动已 grep 证实) |
| 4 | 看门狗汇合 | **通过** | Phase 5"先了结的是失败则等另一路"(`retry.py:540-543`)逐字复用;看门狗掐死 → `StreamLivenessTimeout`→`TransientError`→`_attempt` 既有失败分支(`breaker.record_failure`/`attempt_fails`/遥测 error 行照常,`:415-437`)。测试 `test_stall_hedge.py::test_watchdog_kills_primary_hedge_leg_survives` 断言熔断失败样本 +1、主路 error 行携 TransientError 对象、副路正常行——N9 不对称(掐死喂熔断 vs 输家取消不喂)未改任一 |
| 5 | 配置守卫 | **通过** | `check_hedge_assembly` 返回 `tuple[float|None, float|None]`(`config.py:819/912`),两调用点(`config.py:391`、`client.py:255`)均解构;两阈值皆 None 才早退(`:855`,先于空源检查,B2 钉死落实);三守卫齐备:有效最小值 ValueError(`:885-894`,`inter_token_timeout_s or timeout_s` 回落与 `openai_compat.py` 看门狗装配同款)、deadline 冲突 ValueError(`:895-899`)、单源 warning(`:900-906`);B4 落 `_warn_hedge_keys_on_non_stream_scope` + Embedding/Ocr 两 `__post_init__`(`:950/:996`,含 1.3.7 AFTER_S 同病补齐);`STREAM` 保留段(`:57`);三层分发正确:`from_settings` 恒分发(`client.py:543`)、直构造 `None` 不 stomp、非 None 经 `dataclasses.replace` 触发 `__post_init__` 重跑校验(`client.py:267-270`)。测试 `test_config.py:1304-1436` 全覆盖 |
| 6 | 遥测链路 | **通过** | 协议 +2 参无默认值(`ports.py:343-344`,共 38 参,`test_ports.py:324` 钉住);`_AttemptUsage.of()` 不从 response 取(B2 裁决 b,`telemetry.py:219-223`);`emit_attempt` 显式转发、`emit_cache_hit`/`emit_terminal_failure` 显式 None(`:465-466/:515-516/:570-571`);`_record` +2 参透传(`:616-617/:686-687`);schema 五编辑点齐备且保序对齐(`schema.py:66-67/110-111/146-147/175-176/227-228`)物理列 39;DDL 幂等有测试(`test_telemetry.py:460-480` 二次初始化零 ALTER、`:485-503` manual 档点名两条新 ALTER);`test_hedge.py:145-148` RecordingEmitter 显式全签名 +2 kw(B1)已同步。附带:`sqlite.py:146` 计数已同步 38;`postgres.py:411` docstring 本就无字段计数文本,计划"计数同步"对它无对象,无残留"36"字样(全 src grep 证实) |
| 7 | 默认关闭回归 | **通过** | 入口门第一分支(两阈值皆 None)逐字走 1.3.7 单路且 `first_token_event=None, content_event=None`(`retry.py:285-295`);第二分支(仅 stall + 非流式)同形(`:297-307`);T1 阶段单路调用点恒传 None 已并入最终形态;`SourceConfig.stall_threshold_ms` 默认 1000 只影响计量(这是已批准的 P1 行为变更本体,CHANGELOG 行为变更段已披露,非回归);findings 登记批次 E 门:`1688 passed, 17 skipped` 且既有语义断言一行未改 |
| 8 | 反 gold-plating | **通过** | 全 src grep `1.3.8` 标记仅落在计划 §2.1 清单内 8 个文件(ports/types/config/client/retry/telemetry/schema/openai_compat);`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`embedding.py`/`ocr.py`/`tests/contracts/**` 零新增引用(仅既存 issue #8 stall-window 语境);无 `except Exception: pass`、无 `print()`、日志全 loguru、公共函数类型注解齐全;缓存指纹 `build_model_fingerprint` 只取 model/extra_body/thinking 三件套(`client.py:125-164`),`stall_threshold_ms` 不进指纹——改阈值不触发缓存冷启动,无意外副作用 |
| 9 | 测试质量 | **通过(带 1 条 P2)** | 批次 D 复检用例确实走到"waiter 正常完成 → is_set→clear→continue"路径(不是平静场景:chunk 压在 0.049s、阈值 0.05s,复检-继续被真实执行);对冲赢家净口径、看门狗记账、两败一次预算、取消穿透、对冲路恒 None 的断言强度与 `test_hedge.py` 同档(同一设施范式 + 逐次身份记录 + 任务泄漏断言)。`test_stall_hedge.py` 还主动补了计划外的 `test_single_leg_attempt_row_carries_stall`(T3 缺口钉:chat 成功行经 `_emit` 端到端落 stall 两值),属范围纪律内的测试加固,非 gold-plating |
## 发现列表
**阻断(B):无。**
**建议(N/P2):**
- **N1(P2)`retry.py:369-371` 注释阶段性残留**:`_attempt` 内 `content_event=content_event` 处注释仍写"本阶段(T1)所有调用点恒传 None = 不观测;T4 才在启用 stall 对冲的流式原路上接真事件"。T4 已落地,该缝现在转发的是调用方传入的真事件,"本阶段(T1)"措辞会让后来者误以为代码停在 T1 中间态。最小修:把"本阶段(T1)…T4 才…"改为终态表述("未启用 stall 对冲时调用方传 None;启用时流式原路携真事件,对冲路恒 None")。不影响行为,可合并后随手改。
- **N2(P2)复检用例时序余量仅 1ms**:`test_stall_hedge.py::test_stall_recheck_prevents_false_trigger` 用 `chunk_after=0.049s` 对阈值 `0.05s`,与本文件自定"容差 4–10×"纪律相逆(此处是反向边界,宽余量反而测不到复检路径,故取值本身可辩护);且真实 loop 钟下"chunk 与超时同一 pass 到达"不可确定性复现,该用例实际测到的是"超时前一刻到达 → 复检-继续",严格同 pass 竞态仍只剩代码走查兜底(已走查,正确)。风险是 CI 抖动下 sleep 晚醒 >1ms 会假触发使 `transport.calls` 多出 s2 而红。计划 §7 已预留缓解(调宽至 0.45× 阈值并记 findings),合并不阻塞,建议观察首轮 CI 表现。
## 残余风险与说明
- 本审查为只读静态审查 + 已登记绿态证据复核,未重跑测试;合并前按 CLAUDE.md §4.4.1 第 4 步仍需跑 slow 交集子集(Redis 时间语义变体 + `test_smoke_gateway.py`),`test_thinking_live.py` 矩阵按 2026-09-10 决议不跑(复用 1.3.7 证据)。
- `_past_hedge_window` 复检窗口只能收窄不能归零(计划 §8 已诚实标注),属已批准残留。
- 对冲取消与外部取消同时到达时可能误贴 `hedge_cancelled` 标签,`retry.py:418-423` 注释已声明记账方向一致、属已批准可接受残留。
**Merge verdict: OK with notes**(N1/N2 不阻塞;建议合并后小修 N1 注释)。
---
**结论:可合并(OK with notes)。发现计数:阻断 0 / 建议 2(N1 `retry.py:369-371` 注释阶段性残留;N2 `test_stall_hedge.py` 复检用例 1ms 时序余量偏紧)。**
@@ -0,0 +1,94 @@
# 1.3.8 streaming-stall 合并前独立验证报告(verifier,全新上下文)
- 仓库: `/home/iomgaa/Projects/PolyGateway`;分支 `feature/1.3.8-streaming-stall`
- HEAD 核实: `.git/HEAD` → `ref: refs/heads/feature/1.3.8-streaming-stall`;`.git/refs/heads/feature/1.3.8-streaming-stall` = `8489b8ea50308ca75e0876d293fef7feddf9b086`(即 8489b8e)✓
- **能力声明(重要)**: 本 verifier 会话只有 read/grep/find/ls 四类只读工具,**无 shell 执行能力**(角色纪律: 禁止 shell、禁止写文件)。因此动作 b(套件重跑)、c 的重跑部分、d(行为抽测)、e(`make lint` 重跑)**无法由我本人执行**;我已亲自读取实现方落盘的全部日志与全部相关源码做静态+证据核实,运行时必须由 supervisor 按文末清单补跑后方可合并。pi-lens/LSP 诊断未作为任何判据(指令 g)。
## 结论
**有条件通过(OK with notes)**: T1–T5 全部声明在源码与落盘证据两个维度均核实为真;红绿证据链真实(红日志含真实失败因、绿日志计数单调递增且与提交序一致)。**两个证据缺口**(见发现 P2-1/P2-2)必须补跑: integration 套件无任何落盘证据、且本会话无法亲跑任何运行时命令。
## 逐声明验证证据
### a) 提交序列(静态替代 `git log`)
`.git/logs/HEAD` reflog 末端逐行亲读,分支自 main `a28d944`(1.3.7)拉出后序列:
| 提交 | 主题 | 对应 |
| --- | --- | --- |
| 03cbf4c / 97de410 / 929d60e / 6f3a620 | docs: 设计 + 评审勘误 + 计划 | 文档前置 |
| 901a193 | feat: content event + stall accounting to transport port | T1 |
| 53bcf43 | feat: generation_ms 净口径重定义 | T2 |
| e0b0be0 | feat: stall_ms/stall_count 遥测列 | T3 |
| 2ac05a2 | feat: opt-in 中段沉默对冲 | T4 |
| ca8743b | docs: 净口径与中段对冲文档 | 文档 |
| 8489b8e(HEAD) | docs: resolve branch review note(content_event 接线终态注释) | 文档 |
与声明序列(901a193/53bcf43/e0b0be0/2ac05a2/ca8743b/8489b8e + 文档提交)**逐项一致** ✓
### T1 端口契约 —— 成立
- `src/polygateway/ports.py` `Transport.complete`: kw-only 参数列含 `content_event: asyncio.Event | None`,**无默认值**(与 `first_token_event` 同一约定,docstring 明示 1.3.8 #27/#28)✓
- `src/polygateway/types.py:619-628` `TransportResult.stall_ms: float|None=None` / `stall_count`,docstring 钉死 None(无从观测)vs 0.0(观测过零沉默)区分、尾部不计 ✓
- `types.py:683-689` `SourceConfig.stall_threshold_ms: int = 1000`,docstring 明示"严格大于才计";`types.py:734-743` `_validate_stall_threshold` 拒 bool/非 int/≤0 ✓
- `transports/openai_compat.py:88-109` 纯函数 `_stall_from_chunk_times`: `len<2 → (None,None)`(L98-99);`gap_ms > threshold_ms` 严格大于(L106);无超阈 → `(0.0,0.0)`(L109);只遍历相邻对(zip times/times[1:])→ 尾部天然不计 ✓
- `openai_compat.py:608-618` 流式循环: 每增量 chunk `chunk_times.append(now)` + `content_event.set()`;L634 由 `_stall_from_chunk_times(chunk_times, source.stall_threshold_ms)` 派生两值进 TransportResult(L643-644)✓
- 非流式 `_complete_once`(L698-769): 收两事件永不置位,TransportResult 不传 stall 字段(默认 None)✓
### T2 净口径 —— 成立
- `types.py:331-336` `CallStats.stall_ms/stall_count: int|None = None`;`types.py:322-328` `generation_ms` docstring 重定义为净口径(含 clamp、还原公式、排除条款)✓
- 唯一换算点 `middleware/retry.py:671-681` `_record_winner_sample`: `net_ms = max(0, wall_ms - (stall_ms or 0))`(clamp 到 0 ✓);None 透传不退减 ✓
- 取整唯一处 `retry.py:376-378`(墙钟/stall 同点 int 化,墙钟=generation_ms+stall_ms 可还原成立)✓
- embedding/ocr 路径:`record_generation` 的 stall 参数默认 None(`types.py:386-387`),既有调用点不传 → 恒 None ✓
### T3 遥测 —— 成立
- `telemetry/schema.py` `COLUMNS` 亲数 **38 项**(末两项 stall_ms/stall_count,L227-228);`SQLITE_DDL`(L66-67)与 `PG_DDL`(L110-111)均含两列;`SQLITE_BACKFILL`(L146-147)与 `_PG_BACKFILL_DECLS`(L175-176)列序对齐 ✓
- `ports.py` `TelemetryRecorder.record_llm_call` 38 个 kw 参数(末两个 stall_ms/stall_count,无默认值)✓
- 迁移幂等: SQLite `sqlite.py:125-143` 先 `PRAGMA table_info` 探测、`column in existing: continue` 跳过、duplicate column 竞态视为成功;PG `postgres.py:352-369` 同款先探测后 ALTER ✓
- 取消输家行 stall 恒 None: `retry.py` CancelledError 分支 `self._emit(..., error=label)` 不传 stall → `_emit` 默认 None(L774-775);emitter `emit_cache_hit`/`emit_terminal_failure` 显式 None(`middleware/telemetry.py:515-516,570-571`)✓
### T4 中段对冲 —— 成立
- `config.py:201` `hedge_stall_after_s: float|None = None`(默认关闭);`_load_hedge`(L801-807)读 `{SCOPE}__HEDGE__STALL_AFTER_S`;`_load_stream`(L756-774)读 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`;`STREAM` 已进 `_RESERVED_SEGMENTS`(L57)✓
- 三守卫唯一事实源 `check_hedge_assembly`(L810-912): stall ≥ 有效最小值(`inter_token_timeout_s or timeout_s` 回落)→ ValueError(L888-894);stall ≥ call_deadline_s → ValueError(L895-899);单源 warning(L900-906);两阈值皆 None 才早退(审查 B2)✓;`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 共用同一份(config.py:391-401 / client.py:255-263)✓
- Embedding/OCR scope 对冲键 warning: `_warn_hedge_keys_on_non_stream_scope`(config.py:915-927),被 EmbeddingSettings(L950)与 OcrSettings(L996)`__post_init__` 调用 ✓
- 编排 `retry.py`: 仅 stall+非流式 → 逐字单路(L290-301);两阶段触发窗 `_past_hedge_window`(L591-652):阶段 1 首 token 前走 AFTER_S、仅 stall 时须等首 token(防 pre-TTFT 误吞);阶段 2 循环 `asyncio.wait(timeout=stall_after_s, FIRST_COMPLETED)` + N3① 超时后复检 `content_event.is_set()` 防误触发 + N3② check→clear 无 await;触发后 FIRST_COMPLETED 竞速(L538-539);对冲路恒 `content_event=None`(L533);输家 `cancel()` 前置 `_HEDGE_LOSER_ATTR` 标记(L568-571),_attempt 取消分支据此标 `hedge_cancelled` 且**不 record_failure**(不喂熔断,L414-429)✓
- 三层可调: env 键 → `GatewaySettings.stall_threshold_ms`(config.py:206)→ `from_settings` 恒分发(client.py:543);直传 `GatewayClient(stall_threshold_ms=...)` 经 `dataclasses.replace` 分发且重跑 `__post_init__` 校验(client.py:264-270)✓
### T5 默认关闭回归 —— 静态成立 + 落盘证据
- 代码门: `retry.py:279` `if self._hedge_after_s is None and self._stall_after_s is None:` → 单路 `first_token_event=None, content_event=None`(注释明示"逐字 1.3.7 单路路径,默认关闭回归门据此成立")✓
- 落盘: `tests/outputs/138/t4/green-unit.log` 末行 `1688 passed, 17 skipped in 54.90s`(与声明 5 的 1688 一致)✓
### c) 红绿证据真实性抽查 —— 真实
- t1 red: ImportError `_stall_from_chunk_times`(功能不存在); green: 260 passed ✓
- t2 red: 6 failed(CallStats 无 stall_ms 属性、record_generation 拒 stall kw、净口径 5000≠1800、clamp 5000≠0 等精确断言); green: batch 40 passed / unit 1608 / unit+contracts 1669 ✓
- t3 red: 14 failed(COLUMNS 缺两列、`$` 占位符 36≠38、真实 SQLite `no such column: stall_ms`、幂等补列断言红); green: batch 238 / unit 1673 ✓
- t4 red: 16 failed(RetryMW 拒 `stall_after_s` kw、settings 无 `hedge_stall_after_s`、STREAM 段造出假源 `stream_1`、三守卫 DID NOT RAISE);green: batch D 169 passed、contracts 61 passed ✓
- t4/t5 lint: ruff `All checks passed!` + import-linter `Contracts: 1 kept, 0 broken` ✓
- 计数链单调: 1602(T1)→1608(T2)→1673(T3)→1688(T4),与逐批新增用例一致;红日志失败因均为"功能尚不存在"类,非环境噪声 ✓
### f) 文档一致性 —— 一致
- CHANGELOG 未发布段: 行为变更(口径重定义+还原公式+None 条款+取消输家恒 NULL)、遥测两列(36→38,旧行不回填)、中段对冲(默认关闭/独立生效/三守卫/19.0% 触发率诚实标注)✓
- README §"1.3.8 生成时间口径与中段沉默对冲"(L115-140): `generation_ms` 定义句"净生成时间 = 赢家墙钟 − Σ(> STALL_THRESHOLD_MS 的相邻增量 chunk 沉默)"与代码 `max(0, wall-stall)` 一致;能力表"遥测必录 38 字段"(L26)、逻辑调用统计段(L27)、配置表两键(L137-138)与代码一致 ✓
- `.env.example` L83-89: `STREAM__STALL_THRESHOLD_MS=1000` 与 `HEDGE__STALL_AFTER_S` 两键注释与守卫文案一致 ✓
- ARCHITECTURE.md §7.2(L504/506)净口径+两阶段触发窗登记段、§7.5(L569/571)38 字段与 stall 两列登记段,与代码行为一致 ✓
## 发现列表
- **P2-1(证据缺口,合并前必须补)**: `tests/outputs/138/` 下**无任何 tests/integration 运行证据**;动作 b 要求 `pytest tests/unit tests/contracts tests/integration`。本 diff 直接触及 PG 遥测列(`tests/integration/test_postgres_telemetry.py` 的 `_EXPECTED_COLUMNS` 已含 stall 两列、占位符/列序断言只在真实 PG 上有效)与治理栈,integration 未跑 = PG 侧补列/写入路径只有静态与 SQLite 证据。
- **P2-2(流程限制)**: 本 verifier 无 shell,动作 b/c 重跑/d/e 全部无法亲跑;现结论中"套件绿"的依据是我亲读的实现方落盘日志(文件真实、失败因真实、计数链自洽),非我本人执行。合并前须由 supervisor 补跑下方命令清单,输出落 `tests/outputs/138/verify/`。
## supervisor 必须执行的命令(全部 `conda run -n PolyGateway`,禁管道,落 `tests/outputs/138/verify/`)
1. `git log --oneline main..HEAD`(核对序列;我已于 reflog 静态核实,仍建议权威跑一次)
2. `pytest tests/unit tests/contracts tests/integration -q`(记 exit code 与计数;unit 期望 1688,contracts 期望 61)
3. 关键用例重跑(现绿确认): `pytest tests/unit/test_retry.py::TestGenerationMs tests/unit/test_stall_hedge.py::TestStallHedgeTrigger::test_stall_recheck_prevents_false_trigger tests/unit/test_stall_hedge.py::TestStallHedgeWatchdogAndFailure::test_watchdog_kills_primary_hedge_leg_survives tests/unit/test_telemetry.py::TestStallColumns -q`
4. 行为抽测 d①-③(流式假传输端到端 stall/净 generation_ms;仅 STALL_AFTER_S 下沉默触发对冲且 hedge_won;两键不设时 hedges=0/hedge_won=False)
5. `make lint`
**合并判定: OK with notes** —— 静态与落盘证据全绿;P2-1/P2-2 补跑通过后即可合并,若补跑出现任何红则转 BLOCK。
@@ -0,0 +1,91 @@
核实完毕。所有关键引用已对照源码验证。以下为审查报告全文。
---
# 流式中段沉默设计(1.3.8)独立审查报告
- 审查对象: `designs/2026-09-11-streaming-stall-design.md`(已批准,1B' + 2B)
- 对照: `findings/2026-09-11-streaming-stall-hedging-research.md`、`designs/2026-09-10-24-hedged-requests-design.md`、main `a28d944` 源码(retry.py / openai_compat.py / streaming.py / types.py / ports.py / telemetry/schema.py / middleware/telemetry.py / config.py / client.py 全部实读)
- 立场: 不推翻人类 §9 五项拍板;只找设计与实现层面的漏洞
## 结论: **有条件实施**
总体架构判断正确: 计量与触发共用同一观测原语、复用 1.3.7 准入/记账/取消语义、遥测迁移机制现成,均无方向性问题。但有 **3 处阻断级设计缺口**(chunk 定义未钉死、遥测透传链路改动面遗漏、独立生效的编排入口遗漏)必须在实施前以设计补遗钉死;另有 9 条建议级修正。均为"补钉"而非"返工"。
## 九条审查角度逐条裁决
| # | 角度 | 裁决 | 依据(源码实证) |
| --- | --- | --- | --- |
| 1 | 沉默计时边界 | **存疑**(N1) | 尾部(最后内容 chunk→[DONE]/usage 帧/连接收尾)不是"相邻 chunk 间隔",不会被减,但仍留在墙钟基底里 → 净值**含尾部**;调研 §B3 的先例(AIPerf 剔除 [DONE]、pi-tps 止于最后 message_update)是"尾部不计",与设计的墙钟基底口径不同,设计未察觉也未声明。TTFT 同理留在净值内(人类原话"减去中段沉默"字面如此,可接受,但须明写)。应然口径: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部",README 口径表同步 |
| 2 | 非流式与输家口径 | **成立**(建议 N2) | 排除条款(非流式/chunk<2 → None 且不退减)已明写,双口径是设计内选择;建议补一句"非流式 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 分支"。输家排除由 `_register_hedge` 只记赢家 sink 的机制保证(retry.py `_register_hedge`)✓;但"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立(无 TransportResult,行是 error 行)→ 见 B2 |
| 3 | P2 与看门狗竞态 | **成立**(措辞修正 N9) | 看门狗掐死抛 `StreamLivenessTimeout`(streaming.py:24-50),在 transport 边界翻译为 `TransientError ... from exc`(openai_compat.py:476-477),**不是** CancelledError。编排层**无需区分**两种主路之死: `_attempt_hedged` Phase 5 的"先了结的是失败则等另一路"分支对任何 `_Failed` 一律等副路,两败走 `_combine_failures`(retry.py:171-176)计一次预算——现有机制完整表达设计语义 ✓。但设计"主路之死不算失败"措辞不精确: attempt 级记账照常(breaker `record_failure("timeout")`、`attempt_fails`、`reasons`、遥测 error 行),"不算失败"的只是本轮超级尝试 |
| 4 | 触发信号并发安全 | **存疑**(N3) | `asyncio.Event` 在单循环内 check→clear 之间无 await 即无交错,但**超时到期后触发对冲前必须复检 `is_set()`**——否则"chunk 与超时同一事件循环 pass 到达"会误触发。1.3.7 的 `_past_hedge_window` 是一次性等待(retry.py:542-568),stall 需要**循环**等-清-重等,设计只写"等该事件超时",未钉循环模式。等待者只有一个(编排),对冲路恒传 None 是 1.3.7 约定(retry.py `_attempt_hedged` Phase 4),但设计未声明该约定**延伸到新的 chunk Event 参数** |
| 5 | 取消穿透 | **成立**(并入 N3) | 外部取消到达 → `_attempt_hedged` 的 `except BaseException` 两任务同消、尽力收口、原异常上抛,与 1.3.7 逐字一致 ✓;stall 等待循环每轮迭代自建 waiter,须逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568),设计补一句即可 |
| 6 | 计量正确性 | **存疑**(N4) | 分层可行: 沉默在 transport 由 chunk 时间戳序列算出(openai_compat.py:580-590 循环邻域),净值在 retry 由"墙钟−stall"得。但**两处的钟不同源**: retry 用注入钟 `self._now`(retry.py:332/346,client.py:242 默认 `time.monotonic`),transport 硬编码 `time.monotonic()`(openai_compat.py:562/582)且 transport 不接收注入钟(client.py:508 构造只传 registry/capabilities)。生产同为 monotonic 无事;测试注入 FakeClock 时净值可为负/失真。赢家 sink 与 1.3.7 `generation_sink` 机制协调成立(改记净值即可)✓ |
| 7 | 遥测 DDL 迁移 | **成立**(编辑点清单见 N7) | schema.py 有成熟迁移机制: `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序追加、recorder "先探测后 ALTER"(auto 档)、`telemetry_schema_sql()` 给 manual 档同源 SQL。设计引用的迁移纪律(#12/#13/#15)与现状吻合 ✓。"遥测 DB 从未存过 generation_ms"经核实属实(COLUMNS 36 项无此字段)✓ |
| 8 | 配置守卫完备性 | **存疑**(N5) | 三段键解析**无冲突**: `_load_sources` 以 `len(parts) != 4` 跳过(config.py:431),`STREAM` 进 `_RESERVED_SEGMENTS`(config.py:57)防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,与 HEDGE 先例同型 ✓。但守卫有缺口: ①设计写 `stall_after_s ≥ inter_token_timeout_s`,未写**有效值回落**——运行时是 `inter_token_timeout_s or timeout_s`(openai_compat.py:573),`inter_token` 未设时守卫对象应是 `timeout_s`;②严重度与 1.3.7 不一致: 同类"永不触发"条件 `hedge_after_s ≥ min(timeout_s)` 是 **ValueError**(config.py:820-824),而 stall 场景非流式无残留收益(1.3.7 ttft warning 留有非流式收益,stall 没有),只给 warning 论证不足;③`stall_after_s ≥ call_deadline_s` 的 ValueError"沿用 1.3.7"须点名落进 `check_hedge_assembly`(config.py:772)同函数 |
| 9 | 内部一致性 | **存疑**(N7) | 无硬矛盾,但有三处错位: ①§4.2 改动面写 `config.py:46 邻域`——:46 是 `_SOURCE_FIELDS` 表(per-source 4 段键注册表),而 §6 明写 `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 是 **3 段 scope 级键**,两处矛盾,正确先例是 `_load_hedge`(config.py:745-769)同款 loader + `GatewaySettings` 字段;②§4.2 写 `types.py:417-418 邻域(CallStats 增两字段)`——CallStats 在 types.py:296-324,417-418 邻域是 LLMResponse;③§5.2 写端口加参"须进批准项",但 §9 五项无对应条目(整体批准可视为覆盖,但设计自拟的程序未闭环) |
## 发现列表
### 阻断级(实施前必须以设计补遗钉死)
**B1 | 设计 §3/§4.2/§5.2 | "内容 chunk"定义未钉死,reasoning 增量归属不明**
- 问题: 源码现状是 `_iter_sse_deltas` 对 `content` 与 `reasoning_content` **都**产出(openai_compat.py:65-85),看门狗与 `first_token_event` 都把思考增量当存活证据(openai_compat.py:585-588 注释"思考增量同样是存活证据,与看门狗活性口径一致")。若按"内容 chunk"字面只计 `is_content=True` 的增量,则 reasoning 模型长 thinking 阶段(只流 reasoning_content)会被误计为沉默: P1 的 `stall_ms` 虚高、净口径失真;P2 在正常推理中误触发对冲。本库 reasoning 是一等特性,这不是边缘情形。
- 修法: §3 明写"chunk = 任何增量产出(content 或 reasoning_content),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时"。排除条款的"内容 chunk <2"同步按此口径。
**B2 | 设计 §4.2 改动面/§7 契约表 | 遥测两列的透传链路改动面遗漏,且"输家行可 join"表述不成立**
- 问题: attempt 行的用量字段由 emitter `_AttemptUsage.of(response)` 从 **LLMResponse** 读取(middleware/telemetry.py:216-236)。stall 两列要进 attempt 行,必须二选一: (a) LLMResponse 增 `stall_ms/stall_count`(公共类型,只增带默认值合法,但**改动面与 §7 契约表均未列**)+ `_build_response` 搬运;或 (b) `_emit` 显式传 TransportResult 两值。同时 `ports.py` 的 `TelemetryRecorder.record_llm_call` 签名必须加两参——这是**第三个端口签名变更**,§7 只列了 `Transport.complete` 与 `TransportResult`;emitter 的装配闸 `signature.bind` 以该协议签名为判据(telemetry.py:338),漏改协议直接装配期炸。另外 §4.2"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立: 取消的输家无 TransportResult,其行是 `hedge_cancelled` error 行,stall 恒 None;只有"竞速落选但已完成"的输家才有数据。
- 修法: §4.2/§7 补全链路(选定 (a) 或 (b);ports.py recorder 签名 + emitter + 两 recorder 实现 + schema.py 四处编辑点);修正输家表述为"已完成输家的沉默在其 attempt 行;被取消输家无沉默数据(传输未返回),其存在性由 `hedge_cancelled` 行表达"。
**B3 | 设计 §5.2 改动面 | "独立于 AFTER_S 生效"所需的编排入口改动遗漏**
- 问题: 现状 `RetryMW.__call__` 仅在 `self._hedge_after_s is not None` 时进对冲路径(retry.py `__call__` 分支)。§9-④ 拍板独立生效意味着 `hedge_after_s=None + stall_after_s=25` 必须也能进对冲路径——`__call__` 入口门须改为"两阈值任一已设且(对 stall)请求为流式"。同时: ①非流式 + 仅 stall_after_s → 无中段沉默概念,应逐字走单路(等价未配置),设计未声明;②`_past_hedge_window` 须从一次性等待重构为两阶段(首 token 前 AFTER_S 仅当已设 / 首 token 后 stall 循环),改动面只写"扩为"偏轻;③纯非流式部署设了 stall_after_s 是否给装配 warning,设计未涉及。
- 修法: §5.2 改动面补 `__call__` 入口门与非流式退化声明;§6 配置表补"stall_after_s 仅对流式生效,纯非流式 scope 设置时 warning(或静默,须拍板一句)"。
### 建议级
**N1 | §3/§4.2 | 沉默计时边界未明写(TTFT 与尾部归属)**
- 尾部(末 chunk→[DONE]/usage 帧)不是相邻间隔、不会被减,但留在墙钟基底 → 净值含尾部;TTFT 同理。调研 §B3 先例是"流终止信号后不计",与墙钟基底口径不同。修法: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归属活跃侧,与 pi-tps'止于最后更新'的差异是有意的(墙钟基底),尾部通常 ≈0";README 口径表同步。
**N2 | §4.2 排除条款 | 非流式双口径补一句显式声明**
- "非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件"。
**N3 | §5.2 触发机制 | stall Event 循环的并发与取消纪律未钉**
- ①超时到期后触发对冲前复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮 waiter 收口 + `cancelling()` 穿透逐字套 `_past_hedge_window`(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义。或改用"最后 chunk 时间戳 + 计算剩余时长"模式免 clear 竞态——二选一,设计钉一种。
**N4 | §4.2 改动面 | 钟同源纪律与负值防御**
- retry 墙钟(注入 `now`)与 transport 沉默钟(硬编码 `time.monotonic()`)生产同源、测试分叉。修法: 设计声明"沉默序列与墙钟必须同为 monotonic 语义;`net = max(0, wall − stall)` clamp;FakeClock 测试用假 transport 自控 stall 值,真实 transport 测试不得注入假钟"。
**N5 | §5.2/§6 | 守卫完备性三缺口**
- ①`stall_after_s ≥ inter_token_timeout_s` 须用有效值 `inter_token_timeout_s or timeout_s`(openai_compat.py:573 回落同款);②"永不触发"严重度对照 1.3.7 的 ValueError(config.py:820-824)需论证为何只 warning(stall 无非流式残留收益,升 ValueError 的论据其实更强);③`stall_after_s ≥ call_deadline_s` ValueError 点名落进 `check_hedge_assembly`。
**N6 | §4.2 `stall_count` 定义 | "连续停顿合并一次"在 gap 口径下无所指**
- 间隔口径里任意两个超阈值 gap 之间至少隔一个 chunk,不存在可合并的"连续停顿";该子句移植自 pi-tps 的事件流语义,在本口径下无操作定义。修法: 直接定义"`stall_count` = 超阈值相邻间隔个数",删合并子句。
**N7 | §4.2/§5.2/§6/§7 | 引用勘误与编辑点清单**
- `types.py:417-418` 实为 LLMResponse 邻域(max_inter_token_ms 在 :406),CallStats 在 :296-324;`config.py:401-407` 实为 :427-431;`config.py:46` 是 `_SOURCE_FIELDS`(与 §6 三段键矛盾,见裁决 9);`openai_compat.py:605` 是 max_inter_token_ms 装配处,max_gap 循环在 :580-590;`schema.py:43/85` 指向 ttft_ms 行,实际编辑点 = DDL 末尾(created_at 之后,schema.py 头注释纪律)+ `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"在 §9 无对应条目,补一条批准记录或删该句。
**N8 | §4.2 vs §7 | 类型换算点未钉**
- TransportResult `stall_ms: float | None` vs CallStats `int | None`: 钉一句换算顺序(建议 retry 层 `int(wall_ms) − int(stall_ms)` 或先减后取整,二选一),避免各实现 ±1ms 漂移。
**N9 | §5.2 与看门狗的交互 | 措辞修正 + 一句哲学论证**
- "主路之死不算失败"改为"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker failure/`attempt_fails`/遥测 error 行)照常"。并补一句论证: 看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件却不同处置——前者是既有看门狗语义(1.3.6 起),后者是 1.3.7 已批准语义,本设计不改任一,但运维侧"挂起率"统计须知道这一不对称。
## 核实记录(引用抽查,全部实读源码)
| 设计引用 | 实况 | 结论 |
| --- | --- | --- |
| retry.py:542-562 `_past_hedge_window` | :542 起,返回式逐字一致 | ✓ |
| retry.py:~335/~346 计时点 | :332/:346 | ✓(邻域) |
| openai_compat.py:573 回落 | :573 逐字一致 | ✓ |
| openai_compat.py:605 max_gap | :580-590 循环,:605 装配 | 偏位(N7) |
| types.py:417-418 | CallStats 在 :296-324 | 错位(N7) |
| config.py:46/57/401-407 | :46 ✓ :57 ✓ :401-407 实为 :427-431 | 部分偏位 |
| 遥测无 generation_ms 列 | COLUMNS 36 项核实 | ✓ |
| 看门狗抛 TransientError | streaming.py + openai_compat.py:476 | ✓ |
---
**结论行**: 有条件实施——3 条阻断级(B1 chunk 定义、B2 遥测透传链路、B3 编排入口)+ 9 条建议级(N1-N9),均为设计补遗级修正,无需推翻 §9 拍板。
**发现计数**: B1-B3(阻断级 3 条),N1-N9(建议级 9 条)。
@@ -0,0 +1,37 @@
核查完毕,所有证据齐备。撰写审查报告:
## Review
**结论:有条件执行** — 计划整体忠实于已批准设计(含 B1-B4/N1-N9),同步清单两条 grep 重跑基本属实,TDD 红绿逻辑与编排伪代码经源码逐行验证成立;但有 **2 处计划文本级硬伤**(按字面执行必在 T3/T4 验收门炸出计划声称"零改动/不改断言"的返工)需先修计划,另 5 处建议项。无设计级返工、无人类门事项。
### 8 条核查逐条裁决
**1. 同步清单真实性 — 基本属实,一处虚列。** 重跑两条 grep:§2.2 的 11 处 `def complete` + 11 处调用点全部命中且行号准确(含 test_retry.py:112 第二 fake 与 :123 转发、e2e/conftest.py:294/:306 转发、test_live_evidence.py:280/:1270、test_usage_source_domain.py:142);§2.3 的 13 处 `**fields` fake 计数精确、3 处负例(:3327/:3349/:3354 均为故意残缺显式签名)、2 处 `**fields` recorder(sqlite.py:145/postgres.py:411)、_DummyRecorder(test_ports.py:102)全部属实。**虚列**:§2.3 #8 声称 test_hedge.py:122"零改动"不成立(显式全签名 fake,见 B1);test_backpressure.py:233 是 `*args, **kwargs` 才真零改动。另漏列两处硬编码计数断言(见 N1)。
**2. 签名与行号 — 抽查 14 处,绝大多数精确。** 精确命中:ports.py:56/:306(36 参实测)、types.py:296/:328/:560/:581/:590/:633/:674、openai_compat.py:427/:553/:560/:659/:666(chunk 循环 :581-592、装配 :598-616)、retry.py:275/:314/:344/:346/:436/:484/:533-562/:574-578/:580-594、config.py:57/:195/:745-769/:772-849/:865/:904、client.py:240/:252/:280/:485、middleware/telemetry.py:198/:396/:449/:501/:556/:618、schema.py 五编辑点。**漂移**:`_attempt_hedged` 计划 :371-530、实际 **:415-531**;另有 5 处 ±1-6 小漂移(入口门 :268-279、from_env :394-424、_emit :668-700、_complete_once :659-705、_validate_hedge :375-392)。具名接缝均无歧义,不阻断(见 N2)。
**3. TDD 红绿逻辑 — 四批次全部成立。** 批次 A(端口未加参→TypeError、纯函数不存在→ImportError、字段不存在→TypeError)、批次 B(字段缺失→AttributeError、仍得墙钟值→断言失败)、批次 C(冻结清单先扩→列数/列序断言失败,已验证 `_EXPECTED_COLUMNS` 派生机制;协议未加参而 _DummyRecorder 先加→装配闸 ValueError)、批次 D(kw 不存在→TypeError、`hedges==0`)均为真红,无伪 TDD。措辞瑕疵:批次 D "键未识 ValueError" 实为 AttributeError/TypeError(3 段键被 config.py:431 静默跳过),红仍成立(见 N3)。
**4. 编排语义 — 并发正确性验证通过,与设计 §5.2 一致。** Event 复检防同 pass 误触发 ✓;check→clear 之间同步无 await(单线程循环下无交错)✓;每轮自建 waiter + finally 收口 + `cancelling()` 穿透逐字套用 :544-561 既有范式 ✓;Event 状态持久无丢失唤醒 ✓;残留竞速窗口已诚实标注(设计 N3① 批准形态)。仅 stall 时"阶段 1 无 timeout 等首 token→再进阶段 2"与设计 §5.2/B3 逐字一致——**pre-TTFT 沉默不被 stall 窗误吞正是设计意图**,TTFT 长等待由 ttft 看门狗治理 ✓。入口门三分支自洽(仅 stall+非流式→单路;AFTER_S+非流式→对冲路,事件退化为纯时间阈值,同 1.3.7)✓。**主路被看门狗掐死**:TransientError 在 `_attempt` except 分支转为 `_Failed` 返回(retry.py:393-406),primary task 正常完成 → Phase 5 "先了结的是失败则等另一路"(retry.py:505-509)原样承接,attempt 级记账(breaker/attempt_fails/遥测 error 行)在返回前已完成 ✓——设计 §11-N9 完全可由既有路径表达。
**5. 换算与钟纪律 — 成立。** N8 唯一换算处落点核实:retry.py:346 是现状唯一 int 换算点;sink 三元组升级的消费方仅 `__call__`:289、`_record_generation`:578、`_register_hedge`:593 三处,全在 retry.py 且计划 §3.2 已枚举,无测试直调这两个私有函数(grep 实证)。N4 钟同源核实:client.py:243 `now=time.monotonic` 默认与 transport 内 `time.monotonic` 同钟 ✓。`record_generation` 加 kw-only 默认 None 对 embedding.py:391/ocr.py:407/test_types.py:648-669 零冲击 ✓。
**6. 遥测链路 — 取舍安全,一处 fake 例外。** emit_attempt 加默认 None kw 对全部 ~45 处真实 emitter 直调(均关键字形态,grep 全量清点)与 embedding.py:532/ocr.py:611 安全 ✓;三处装配闸负例在协议 +2 参后仍不满足 `signature.bind`,负例仍绿 ✓(逻辑推理成立:残缺签名缺的不止两参)。**例外即 B1**:RetryMW._emit 是 emit_attempt 的调用方且将显式转发两 kw,"新参默认 None"只保护调用方、不保护被调的显式签名 fake。
**7. 配置守卫 — 落点属实,返回契约有缺口。** check_hedge_assembly 调用点四处(client.py:252、config.py:384、test_config.py:1174/:1184)全关键字形态,加 kw-only 参可行;有效最小值守卫与 openai_compat.py:573 回落同款 ✓;B4 落点核实:EmbeddingSettings.__post_init__ :865 存在且持 `gateway` 字段 ✓,OcrSettings :904 确无 `__post_init__`(计划"新增"表述准确)✓。**缺口**:归一化 stall_after_s 的返回路径未定义(现状返回 `float|None`),二元组返回会炸 test_config.py:1184 的 `is None` 断言,与批次 E 冲突(见 B2);早退须改写为"两阈值皆 None 才早退"(见 B2 附带)。
**8. 默认关闭回归 — 论证成立。** T1 恒传 None(retry.py:275 等)✓、入口门第一分支逐字保留 ✓;stall 序列无条件记录(content_event=None 只跳过 set,不跳过时间戳)→ 流式 ≥2 chunk 恒填 0.0/0.0 或实际值、非流式/<2 恒 None——**计量独立于对冲开关正是 P1 意图,设计与计划一致** ✓。TransportResult 新字段不破坏回归门:test_openai_compat.py 无相等断言、无 asdict/fields 计数(grep 实证)✓;CallStats 新字段默认 None 且无 `== CallStats` 断言 ✓。**字面例外**:B1/N1 所列硬编码计数断言与 test_config.py:1184 是批次 E"不改一行既有断言"的真实例外,计划应显式登记而非被实施者发现。
### 发现列表
| # | 级别 | 位置 | 问题 | 修法 |
| --- | --- | --- | --- | --- |
| B1 | 阻断(事实错误,修法小) | plan §2.3 #8;tests/unit/test_hedge.py:121-133 | RecordingEmitter.emit_attempt 是显式全签名 fake(收 8 个 kw,无 `**kwargs`);T3 后 `_emit` 显式转发 `stall_ms=`/`stall_count=` → TypeError,test_hedge.py 全部成功路径用例红,且违反批次 D/E "1.3.7 既有断言一字不改"承诺。"新参默认 None"的论证只护调用方,护不住被调 fake | §2.3 #8 拆两行:test_backpressure.py:233(`*args, **kwargs`)真零改动;test_hedge.py:122 列入 T3 同步清单(加 `stall_ms=None, stall_count=None` 两 kw,与 T1 fake 签名同步同款处置);批次 E 措辞澄清"fake 签名同步不属于断言改动" |
| B2 | 阻断(契约占位,违反计划自设标准) | plan §3.5 守卫行;config.py:772-849;test_config.py:1171-1190 | 计划要求 "RetryMW 传 stall_after_s=归一化值",但归一化值如何出 check_hedge_assembly 未定义(§3 自称"可执行定义,禁止占位");最自然的二元组返回炸 test_config.py:1184 的 `is None` 断言,与批次 E 直接冲突。附带:§3.5"同 after is None 既有早退形态"字面执行会让仅 stall 配置绕过守卫②③④(批次 D 能抓住,但属误导) | §3.5 钉死返回契约(建议 `tuple[float\|None, float\|None]` 或等值命名结果,config.py:384 与 client.py:252 两处随之解构);test_config.py:1174/:1184 列入 T4 同步清单;早退条件改写为"两阈值皆 None 才早退";批次 E 例外登记本处 + B1 + N1 |
| N1 | 建议 | test_ports.py:313、test_telemetry.py:748(连带 :650/:743/:756 注释文本) | 两处硬编码计数断言(`len(params)-1 == 36`、`len(physical) == 37`)未列入批次 C 清单;T3 验收门会自愈,但破坏"清单即全量"承诺 | 补入批次 C 扩展点(36→38、37→39),注释文本顺手同步 |
| N2 | 建议 | plan §2.1/§8 行号表 | `_attempt_hedged` :371-530 实为 :415-531;另有 5 处 ±1-6 漂移 | 执行前以 grep 复核为准(§6 已有此纪律),修正 §2.1 表防误锚 |
| N3 | 建议 | plan T4 先红清单 | "键未识 ValueError" 不准确:3 段未识键被 config.py:431 静默跳过,实际红因是 AttributeError/TypeError | 措辞修正即可,红成立 |
| N4 | 建议 | middleware/telemetry.py:368 | docstring "组装 36 字段" 未列入计数同步范围(ports.py:280/sqlite.py:146/schema.py:8 已覆盖) | T3 顺手同步,防计数漂移 |
| N5 | 建议 | tests/unit/test_hedge.py:150 `_harness(stall_window_s=300.0)` | 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s` 命名相邻,新文件复制设施时易混 | test_stall_hedge.py 设施复用时加注释区隔两概念 |
**Merge verdict: OK with notes** — B1/B2 修计划文本(均为小时级,不改任何设计语义)后即可按书执行;TDD 门对全部 N 项有自愈能力。
+1 -1
View File
@@ -52,7 +52,7 @@ from polygateway.types import (
ThinkingObservation, ThinkingObservation,
) )
__version__ = "1.3.7" __version__ = "1.3.8"
__all__ = [ __all__ = [
"DEFAULT_PROFILES", "DEFAULT_PROFILES",
+20 -3
View File
@@ -239,6 +239,8 @@ class GatewayClient:
call_deadline_s: float | None = None, call_deadline_s: float | None = None,
hedge_after_s: float | None = None, hedge_after_s: float | None = None,
hedge_max_extra: int = 1, hedge_max_extra: int = 1,
hedge_stall_after_s: float | None = None,
stall_threshold_ms: int | None = None,
now: Any = time.monotonic, now: Any = time.monotonic,
sleep: Any = asyncio.sleep, sleep: Any = asyncio.sleep,
rng: Any = random.random, rng: Any = random.random,
@@ -247,15 +249,25 @@ class GatewayClient:
self._call_deadline_s = ensure_call_deadline( self._call_deadline_s = ensure_call_deadline(
call_deadline_s, "GatewayClient(call_deadline_s=...)" call_deadline_s, "GatewayClient(call_deadline_s=...)"
) )
# 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4): 直接构造这条路 # 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4 / 1.3.8 #28): 直接构造这条路
# 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过 # 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过。
self._hedge_after_s = check_hedge_assembly( # 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s)
self._hedge_after_s, self._hedge_stall_after_s = check_hedge_assembly(
hedge_after_s=hedge_after_s, hedge_after_s=hedge_after_s,
hedge_max_extra=hedge_max_extra, hedge_max_extra=hedge_max_extra,
sources=sources, sources=sources,
call_deadline_s=self._call_deadline_s, call_deadline_s=self._call_deadline_s,
origin="GatewayClient(hedge_after_s=...)", origin="GatewayClient(hedge_after_s=...)",
stall_after_s=hedge_stall_after_s,
stall_origin="GatewayClient(hedge_stall_after_s=...)",
) )
# 沉默计量阈值分发(1.3.8 设计 §6): None = 保留各 SourceConfig 自带值
# (不 stomp 直构造调用方的 per-source 设置);非 None 经 replace 写进每个源
# (replace 触发 SourceConfig.__post_init__ 重跑,值域校验复用同一处)
if stall_threshold_ms is not None:
sources = [
dataclasses.replace(s, stall_threshold_ms=stall_threshold_ms) for s in sources
]
emitter = ( emitter = (
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap) TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
if telemetry is not None if telemetry is not None
@@ -280,6 +292,8 @@ class GatewayClient:
emitter=emitter, emitter=emitter,
# max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者 # max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者
hedge_after_s=self._hedge_after_s, hedge_after_s=self._hedge_after_s,
# 中段沉默对冲阈值(1.3.8 #28): 独立于 hedge_after_s 生效,守卫归一化值
stall_after_s=self._hedge_stall_after_s,
now=now, now=now,
sleep=sleep, sleep=sleep,
rng=rng, rng=rng,
@@ -524,6 +538,9 @@ class GatewayClient:
call_deadline_s=settings.call_deadline_s, call_deadline_s=settings.call_deadline_s,
hedge_after_s=settings.hedge_after_s, hedge_after_s=settings.hedge_after_s,
hedge_max_extra=settings.hedge_max_extra, hedge_max_extra=settings.hedge_max_extra,
hedge_stall_after_s=settings.hedge_stall_after_s,
# settings 值为权威,恒分发(1.3.8 §3.5): 含缺省 1000 在内
stall_threshold_ms=settings.stall_threshold_ms,
) )
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry) _mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过 client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
+109 -25
View File
@@ -54,7 +54,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
"TRUST_ENV": ("trust_env", "bool"), "TRUST_ENV": ("trust_env", "bool"),
"EXTRA_BODY": ("extra_body", "json"), "EXTRA_BODY": ("extra_body", "json"),
} }
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE", "HEDGE"}) _RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE", "HEDGE", "STREAM"})
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"}) _SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
_QUOTA_FULL = frozenset({"wait", "fail_fast"}) _QUOTA_FULL = frozenset({"wait", "fail_fast"})
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是 # 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
@@ -194,6 +194,16 @@ class GatewaySettings:
# 由 `check_hedge_assembly` 单一定义点把关,直接构造/replace/env 三路一致 # 由 `check_hedge_assembly` 单一定义点把关,直接构造/replace/env 三路一致
hedge_after_s: float | None = None hedge_after_s: float | None = None
hedge_max_extra: int = 1 hedge_max_extra: int = 1
# 中段沉默对冲(1.3.8 issue #28): 相邻增量 chunk 间隔超阈值 → 并发异源副路。
# 缺省 None = 关闭;独立于 hedge_after_s 生效(设计 §9-④);仅对流式 chat 请求
# 生效,非流式请求运行期静默退化(入口门)。值域与交叉守卫同归
# `check_hedge_assembly` 单一定义点
hedge_stall_after_s: float | None = None
# 沉默计量阈值(1.3.8 issue #27): 相邻增量 chunk 间隔**严格大于**它才计入
# stall_ms 并从 generation_ms 扣减。默认 1000ms(设计 §9-③ 拍板,三层可调);
# 只影响计量口径,不改任何行为。值域(正整数)归 `SourceConfig.__post_init__`
# 一处——分发经 `dataclasses.replace` 时重跑,本字段不重复校验
stall_threshold_ms: int = 1000
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._normalize() self._normalize()
@@ -378,17 +388,17 @@ class GatewaySettings:
env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等 env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等
空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。 空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。
""" """
object.__setattr__( after_s, stall_after_s = check_hedge_assembly(
self,
"hedge_after_s",
check_hedge_assembly(
hedge_after_s=self.hedge_after_s, hedge_after_s=self.hedge_after_s,
hedge_max_extra=self.hedge_max_extra, hedge_max_extra=self.hedge_max_extra,
sources=self.sources, sources=self.sources,
call_deadline_s=self.call_deadline_s, call_deadline_s=self.call_deadline_s,
origin="GatewaySettings.hedge_after_s", origin="GatewaySettings.hedge_after_s",
), stall_after_s=self.hedge_stall_after_s,
stall_origin="GatewaySettings.hedge_stall_after_s",
) )
object.__setattr__(self, "hedge_after_s", after_s)
object.__setattr__(self, "hedge_stall_after_s", stall_after_s)
@classmethod @classmethod
def from_env( def from_env(
@@ -420,6 +430,7 @@ class GatewaySettings:
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"), circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
call_deadline_s=_load_call_deadline(scope_u, env), call_deadline_s=_load_call_deadline(scope_u, env),
**_load_hedge(scope_u, env), **_load_hedge(scope_u, env),
**_load_stream(scope_u, env),
**_load_pgw(env), **_load_pgw(env),
) )
@@ -742,8 +753,29 @@ def _load_call_deadline(scope: str, env: Mapping[str, str]) -> float | None:
return ensure_call_deadline(_cast(found[1], "float", found[0]), found[0]) return ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])
def _load_stream(scope: str, env: Mapping[str, str]) -> dict[str, object]:
"""读 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(1.3.8 issue #27 设计 §6)。
3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然跳过;
`STREAM` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__STREAM__{N}__*` 也不会
被当成 provider 段造出源(1.3.7 `HEDGE` 同款)。未设 = 1000(设计 §9-③ 拍板);
值域(正整数)归 `SourceConfig.__post_init__` 一处——字段分发经
`dataclasses.replace` 时重跑,本函数只负责解析。
Args:
scope: 已大写的 scope 名。
env: 已合并的环境映射。
Returns:
`{"stall_threshold_ms": int}`,直传构造器。
"""
found = _first(env, f"{scope}__STREAM__STALL_THRESHOLD_MS")
threshold = int(_cast(found[1], "int", found[0])) if found else 1000
return {"stall_threshold_ms": threshold}
def _load_hedge(scope: str, env: Mapping[str, str]) -> dict[str, object]: def _load_hedge(scope: str, env: Mapping[str, str]) -> dict[str, object]:
"""读 `{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA`(issue #24 H4)。 """读 `{SCOPE}__HEDGE__AFTER_S`/`MAX_EXTRA`/`STALL_AFTER_S`(issue #24 H4 + 1.3.8 #28)。
两键均为 3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然 两键均为 3 段键(`split("__")` 长度 3 ≠ 4),`_load_sources` 的段数判据天然
跳过它们;`HEDGE` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__HEDGE__{N}__*` 跳过它们;`HEDGE` 已进 `_RESERVED_SEGMENTS`,4 段的 `{SCOPE}__HEDGE__{N}__*`
@@ -766,7 +798,13 @@ def _load_hedge(scope: str, env: Mapping[str, str]) -> dict[str, object]:
) )
found_extra = _first(env, f"{scope}__HEDGE__MAX_EXTRA") found_extra = _first(env, f"{scope}__HEDGE__MAX_EXTRA")
extra = int(_cast(found_extra[1], "int", found_extra[0])) if found_extra else 1 extra = int(_cast(found_extra[1], "int", found_extra[0])) if found_extra else 1
return {"hedge_after_s": after, "hedge_max_extra": extra} found_stall = _first(env, f"{scope}__HEDGE__STALL_AFTER_S")
stall = (
ensure_call_deadline(_cast(found_stall[1], "float", found_stall[0]), found_stall[0])
if found_stall
else None
)
return {"hedge_after_s": after, "hedge_max_extra": extra, "hedge_stall_after_s": stall}
def check_hedge_assembly( def check_hedge_assembly(
@@ -776,26 +814,32 @@ def check_hedge_assembly(
sources: Sequence[SourceConfig], sources: Sequence[SourceConfig],
call_deadline_s: float | None, call_deadline_s: float | None,
origin: str, origin: str,
) -> float | None: stall_after_s: float | None = None,
"""对冲装配守卫的唯一事实源(issue #24 设计 §5 全表,H4 批准)。 stall_origin: str = "hedge_stall_after_s",
) -> tuple[float | None, float | None]:
"""对冲装配守卫的唯一事实源(issue #24 设计 §5 全表,H4 批准 + 1.3.8 issue #28 §6)。
`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 调同一份,两条装配 `GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 调同一份,两条装配
路的值域/交叉守卫不漂移。返回归一化后的 `hedge_after_s`(None 或有限正数, 路的值域/交叉守卫不漂移。返回契约(1.3.8 审查 B2):
复用 `ensure_call_deadline` 的值域校验);`hedge_after_s is None`(未启用)时 `tuple(归一化 hedge_after_s, 归一化 stall_after_s)`(各为 None 或有限正数,
值域归一化后直接返回,交叉守卫不查——它们没有可校验的对象。 复用 `ensure_call_deadline` 的值域校验);**两阈值皆 None 才早退**——只对
`hedge_after_s is None` 早退会让仅 stall 配置绕过交叉守卫(审查 B2 钉死)。
Args: Args:
hedge_after_s: 对冲触发阈值(秒);None = 关闭。 hedge_after_s: 首 token/整体耗时对冲触发阈值(秒);None = 关闭。
hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。 hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。
sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft_timeout_s)。 sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft/inter_token)。
call_deadline_s: 调用期限(秒);与对冲的组合守卫见设计 §6。 call_deadline_s: 调用期限(秒);与对冲的组合守卫见设计 §6。
origin: after_s 值域报错的定位串(env 键名 / `GatewaySettings.hedge_after_s` / origin: after_s 值域报错的定位串(env 键名 / `GatewaySettings.hedge_after_s` /
`GatewayClient(hedge_after_s=...)`);跨字段守卫与 warning 沿用本模块先例, `GatewayClient(hedge_after_s=...)`);跨字段守卫与 warning 沿用本模块先例,
消息自带字段名与 env 键型,不挂 origin。 消息自带字段名与 env 键型,不挂 origin。
stall_after_s: 中段沉默对冲触发阈值(秒,1.3.8 issue #28);None = 关闭。
stall_origin: stall_after_s 值域报错的定位串(同 origin 的三路纪律)。
Raises: Raises:
ValueError: max_extra 非 int/bool 或出 [1,3];阈值 ≥ 最小源 timeout_s(永不 ValueError: max_extra 非 int/bool 或出 [1,3];after_s ≥ 最小源 timeout_s(永不
可能触发);阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。 可能触发);stall_after_s ≥ 最小源有效 inter-token 超时(回落同款,窗永不开);
阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。
""" """
if isinstance(hedge_max_extra, bool) or not isinstance(hedge_max_extra, int): if isinstance(hedge_max_extra, bool) or not isinstance(hedge_max_extra, int):
raise ValueError( raise ValueError(
@@ -807,16 +851,19 @@ def check_hedge_assembly(
"每次逻辑调用最多并发对冲路数,v1 仅单路生效" "每次逻辑调用最多并发对冲路数,v1 仅单路生效"
) )
after = ensure_call_deadline(hedge_after_s, origin) after = ensure_call_deadline(hedge_after_s, origin)
if after is None: stall = ensure_call_deadline(stall_after_s, stall_origin)
return None if after is None and stall is None:
return None, None
if not sources: if not sources:
# 直传路(GatewayClient(sources=[], hedge_after_s=...))没有 settings 的 # 直传路(GatewayClient(sources=[], ...))没有 settings 的
# 非空守卫先行拦截;报错必须定位到 hedge,而不是裸 min() 空序列异常 # 非空守卫先行拦截;报错必须定位到 hedge 键族,而不是裸 min() 空序列异常。
# 消息保留 "hedge_after_s" 字样: 既有用例据此定位键族(1.3.8 扩含 stall)
raise ValueError( raise ValueError(
"hedge_after_s({SCOPE}__HEDGE__AFTER_S)的交叉守卫要求 sources 不能为空;" "hedge_after_s/hedge_stall_after_s({SCOPE}__HEDGE__AFTER_S/STALL_AFTER_S)"
"对冲已启用但没有可校验的源" "的交叉守卫要求 sources 不能为空;对冲已启用但没有可校验的源"
) )
min_timeout = min(s.timeout_s for s in sources) min_timeout = min(s.timeout_s for s in sources)
if after is not None:
if after >= min_timeout: if after >= min_timeout:
raise ValueError( raise ValueError(
f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s" f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s"
@@ -835,17 +882,49 @@ def check_hedge_assembly(
after, after,
min(ttfts), min(ttfts),
) )
if stall is not None:
# 有效最小值与 openai_compat.py:573 看门狗装配的回落同款:
# 配了 watchdog 的源按 inter_token_timeout_s,未配的按 timeout_s(设计 §11-N5)
min_inter = min((s.inter_token_timeout_s or s.timeout_s) for s in sources)
if stall >= min_inter:
raise ValueError(
f"hedge_stall_after_s({{SCOPE}}__HEDGE__STALL_AFTER_S={stall})须 < 最小源"
f"有效 inter-token 超时({min_inter};inter_token_timeout_s 缺省回落"
f"timeout_s,与看门狗装配同款);中段对冲窗永不开,配置即错误"
)
if call_deadline_s is not None and stall >= call_deadline_s:
raise ValueError(
f"hedge_stall_after_s({stall})须 < call_deadline_s({call_deadline_s});"
"期限会先于中段对冲触发,对冲形同虚设(设计 §6)"
)
if len(sources) == 1: if len(sources) == 1:
logger.warning( logger.warning(
"单源 scope 配置了对冲阈值 hedge_after_s={};运行期拿不到异源候选,对冲自然静默", "单源 scope 配置了对冲阈值 hedge_after_s={}/hedge_stall_after_s={};"
"运行期拿不到异源候选,对冲自然静默",
after, after,
stall,
) )
if hedge_max_extra > 1: if hedge_max_extra > 1:
logger.warning( logger.warning(
"hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)", "hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)",
hedge_max_extra, hedge_max_extra,
) )
return after return after, stall
def _warn_hedge_keys_on_non_stream_scope(gateway: GatewaySettings, scope_kind: str) -> None:
"""B4(1.3.8 设计 §11): 装配期可证明纯非流式的 scope 配了对冲键 → warning。
chat scope 不做(`stream` 是 per-call 参数,装配期无信号,B4 裁决);对冲键在
Embedding/OCR 链路没有任何消费者,静默退化必须说出来。`hedge_after_s` 一并
检测(1.3.7 同病顺带补齐,缺陷修复不扩公共面)。
"""
if gateway.hedge_after_s is not None or gateway.hedge_stall_after_s is not None:
logger.warning(
"对冲键(HEDGE__AFTER_S/HEDGE__STALL_AFTER_S)仅对 chat scope 生效: "
"{} scope 非流式,对冲配置静默退化(1.3.8 B4)",
scope_kind,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -868,6 +947,7 @@ class EmbeddingSettings:
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}") raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
if self.expected_dim is not None and self.expected_dim < 1: if self.expected_dim is not None and self.expected_dim < 1:
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}") raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
_warn_hedge_keys_on_non_stream_scope(self.gateway, "embedding")
@classmethod @classmethod
def from_env( def from_env(
@@ -911,6 +991,10 @@ class OcrSettings:
gateway: GatewaySettings gateway: GatewaySettings
def __post_init__(self) -> None:
"""内嵌 gateway 由 `GatewaySettings.__post_init__` 自己把关;此处只补 B4 对冲键 warning。"""
_warn_hedge_keys_on_non_stream_scope(self.gateway, "ocr")
@classmethod @classmethod
def from_env( def from_env(
cls, cls,
+135 -26
View File
@@ -58,6 +58,7 @@ if TYPE_CHECKING:
RetryPolicy, RetryPolicy,
SourceConfig, SourceConfig,
TransportResult, TransportResult,
_CallContext,
) )
@@ -165,6 +166,11 @@ def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool:
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser" _HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
# 生成样本三元组(1.3.8 设计 §4.2 N8): (墙钟 ms, 沉默总额 ms, 超阈值沉默段数)。
# 取整换算全库只在 _attempt 成功分支发生一次,消费侧(_record_generation/
# _register_hedge)只做净口径换算与透传,不再取整。
_GenerationSample = tuple[int, int | None, int | None]
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。""" """编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
@@ -195,6 +201,7 @@ class RetryMW:
pacer: AdaptivePacer | None = None, pacer: AdaptivePacer | None = None,
emitter: TelemetryEmitter | None = None, emitter: TelemetryEmitter | None = None,
hedge_after_s: float | None = None, hedge_after_s: float | None = None,
stall_after_s: float | None = None,
now: Callable[[], float] = time.monotonic, now: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
rng: Callable[[], float] = random.random, rng: Callable[[], float] = random.random,
@@ -216,6 +223,9 @@ class RetryMW:
# 值域/交叉守卫在装配层(config.check_hedge_assembly),本类不重复校验; # 值域/交叉守卫在装配层(config.check_hedge_assembly),本类不重复校验;
# hedge_max_extra 不下传——v1 编排固定单路对冲(H5) # hedge_max_extra 不下传——v1 编排固定单路对冲(H5)
self._hedge_after_s = hedge_after_s self._hedge_after_s = hedge_after_s
# 中段沉默对冲触发阈值(1.3.8 issue #28): None = 关闭;独立于 _hedge_after_s
# 生效(设计 §9-④),仅对流式请求开窗。守卫同在装配层,本类不重复校验
self._stall_after_s = stall_after_s
self._now = now self._now = now
self._sleep = sleep self._sleep = sleep
self._rng = rng self._rng = rng
@@ -262,17 +272,31 @@ class RetryMW:
await self._admission.on_no_runnable(gate_rejections, reasons, clock) await self._admission.on_no_runnable(gate_rejections, reasons, clock)
continue continue
async with clock.attempting() as attempt: async with clock.attempting() as attempt:
# 每轮一个 sink: 裸生成时间由编排裁定归属(T2 单路 = 成功那轮; # 每轮一个 sink: 生成样本(墙钟+沉默归因)由编排裁定归属
# T3 对冲 = 赢家那一路),`_attempt` 只负责把本次 transport 耗时投进来 # (单路 = 成功那轮;对冲 = 赢家那一路),`_attempt` 只负责把本次
generation_sink: list[int] = [] # transport 耗时与沉默观测投进来
if self._hedge_after_s is None: generation_sink: list[_GenerationSample] = []
# 默认关闭: 逐字 1.3.6 单路路径(默认关闭回归门据此成立) if self._hedge_after_s is None and self._stall_after_s is None:
# 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立)
outcome = await self._attempt( outcome = await self._attempt(
request, request,
*picked, *picked,
reasons, reasons,
attempt_fails, attempt_fails,
first_token_event=None, first_token_event=None,
content_event=None,
generation_sink=generation_sink,
)
elif self._hedge_after_s is None and not request.stream:
# 仅 stall 已设且请求非流式: 中段沉默无非流式概念,逐字走单路
# (等价未配置,设计 §11-B3 入口门)
outcome = await self._attempt(
request,
*picked,
reasons,
attempt_fails,
first_token_event=None,
content_event=None,
generation_sink=generation_sink, generation_sink=generation_sink,
) )
else: else:
@@ -283,10 +307,9 @@ class RetryMW:
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock) # 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
attempt.refund() attempt.refund()
if isinstance(outcome, LLMResponse): if isinstance(outcome, LLMResponse):
# 与 register_attempt 同款 None 守卫: 库内现场构造的请求跳过登记;
# 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空 # 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空
if request.call_context is not None and generation_sink: # (守卫在 _record_generation 内: 上下文缺失/空 sink 均跳过)
request.call_context.record_generation(generation_sink[0], accumulate=False) self._record_generation(request, generation_sink)
return outcome return outcome
if not rate_limited: if not rate_limited:
fails += 1 fails += 1
@@ -312,7 +335,8 @@ class RetryMW:
attempt_fails: dict[str, int], attempt_fails: dict[str, int],
*, *,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
generation_sink: list[int], content_event: asyncio.Event | None,
generation_sink: list[_GenerationSample],
) -> LLMResponse | _Failed: ) -> LLMResponse | _Failed:
call_id = str(uuid.uuid4()) call_id = str(uuid.uuid4())
started = self._now() started = self._now()
@@ -326,8 +350,9 @@ class RetryMW:
if request.call_context is not None: if request.call_context is not None:
request.call_context.register_attempt() request.call_context.register_attempt()
try: try:
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身,起点紧贴调用前、 # 净生成时间(1.3.8): 此处只量 transport 墙钟(起点紧贴调用前、
# 终点为返回后首句(中间无 await);取消落进来时 transport 未返回,不计。 # 终点为返回后首句,中间无 await),沉默扣减在消费侧统一换算;
# 取消落进来时 transport 未返回,不计。
# 与该链路 total_latency_ms 同一只注入钟,差值(波动开销)才有意义 # 与该链路 total_latency_ms 同一只注入钟,差值(波动开销)才有意义
gen_started = self._now() gen_started = self._now()
result = await self._transport.complete( result = await self._transport.complete(
@@ -342,8 +367,16 @@ class RetryMW:
# 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路, # 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路,
# 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token # 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token
first_token_event=first_token_event, first_token_event=first_token_event,
# 中段沉默处置位(1.3.8): 未启用 stall 对冲时调用方传 None = 不观测;
# 启用时流式原路携真事件,对冲路恒 None(设计 §5.2 N3③)
content_event=content_event,
) )
generation_sink.append(int((self._now() - gen_started) * 1000)) # 取整全库唯一处(设计 §4.2 N8): transport 的 float 沉默值在此
# 一次性转 int;None(非流式/增量 chunk<2)透传,消费侧不退减
wall_ms = int((self._now() - gen_started) * 1000)
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms)
stall_count_i = None if result.stall_count is None else int(result.stall_count)
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
if result.usage_source == "unavailable": if result.usage_source == "unavailable":
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回, # 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9) # 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
@@ -357,7 +390,15 @@ class RetryMW:
self._feed_outcome(source.name, ok=True) self._feed_outcome(source.name, ok=True)
self._pacer.on_success(source.name) self._pacer.on_success(source.name)
response = self._build_response(source, result, call_id, started) response = self._build_response(source, result, call_id, started)
await self._emit(request, source, call_id, started, response=response) await self._emit(
request,
source,
call_id,
started,
response=response,
stall_ms=stall_ms_i,
stall_count=stall_count_i,
)
return response return response
except RequestRejectedError as exc: except RequestRejectedError as exc:
actual, settlement_known = 0, True # 逐字保住 1.3.5: 坏请求全额退还 actual, settlement_known = 0, True # 逐字保住 1.3.5: 坏请求全额退还
@@ -424,7 +465,12 @@ class RetryMW:
# Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性) # Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性)
source, permit, entry = picked source, permit, entry = picked
first_token: asyncio.Event = asyncio.Event() first_token: asyncio.Event = asyncio.Event()
sink_p: list[int] = [] # 中段沉默观测(1.3.8): 仅"流式 + stall 阈值已设"的原路携带真事件;
# 其余恒 None = 不观测(对冲路亦恒 None,Phase 4,N3③)
content_event = (
asyncio.Event() if (request.stream and self._stall_after_s is not None) else None
)
sink_p: list[_GenerationSample] = []
primary = asyncio.create_task( primary = asyncio.create_task(
self._attempt( self._attempt(
request, request,
@@ -434,13 +480,15 @@ class RetryMW:
reasons, reasons,
attempt_fails, attempt_fails,
first_token_event=first_token, first_token_event=first_token,
content_event=content_event,
generation_sink=sink_p, generation_sink=sink_p,
) )
) )
hedge: asyncio.Task[LLMResponse | _Failed] | None = None hedge: asyncio.Task[LLMResponse | _Failed] | None = None
try: try:
# Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长) # Phase 2 触发窗: 两阶段(1.3.8)——首 token 前走 AFTER_S,首 token 后
if not await self._past_hedge_window(primary, first_token): # 走 STALL_AFTER_S;只认 loop 相对时长,绝不读注入钟(设计 §4.1)
if not await self._past_hedge_window(primary, first_token, content_event):
# 原路已了结/首 token 已至: 等价于未配置对冲 # 原路已了结/首 token 已至: 等价于未配置对冲
outcome = await primary outcome = await primary
if isinstance(outcome, LLMResponse): if isinstance(outcome, LLMResponse):
@@ -473,8 +521,8 @@ class RetryMW:
self._record_generation(request, sink_p) self._record_generation(request, sink_p)
return outcome return outcome
# Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None, # Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None,
# 不再触发梯次对冲 # 不再触发梯次对冲;content_event 同恒 None(N3③,v1 不梯次)
sink_h: list[int] = [] sink_h: list[_GenerationSample] = []
hedge = asyncio.create_task( hedge = asyncio.create_task(
self._attempt( self._attempt(
request, request,
@@ -482,6 +530,7 @@ class RetryMW:
reasons, reasons,
attempt_fails, attempt_fails,
first_token_event=None, first_token_event=None,
content_event=None,
generation_sink=sink_h, generation_sink=sink_h,
) )
) )
@@ -539,18 +588,53 @@ class RetryMW:
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
raise raise
async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool: async def _past_hedge_window(
"""对冲触发窗: 阈值到 + 首 token 未至 + 原路在途,三者齐备才放行对冲。 self,
primary: asyncio.Task,
first_token: asyncio.Event,
content_event: asyncio.Event | None,
) -> bool:
"""对冲触发窗两阶段(1.3.8 设计 §5.2): 首 token 前走 AFTER_S,首 token 后走 STALL_AFTER_S。
只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`——测试 只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`——测试
伪造注入钟跳变不得触发对冲(设计 §4.1)。waiter 任务必须收口,且**不得吞 伪造注入钟跳变不得触发对冲(设计 §4.1)。waiter 任务必须收口,且**不得吞
外部取消**(finally 里 await 已取消的 waiter 会接住一个 CancelledError, 外部取消**(finally 里 await 已取消的 waiter 会接住一个 CancelledError,
须靠 cancelling() 区分它是 waiter 自己的还是外面打进来的)。 须靠 cancelling() 区分它是 waiter 自己的还是外面打进来的)。
""" """
# 阶段 1 首 token 前: AFTER_S 已设才开窗(设计 §5.2);仅 stall 时此阶段不
# 触发,但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall
# 窗误吞(计划 §3.6)
waiter = asyncio.create_task(first_token.wait()) waiter = asyncio.create_task(first_token.wait())
try: try:
if self._hedge_after_s is not None:
await asyncio.wait( await asyncio.wait(
{primary, waiter}, timeout=self._hedge_after_s, return_when=asyncio.FIRST_COMPLETED {primary, waiter},
timeout=self._hedge_after_s,
return_when=asyncio.FIRST_COMPLETED,
)
else:
await asyncio.wait({primary, waiter}, return_when=asyncio.FIRST_COMPLETED)
finally:
waiter.cancel()
try:
await waiter
except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透
raise
if self._hedge_after_s is not None and not primary.done() and not first_token.is_set():
return True # AFTER_S 触发: 阈值到 + 首 token 未至 + 原路在途(1.3.7 原语义)
if primary.done():
return False
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
if self._stall_after_s is None or content_event is None:
return False
while not primary.done():
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
try:
await asyncio.wait(
{primary, waiter},
timeout=self._stall_after_s,
return_when=asyncio.FIRST_COMPLETED,
) )
finally: finally:
waiter.cancel() waiter.cancel()
@@ -559,7 +643,13 @@ class RetryMW:
except asyncio.CancelledError: except asyncio.CancelledError:
if asyncio.current_task().cancelling(): # 外部取消,穿透 if asyncio.current_task().cancelling(): # 外部取消,穿透
raise raise
return not primary.done() and not first_token.is_set() if primary.done():
return False
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
content_event.clear() # N3② check→clear 之间无 await
continue
return True # 复检仍未置位 = 真沉默 → 触发对冲
return False
@staticmethod @staticmethod
def _succeeded(task: asyncio.Task, done: set[asyncio.Task]) -> bool: def _succeeded(task: asyncio.Task, done: set[asyncio.Task]) -> bool:
@@ -572,14 +662,27 @@ class RetryMW:
) )
@staticmethod @staticmethod
def _record_generation(request: ChatRequest, sink: list[int]) -> None: def _record_generation(request: ChatRequest, sink: list[_GenerationSample]) -> None:
"""record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 sink 均跳过。""" """record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 sink 均跳过。"""
if request.call_context is not None and sink: if request.call_context is not None and sink:
request.call_context.record_generation(sink[0], accumulate=False) RetryMW._record_winner_sample(request.call_context, sink[0])
@staticmethod
def _record_winner_sample(context: _CallContext, sample: _GenerationSample) -> None:
"""净口径唯一换算(设计 §4.2): net = max(0, 墙钟 − 沉默),clamp 防御(N4)。
stall 两值透传: None(非流式/增量 chunk<2)时 net=墙钟不退减、快照归因
保持 None(排除条款——None 透传天然达成,无需分支)。
"""
wall_ms, stall_ms, stall_count = sample
net_ms = max(0, wall_ms - (stall_ms or 0))
context.record_generation(
net_ms, accumulate=False, stall_ms=stall_ms, stall_count=stall_count
)
@staticmethod @staticmethod
def _register_hedge( def _register_hedge(
request: ChatRequest, *, hedge_won: bool, winner_sink: list[int] | None request: ChatRequest, *, hedge_won: bool, winner_sink: list[_GenerationSample] | None
) -> None: ) -> None:
"""对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。 """对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。
@@ -590,7 +693,7 @@ class RetryMW:
if context is None: if context is None:
return return
if winner_sink: if winner_sink:
context.record_generation(winner_sink[0], accumulate=False) RetryMW._record_winner_sample(context, winner_sink[0])
context.register_hedge(hedge_won=hedge_won) context.register_hedge(hedge_won=hedge_won)
async def _on_rejected( async def _on_rejected(
@@ -668,11 +771,15 @@ class RetryMW:
*, *,
response: LLMResponse | None = None, response: LLMResponse | None = None,
error: PolyGatewayError | str | None = None, error: PolyGatewayError | str | None = None,
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None: ) -> None:
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。 """逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常 异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。 类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。
stall 两值(1.3.8)只在成功分支由 `_attempt` 显式传入(计划 §3.4);
失败/取消分支恒 None——被取消输家无 TransportResult(设计 §11-B2)。
""" """
if self._emitter is None: if self._emitter is None:
return return
@@ -688,6 +795,8 @@ class RetryMW:
reasoning_applies=True, reasoning_applies=True,
# 公开方法四值之一;本中间件只服务 chat 洋葱 # 公开方法四值之一;本中间件只服务 chat 洋葱
operation="chat", operation="chat",
stall_ms=stall_ms,
stall_count=stall_count,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+26 -1
View File
@@ -216,6 +216,11 @@ class _AttemptUsage:
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。 # 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎 # 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
# 1.3.8(issue #27): stall 归因**不从 response 取**——LLMResponse 不增顶层
# 字段(设计 §11-B2 裁决 b),stall 由 retry 层经 emit_attempt 显式 kw 传入;
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
stall_ms: int | None = None
stall_count: int | None = None
@classmethod @classmethod
def of(cls, response: LLMResponse | None) -> _AttemptUsage: def of(cls, response: LLMResponse | None) -> _AttemptUsage:
@@ -365,7 +370,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
class TelemetryEmitter: class TelemetryEmitter:
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。""" """从请求与结果组装 38 字段并写入 recorder;一切写失败降级 warning。"""
def __init__( def __init__(
self, self,
@@ -405,6 +410,12 @@ class TelemetryEmitter:
reasoning_applies: bool, reasoning_applies: bool,
operation: CallOperation, operation: CallOperation,
class_prefixed_error: bool = False, class_prefixed_error: bool = False,
# 1.3.8(issue #27): 沉默归因由调用点显式传入(成功分支从 TransportResult
# 换算;失败/取消分支不传)。**默认 None 语义安全**: 漏传落 NULL 是合法事实
# ("非流式/无沉默观测"),不是"没表态"——与 reasoning_applies 不同类;
# 默认保住约 50 处既有调用点零改动(计划 §3.4 取舍)
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None: ) -> None:
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
@@ -451,6 +462,8 @@ class TelemetryEmitter:
event_kind="attempt", event_kind="attempt",
attempts=None, attempts=None,
total_latency_ms=None, total_latency_ms=None,
stall_ms=stall_ms,
stall_count=stall_count,
) )
async def emit_cache_hit( async def emit_cache_hit(
@@ -498,6 +511,9 @@ class TelemetryEmitter:
event_kind="cache_hit", event_kind="cache_hit",
attempts=None, attempts=None,
total_latency_ms=None, total_latency_ms=None,
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
stall_ms=None,
stall_count=None,
) )
async def emit_terminal_failure( async def emit_terminal_failure(
@@ -550,6 +566,9 @@ class TelemetryEmitter:
event_kind="terminal_failure", event_kind="terminal_failure",
attempts=stats.attempts, attempts=stats.attempts,
total_latency_ms=stats.total_latency_ms, total_latency_ms=stats.total_latency_ms,
# 终态行无具体源与 transport 观测: 显式 None
stall_ms=None,
stall_count=None,
) )
async def _record( async def _record(
@@ -592,6 +611,10 @@ class TelemetryEmitter:
event_kind: EventKind, event_kind: EventKind,
attempts: int | None, attempts: int | None,
total_latency_ms: int | None, total_latency_ms: int | None,
# —— 1.3.8: 沉默归因两列(issue #27);各入口已按自己的事实定型,
# 本方法只搬运(同 reasoning_effort 先例)——
stall_ms: int | None,
stall_count: int | None,
) -> None: ) -> None:
try: try:
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
@@ -660,6 +683,8 @@ class TelemetryEmitter:
error_body=errors.error_body, error_body=errors.error_body,
attempts=attempts, attempts=attempts,
total_latency_ms=total_latency_ms, total_latency_ms=total_latency_ms,
stall_ms=stall_ms,
stall_count=stall_count,
) )
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
+10 -1
View File
@@ -51,6 +51,12 @@ class Transport(Protocol):
`first_token_event` 同一约定(1.3.7 对冲 H2): `None` = 调用方不观测首 token `first_token_event` 同一约定(1.3.7 对冲 H2): `None` = 调用方不观测首 token
(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间 (未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间
阈值),流式实现在首个增量(内容或思考)到达时置位。 阈值),流式实现在首个增量(内容或思考)到达时置位。
`content_event` 同一约定(1.3.8 中段沉默对冲,issue #27/#28): `None` = 调用方
不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content`
或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk、
usage 帧、SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 `None`
(v1 不梯次,设计 §5.2 N3③)。
""" """
async def complete( async def complete(
@@ -63,6 +69,7 @@ class Transport(Protocol):
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ... ) -> TransportResult: ...
@@ -277,7 +284,7 @@ class TelemetryStatusProvider(Protocol):
@runtime_checkable @runtime_checkable
class TelemetryRecorder(Protocol): class TelemetryRecorder(Protocol):
"""遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。 """遥测后端;38 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8),唯一调用点是 TelemetryEmitter。
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名 新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。 Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
@@ -342,4 +349,6 @@ class TelemetryRecorder(Protocol):
error_body: str | None, error_body: str | None,
attempts: int | None, attempts: int | None,
total_latency_ms: int | None, total_latency_ms: int | None,
stall_ms: int | None,
stall_count: int | None,
) -> None: ... ) -> None: ...
+18 -4
View File
@@ -5,8 +5,8 @@
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 + `DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 38 个 INSERT 字段 +
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。 `created_at` = 39;列数断言一律按物理列数写,两套口径混用是最易错处。
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
互不依赖(import-linter 契约执法)。 互不依赖(import-linter 契约执法)。
@@ -62,7 +62,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cause_type TEXT, cause_type TEXT,
error_body TEXT, error_body TEXT,
attempts INTEGER, attempts INTEGER,
total_latency_ms INTEGER total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
); );
""" """
@@ -104,7 +106,9 @@ CREATE TABLE IF NOT EXISTS llm_calls (
cause_type TEXT, cause_type TEXT,
error_body TEXT, error_body TEXT,
attempts INTEGER, attempts INTEGER,
total_latency_ms INTEGER total_latency_ms INTEGER,
stall_ms INTEGER,
stall_count INTEGER
); );
""" """
@@ -137,6 +141,10 @@ SQLITE_BACKFILL = (
("error_body", "TEXT"), ("error_body", "TEXT"),
("attempts", "INTEGER"), ("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"), ("total_latency_ms", "INTEGER"),
# 1.3.8 沉默归因两列(issue #27): 同样可空无默认——旧行 NULL = "补列之前
# 没记过这件事",与"观测过且零沉默"(0)是两回事
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
) )
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
@@ -163,6 +171,9 @@ _PG_BACKFILL_DECLS = (
("error_body", "TEXT"), ("error_body", "TEXT"),
("attempts", "INTEGER"), ("attempts", "INTEGER"),
("total_latency_ms", "INTEGER"), ("total_latency_ms", "INTEGER"),
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
("stall_ms", "INTEGER"),
("stall_count", "INTEGER"),
) )
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
@@ -212,6 +223,9 @@ COLUMNS = (
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因(issue #27)——
"stall_ms",
"stall_count",
) )
_COLUMN_SET = frozenset(COLUMNS) _COLUMN_SET = frozenset(COLUMNS)
+1 -1
View File
@@ -143,7 +143,7 @@ class SQLiteRecorder:
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
async def record_llm_call(self, **fields: object) -> None: async def record_llm_call(self, **fields: object) -> None:
"""写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。 """写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
占位符同序——两者必须一起改,分开改就是把值写进错位的列。 占位符同序——两者必须一起改,分开改就是把值写进错位的列。
+45 -3
View File
@@ -85,6 +85,29 @@ def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool,
return None return None
def _stall_from_chunk_times(
times: list[float], threshold_ms: int
) -> tuple[float | None, float | None]:
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
无超阈段但 chunk ≥2 时返回 (0.0, 0.0) 而非 None——"观测过且零沉默"与
"无从观测"是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
"""
if len(times) < 2:
return None, None
total = 0.0
count = 0
# strict=False: 两序列恒差 1(times[1:] 少末尾),B905 要求显式表态——不等长是本设计的本意
for prev, cur in zip(times, times[1:], strict=False):
gap_ms = (cur - prev) * 1000
if gap_ms > threshold_ms:
total += gap_ms
count += 1
return (total, float(count)) if count else (0.0, 0.0)
async def _iter_sse_deltas( async def _iter_sse_deltas(
lines: AsyncIterator[str], usage_sink: dict[str, Any] lines: AsyncIterator[str], usage_sink: dict[str, Any]
) -> AsyncIterator[tuple[bool, str]]: ) -> AsyncIterator[tuple[bool, str]]:
@@ -434,6 +457,7 @@ class OpenAICompatTransport:
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ) -> TransportResult:
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。 """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
@@ -441,6 +465,8 @@ class OpenAICompatTransport:
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。 在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
`first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径 `first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径
(`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。 (`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
增量 chunk 置位一次,非流式路径收它但永不置位。
""" """
profile = get_provider(source.provider, registry=self._registry) profile = get_provider(source.provider, registry=self._registry)
try: try:
@@ -466,11 +492,11 @@ class OpenAICompatTransport:
try: try:
if stream: if stream:
result = await self._complete_stream( result = await self._complete_stream(
client, url, payload, source, profile, first_token_event client, url, payload, source, profile, first_token_event, content_event
) )
else: else:
result = await self._complete_once( result = await self._complete_once(
client, url, payload, source, profile, first_token_event client, url, payload, source, profile, first_token_event, content_event
) )
except StreamLivenessTimeout as exc: except StreamLivenessTimeout as exc:
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
@@ -558,6 +584,7 @@ class OpenAICompatTransport:
source: SourceConfig, source: SourceConfig,
profile: ProviderProfile, profile: ProviderProfile,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ) -> TransportResult:
started = time.monotonic() started = time.monotonic()
async with client.stream("POST", url, json=payload) as resp: async with client.stream("POST", url, json=payload) as resp:
@@ -578,8 +605,17 @@ class OpenAICompatTransport:
ttft_ms: float | None = None ttft_ms: float | None = None
last = started last = started
max_gap = 0.0 max_gap = 0.0
chunk_times: list[float] = []
async for is_content, text in guarded: async for is_content, text in guarded:
now = time.monotonic() now = time.monotonic()
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
# 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta
# 被过滤,这里逐字追加即可;阈值判定不在热路径,派生收敛在
# _stall_from_chunk_times(设计 §3 派生原则)
chunk_times.append(now)
if content_event is not None:
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
content_event.set()
if ttft_ms is None: if ttft_ms is None:
ttft_ms = (now - started) * 1000 ttft_ms = (now - started) * 1000
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据, # 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
@@ -595,6 +631,7 @@ class OpenAICompatTransport:
self._reject_empty_completion(content, source) self._reject_empty_completion(content, source)
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged) prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage")) reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms)
return TransportResult( return TransportResult(
content=content, content=content,
thinking=thinking, thinking=thinking,
@@ -603,6 +640,8 @@ class OpenAICompatTransport:
usage_source=usage_source, usage_source=usage_source,
ttft_ms=ttft_ms, ttft_ms=ttft_ms,
max_inter_token_ms=(max_gap if ttft_ms is not None else None), max_inter_token_ms=(max_gap if ttft_ms is not None else None),
stall_ms=stall_ms,
stall_count=stall_count,
raw={"usage": sink.get("usage")}, raw={"usage": sink.get("usage")},
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
model_reported=_coerce_model_reported(sink.get("model")), model_reported=_coerce_model_reported(sink.get("model")),
@@ -664,11 +703,14 @@ class OpenAICompatTransport:
source: SourceConfig, source: SourceConfig,
profile: ProviderProfile, profile: ProviderProfile,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ) -> TransportResult:
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。 """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化 接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。 为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
TransportResult 默认 None(排除条款)。
""" """
resp = await client.post(url, json=payload) resp = await client.post(url, json=payload)
if resp.status_code != 200: if resp.status_code != 200:
+65 -3
View File
@@ -320,9 +320,20 @@ class CallStats:
hedges: int = 0 hedges: int = 0
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。""" """本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
generation_ms: int = 0 generation_ms: int = 0
"""裸生成时间: 赢家/成功那次 transport 调用的墙钟时长(口径见设计 §4.5 H8)。""" """净生成时间(1.3.8 起重定义,设计 §4.2): 赢家/成功那次 transport 墙钟
− Σ(超阈值中段沉默);仍含 TTFT 与尾部(末 chunk→流结束)。
非流式/增量 chunk<2 无沉默观测、不退减(= 原墙钟,此时 stall 两值 None)。
墙钟还原 = generation_ms + stall_ms。1.3.7 及以前本字段是完整墙钟,
口径变化见 CHANGELOG 行为变更段。"""
hedge_won: bool = False hedge_won: bool = False
"""赢家是否为对冲路;无对冲恒 False。""" """赢家是否为对冲路;无对冲恒 False。"""
stall_ms: int | None = None
"""赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。
墙钟 = generation_ms + stall_ms;None 是"无沉默观测",不是 0。"""
stall_count: int | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。"""
class _CallContext: class _CallContext:
@@ -340,6 +351,8 @@ class _CallContext:
"_hedge_won", "_hedge_won",
"_hedges", "_hedges",
"_now", "_now",
"_stall_count",
"_stall_ms",
"_started", "_started",
"_terminal_claimed", "_terminal_claimed",
"logical_call_id", "logical_call_id",
@@ -353,6 +366,8 @@ class _CallContext:
self._generation_ms = 0 self._generation_ms = 0
self._hedges = 0 self._hedges = 0
self._hedge_won = False self._hedge_won = False
self._stall_ms: int | None = None
self._stall_count: int | None = None
self._terminal_claimed = False self._terminal_claimed = False
def register_attempt(self) -> None: def register_attempt(self) -> None:
@@ -363,9 +378,23 @@ class _CallContext:
""" """
self._attempts += 1 self._attempts += 1
def record_generation(self, elapsed_ms: int, *, accumulate: bool) -> None: def record_generation(
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。""" self,
elapsed_ms: int,
*,
accumulate: bool,
stall_ms: int | None = None,
stall_count: int | None = None,
) -> None:
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。
stall 两值(1.3.8 净口径归因): chat 赢家路传入;embedding/ocr 既有调用
恒 None = "无沉默观测"(不是"沉默为 0")——它们是各自链路的墙钟,无中段
沉默概念。与 generation 同一次登记覆盖,不单独累加。
"""
self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms
self._stall_ms = stall_ms
self._stall_count = stall_count
def register_hedge(self, *, hedge_won: bool) -> None: def register_hedge(self, *, hedge_won: bool) -> None:
"""对冲路实际发出即计数;赢家裁定后一次性登记。""" """对冲路实际发出即计数;赢家裁定后一次性登记。"""
@@ -381,6 +410,8 @@ class _CallContext:
hedges=self._hedges, hedges=self._hedges,
generation_ms=self._generation_ms, generation_ms=self._generation_ms,
hedge_won=self._hedge_won, hedge_won=self._hedge_won,
stall_ms=self._stall_ms,
stall_count=self._stall_count,
) )
def claim_terminal(self) -> bool: def claim_terminal(self) -> bool:
@@ -585,6 +616,17 @@ class TransportResult:
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是 `medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。""" "未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
stall_ms: float | None = None
"""超阈值相邻增量 chunk 沉默总额(1.3.8, issue #27;毫秒,未取整——换算唯一处
在 retry 层,设计 §4.2 N8)。
非流式/增量 chunk <2 → `None`("无从观测");观测过但零超阈沉默是 `0.0`,
两者不得混——下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
尾部(末 chunk → 流结束)不计,归活跃侧(设计 §4.2 N1)。"""
stall_count: float | None = None
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
`stall_ms`。"""
@dataclass(frozen=True) @dataclass(frozen=True)
class SourceConfig: class SourceConfig:
@@ -638,10 +680,19 @@ class SourceConfig:
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果 后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。""" 就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
stall_threshold_ms: int = 1000
"""流式中段沉默的计量阈值(1.3.8, issue #27): 相邻增量 chunk 间隔**严格大于**
本值才计入 `TransportResult.stall_ms`/`stall_count`。
纯计量口径,不触发任何动作;触发中段对冲的动作阈值是 scope 级
`HEDGE__STALL_AFTER_S`(设计 §6 两参数分工)。scope 级键
`STREAM__STALL_THRESHOLD_MS` 经装配层 `dataclasses.replace` 分发进本字段。"""
def __post_init__(self) -> None: def __post_init__(self) -> None:
self._validate_identity() self._validate_identity()
self._validate_gates() self._validate_gates()
self._validate_watchdog() self._validate_watchdog()
self._validate_stall_threshold()
self._validate_thinking() self._validate_thinking()
self._freeze_extra_body() self._freeze_extra_body()
@@ -680,6 +731,17 @@ class SourceConfig:
): ):
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s") raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
def _validate_stall_threshold(self) -> None:
# bool 是 int 子类: True/False 会被当成 1/0 静默放行,必须显式拒(P5)
if (
isinstance(self.stall_threshold_ms, bool)
or not isinstance(self.stall_threshold_ms, int)
or self.stall_threshold_ms <= 0
):
raise ValueError(
f"SourceConfig.stall_threshold_ms 必须是正整数: {self.stall_threshold_ms!r}"
)
def _validate_thinking(self) -> None: def _validate_thinking(self) -> None:
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。 """推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
+2
View File
@@ -292,6 +292,7 @@ class ObservedTransport:
call_id: str, call_id: str,
reasoning_effort: Effort | None, reasoning_effort: Effort | None,
first_token_event: asyncio.Event | None, first_token_event: asyncio.Event | None,
content_event: asyncio.Event | None,
) -> TransportResult: ) -> TransportResult:
"""与生产端口逐参数同签名。""" """与生产端口逐参数同签名。"""
with self._capture.attempt_context(call_id): with self._capture.attempt_context(call_id):
@@ -304,6 +305,7 @@ class ObservedTransport:
call_id=call_id, call_id=call_id,
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
first_token_event=first_token_event, first_token_event=first_token_event,
content_event=content_event,
) )
async def embed( async def embed(
+16 -4
View File
@@ -69,6 +69,9 @@ _EXPECTED_COLUMNS = [
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
] ]
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片 # 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
@@ -167,6 +170,9 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object
# 逻辑快照两列只属终态行 # 逻辑快照两列只属终态行
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
"stall_ms": None,
"stall_count": None,
} }
fields.update(overrides) fields.update(overrides)
return fields return fields
@@ -769,7 +775,7 @@ class TestCallerDimensionsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾 # 22 → 38 个 recorder 字段(加 created_at 共 39 个物理列),且新列追加在末尾
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
rows = await _fetch( rows = await _fetch(
schema_dsn, schema_dsn,
@@ -1100,7 +1106,7 @@ class TestCallObservabilityColumnsAcceptance:
""" """
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema): async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
"""27 列旧表 auto 补齐到 37 列,新行两类取值读得回,**历史行十列一律 NULL**。 """27 列旧表 auto 补齐到 39 列(1.3.5 十列 + 1.3.8 两列),新行两类取值读得回,**历史行新增列一律 NULL**。
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过 旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行 这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
@@ -1142,7 +1148,7 @@ class TestCallObservabilityColumnsAcceptance:
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
schema, schema,
) )
# 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾: # 26 → 38 个 recorder 字段(加 created_at 共 39 个物理列),新列追加在末尾:
# 列序与新建库一致才不会让两条升级路径分叉 # 列序与新建库一致才不会让两条升级路径分叉
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
@@ -1167,6 +1173,9 @@ class TestCallObservabilityColumnsAcceptance:
"error_body": "upstream said 503", "error_body": "upstream said 503",
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
"stall_ms": None,
"stall_count": None,
} }
assert dict(by_id["term"]) == { assert dict(by_id["term"]) == {
"call_id": "term", "call_id": "term",
@@ -1180,6 +1189,9 @@ class TestCallObservabilityColumnsAcceptance:
"error_body": None, "error_body": None,
"attempts": 3, "attempts": 3,
"total_latency_ms": 4200, "total_latency_ms": 4200,
# 1.3.8 两列: 终态行无 transport 观测 → NULL
"stall_ms": None,
"stall_count": None,
} }
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红) # 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
assert dict(by_id["pre135"]) == { assert dict(by_id["pre135"]) == {
@@ -1337,7 +1349,7 @@ class TestPublishedSchemaScript:
await _execute_script(fresh_dsn, script) await _execute_script(fresh_dsn, script)
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)] actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
# 物理列 = 36 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份 # 物理列 = 38 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
assert set(actual) == set(COLUMNS) | {"created_at"} assert set(actual) == set(COLUMNS) | {"created_at"}
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
assert actual == _EXPECTED_COLUMNS assert actual == _EXPECTED_COLUMNS
@@ -76,7 +76,16 @@ class ScriptedTransport:
self.entered = asyncio.Event() self.entered = asyncio.Event()
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append(source.name) self.calls.append(source.name)
if self.hang: if self.hang:
+10 -1
View File
@@ -211,7 +211,16 @@ class ClockAdvancingTransport:
self.calls = [] self.calls = []
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append((source.name, call_id)) self.calls.append((source.name, call_id))
advance, action = self.script.pop(0) advance, action = self.script.pop(0)
+20 -2
View File
@@ -222,7 +222,16 @@ class _ScriptedGenClockTransport:
self.calls = [] self.calls = []
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append(call_id) self.calls.append(call_id)
result = self._results.pop(0) result = self._results.pop(0)
@@ -1825,7 +1834,16 @@ class _ClockJumpTransport:
self.calls = [] self.calls = []
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append(call_id) self.calls.append(call_id)
self._clock.advance(self._jump) self._clock.advance(self._jump)
+174 -6
View File
@@ -1179,17 +1179,16 @@ class TestHedgeConfig:
origin="GatewayClient(hedge_after_s=8)", origin="GatewayClient(hedge_after_s=8)",
) )
assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键 assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键
# 未启用对冲(None)时空 sources 直接放行: 交叉守卫没有可校验的对象 # 未启用对冲(两阈值皆 None)时空 sources 直接放行: 交叉守卫没有可校验的对象;
assert ( # 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s),
check_hedge_assembly( # 两阈值皆 None 才早退
assert check_hedge_assembly(
hedge_after_s=None, hedge_after_s=None,
hedge_max_extra=1, hedge_max_extra=1,
sources=[], sources=[],
call_deadline_s=None, call_deadline_s=None,
origin="test", origin="test",
) ) == (None, None)
is None
)
def test_hedge_guard_below_min_timeout_raises(self): def test_hedge_guard_below_min_timeout_raises(self):
"""阈值 ≥ 最小源 timeout_s = 对冲永不可能触发,装配期炸掉(ValueError)。""" """阈值 ≥ 最小源 timeout_s = 对冲永不可能触发,装配期炸掉(ValueError)。"""
@@ -1283,3 +1282,172 @@ class TestHedgeConfig:
client = GatewayClient.from_settings(s) client = GatewayClient.from_settings(s)
assert client._hedge_after_s == 8.0 assert client._hedge_after_s == 8.0
assert client._terminal._hedge_after_s == 8.0 assert client._terminal._hedge_after_s == 8.0
class TestStallConfig:
"""1.3.8 两新键: `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(计量,默认 1000 三层可调)
与 `{SCOPE}__HEDGE__STALL_AFTER_S`(中段对冲,未设=关闭)及其装配守卫(设计 §6,
计划批次 D;单一定义点仍是 `config.check_hedge_assembly`)。"""
def _two_source_env(self, **overrides):
"""双源 env(同 provider 避免注册表依赖): 隔离单源 warning 的干扰。"""
return _env(
**{
"LLM__QWEN__2__BASE_URL": "https://gw-b.example/v1",
"LLM__QWEN__2__API_KEY": "sk-b",
"LLM__QWEN__2__MODEL": "qwen-plus",
"LLM__QWEN__2__TIMEOUT_S": "90",
**overrides,
}
)
def test_stall_threshold_key_loads_three_layers(self):
"""计量阈值三层调整接口(§9-③ 人类要求): env 键 / settings 字段 / client kwarg。
分发语义: from_settings 恒以 settings 值为权威(覆盖源自带值);client 直传
None = 保留各 SourceConfig 自带值,非 None 经 dataclasses.replace 分发。"""
from tests.unit.test_client import _client
# 层 1: env 键 → settings 字段 → from_settings 分发进每个 SourceConfig
env = self._two_source_env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2500"})
s = GatewaySettings.from_env("LLM", env=env)
assert s.stall_threshold_ms == 2500
client = GatewayClient.from_settings(s)
assert {src.stall_threshold_ms for src in client._terminal._sources} == {2500}
# 层 2: settings 字段直接构造(dataclasses.replace 同路),settings 值权威:
# 源自带 500 也被 scope 值覆盖(恒分发,计划 §3.5)
base = GatewaySettings.from_env("LLM", env=_env())
custom_source = dataclasses.replace(base.sources[0], stall_threshold_ms=500)
s2 = dataclasses.replace(base, sources=(custom_source,), stall_threshold_ms=800)
client2 = GatewayClient.from_settings(s2)
assert client2._terminal._sources[0].stall_threshold_ms == 800
# 层 3: client kwarg——非 None 分发,None(缺省)保留源自带值
client3 = _client(stall_threshold_ms=600)
assert client3._terminal._sources[0].stall_threshold_ms == 600
client4 = _client(sources=[custom_source])
assert client4._terminal._sources[0].stall_threshold_ms == 500 # 不 stomp
# 未设恒 1000(SourceConfig 缺省)
assert GatewaySettings.from_env("LLM", env=_env()).stall_threshold_ms == 1000
assert _client()._terminal._sources[0].stall_threshold_ms == 1000
def test_stream_segment_reserved(self):
"""`STREAM` 进保留段: 四段 `LLM__STREAM__1__*` 不造源(1.3.7 HEDGE 同款)。"""
env_collision = _env(
**{
"LLM__STREAM__1__BASE_URL": "https://gw-c.example/v1",
"LLM__STREAM__1__API_KEY": "sk-c",
"LLM__STREAM__1__MODEL": "m-c",
"LLM__STREAM__1__TIMEOUT_S": "60",
}
)
s = GatewaySettings.from_env("LLM", env=env_collision)
assert [src.name for src in s.sources] == ["qwen_1"]
# 三段键天然跳过 `_load_sources` 的段数判据,也不造源
env = _env(**{"LLM__STREAM__STALL_THRESHOLD_MS": "2000"})
s2 = GatewaySettings.from_env("LLM", env=env)
assert [src.name for src in s2.sources] == ["qwen_1"]
def test_stall_after_s_guard_effective_minimum_raises(self):
"""`stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError。
有效值回落与 openai_compat.py:573 看门狗装配同款: 配 watchdog 的源用
inter_token_timeout_s,未配的源用 timeout_s;对冲窗永不开装配期直接拒绝。"""
# 源 1 配 watchdog(inter_token 15)、源 2 纯 timeout(90): 有效最小值 = 15
env = self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__STALL_AFTER_S": "15", # ≥ 15 → 拒绝(边界含等号)
}
)
with pytest.raises(ValueError, match="STALL_AFTER_S"):
GatewaySettings.from_env("LLM", env=env)
s = GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
"LLM__HEDGE__STALL_AFTER_S": "14",
}
),
)
assert s.hedge_stall_after_s == 14.0
# 全源未配 watchdog: 有效最小值 = min(timeout_s) = 90
with pytest.raises(ValueError, match="STALL_AFTER_S"):
GatewaySettings.from_env(
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "90"})
)
def test_stall_after_s_guard_deadline_conflict_raises(self):
"""`stall_after_s ≥ call_deadline_s` → ValueError(与 after_s 同款,§11-N5)。"""
env = self._two_source_env(
**{"LLM__HEDGE__STALL_AFTER_S": "30", "LLM__CALL_DEADLINE_S": "30"}
)
with pytest.raises(ValueError, match="call_deadline_s"):
GatewaySettings.from_env("LLM", env=env)
s = GatewaySettings.from_env(
"LLM",
env=self._two_source_env(
**{"LLM__HEDGE__STALL_AFTER_S": "29", "LLM__CALL_DEADLINE_S": "30"}
),
)
assert s.hedge_stall_after_s == 29.0 and s.call_deadline_s == 30.0
def test_stall_after_s_single_source_warns(self):
"""单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。"""
with _captured_warnings() as warnings:
s = GatewaySettings.from_env(
"LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
)
assert s.hedge_stall_after_s == 8.0
assert any("单源" in m for m in warnings)
def test_embedding_ocr_scope_hedge_keys_warn(self):
"""B4: Embedding/OCR scope 配对冲键(AFTER_S 或 STALL_AFTER_S)→ 装配 warning;
chat scope 无此 warning(per-call stream,装配期无信号,B4 裁决)。"""
# Embedding: env 路(AFTER_S 与 STALL_AFTER_S 各验一次)
embed_env = {
"EMBED__QWEN__1__BASE_URL": "https://gw.example/v1",
"EMBED__QWEN__1__API_KEY": "sk-a",
"EMBED__QWEN__1__MODEL": "text-embedding-v3",
"EMBED__QWEN__1__TIMEOUT_S": "60",
"EMBED__RETRY__MAX_ATTEMPTS": "3",
"EMBED__RETRY__BACKOFF_BASE_S": "1.0",
"EMBED__RETRY__BACKOFF_MAX_S": "10.0",
"EMBED__BREAKER__FAIL_THRESHOLD": "5",
"EMBED__BREAKER__COOLDOWN_S": "60",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
"EMBED__BATCH_SIZE": "64",
}
with _captured_warnings() as warnings:
EmbeddingSettings.from_env(
"EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"}
)
assert any("仅对 chat scope 生效" in m for m in warnings)
with _captured_warnings() as warnings:
EmbeddingSettings.from_env(
"EMBED", env={**embed_env, "EMBED__HEDGE__STALL_AFTER_S": "8"}
)
assert any("仅对 chat scope 生效" in m for m in warnings)
# Embedding: 直构造路(__post_init__ 那道,非 from_env 专属)
gw = GatewaySettings.from_env("EMBED", env=embed_env)
gw_hedged = dataclasses.replace(gw, hedge_stall_after_s=8.0)
with _captured_warnings() as warnings:
EmbeddingSettings(gateway=gw_hedged, batch_size=64)
assert any("仅对 chat scope 生效" in m for m in warnings)
# OCR: env 路(1.3.7 AFTER_S 同病顺带补齐 + 新 STALL_AFTER_S)
ocr_env = dict(TestOcrSettings._OCR_ENV)
with _captured_warnings() as warnings:
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
with _captured_warnings() as warnings:
OcrSettings.from_env("OCR", env={**ocr_env, "OCR__HEDGE__STALL_AFTER_S": "8"})
assert any("仅对 chat scope 生效" in m for m in warnings)
# chat 侧: 同样的对冲键不产生 B4 warning(双源 env 隔离单源 warning 干扰)
with _captured_warnings() as warnings:
GatewaySettings.from_env(
"LLM", env=self._two_source_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})
)
assert not any("仅对 chat scope 生效" in m for m in warnings)
+51 -1
View File
@@ -49,14 +49,26 @@ class HedgeTransport:
self.calls: list[str] = [] self.calls: list[str] = []
# 逐次记录收到的 first_token_event 身份(H5: 对冲路恒为 None,不再梯次) # 逐次记录收到的 first_token_event 身份(H5: 对冲路恒为 None,不再梯次)
self.ft_events: list[object] = [] self.ft_events: list[object] = []
# content_event 同款逐次记录(1.3.8 N3③: 对冲路恒 None 的断言原料)
self.ce_events: list[object] = []
self.entered = {name: asyncio.Event() for name in scripts} self.entered = {name: asyncio.Event() for name in scripts}
self.release = {name: asyncio.Event() for name in scripts} self.release = {name: asyncio.Event() for name in scripts}
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append(source.name) self.calls.append(source.name)
self.ft_events.append(first_token_event) self.ft_events.append(first_token_event)
self.ce_events.append(content_event)
actions = self._scripts[source.name] actions = self._scripts[source.name]
action = actions.pop(0) if len(actions) > 1 else actions[0] action = actions.pop(0) if len(actions) > 1 else actions[0]
kind = action[0] kind = action[0]
@@ -130,6 +142,10 @@ class RecordingEmitter:
error, error,
reasoning_applies, reasoning_applies,
operation, operation,
# 1.3.8: `_emit` 显式转发 stall 两值(审查 B1)——显式全签名 fake 不加则 TypeError;
# 默认 None 只护调用方,护不住被调的显式签名
stall_ms=None,
stall_count=None,
): ):
self.rows.append( self.rows.append(
{ {
@@ -139,6 +155,9 @@ class RecordingEmitter:
if request.call_context is not None if request.call_context is not None
else None, else None,
"error": error, "error": error,
# 1.3.8: 逐行捕获 stall 归因(取消输家行须恒 None,设计 §11-B2)
"stall_ms": stall_ms,
"stall_count": stall_count,
} }
) )
@@ -443,6 +462,16 @@ class TestHedgeSettlementAndSignals:
assert winner["error"] is None assert winner["error"] is None
assert loser["logical_call_id"] == winner["logical_call_id"] == ctx.logical_call_id assert loser["logical_call_id"] == winner["logical_call_id"] == ctx.logical_call_id
async def test_hedge_cancelled_row_has_null_stall(self):
"""被取消输家无 TransportResult: `hedge_cancelled` 行 stall 两值恒 None(1.3.8 B2)。"""
emitter = RecordingEmitter()
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
await asyncio.wait_for(mw(_req()), timeout=5)
loser = next(r for r in emitter.rows if r["error"] == "hedge_cancelled")
assert loser["stall_ms"] is None
assert loser["stall_count"] is None
async def test_loser_does_not_feed_breaker(self): async def test_loser_does_not_feed_breaker(self):
"""输家取消不喂熔断失败计数、不喂健康分;赢家照常 record_success(④)。""" """输家取消不喂熔断失败计数、不喂健康分;赢家照常 record_success(④)。"""
selector = RecordingSelector() selector = RecordingSelector()
@@ -557,6 +586,27 @@ class TestHedgeFailureCombination:
# 两败轮次同样登记对冲路数(设计 §4.5: 实际并发发出即计) # 两败轮次同样登记对冲路数(设计 §4.5: 实际并发发出即计)
assert ctx.snapshot().hedges == 2 assert ctx.snapshot().hedges == 2
async def test_stall_attribution_none_when_no_winner(self):
"""两败轮次无赢家(1.3.8 排除条款): generation 不记、stall 归因恒 None。
对冲已实际发出(hedges 照登),但无赢家路 → 裸生成时间与沉默归因都
无归属;两路各自的沉默归属各自 attempt 遥测行(那是 T3 的事)。
"""
transport = HedgeTransport(
{
"s1": [("fail_after", 0.1, lambda: TransientError("p", source_name="s1"))],
"s2": [("fail_after", 0.12, lambda: TransientError("h", source_name="s2"))],
}
)
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1)
ctx = _ctx()
with pytest.raises(AllSourcesExhausted):
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is False
assert stats.generation_ms == 0
assert stats.stall_ms is None and stats.stall_count is None
async def test_both_429_refund_no_budget(self): async def test_both_429_refund_no_budget(self):
"""两路皆 429: 免预算且耗时退 stall 账——小 stall 窗下终局 stalled 而非耗尽。""" """两路皆 429: 免预算且耗时退 stall 账——小 stall 窗下终局 stalled 而非耗尽。"""
+2
View File
@@ -278,6 +278,7 @@ async def _complete(observed, *, call_id="a", stream=False):
call_id=call_id, call_id=call_id,
reasoning_effort=None, reasoning_effort=None,
first_token_event=None, first_token_event=None,
content_event=None,
) )
@@ -1268,6 +1269,7 @@ async def test_structured_first_attempt_requires_exact_initial_messages():
call_id="first", call_id="first",
reasoning_effort=None, reasoning_effort=None,
first_token_event=None, first_token_event=None,
content_event=None,
) )
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0] event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
assert not request_is_valid(event) assert not request_is_valid(event)
+199 -1
View File
@@ -25,6 +25,7 @@ from polygateway.transports.openai_compat import (
_iter_sse_deltas, _iter_sse_deltas,
_parse_retry_after, _parse_retry_after,
_sse_data_payload, _sse_data_payload,
_stall_from_chunk_times,
_translate_429, _translate_429,
) )
from polygateway.types import ( from polygateway.types import (
@@ -81,7 +82,14 @@ def _transport_for(handler, *, registry=None):
async def _complete( async def _complete(
transport, source, *, stream=True, overlay=None, reasoning_effort=None, first_token_event=None transport,
source,
*,
stream=True,
overlay=None,
reasoning_effort=None,
first_token_event=None,
content_event=None,
): ):
return await transport.complete( return await transport.complete(
messages=[{"role": "user", "content": "hi"}], messages=[{"role": "user", "content": "hi"}],
@@ -91,6 +99,7 @@ async def _complete(
call_id="cid-1", call_id="cid-1",
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
first_token_event=first_token_event, first_token_event=first_token_event,
content_event=content_event,
) )
@@ -277,6 +286,195 @@ class TestFirstTokenEvent:
) )
class _CountingEvent(asyncio.Event):
"""统计 set() 次数的 Event: content_event "每增量 chunk 置位一次"的观测设施。"""
def __init__(self) -> None:
super().__init__()
self.set_count = 0
def set(self) -> None:
self.set_count += 1
super().set()
def _gated_sse_response(*items):
"""事件驱动的 SSE 响应体: str 段直接产出,asyncio.Event 段等待置位。
停顿由测试体用真实 `asyncio.sleep` 制造(计划 §5 真实 loop 钟范式,4× 余量);
闸门只决定"停顿发生在哪两帧之间",不断定时长,两个方向都不撞窗口。
"""
async def gen():
for item in items:
if isinstance(item, asyncio.Event):
await item.wait()
else:
yield item.encode()
return httpx.Response(200, content=gen(), headers={"content-type": "text/event-stream"})
class TestContentEvent:
"""增量 chunk 处置位(1.3.8, issue #27/#28): 中段沉默计量与对冲的信号源。"""
async def test_stream_sets_content_event_per_chunk(self):
"""流式每个增量 chunk(内容或思考)置位一次——对冲触发窗的沉默复位信号。"""
def handler(request):
return _sse_stream(
_chunk(reasoning="ponder"),
_chunk(content="hi"),
_chunk(content=" there"),
_chunk(usage=_USAGE),
)
event = _CountingEvent()
result = await _complete(_transport_for(handler), _source(), content_event=event)
assert result.content == "hi there"
assert event.set_count == 3 # 3 个增量 chunk 各置位一次
async def test_reasoning_chunk_counts_as_liveness(self):
"""reasoning_content 增量同样置位并进 chunk 序列(B1: 思考增量是存活证据)。"""
def handler(request):
return _sse_stream(
_chunk(reasoning="step1"),
_chunk(reasoning="step2"),
_chunk(content="ok"),
_chunk(usage=_USAGE),
)
event = _CountingEvent()
result = await _complete(_transport_for(handler), _source(), content_event=event)
assert result.thinking == "step1step2"
assert event.set_count == 3
# 快速投递下零超阈沉默: "观测过且零沉默"(0.0) ≠ "无从观测"(None);
# 两值为 0.0 而非 None 同时证明 chunk 序列 ≥2(思考增量也入了序列)
assert result.stall_ms == 0.0 and result.stall_count == 0.0
async def test_non_stream_never_sets_content_event(self):
"""非流式无中途信号: 给了事件也永不置位;stall 两值恒 None(排除条款)。"""
def handler(request):
return httpx.Response(
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
)
event = _CountingEvent()
result = await _complete(
_transport_for(handler), _source(), stream=False, content_event=event
)
assert result.content == "42"
assert event.set_count == 0
assert result.stall_ms is None and result.stall_count is None
async def test_none_content_event_keeps_behavior(self):
"""`None` = 调用方不观测增量 chunk(未启用中段对冲): 行为与旧版逐字相同。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
result = await _complete(_transport_for(handler), _source(), content_event=None)
assert result.content == "ok"
assert result.ttft_ms is not None
async def test_content_event_is_required_keyword(self):
"""端口必填约定同 first_token_event: 漏传必须 TypeError。"""
def handler(request):
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
transport = _transport_for(handler)
with pytest.raises(TypeError):
await transport.complete(
messages=[{"role": "user", "content": "hi"}],
source=_source(),
stream=True,
overlay={},
call_id="cid-1",
reasoning_effort=None,
first_token_event=None,
)
class TestStallFromChunkTimes:
"""沉默计量纯函数(设计 §4.2): 严格大于阈值才减、chunk<2 排除、尾部不计。"""
def test_gap_above_threshold_accumulates(self):
stall_ms, stall_count = _stall_from_chunk_times([0.0, 0.3, 2.0, 2.1], 1000)
assert stall_ms == pytest.approx(1700.0) # 仅 0.3→2.0 的 1.7s 段超阈
assert stall_count == 1.0
def test_gap_exactly_at_threshold_not_deducted(self):
"""间隔恰好 == 阈值不减(严格大于,pi-tps 口径)。"""
assert _stall_from_chunk_times([0.0, 1.0], 1000) == (0.0, 0.0)
def test_fewer_than_two_chunks_returns_none(self):
"""0/1 个 chunk → (None, None): 无从观测,不得伪造 0(vLLM 排除条款)。"""
assert _stall_from_chunk_times([], 1000) == (None, None)
assert _stall_from_chunk_times([0.5], 1000) == (None, None)
async def test_tail_not_counted(self):
"""末 chunk → 流结束(usage/[DONE])的间隔不进 stall(尾部归活跃侧,N1)。"""
gate = asyncio.Event()
def handler(request):
return _gated_sse_response(
_chunk(content="a"),
_chunk(content="b"),
gate,
_chunk(usage=_USAGE),
"data: [DONE]\n\n",
)
async def open_after_pause():
await asyncio.sleep(0.2) # 真实尾部停顿 200ms(阈值 50ms 的 4×;方向安全: 只会更大)
gate.set()
task = asyncio.create_task(
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
)
await asyncio.gather(task, open_after_pause())
result = task.result()
assert result.content == "ab"
# 唯一的相邻 chunk 间隔是快速段;200ms 的尾部停顿若被计入即超阈暴露
assert result.stall_ms == 0.0 and result.stall_count == 0.0
async def test_empty_or_usage_frames_not_timed(self):
"""空 chunk/usage 帧/keepalive 不产生时间戳记录点: 三段停顿只算一次 chunk 间隔。"""
gates = [asyncio.Event() for _ in range(3)]
def handler(request):
return _gated_sse_response(
_chunk(content="a"),
gates[0],
'data: {"choices":[{"delta":{}}]}\n\n', # 空 chunk
gates[1],
": ping\n\n", # SSE keepalive
gates[2],
_chunk(usage=_USAGE), # usage-only 帧
_chunk(content="b"),
"data: [DONE]\n\n",
)
async def open_gates():
for gate in gates:
await asyncio.sleep(0.2)
gate.set()
task = asyncio.create_task(
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
)
await asyncio.gather(task, open_gates())
result = task.result()
assert result.content == "ab"
# 三个非增量帧若被计时,三段 200ms 间隔各自超阈(count==3);
# 真相: chunk1→chunk2 仅一个间隔(~600ms),count==1
assert result.stall_count == 1.0
assert result.stall_ms >= 400.0 # 下界证明停顿真实发生(慢机器只会更大,不设上界)
class TestMissingDoneSemantics: class TestMissingDoneSemantics:
def _no_done_handler(self, request): def _no_done_handler(self, request):
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False) return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
+13 -2
View File
@@ -73,7 +73,16 @@ class _DummyMw:
class _DummyTransport: class _DummyTransport:
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
raise NotImplementedError raise NotImplementedError
@@ -138,6 +147,8 @@ class _DummyRecorder:
error_body, error_body,
attempts, attempts,
total_latency_ms, total_latency_ms,
stall_ms,
stall_count,
) -> None: ... ) -> None: ...
@@ -310,7 +321,7 @@ class TestTelemetryRecorderSignature:
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
} <= set(params) } <= set(params)
assert len(params) - 1 == 36 # 减掉 self assert len(params) - 1 == 38 # 减掉 self
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name", "name",
+68 -3
View File
@@ -80,7 +80,16 @@ class FakeTransport:
self.entered = asyncio.Event() self.entered = asyncio.Event()
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
self.calls.append((source.name, call_id)) self.calls.append((source.name, call_id))
self.efforts.append(reasoning_effort) self.efforts.append(reasoning_effort)
@@ -98,21 +107,34 @@ class _GenClockTransport:
generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包 generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包
transport 内部;退避耗时由用例自带的 sleep 闭包推进,与本包装无关。 transport 内部;退避耗时由用例自带的 sleep 闭包推进,与本包装无关。
`stalls`(1.3.8 计划 §3.3): 逐次自控上报的 (stall_ms, stall_count),
fake 自己报数、不经真实计时;None(默认)= 该次调用无沉默观测。
""" """
def __init__(self, script, advances, clock): def __init__(self, script, advances, clock, stalls=None):
self._inner = FakeTransport(script) self._inner = FakeTransport(script)
self._advances = list(advances) self._advances = list(advances)
self._clock = clock self._clock = clock
self._stalls = list(stalls) if stalls is not None else None
@property @property
def calls(self): def calls(self):
return self._inner.calls return self._inner.calls
async def complete( async def complete(
self, *, messages, source, stream, overlay, call_id, reasoning_effort, first_token_event self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
): ):
advance = self._advances.pop(0) advance = self._advances.pop(0)
stall = self._stalls.pop(0) if self._stalls is not None else None
result = await self._inner.complete( result = await self._inner.complete(
messages=messages, messages=messages,
source=source, source=source,
@@ -121,7 +143,10 @@ class _GenClockTransport:
call_id=call_id, call_id=call_id,
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
first_token_event=first_token_event, first_token_event=first_token_event,
content_event=content_event,
) )
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
self._clock.advance(advance) self._clock.advance(advance)
return result return result
@@ -998,6 +1023,46 @@ class TestGenerationMs:
stats = ctx.snapshot() stats = ctx.snapshot()
assert stats.hedges == 0 and stats.hedge_won is False assert stats.hedges == 0 and stats.hedge_won is False
async def test_generation_ms_net_of_stall(self):
"""净口径(1.3.8 设计 §4.2): generation_ms = 墙钟 − Σ(超阈值中段沉默)。
FakeClock 推进 5.0s 墙钟、fake 自报 stall 3200ms/2 段 → 净 1800ms;
归因两值进快照,墙钟还原公式 generation_ms + stall_ms == 5000 成立。
推进量取二进制可精确表示值(同类既有用例惯例,防 int 截断抖动)。
"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(3200.0, 2)])
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
resp = await mw(dataclasses.replace(_REQ, call_context=ctx))
assert resp.content == "ok"
stats = ctx.snapshot()
assert stats.generation_ms == 1800
assert stats.stall_ms == 3200 and stats.stall_count == 2
assert stats.generation_ms + stats.stall_ms == 5000 # 墙钟还原公式
async def test_generation_ms_clamps_negative_to_zero(self):
"""沉默上报超过墙钟(异常数据/钟偏差防御) → 净口径 clamp 到 0(N4),归因原样保留。"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock, stalls=[(6000.0, 3)])
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
stats = ctx.snapshot()
assert stats.generation_ms == 0
assert stats.stall_ms == 6000 and stats.stall_count == 3
async def test_non_stream_stall_none_no_deduction(self):
"""stall None(非流式/增量 chunk<2,排除条款) → 净口径不退减(=墙钟),归因 None。"""
clock = FakeClock()
transport = _GenClockTransport([_ok()], [5.0], clock) # 不报 stall = 无观测
mw, *_ = _harness([_src("a")], [], clock=clock, transport=transport)
ctx = self._ctx(clock)
await mw(dataclasses.replace(_REQ, call_context=ctx))
stats = ctx.snapshot()
assert stats.generation_ms == 5000
assert stats.stall_ms is None and stats.stall_count is None
class TestLogicalAttemptCounting: class TestLogicalAttemptCounting:
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。 """尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
+479
View File
@@ -0,0 +1,479 @@
"""中段沉默对冲编排测试(1.3.8 issue #28 设计 §5.2 / 计划批次 D)。
设施纪律(计划 §5): 复用 test_hedge.py 范式——两源 scope、事件驱动假 transport、
**真实 loop 钟**(不注入 FakeClock 驱动触发;注入钟仅用于赢家净口径精确断言,
触发路径结构性不读它)、阈值 0.05s、断言容差 4–10×;取消窗口用 entered Event
栅栏钉死,禁 sleep 撞窗口;不标 slow。
命名区隔(审查 N5): 本文件的 `stall_after_s` 是**对冲触发阈值**(相邻增量 chunk
间隔超过它 → 触发对冲);test_hedge.py:150 的 `stall_window_s` 是 **StallClock
预算**(非生产性等待的调用级上限)——同名词不同概念,勿混。
"""
import asyncio
import dataclasses
import time
import pytest
from polygateway.backends.memory.breaker import InMemoryGate
from polygateway.backends.memory.limiter import InMemoryLimiter
from polygateway.errors import AllSourcesExhausted, TransientError
from polygateway.middleware.retry import RetryMW
from polygateway.sources import SourceCooldownMemo
from polygateway.types import (
BackpressurePolicy,
BreakerConfig,
ChatRequest,
GlobalLimits,
RetryPolicy,
_CallContext,
)
from tests.contracts.conftest import FakeClock
from tests.unit.test_hedge import RecordingEmitter
from tests.unit.test_retry import StaticSelector, _ok, _src
_BREAKER = BreakerConfig(fail_threshold=3, cooldown_s=60.0, probe_ttl_s=120.0)
_NO_GLOBAL = GlobalLimits(max_concurrency=0, rpm=0, tpm=0)
_STALL_AFTER_S = 0.05 # 真实 loop 钟对冲触发阈值(≠ StallClock 预算 stall_window_s,N5)
class StallHedgeTransport:
"""事件驱动假 transport(中段沉默剧本): 逐步骤控制 chunk 事件/挂起/成败。
剧本 = 每源一串**步骤**,每次 `complete` 调用从上次位置继续执行到终态步骤
(`ok`/`fail`)为止;多轮调用(重试)顺序消费后续步骤,耗尽即 AssertionError。
步骤:
("chunk",) — 置 first_token_event 与 content_event(首个增量 chunk)
("chunk_after", delay) — 睡 delay 后只置 content_event(后续增量 chunk)
("hang",) — 置位该源 entered,挂起直到该源 release(或被取消)
("ok", content) — 终态: 立即成功(带已登记的 stall/clock 效果)
("ok_after", delay, content) — 终态: 真实 loop 钟睡 delay 后成功
("fail_after", delay, factory) — 终态: 睡 delay 后抛 `factory()` 新造的异常
("stall", ms, count) — 非终态: 给本剧本终态结果打自报 stall 值(假
transport 自己报数,不经真实计时,计划 §3.3)
("advance", secs) — 非终态: 推进注入钟(仅配 FakeClock 的用例)
"""
def __init__(self, scripts: dict[str, list[tuple]], clock: FakeClock | None = None):
self._scripts = {name: list(steps) for name, steps in scripts.items()}
self._pos = dict.fromkeys(scripts, 0)
self._clock = clock
self.calls: list[str] = []
# 逐次记录收到的 first_token_event/content_event 身份(对冲路恒 None 断言原料)
self.ft_events: list[object] = []
self.ce_events: list[object] = []
self.entered = {name: asyncio.Event() for name in scripts}
self.release = {name: asyncio.Event() for name in scripts}
async def complete(
self,
*,
messages,
source,
stream,
overlay,
call_id,
reasoning_effort,
first_token_event,
content_event,
):
self.calls.append(source.name)
self.ft_events.append(first_token_event)
self.ce_events.append(content_event)
steps = self._scripts[source.name]
stall: tuple[float, float] | None = None
while self._pos[source.name] < len(steps):
action = steps[self._pos[source.name]]
self._pos[source.name] += 1
kind = action[0]
if kind == "chunk":
if first_token_event is not None:
first_token_event.set()
if content_event is not None:
content_event.set()
elif kind == "chunk_after":
await asyncio.sleep(action[1])
if content_event is not None:
content_event.set()
elif kind == "hang":
self.entered[source.name].set()
await self.release[source.name].wait()
elif kind == "stall":
stall = (action[1], action[2])
elif kind == "advance":
assert self._clock is not None, "advance 步骤需要构造时注入 FakeClock"
self._clock.advance(action[1])
elif kind == "ok":
result = _ok(action[1])
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
return result
elif kind == "ok_after":
await asyncio.sleep(action[1])
result = _ok(action[2])
if stall is not None:
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
return result
elif kind == "fail_after":
await asyncio.sleep(action[1])
raise action[2]()
else:
raise AssertionError(f"未知剧本步骤: {action!r}")
raise AssertionError(f"{source.name} 剧本耗尽且无终态步骤")
def _harness(
sources,
transport,
*,
hedge_after_s=None,
stall_after_s=None,
max_attempts=3,
emitter=None,
selector=None,
limiter=None,
gate=None,
stall_window_s=300.0,
now=None,
):
"""真实 loop 钟装配(同 test_hedge.py 范式;`now` 仅供净口径精确断言注入 FakeClock)。
`stall_window_s` 是 StallClock 预算(N5 命名区隔),与 `stall_after_s` 无关。
"""
limiter = limiter or InMemoryLimiter(
scope="llm",
sources={s.name: s for s in sources},
global_limits=_NO_GLOBAL,
lease_ttl_s=100.0,
)
gate = gate or InMemoryGate(config=_BREAKER)
mw = RetryMW(
scope="llm",
sources=sources,
# 固定配置序: 原路恒为 s1、对冲路恒为 s2,断言不依赖选源器内部状态
selector=selector if selector is not None else StaticSelector(),
limiter=limiter,
gate=gate,
transport=transport,
retry=RetryPolicy(max_attempts=max_attempts, backoff_base_s=0.01, backoff_max_s=0.05),
backpressure=BackpressurePolicy(stall_window_s=stall_window_s, poll_interval_s=0.01),
quota_full="wait",
cooldown_memo=SourceCooldownMemo(),
emitter=emitter,
hedge_after_s=hedge_after_s,
stall_after_s=stall_after_s,
**({"now": now} if now is not None else {}),
)
return mw, limiter, gate
def _req(*, stream=True, ctx=None):
"""中段沉默是流式概念,本文件默认 stream=True(test_hedge.py 默认 False 相反)。"""
return ChatRequest(
messages=[{"role": "user", "content": "hi"}], stream=stream, call_context=ctx
)
def _ctx(now=None):
return _CallContext(now=now if now is not None else time.monotonic)
class TestStallHedgeTrigger:
"""阶段 2 触发(设计 §5.2): 首 token 已至后相邻增量 chunk 间隔超阈值 → 对冲。"""
async def test_stall_triggers_hedge_after_first_token(self):
"""s1 首 chunk 后沉默、s2 即时成功: stall 窗触发对冲,赢家为对冲路。
净口径顺延断言(计划批次 D 首行): 注入 FakeClock + s2 自报 stall
(1500ms/2 段)、advance 2.0s → 赢家 `generation_ms == 500` 精确值,
`generation_ms + stall_ms == total_latency_ms`(同钟还原公式)。
遥测行(T3 缺口补齐钉): 赢家行 stall 两值为自报值,输家行恒 None。
"""
clock = FakeClock()
emitter = RecordingEmitter()
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [("chunk",), ("hang",), ("ok", "late")],
"s2": [("stall", 1500.0, 2), ("advance", 2.0), ("ok", "fast")],
},
clock=clock,
)
mw, _, _ = _harness(
[s1, s2], transport, stall_after_s=_STALL_AFTER_S, emitter=emitter, now=clock
)
ctx = _ctx(now=clock)
started = time.monotonic()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
elapsed = time.monotonic() - started
assert resp.content == "fast" and resp.source_name == "s2"
assert elapsed < 10 * _STALL_AFTER_S # 沉默路被对冲截断,而非等到看门狗
assert transport.calls == ["s1", "s2"]
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True and stats.attempts == 2
# 赢家路净口径: 墙钟 2000(advance 精确推进)− 沉默 1500 = 500
assert stats.generation_ms == 500
assert stats.stall_ms == 1500 and stats.stall_count == 2
assert stats.generation_ms + stats.stall_ms == stats.total_latency_ms # 还原公式
# 逐次遥测: 赢家行带 stall 归因,被取消输家行恒 None(设计 §11-B2)
winner = next(r for r in emitter.rows if r["source"] == "s2")
loser = next(r for r in emitter.rows if r["source"] == "s1")
assert winner["stall_ms"] == 1500 and winner["stall_count"] == 2
assert winner["error"] is None
assert loser["error"] == "hedge_cancelled"
assert loser["stall_ms"] is None and loser["stall_count"] is None
async def test_single_leg_attempt_row_carries_stall(self):
"""T3 缺口补齐钉: 未启用对冲的 chat 成功行也须经 `_emit` 落 stall 两值。
计划 §3.4 指定 `retry._emit` 显式转发(归 T3);批次 C 落行断言只覆盖了
emitter 直调,chat 路径漏传无人把守——本用例钉住端到端路径。
"""
clock = FakeClock()
emitter = RecordingEmitter()
transport = StallHedgeTransport(
{"s1": [("stall", 300.0, 1), ("advance", 1.0), ("ok", "x")]}, clock=clock
)
mw, _, _ = _harness([_src("s1")], transport, emitter=emitter, now=clock)
ctx = _ctx(now=clock)
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "x"
assert len(emitter.rows) == 1
row = emitter.rows[0]
assert row["error"] is None
assert row["stall_ms"] == 300 and row["stall_count"] == 1
stats = ctx.snapshot()
assert stats.generation_ms == 700 and stats.stall_ms == 300
async def test_stall_recheck_prevents_false_trigger(self):
"""chunk 在超时到期前一刻到达(0.049s 置位,阈值 0.05s)→ 不触发(N3① 复检)。
只断"不触发 + attempts==1"结果,不断精确时序(计划 §7 抖动纪律)。
"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [("chunk",), ("chunk_after", 0.049), ("ok", "done")],
"s2": [("ok", "other")],
}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "done" and resp.source_name == "s1"
assert transport.calls == ["s1"] # 对冲从未发出
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
async def test_stall_and_after_s_independent_matrix(self):
"""参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。"""
# 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 2: 仅 STALL_AFTER_S + 流式 pre-TTFT 沉默 → **不触发**(阶段 1 不开窗)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "s1ok")], "s2": [("ok", "other")]}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
await asyncio.sleep(4 * _STALL_AFTER_S) # 4× 余量确认 stall 窗管不到 pre-TTFT
assert transport.calls == ["s1"]
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
# 格 3: 两者皆设 + 首 chunk 后沉默 → 阶段 2 stall 窗触发
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
mw, _, _ = _harness(
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}
)
mw, _, _ = _harness(
[s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert ctx.snapshot().hedges == 1
# 格 4: 皆无 → 逐字单路(默认关闭回归门的单元格形态)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport({"s1": [("hang",), ("ok", "solo")]})
mw, _, _ = _harness([s1, s2], transport)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
await transport.entered["s1"].wait()
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.content == "solo" and resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
assert transport.ce_events == [None] # 未配置 = 不观测增量 chunk
async def test_stall_only_non_stream_degrades_to_single_leg(self):
"""仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx)))
await transport.entered["s1"].wait()
await asyncio.sleep(4 * _STALL_AFTER_S) # 阈值窗已过,仍不得有第二请求
assert transport.calls == ["s1"]
transport.release["s1"].set()
resp = await asyncio.wait_for(task, timeout=5)
assert resp.source_name == "s1"
stats = ctx.snapshot()
assert stats.hedges == 0 and stats.attempts == 1
assert transport.ce_events == [None] # 非流式不建 content_event
class TestStallHedgeWatchdogAndFailure:
"""与看门狗的交互(设计 §5.2): 主路被掐死 ≠ 本轮失败,副路已在跑则等副路。"""
async def test_watchdog_kills_primary_hedge_leg_survives(self):
"""主路首 chunk 后被看门狗掐死(Transient)、副路在途 → 副路结果裁定。
attempt 级记账照常(N9): 主路源熔断失败计数 +1、遥测主路 error 行
(TransientError 对象)+ 副路正常行;"先了结的是失败则等另一路"逐字复用。
"""
emitter = RecordingEmitter()
gate = InMemoryGate(config=_BREAKER)
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [
("chunk",),
(
"fail_after",
0.12,
lambda: TransientError("inter_token timeout", source_name="s1"),
),
],
"s2": [("ok_after", 0.2, "rescued")],
}
)
mw, _, _ = _harness(
[s1, s2], transport, stall_after_s=_STALL_AFTER_S, emitter=emitter, gate=gate
)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.content == "rescued" and resp.source_name == "s2"
stats = ctx.snapshot()
assert stats.hedges == 1 and stats.hedge_won is True
# 掐死是 attempt 级失败: 熔断失败样本 +1(对冲输家取消不喂,此处是喂的——N9 不对称)
gate_s1 = gate._gates["s1"]
assert gate_s1.a0 + gate_s1.a1 == 1
# 遥测: 主路 error 行携 TransientError 对象(掐死),副路正常行
primary_row = next(r for r in emitter.rows if r["source"] == "s1")
hedge_row = next(r for r in emitter.rows if r["source"] == "s2")
assert isinstance(primary_row["error"], TransientError)
assert hedge_row["error"] is None
async def test_both_fail_counts_budget_once(self):
"""主路看门狗掐死 + 副路 Transient: 两败只计一次预算(_combine_failures 复用)。
max_attempts=2 时恰进第二轮: 若两败计两次,第一轮即耗尽,第二轮的两路
调用根本不会发生。两败轮次无赢家 → generation 不记、stall 快照恒 None。
"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{
"s1": [
("chunk",),
("fail_after", 0.1, lambda: TransientError("wd", source_name="s1")),
("chunk",),
("fail_after", 0.1, lambda: TransientError("wd", source_name="s1")),
],
"s2": [
("fail_after", 0.12, lambda: TransientError("h", source_name="s2")),
("fail_after", 0.12, lambda: TransientError("h", source_name="s2")),
],
}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S, max_attempts=2)
ctx = _ctx()
with pytest.raises(AllSourcesExhausted) as ei:
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert ei.value.reason == "retry_exhausted"
assert transport.calls == ["s1", "s2", "s1", "s2"] # 两败计一次才有第二轮
stats = ctx.snapshot()
assert stats.hedges == 2 # 两败轮次路数照登(实际并发发出即计)
assert stats.hedge_won is False
assert stats.generation_ms == 0 # 无赢家: 裸生成时间无归属不记
assert stats.stall_ms is None and stats.stall_count is None
class TestStallHedgeCancellation:
"""取消穿透(计划取消铁律): stall 等待中外部取消,waiter 收口无残留。"""
async def test_external_cancel_during_stall_wait_penetrates(self):
"""阶段 2 循环内外部 `task.cancel()`: CancelledError 穿透、无残留任务。
阈值取 5.0s 宽窗: 取消确定落在 stall 等待内(阶段 2 循环),不依赖时序。
对冲尚未发出(宽窗未到期)→ 只有原路被取消,permit 释放。
"""
before = asyncio.all_tasks()
emitter = RecordingEmitter()
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "x")], "s2": [("ok", "y")]}
)
mw, limiter, _ = _harness([s1, s2], transport, stall_after_s=5.0, emitter=emitter)
task = asyncio.ensure_future(mw(_req(ctx=_ctx())))
await transport.entered["s1"].wait() # 原路已交付首 chunk 并挂起: 编排处于阶段 2
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert transport.calls == ["s1"] # 宽窗未到期,对冲从未发出
assert {r["source"]: r["error"] for r in emitter.rows} == {"s1": "cancelled"}
assert (await limiter.source_stats("s1")).inflight == 0
assert asyncio.all_tasks() == before # waiter/原路全收口,无残留
class TestStallHedgeEventDiscipline:
"""Event 并发纪律(N3③): 对冲路 content_event 恒 None(v1 不梯次)。"""
async def test_hedge_leg_content_event_always_none(self):
"""原路(流式 + stall 已设)收真事件,对冲路恒 None——逐次身份记录断言。"""
s1, s2 = _src("s1"), _src("s2")
transport = StallHedgeTransport(
{"s1": [("chunk",), ("hang",), ("ok", "late")], "s2": [("ok", "win")]}
)
mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S)
ctx = _ctx()
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
assert resp.source_name == "s2"
assert transport.calls == ["s1", "s2"]
# 原路携带首 token + content 双观测;对冲路两事件恒 None(N3③,同 H5 首 token 约定)
assert [e is not None for e in transport.ft_events] == [True, False]
assert [e is not None for e in transport.ce_events] == [True, False]
+146 -14
View File
@@ -89,6 +89,9 @@ _EXPECTED_COLUMNS = [
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# —— 1.3.8 沉默归因两列(issue #27)——
"stall_ms",
"stall_count",
] ]
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用 # 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
@@ -181,6 +184,9 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
# 逻辑快照两列只属终态行 # 逻辑快照两列只属终态行
"attempts": None, "attempts": None,
"total_latency_ms": None, "total_latency_ms": None,
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
"stall_ms": None,
"stall_count": None,
} }
fields.update(overrides) fields.update(overrides)
await recorder.record_llm_call(**fields) await recorder.record_llm_call(**fields)
@@ -262,7 +268,7 @@ class TestSchemaModule:
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at # COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"] assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
assert len(COLUMNS) == 36 assert len(COLUMNS) == 38
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位) # 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
for ddl in (SQLITE_DDL, PG_DDL): for ddl in (SQLITE_DDL, PG_DDL):
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
@@ -276,8 +282,8 @@ class TestSchemaModule:
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER", "ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
) )
assert PG_BACKFILL[-1] == ( assert PG_BACKFILL[-1] == (
"total_latency_ms", "stall_count",
"ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER", "ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER",
) )
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL) assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
@@ -291,10 +297,10 @@ class TestSchemaModule:
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
# 全量 36 列: 占位符随列数增长,且不留空洞 # 全量 38 列: 占位符随列数增长,且不留空洞
assert insert_sql("postgres", COLUMNS).count("$") == 36 assert insert_sql("postgres", COLUMNS).count("$") == 38
assert "$36)" in insert_sql("postgres", COLUMNS) assert "$38)" in insert_sql("postgres", COLUMNS)
assert insert_sql("sqlite", COLUMNS).count("?") == 36 assert insert_sql("sqlite", COLUMNS).count("?") == 38
# 裁剪列表按位置占位符重新编号,不留空洞 # 裁剪列表按位置占位符重新编号,不留空洞
assert insert_sql("postgres", ["call_id", "model"]) == ( assert insert_sql("postgres", ["call_id", "model"]) == (
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING" "INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
@@ -371,7 +377,130 @@ class TestBackendColumnParity:
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
from polygateway.telemetry.schema import COLUMNS from polygateway.telemetry.schema import COLUMNS
assert COLUMNS[-4:] == ("cause_type", "error_body", "attempts", "total_latency_ms") assert COLUMNS[-6:] == (
"cause_type",
"error_body",
"attempts",
"total_latency_ms",
"stall_ms",
"stall_count",
)
# 1.3.7(1.3.8 补列前)的表形态: 36 个 INSERT 字段 + created_at = 37 物理列。
# 冻结字面量而非由常量切片派生: 后者会随 schema 改动静默漂移到另一张表上
# (与 _PRE_TENANT_DDL 同款纪律)。
_PRE_138_DDL = """
CREATE TABLE IF NOT EXISTS llm_calls (
call_id TEXT PRIMARY KEY,
parent_call_id TEXT,
session_id TEXT,
model TEXT NOT NULL,
provider TEXT NOT NULL,
source_name TEXT NOT NULL,
messages TEXT NOT NULL,
response TEXT NOT NULL,
thinking TEXT NOT NULL DEFAULT '',
prompt_tokens INTEGER NOT NULL,
completion_tokens INTEGER NOT NULL,
usage_source TEXT NOT NULL,
latency_ms INTEGER NOT NULL,
ttft_ms REAL,
max_inter_token_ms REAL,
cache_hit INTEGER NOT NULL DEFAULT 0,
error TEXT,
cost REAL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
cached_prompt_tokens INTEGER,
model_reported TEXT,
sampling TEXT,
reasoning_tokens INTEGER,
tenant_id TEXT NOT NULL DEFAULT '',
meta TEXT NOT NULL DEFAULT '{}',
thinking_observation TEXT,
reasoning_effort TEXT,
scope TEXT,
operation TEXT,
logical_call_id TEXT,
event_kind TEXT,
http_status_code INTEGER,
error_type TEXT,
cause_type TEXT,
error_body TEXT,
attempts INTEGER,
total_latency_ms INTEGER
);
"""
class TestStallColumns:
"""1.3.8 沉默归因两列(issue #27): 落行、缺省 NULL、迁移幂等与 manual 告警。
新列可空、无默认值、追加末尾——旧行的 NULL 表达"补列之前根本没记过这件事"。
"""
async def test_stall_values_round_trip_and_default_is_null(self, tmp_path):
"""stall 值原样落行读回;未观测行(默认 None)两列是 NULL 而非 0。"""
db = tmp_path / "stall.db"
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-stall", stall_ms=3200, stall_count=2)
await _record_minimal(recorder, call_id="c-plain")
recorder.close()
conn = sqlite3.connect(db)
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-stall'"
).fetchone() == (3200, 2)
# "观测过且零沉默"(0)与"无从观测"(NULL)是两回事: 未传值的行必须是 NULL
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-plain'"
).fetchone() == (None, None)
conn.close()
async def test_auto_migrate_backfills_the_two_columns_idempotently(self, tmp_path):
"""1.3.7 形态旧表(37 物理列)经 auto 补两列;二次初始化幂等不炸。"""
db = tmp_path / "pre138.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_138_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder, call_id="c-new", stall_ms=7, stall_count=1)
recorder.close()
# 二次初始化: 探测到列已齐,一条 ALTER 都不发(幂等)
recorder2 = SQLiteRecorder(db, auto_migrate=True)
await _record_minimal(recorder2, call_id="c-new2")
recorder2.close()
conn = sqlite3.connect(db)
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
assert conn.execute(
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-new'"
).fetchone() == (7, 1)
conn.close()
async def test_manual_mode_warning_names_both_new_alters(self, tmp_path, captured_warnings):
"""manual 档缺列告警逐字含两条新 ALTER,物理列原封不动。"""
db = tmp_path / "manual138.db"
conn = sqlite3.connect(db)
conn.execute(_PRE_138_DDL)
conn.commit()
conn.close()
recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-manual", stall_ms=9, stall_count=3)
recorder.close()
conn = sqlite3.connect(db)
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 37
conn.close()
assert len(captured_warnings) == 1
message = captured_warnings[0]
assert "stall_ms" in message and "stall_count" in message
assert "ALTER TABLE llm_calls ADD COLUMN stall_ms INTEGER" in message
assert "ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER" in message
class TestSQLiteRecorder: class TestSQLiteRecorder:
@@ -647,7 +776,7 @@ class TestSQLiteCallerDimensionsAcceptance:
conn = sqlite3.connect(db) conn = sqlite3.connect(db)
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
assert cols == _EXPECTED_COLUMNS # 22 → 36 个 recorder 字段(+ created_at 共 37 物理列) assert cols == _EXPECTED_COLUMNS # 22 → 38 个 recorder 字段(+ created_at 共 39 物理列)
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall()) rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
assert rows["new-row"] == "tenant-a" assert rows["new-row"] == "tenant-a"
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
@@ -697,7 +826,7 @@ class TestSQLiteSchemaMode:
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23, 列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
补齐后 36 + `created_at` = 37。混用 INSERT 字段数与物理列数是本处最易错的地方。 补齐后 38 + `created_at` = 39。混用 INSERT 字段数与物理列数是本处最易错的地方。
""" """
def _physical_columns(self, db: Path) -> list[str]: def _physical_columns(self, db: Path) -> list[str]:
@@ -736,7 +865,7 @@ class TestSQLiteSchemaMode:
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path): async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 37。""" """auto + 同款旧表: 现状回归,补列后物理列数 23 → 39。"""
db = tmp_path / "auto_legacy.db" db = tmp_path / "auto_legacy.db"
_make_pre_tenant_db(db) _make_pre_tenant_db(db)
@@ -745,10 +874,10 @@ class TestSQLiteSchemaMode:
recorder.close() recorder.close()
assert self._physical_columns(db) == _EXPECTED_COLUMNS assert self._physical_columns(db) == _EXPECTED_COLUMNS
assert len(self._physical_columns(db)) == 37 assert len(self._physical_columns(db)) == 39
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path): async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
"""manual 只管 ALTER,不管 CREATE: 全新库照建,37 个物理列齐全(设计 §4.2)。""" """manual 只管 ALTER,不管 CREATE: 全新库照建,39 个物理列齐全(设计 §4.2)。"""
db = tmp_path / "manual_fresh.db" db = tmp_path / "manual_fresh.db"
recorder = SQLiteRecorder(db, auto_migrate=False) recorder = SQLiteRecorder(db, auto_migrate=False)
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
@@ -985,6 +1114,9 @@ class TestPostgresBackfillDiscipline:
"error_body", "error_body",
"attempts", "attempts",
"total_latency_ms", "total_latency_ms",
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
"stall_ms",
"stall_count",
] ]
def _recorder(self, conn): def _recorder(self, conn):
@@ -3353,7 +3485,7 @@ class TestRecorderShapeGate:
class NarrowRecorder: class NarrowRecorder:
async def record_llm_call(self, *, call_id, model) -> None: ... async def record_llm_call(self, *, call_id, model) -> None: ...
# 若闸内硬编码了 36 个名字,这里必然拒绝 # 若闸内硬编码了 38 个名字,这里必然拒绝
tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None) tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None)
+49
View File
@@ -185,6 +185,19 @@ class TestSourceConfig:
_make_source(missing_done="ignore") _make_source(missing_done="ignore")
def test_source_config_stall_threshold_validation():
"""`stall_threshold_ms`(1.3.8 沉默计量阈值): 默认 1000;0/负/bool → ValueError。"""
assert _make_source().stall_threshold_ms == 1000
assert _make_source(stall_threshold_ms=50).stall_threshold_ms == 50
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=0)
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=-5)
# bool 是 int 子类: True 会被当成 1 静默放行,必须显式拒(P5)
with pytest.raises(ValueError):
_make_source(stall_threshold_ms=True)
class TestEffectiveEstTokens: class TestEffectiveEstTokens:
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。""" """TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
@@ -293,6 +306,12 @@ class TestAuxTypes:
assert s.thinking_observation is ThinkingObservation.UNKNOWN assert s.thinking_observation is ThinkingObservation.UNKNOWN
def test_transport_result_stall_fields_default_none():
"""TransportResult 沉默两字段(1.3.8)默认 None;旧位置构造不炸(只增不改)。"""
result = TransportResult("c", "t", 1, 2, "measured", None, None, {})
assert result.stall_ms is None and result.stall_count is None
class TestOcrTypes: class TestOcrTypes:
"""M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。""" """M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。"""
@@ -671,6 +690,36 @@ class TestCallStatsAndContext:
stats = ctx.snapshot() stats = ctx.snapshot()
assert (stats.hedges, stats.generation_ms, stats.hedge_won) == (1, 200, True) assert (stats.hedges, stats.generation_ms, stats.hedge_won) == (1, 200, True)
def test_callstats_stall_fields_default_none(self):
"""1.3.8 归因两字段默认 None: 仅旧参数构造不炸(1.3.7 及以前兼容形态)。"""
from polygateway.types import CallStats
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
assert stats.stall_ms is None
assert stats.stall_count is None
explicit = CallStats(
logical_call_id="lc-2",
attempts=2,
total_latency_ms=15,
stall_ms=3200,
stall_count=2,
)
assert (explicit.stall_ms, explicit.stall_count) == (3200, 2)
def test_callcontext_record_generation_with_stall_and_snapshot(self):
"""stall 两值经 record_generation 进 snapshot;不传恒 None(embedding/ocr 形态)。"""
from polygateway.types import _CallContext
ctx = _CallContext(now=_FakeMonotonic())
ctx.record_generation(1800, accumulate=False, stall_ms=3200, stall_count=2)
stats = ctx.snapshot()
assert (stats.generation_ms, stats.stall_ms, stats.stall_count) == (1800, 3200, 2)
# 不传(embedding/ocr 既有调用形态): 归因恒 None,None 是"无观测"不是 0
plain = _CallContext(now=_FakeMonotonic())
plain.record_generation(500, accumulate=False)
plain_stats = plain.snapshot()
assert plain_stats.stall_ms is None and plain_stats.stall_count is None
def test_context_counts_attempts_and_freezes_elapsed(self): def test_context_counts_attempts_and_freezes_elapsed(self):
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。""" """快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
from polygateway.types import _CallContext from polygateway.types import _CallContext
+1
View File
@@ -140,6 +140,7 @@ async def test_salvage_override_stays_in_domain(usage):
call_id="cid", call_id="cid",
reasoning_effort=None, reasoning_effort=None,
first_token_event=None, first_token_event=None,
content_event=None,
) )
assert result.usage_source in USAGE_SOURCES assert result.usage_source in USAGE_SOURCES