From 03cbf4c8db64595133e88092c4d6350149adaab2 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 13 Sep 2026 22:53:00 -0400 Subject: [PATCH 01/12] docs: add approved streaming stall measurement and mid-stream hedging design Findings: industry paradigm research (watchdog-retry is the norm, mid-stream hedging has no public precedent; pi-tps threshold-based stall deduction; Gregg off-CPU paired reporting; vLLM exclusion clauses). Design: generation_ms redefined as net generation time (wall minus all inter-chunk stalls over threshold), CallStats gains stall_ms/stall_count, HEDGE__STALL_AFTER_S mid-stream hedge independent of HEDGE__AFTER_S. Issues #27 #28. --- .../2026-09-11-streaming-stall-design.md | 210 ++++++++++++++++++ ...-09-11-streaming-stall-hedging-research.md | 174 +++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 research-wiki/designs/2026-09-11-streaming-stall-design.md create mode 100644 research-wiki/findings/2026-09-11-streaming-stall-hedging-research.md diff --git a/research-wiki/designs/2026-09-11-streaming-stall-design.md b/research-wiki/designs/2026-09-11-streaming-stall-design.md new file mode 100644 index 0000000..512f213 --- /dev/null +++ b/research-wiki/designs/2026-09-11-streaming-stall-design.md @@ -0,0 +1,210 @@ +# 流式中段沉默:计量口径与对冲(已批准设计) + +- 状态: **已批准(2026-09-11 人类拍板 §9 五项,见 §9 决策记录)**;本文件只做设计,不含实现 +- 基线: 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:~335` `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:605` 现算 `max_gap` 处已逐 chunk 循环,时间戳序列在此追加记录,增量成本近零) | +| 记录内容 | 每个**内容 chunk** 的单调钟时间戳;空 chunk / SSE keepalive 注释**不计时**(LangChain 语义,调研 §A3——keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,我们的序列天然免疫) | +| 派生原则 | 一切计量(`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:417-418`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 | +| 改动面 | README/CHANGELOG 口径澄清,零代码 | +| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py:43/85`),存量数据即可分析(本次实测正是用它定位的病) | +| 缺点 | 多段沉默场景**系统性高估**活跃时间(调研 §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`(超阈值段数,连续停顿合并一次);**不新增 `active_generation_ms`**——`generation_ms` 本身就是净口径 | +| 归因与还原 | 墙钟 = `generation_ms + stall_ms`;净数与沉默总额/次数成对报告,Gregg 成对报告原则(调研 §B2)在净口径下依然满足 | +| 排除条款(照 vLLM,与原案一致) | 非流式 / 内容 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` **不退减**(= 原墙钟);对冲输家的沉默**不进**赢家快照(输家数据留在其 attempt 遥测行,可 join 还原) | +| 阈值 | 新配置 `{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:417-418` 邻域(`CallStats` 增两字段);`ports.py` `TransportResult` 增 `stall_ms`/`stall_count: float \| None` 默认 None;`openai_compat.py:605` 邻域从 chunk 时间戳序列计算两值(沿用 §3 原语);`middleware/retry.py:~335/~346` 邻域(赢家记账改为 `record_generation`(净值) + context 存 stall 两值进快照,口径与 H8 同款);`telemetry/schema.py:43/85` 邻域(PG/SQLite 各一次可空 DDL)+ recorder + `middleware/telemetry.py` 透传;`config.py:46` 邻域(新键 + loader);`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" | +| 对冲执行 | 触发后主路**不取消**;并发异源副路走**完整准入 + 熔断**,无配额静默放弃(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` 被看门狗掐死,**对冲路已在跑 → 主路之死不算失败,等副路**;副路未起 → 照旧 Transient 重试。装配守卫:`stall_after_s ≥ inter_token_timeout_s` 时 warning(对冲窗永不开,形同虚设);建议 `stall_after_s < inter_token_timeout_s` | +| 成本披露 | 实测沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft 仅 3/4407 超 20s);每触发一次最多 +1 并发请求 + 一份 est 预扣滞留(1.3.7 §4.4 口径);**默认必须关闭**,文档明写"对冲 = 用配额换延迟" | +| 改动面 | `retry.py:542-562` 邻域(双阈值触发窗 + 与看门狗掐死的汇合规则);`ports.py` + `openai_compat.py:569-575/605` 邻域(`complete` 加内容 chunk Event 参数——公共端口签名第二次变更,须进批准项);`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`;`stall_after_s ≥ inter_token_timeout_s` → 装配 warning(§5.2);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);与 `call_deadline_s` 组合守卫沿用 1.3.7 §5/§6 | + +`STREAM` 段须加入 `_RESERVED_SEGMENTS`(`config.py:57` 邻域,1.3.7 为 `HEDGE` 做过同款);3 段键天然跳过 `_load_sources`(`config.py:401-407`)。装配路径与 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) | +| 文档登记义务 | 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. 自查记录 + +- 行数 210 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。 +- 方案改动面均列具体文件:行号(1B'、2B);行号沿用 PM 已验证事实,未重新验证。 +- 调研结论引用处标注(调研 §x.y);未重查网络。 +- 2026-09-11 批准确认:§9 五项 + 同批发 1.3.8 全部落地本文;1A/1C 保留为否决留档,1B 原案标注被 1B' 取代。 diff --git a/research-wiki/findings/2026-09-11-streaming-stall-hedging-research.md b/research-wiki/findings/2026-09-11-streaming-stall-hedging-research.md new file mode 100644 index 0000000..de76a0f --- /dev/null +++ b/research-wiki/findings/2026-09-11-streaming-stall-hedging-research.md @@ -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 数据库)。 From 97de4104c63dea771958a96b417d66bfa3ec46b8 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 13 Sep 2026 23:20:52 -0400 Subject: [PATCH 02/12] docs: finalize streaming stall design with independent review errata B1 chunk = any delta (content or reasoning), B2 telemetry plumbing via explicit _emit pass, B3 orchestration entry gate for independent stall hedging; N1-N9 advisory fixes merged. Human decisions recorded in design section 9. --- .../2026-09-11-streaming-stall-design.md | 61 +++++++++---- ...026-09-11-streaming-stall-design-review.md | 91 +++++++++++++++++++ 2 files changed, 132 insertions(+), 20 deletions(-) create mode 100644 research-wiki/reviews/2026-09-11-streaming-stall-design-review.md diff --git a/research-wiki/designs/2026-09-11-streaming-stall-design.md b/research-wiki/designs/2026-09-11-streaming-stall-design.md index 512f213..c1624b1 100644 --- a/research-wiki/designs/2026-09-11-streaming-stall-design.md +++ b/research-wiki/designs/2026-09-11-streaming-stall-design.md @@ -1,6 +1,6 @@ # 流式中段沉默:计量口径与对冲(已批准设计) -- 状态: **已批准(2026-09-11 人类拍板 §9 五项,见 §9 决策记录)**;本文件只做设计,不含实现 +- 状态: **已批准 + 独立审查补遗已并入(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 对冲机制(本设计直接站在其编排与记账语义上) @@ -11,7 +11,7 @@ ### P1:中段沉默计入 `generation_ms`,"裸生成时间"名不副实(issue #27) -`CallStats.generation_ms` 的口径是赢家 transport 调用的完整墙钟:`middleware/retry.py:~335` `gen_started` 紧贴 `transport.complete()` 调用前,`:~346` sink 在返回后立即记录。对流式,`complete()` 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。 +`CallStats.generation_ms` 的口径是赢家 transport 调用的完整墙钟:`middleware/retry.py:332` `gen_started` 紧贴 `transport.complete()` 调用前,`:346` sink 在返回后立即记录。对流式,`complete()` 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。 下游实测(Terminal-Bench,4407 次成功调用): @@ -43,23 +43,23 @@ | 沉默扣除唯一先例 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 | +| 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 单调时间戳序列 +## 3. 共享观测原语:增量 chunk 单调时间戳序列 P1(计量)与 P2(中段触发)共用同一份底层观测,避免两套计时逻辑漂移: | 项 | 设计 | | --- | --- | -| 记录点 | transport 层(`openai_compat.py:605` 现算 `max_gap` 处已逐 chunk 循环,时间戳序列在此追加记录,增量成本近零) | -| 记录内容 | 每个**内容 chunk** 的单调钟时间戳;空 chunk / SSE keepalive 注释**不计时**(LangChain 语义,调研 §A3——keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,我们的序列天然免疫) | +| 记录点 | 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 事件同构,零新机制类别。 +P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk 即刷新一个 `asyncio.Event`(语义 = "自上一增量 chunk 以来未再沉默"),编排侧以"等该事件超时"实现 stall 计时器——与 1.3.7 首 token 事件同构,零新机制类别。 ## 4. P1 方案:沉默计量口径(1B' 已批准) @@ -67,9 +67,9 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk | 项 | 内容 | | --- | --- | -| 语义 | 下游用既有 `LLMResponse.max_inter_token_ms`(`types.py:417-418`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 | +| 语义 | 下游用既有 `LLMResponse.max_inter_token_ms`(`types.py:406`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 | | 改动面 | README/CHANGELOG 口径澄清,零代码 | -| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py:43/85`),存量数据即可分析(本次实测正是用它定位的病) | +| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py` COLUMNS),存量数据即可分析(本次实测正是用它定位的病) | | 缺点 | 多段沉默场景**系统性高估**活跃时间(调研 §B3:只减最大一段无任何先例);口径责任散落每个下游,各家算法漂移后跨项目不可比;无 `stall_count`,违反 Gregg 归因原则 | **否决理由(人类裁决,2026-09-11)**:口径必须库内收敛,不得散落各下游自算。 @@ -80,12 +80,13 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk | 项 | 内容 | | --- | --- | -| 语义 | `generation_ms` 重定义为**净生成时间** = 赢家 transport 墙钟 − Σ(所有 > 阈值的相邻**内容 chunk** 沉默,pi-tps 式口径);`CallStats` 新增两归因字段(默认 None):`stall_ms: int \| None`(沉默总额)、`stall_count: int \| None`(超阈值段数,连续停顿合并一次);**不新增 `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` **不退减**(= 原墙钟);对冲输家的沉默**不进**赢家快照(输家数据留在其 attempt 遥测行,可 join 还原) | +| 排除条款(照 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:417-418` 邻域(`CallStats` 增两字段);`ports.py` `TransportResult` 增 `stall_ms`/`stall_count: float \| None` 默认 None;`openai_compat.py:605` 邻域从 chunk 时间戳序列计算两值(沿用 §3 原语);`middleware/retry.py:~335/~346` 邻域(赢家记账改为 `record_generation`(净值) + context 存 stall 两值进快照,口径与 H8 同款);`telemetry/schema.py:43/85` 邻域(PG/SQLite 各一次可空 DDL)+ recorder + `middleware/telemetry.py` 透传;`config.py:46` 邻域(新键 + loader);`client.py` 构造透传 | +| 改动面 | `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` 构造透传 | #### 契约变更的正当性与披露 @@ -137,13 +138,13 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk | 项 | 内容 | | --- | --- | -| 触发 | 新配置 `{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" | +| 触发 | 新配置 `{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` 被看门狗掐死,**对冲路已在跑 → 主路之死不算失败,等副路**;副路未起 → 照旧 Transient 重试。装配守卫:`stall_after_s ≥ inter_token_timeout_s` 时 warning(对冲窗永不开,形同虚设);建议 `stall_after_s < inter_token_timeout_s` | +| 与看门狗的交互 | 两参数语义独立:若主路沉默超 `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:542-562` 邻域(双阈值触发窗 + 与看门狗掐死的汇合规则);`ports.py` + `openai_compat.py:569-575/605` 邻域(`complete` 加内容 chunk Event 参数——公共端口签名第二次变更,须进批准项);`config.py` 邻域(新键 + 交叉守卫);`client.py` 透传;遥测复用 `hedge_cancelled`,零新列 | +| 改动面 | `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 链叠加时行为难推理。否决,仅留档。 @@ -165,9 +166,9 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk | 键 | 值域 | 缺省 | 守卫与既有键关系 | | --- | --- | --- | --- | | `{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`;`stall_after_s ≥ inter_token_timeout_s` → 装配 warning(§5.2);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);与 `call_deadline_s` 组合守卫沿用 1.3.7 §5/§6 | +| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒(复用 `ensure_call_deadline` 同款校验) | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S`;仅对流式生效,纯非流式 scope 设置时装配 warning(非流式静默退化);`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:401-407`)。装配路径与 1.3.7 对冲同款:`GatewaySettings` 字段 + `__post_init__` 守卫 + `GatewayClient.__init__` keyword-only 入口即校;不提供 per-call 覆盖(阈值是源/渠道特性,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. 与既有契约的关系 @@ -175,7 +176,7 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk | --- | --- | | `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) | +| 公共端口签名 | `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 纯增量(新键 + 默认关闭行为) | @@ -204,7 +205,27 @@ P2 触发所需的活体信号由此派生:transport 每记一个内容 chunk ## 10. 自查记录 -- 行数 210 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。 -- 方案改动面均列具体文件:行号(1B'、2B);行号沿用 PM 已验证事实,未重新验证。 +- 行数 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 与看门狗交互行 | diff --git a/research-wiki/reviews/2026-09-11-streaming-stall-design-review.md b/research-wiki/reviews/2026-09-11-streaming-stall-design-review.md new file mode 100644 index 0000000..9842b17 --- /dev/null +++ b/research-wiki/reviews/2026-09-11-streaming-stall-design-review.md @@ -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 条)。 \ No newline at end of file From 929d60e501a940cc286666f5218b3abcc619aa36 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Sun, 13 Sep 2026 23:46:34 -0400 Subject: [PATCH 03/12] docs: add 1.3.8 implementation plan and B4 design errata B4: assembly-time non-streaming hedge warning operationalized to embedding/OCR scopes only (chat stream is per-call, no assembly signal); AFTER_S same-gap backfill included. Plan T1-T5 with real grep sync lists and fail-first test batches. --- .../2026-09-11-streaming-stall-design.md | 3 +- .../plans/2026-09-11-streaming-stall.md | 461 ++++++++++++++++++ 2 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 research-wiki/plans/2026-09-11-streaming-stall.md diff --git a/research-wiki/designs/2026-09-11-streaming-stall-design.md b/research-wiki/designs/2026-09-11-streaming-stall-design.md index c1624b1..192f12f 100644 --- a/research-wiki/designs/2026-09-11-streaming-stall-design.md +++ b/research-wiki/designs/2026-09-11-streaming-stall-design.md @@ -166,7 +166,7 @@ P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk | 键 | 值域 | 缺省 | 守卫与既有键关系 | | --- | --- | --- | --- | | `{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`;仅对流式生效,纯非流式 scope 设置时装配 warning(非流式静默退化);`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 同款) | +| `{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 同款判据)。 @@ -229,3 +229,4 @@ P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk | 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 行 | diff --git a/research-wiki/plans/2026-09-11-streaming-stall.md b/research-wiki/plans/2026-09-11-streaming-stall.md new file mode 100644 index 0000000..16bc505 --- /dev/null +++ b/research-wiki/plans/2026-09-11-streaming-stall.md @@ -0,0 +1,461 @@ +--- +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/`(pytest 输出文件,不提交) | + +Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿证据落本会话工具输出 + `tests/outputs/138/t/` 文件);每次提交执行 `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`(:371-530)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 | +| 8 | 发射器 fake:`tests/unit/test_hedge.py:122`、`tests/unit/test_backpressure.py:233`(`emit_attempt`) | **零改动**(`emit_attempt` 新参默认 None,见 §3.4 取舍) | + +## 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,签名加 `stall_after_s` 参,`None` 时值域归一化后交叉守卫不查——同 `after is None` 既有早退形态) | ①值域 = `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=` 归一化值;`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: + + 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`;`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_telemetry.py::TestBackendColumnParity::test_new_columns_are_appended_last`(扩) | `COLUMNS[-2:] == ("stall_ms", "stall_count")` | +| `test_telemetry.py::` | 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/:371-530/:533-562`(§3.6)。 +- **先失败测试清单**(批次 D;先红 = 键未识 ValueError/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` 全绿且**不改一行既有断言**(1.3.6/1.3.7 取消/对冲/看门狗语义回归) | + +- **证据出口**:`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);`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/` 逐卡列出 | +| 设计保真 | 是——净口径/排除条款/换算唯一处/两阶段触发窗/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① 已是设计批准的最强形态) | From 6f3a620c73c9529470e51524abd29e5b2c78f80b Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 00:10:37 -0400 Subject: [PATCH 04/12] docs: fix plan per independent review (B1 emitter fake sync, B2 guard return contract, N1-N5) --- .../plans/2026-09-11-streaming-stall.md | 20 +++++----- .../2026-09-11-streaming-stall-plan-review.md | 37 +++++++++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 research-wiki/reviews/2026-09-11-streaming-stall-plan-review.md diff --git a/research-wiki/plans/2026-09-11-streaming-stall.md b/research-wiki/plans/2026-09-11-streaming-stall.md index 16bc505..4a63804 100644 --- a/research-wiki/plans/2026-09-11-streaming-stall.md +++ b/research-wiki/plans/2026-09-11-streaming-stall.md @@ -44,7 +44,7 @@ Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿 | 修改 | `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`(:371-530)Phase 1/4 接线 content_event、Phase 5 汇合复用;`_past_hedge_window`(:533-562)两阶段重构;`_record_generation`/`_register_hedge` 邻域(净口径计算收敛一处);`__init__` 加 `stall_after_s` kw | +| 修改 | `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 调用点透传 | @@ -106,7 +106,8 @@ Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿 | 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 | -| 8 | 发射器 fake:`tests/unit/test_hedge.py:122`、`tests/unit/test_backpressure.py:233`(`emit_attempt`) | **零改动**(`emit_attempt` 新参默认 None,见 §3.4 取舍) | +| 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. 跨任务接口(可执行定义,禁止占位) @@ -227,8 +228,8 @@ generation_sink.append((wall_ms, stall_ms_i, stall_count_i)) | --- | --- | | 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,签名加 `stall_after_s` 参,`None` 时值域归一化后交叉守卫不查——同 `after is None` 既有早退形态) | ①值域 = `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=` 归一化值;`from_settings`(:525 邻域)透传 | +| 守卫(全部落进 `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 唯一实现形态) @@ -340,12 +341,13 @@ async def _past_hedge_window(self, primary, first_token, content_event) -> bool: ### 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`;`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` 冻结清单扩展。 +- **改动**:`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::` | emit attempt(stall_ms=3200, stall_count=2)→ SQLite 行两列恰为该值;success 非流式行两列 NULL | | `test_telemetry.py::<迁移幂等新用例>` | 既有 1.3.5 形态表(37 物理列)开 auto_migrate recorder → ALTER 补两列不炸;**重复初始化第二次**幂等;manual 档缺列告警逐字含两条新 ALTER | @@ -359,8 +361,8 @@ async def _past_hedge_window(self, primary, first_token, content_event) -> bool: ### 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/:371-530/:533-562`(§3.6)。 -- **先失败测试清单**(批次 D;先红 = 键未识 ValueError/None 断言失败、对冲未触发 `hedges==0`、挂起用例超时): +- **改动**:`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×) | 断言点 | | --- | --- | @@ -378,7 +380,7 @@ async def _past_hedge_window(self, primary, first_token, content_event) -> bool: | `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` 全绿且**不改一行既有断言**(1.3.6/1.3.7 取消/对冲/看门狗语义回归) | +| 批次 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`。 @@ -408,7 +410,7 @@ async def _past_hedge_window(self, primary, first_token, content_event) -> bool: | 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);`test_telemetry.py:53-99` 冻结清单的扩展属"先红"手段(先改清单 → schema 未改即红)。 +**设施复用核对(动手前必读)**:`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. 风险节与验收门 diff --git a/research-wiki/reviews/2026-09-11-streaming-stall-plan-review.md b/research-wiki/reviews/2026-09-11-streaming-stall-plan-review.md new file mode 100644 index 0000000..60ead7c --- /dev/null +++ b/research-wiki/reviews/2026-09-11-streaming-stall-plan-review.md @@ -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 项有自愈能力。 \ No newline at end of file From 901a19351483ea3533691bee15cf071f9428c9a2 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 00:35:35 -0400 Subject: [PATCH 05/12] feat: add a content event and stall accounting to the transport port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1(计划 §4):观测原语与端口契约(issue #27/#28 底层)。 - Transport.complete 加必填 kw content_event(顺序在 first_token_event 后,无默认值,同款约定):流式每增量 chunk 置位一次,非流式/对冲路 恒不置位,None = 不观测 - TransportResult 增 stall_ms/stall_count(带默认 None):超阈值相邻 增量 chunk 沉默总额与段数,chunk<2/非流式 → None,尾部不计 - SourceConfig 增 stall_threshold_ms(默认 1000,正整数拒 bool, _validate_watchdog 同款落点) - _complete_stream 记增量 chunk 时间戳(空 chunk/usage 帧/keepalive 不产生记录点)+ 模块级纯函数 _stall_from_chunk_times(严格大于 阈值才减);max_inter_token_ms 原语义一字不动 - §2.2 同步清单全量:11 实现 + 11 调用点(重跑 grep 复核零遗漏) - 批次 A 12 用例先红(TypeError/ImportError/AttributeError)后绿, 证据 tests/outputs/138/t1/ --- src/polygateway/middleware/retry.py | 9 +- src/polygateway/ports.py | 7 + src/polygateway/transports/openai_compat.py | 48 ++++- src/polygateway/types.py | 31 +++ tests/e2e/conftest.py | 2 + .../test_redis_cross_connection.py | 11 +- tests/unit/test_backpressure.py | 11 +- tests/unit/test_client.py | 22 +- tests/unit/test_hedge.py | 14 +- tests/unit/test_live_evidence.py | 2 + tests/unit/test_openai_compat.py | 200 +++++++++++++++++- tests/unit/test_ports.py | 11 +- tests/unit/test_retry.py | 23 +- tests/unit/test_types.py | 19 ++ tests/unit/test_usage_source_domain.py | 1 + 15 files changed, 398 insertions(+), 13 deletions(-) diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 6540679..0350d37 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -273,6 +273,7 @@ class RetryMW: reasons, attempt_fails, first_token_event=None, + content_event=None, generation_sink=generation_sink, ) else: @@ -312,6 +313,7 @@ class RetryMW: attempt_fails: dict[str, int], *, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, generation_sink: list[int], ) -> LLMResponse | _Failed: call_id = str(uuid.uuid4()) @@ -342,6 +344,9 @@ class RetryMW: # 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路, # 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token first_token_event=first_token_event, + # 中段沉默处置位(1.3.8): 本阶段(T1)所有调用点恒传 None = 不观测; + # T4 才在启用 stall 对冲的流式原路上接真事件(设计 §5.2 N3③) + content_event=content_event, ) generation_sink.append(int((self._now() - gen_started) * 1000)) if result.usage_source == "unavailable": @@ -434,6 +439,7 @@ class RetryMW: reasons, attempt_fails, first_token_event=first_token, + content_event=None, generation_sink=sink_p, ) ) @@ -473,7 +479,7 @@ class RetryMW: self._record_generation(request, sink_p) return outcome # Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None, - # 不再触发梯次对冲 + # 不再触发梯次对冲;content_event 同恒 None(N3③,v1 不梯次) sink_h: list[int] = [] hedge = asyncio.create_task( self._attempt( @@ -482,6 +488,7 @@ class RetryMW: reasons, attempt_fails, first_token_event=None, + content_event=None, generation_sink=sink_h, ) ) diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 7b595a1..6617509 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -51,6 +51,12 @@ class Transport(Protocol): `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( @@ -63,6 +69,7 @@ class Transport(Protocol): call_id: str, reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, ) -> TransportResult: ... diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 554141b..23e2f47 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -85,6 +85,29 @@ def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, 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( lines: AsyncIterator[str], usage_sink: dict[str, Any] ) -> AsyncIterator[tuple[bool, str]]: @@ -434,6 +457,7 @@ class OpenAICompatTransport: call_id: str, reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, ) -> TransportResult: """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。 @@ -441,6 +465,8 @@ class OpenAICompatTransport: 在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。 `first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径 (`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。 + `content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个** + 增量 chunk 置位一次,非流式路径收它但永不置位。 """ profile = get_provider(source.provider, registry=self._registry) try: @@ -466,11 +492,11 @@ class OpenAICompatTransport: try: if 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: 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: raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc @@ -558,6 +584,7 @@ class OpenAICompatTransport: source: SourceConfig, profile: ProviderProfile, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, ) -> TransportResult: started = time.monotonic() async with client.stream("POST", url, json=payload) as resp: @@ -578,8 +605,17 @@ class OpenAICompatTransport: ttft_ms: float | None = None last = started max_gap = 0.0 + chunk_times: list[float] = [] async for is_content, text in guarded: 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: ttft_ms = (now - started) * 1000 # 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据, @@ -595,6 +631,7 @@ class OpenAICompatTransport: self._reject_empty_completion(content, source) prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged) reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage")) + stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms) return TransportResult( content=content, thinking=thinking, @@ -603,6 +640,8 @@ class OpenAICompatTransport: usage_source=usage_source, ttft_ms=ttft_ms, 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")}, cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), model_reported=_coerce_model_reported(sink.get("model")), @@ -664,11 +703,14 @@ class OpenAICompatTransport: source: SourceConfig, profile: ProviderProfile, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, ) -> TransportResult: """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。 接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化 - 为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。 + 为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8) + 同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走 + TransportResult 默认 None(排除条款)。 """ resp = await client.post(url, json=payload) if resp.status_code != 200: diff --git a/src/polygateway/types.py b/src/polygateway/types.py index a9499aa..536e044 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -585,6 +585,17 @@ class TransportResult: `medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是 "未表态/不注入推理参数"(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) class SourceConfig: @@ -638,10 +649,19 @@ class SourceConfig: 后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果 就是 `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: self._validate_identity() self._validate_gates() self._validate_watchdog() + self._validate_stall_threshold() self._validate_thinking() self._freeze_extra_body() @@ -680,6 +700,17 @@ class SourceConfig: ): 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: """推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 5c94c22..bdf1ac8 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -292,6 +292,7 @@ class ObservedTransport: call_id: str, reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, + content_event: asyncio.Event | None, ) -> TransportResult: """与生产端口逐参数同签名。""" with self._capture.attempt_context(call_id): @@ -304,6 +305,7 @@ class ObservedTransport: call_id=call_id, reasoning_effort=reasoning_effort, first_token_event=first_token_event, + content_event=content_event, ) async def embed( diff --git a/tests/integration/test_redis_cross_connection.py b/tests/integration/test_redis_cross_connection.py index ff980ba..fc2f5a2 100644 --- a/tests/integration/test_redis_cross_connection.py +++ b/tests/integration/test_redis_cross_connection.py @@ -76,7 +76,16 @@ class ScriptedTransport: self.entered = asyncio.Event() 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) if self.hang: diff --git a/tests/unit/test_backpressure.py b/tests/unit/test_backpressure.py index 3186e1d..c92581c 100644 --- a/tests/unit/test_backpressure.py +++ b/tests/unit/test_backpressure.py @@ -211,7 +211,16 @@ class ClockAdvancingTransport: self.calls = [] 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)) advance, action = self.script.pop(0) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index d2b180f..830548c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -222,7 +222,16 @@ class _ScriptedGenClockTransport: self.calls = [] 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) result = self._results.pop(0) @@ -1825,7 +1834,16 @@ class _ClockJumpTransport: self.calls = [] 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._clock.advance(self._jump) diff --git a/tests/unit/test_hedge.py b/tests/unit/test_hedge.py index d419414..3fafc94 100644 --- a/tests/unit/test_hedge.py +++ b/tests/unit/test_hedge.py @@ -49,14 +49,26 @@ class HedgeTransport: self.calls: list[str] = [] # 逐次记录收到的 first_token_event 身份(H5: 对冲路恒为 None,不再梯次) 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.release = {name: asyncio.Event() for name in scripts} 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.ft_events.append(first_token_event) + self.ce_events.append(content_event) actions = self._scripts[source.name] action = actions.pop(0) if len(actions) > 1 else actions[0] kind = action[0] diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py index c58238f..6d6723d 100644 --- a/tests/unit/test_live_evidence.py +++ b/tests/unit/test_live_evidence.py @@ -278,6 +278,7 @@ async def _complete(observed, *, call_id="a", stream=False): call_id=call_id, reasoning_effort=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", reasoning_effort=None, first_token_event=None, + content_event=None, ) event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0] assert not request_is_valid(event) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 1fcecd8..79772d7 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -25,6 +25,7 @@ from polygateway.transports.openai_compat import ( _iter_sse_deltas, _parse_retry_after, _sse_data_payload, + _stall_from_chunk_times, _translate_429, ) from polygateway.types import ( @@ -81,7 +82,14 @@ def _transport_for(handler, *, registry=None): 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( messages=[{"role": "user", "content": "hi"}], @@ -91,6 +99,7 @@ async def _complete( call_id="cid-1", reasoning_effort=reasoning_effort, 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: def _no_done_handler(self, request): return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False) diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 0eb0efb..819eec0 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -73,7 +73,16 @@ class _DummyMw: class _DummyTransport: 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 diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 3a2f951..62bdc41 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -80,7 +80,16 @@ class FakeTransport: self.entered = asyncio.Event() 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.efforts.append(reasoning_effort) @@ -110,7 +119,16 @@ class _GenClockTransport: return self._inner.calls 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) result = await self._inner.complete( @@ -121,6 +139,7 @@ class _GenClockTransport: call_id=call_id, reasoning_effort=reasoning_effort, first_token_event=first_token_event, + content_event=content_event, ) self._clock.advance(advance) return result diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index d9e1863..55bbc40 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -185,6 +185,19 @@ class TestSourceConfig: _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: """TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。""" @@ -293,6 +306,12 @@ class TestAuxTypes: 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: """M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。""" diff --git a/tests/unit/test_usage_source_domain.py b/tests/unit/test_usage_source_domain.py index 61c6b37..152e9fc 100644 --- a/tests/unit/test_usage_source_domain.py +++ b/tests/unit/test_usage_source_domain.py @@ -140,6 +140,7 @@ async def test_salvage_override_stays_in_domain(usage): call_id="cid", reasoning_effort=None, first_token_event=None, + content_event=None, ) assert result.usage_source in USAGE_SOURCES From 53bcf431721083ae30b670cf9656bb8c411dcbdb Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 00:56:54 -0400 Subject: [PATCH 06/12] feat: redefine generation_ms as net generation time with stall attribution CallStats gains stall_ms/stall_count (None = no stall observation); winner sample triple (wall, stall_ms, stall_count) converted once in _attempt success branch; net = max(0, wall - stall) converged into the shared _record_winner_sample used by both the single-path guard and hedge winner registration. Two-fail hedge rounds record nothing (no winner to attribute). Design section 4.2; issues #27. --- src/polygateway/middleware/retry.py | 57 +++++++++++++++++++++-------- src/polygateway/types.py | 37 +++++++++++++++++-- tests/unit/test_hedge.py | 21 +++++++++++ tests/unit/test_retry.py | 48 +++++++++++++++++++++++- tests/unit/test_types.py | 30 +++++++++++++++ 5 files changed, 173 insertions(+), 20 deletions(-) diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 0350d37..5c01c29 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -58,6 +58,7 @@ if TYPE_CHECKING: RetryPolicy, SourceConfig, TransportResult, + _CallContext, ) @@ -165,6 +166,11 @@ def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool: _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 读它选遥测标签。""" @@ -262,9 +268,10 @@ class RetryMW: await self._admission.on_no_runnable(gate_rejections, reasons, clock) continue async with clock.attempting() as attempt: - # 每轮一个 sink: 裸生成时间由编排裁定归属(T2 单路 = 成功那轮; - # T3 对冲 = 赢家那一路),`_attempt` 只负责把本次 transport 耗时投进来 - generation_sink: list[int] = [] + # 每轮一个 sink: 生成样本(墙钟+沉默归因)由编排裁定归属 + # (单路 = 成功那轮;对冲 = 赢家那一路),`_attempt` 只负责把本次 + # transport 耗时与沉默观测投进来 + generation_sink: list[_GenerationSample] = [] if self._hedge_after_s is None: # 默认关闭: 逐字 1.3.6 单路路径(默认关闭回归门据此成立) outcome = await self._attempt( @@ -284,10 +291,9 @@ class RetryMW: # 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock) attempt.refund() if isinstance(outcome, LLMResponse): - # 与 register_attempt 同款 None 守卫: 库内现场构造的请求跳过登记; # 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空 - if request.call_context is not None and generation_sink: - request.call_context.record_generation(generation_sink[0], accumulate=False) + # (守卫在 _record_generation 内: 上下文缺失/空 sink 均跳过) + self._record_generation(request, generation_sink) return outcome if not rate_limited: fails += 1 @@ -314,7 +320,7 @@ class RetryMW: *, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, - generation_sink: list[int], + generation_sink: list[_GenerationSample], ) -> LLMResponse | _Failed: call_id = str(uuid.uuid4()) started = self._now() @@ -328,8 +334,9 @@ class RetryMW: if request.call_context is not None: request.call_context.register_attempt() try: - # 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身,起点紧贴调用前、 - # 终点为返回后首句(中间无 await);取消落进来时 transport 未返回,不计。 + # 净生成时间(1.3.8): 此处只量 transport 墙钟(起点紧贴调用前、 + # 终点为返回后首句,中间无 await),沉默扣减在消费侧统一换算; + # 取消落进来时 transport 未返回,不计。 # 与该链路 total_latency_ms 同一只注入钟,差值(波动开销)才有意义 gen_started = self._now() result = await self._transport.complete( @@ -348,7 +355,12 @@ class RetryMW: # T4 才在启用 stall 对冲的流式原路上接真事件(设计 §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": # 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回, # 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9) @@ -429,7 +441,7 @@ class RetryMW: # Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性) source, permit, entry = picked first_token: asyncio.Event = asyncio.Event() - sink_p: list[int] = [] + sink_p: list[_GenerationSample] = [] primary = asyncio.create_task( self._attempt( request, @@ -480,7 +492,7 @@ class RetryMW: return outcome # 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( self._attempt( request, @@ -579,14 +591,27 @@ class RetryMW: ) @staticmethod - def _record_generation(request: ChatRequest, sink: list[int]) -> None: + def _record_generation(request: ChatRequest, sink: list[_GenerationSample]) -> None: """record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 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 def _register_hedge( - request: ChatRequest, *, hedge_won: bool, winner_sink: list[int] | None + request: ChatRequest, *, hedge_won: bool, winner_sink: list[_GenerationSample] | None ) -> None: """对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。 @@ -597,7 +622,7 @@ class RetryMW: if context is None: return 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) async def _on_rejected( diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 536e044..63e04c0 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -320,9 +320,20 @@ class CallStats: hedges: int = 0 """本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 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 """赢家是否为对冲路;无对冲恒 False。""" + stall_ms: int | None = None + """赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。 + + 墙钟 = generation_ms + stall_ms;None 是"无沉默观测",不是 0。""" + stall_count: int | None = None + """超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。""" class _CallContext: @@ -340,6 +351,8 @@ class _CallContext: "_hedge_won", "_hedges", "_now", + "_stall_count", + "_stall_ms", "_started", "_terminal_claimed", "logical_call_id", @@ -353,6 +366,8 @@ class _CallContext: self._generation_ms = 0 self._hedges = 0 self._hedge_won = False + self._stall_ms: int | None = None + self._stall_count: int | None = None self._terminal_claimed = False def register_attempt(self) -> None: @@ -363,9 +378,23 @@ class _CallContext: """ self._attempts += 1 - def record_generation(self, elapsed_ms: int, *, accumulate: bool) -> None: - """chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。""" + def record_generation( + 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._stall_ms = stall_ms + self._stall_count = stall_count def register_hedge(self, *, hedge_won: bool) -> None: """对冲路实际发出即计数;赢家裁定后一次性登记。""" @@ -381,6 +410,8 @@ class _CallContext: hedges=self._hedges, generation_ms=self._generation_ms, hedge_won=self._hedge_won, + stall_ms=self._stall_ms, + stall_count=self._stall_count, ) def claim_terminal(self) -> bool: diff --git a/tests/unit/test_hedge.py b/tests/unit/test_hedge.py index 3fafc94..7db4980 100644 --- a/tests/unit/test_hedge.py +++ b/tests/unit/test_hedge.py @@ -569,6 +569,27 @@ class TestHedgeFailureCombination: # 两败轮次同样登记对冲路数(设计 §4.5: 实际并发发出即计) 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): """两路皆 429: 免预算且耗时退 stall 账——小 stall 窗下终局 stalled 而非耗尽。""" diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 62bdc41..7c9f5b3 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -107,12 +107,15 @@ class _GenClockTransport: generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包 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._advances = list(advances) self._clock = clock + self._stalls = list(stalls) if stalls is not None else None @property def calls(self): @@ -131,6 +134,7 @@ class _GenClockTransport: content_event, ): advance = self._advances.pop(0) + stall = self._stalls.pop(0) if self._stalls is not None else None result = await self._inner.complete( messages=messages, source=source, @@ -141,6 +145,8 @@ class _GenClockTransport: 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) return result @@ -1017,6 +1023,46 @@ class TestGenerationMs: stats = ctx.snapshot() 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: """尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。 diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 55bbc40..3172361 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -690,6 +690,36 @@ class TestCallStatsAndContext: stats = ctx.snapshot() 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): """快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。""" from polygateway.types import _CallContext From e0b0be09a73a6ede7fec994042554fac853e8f17 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 01:16:33 -0400 Subject: [PATCH 07/12] feat: add stall_ms and stall_count telemetry columns Third approved port change: TelemetryRecorder.record_llm_call gains stall_ms/stall_count (assembly gate derives from this signature). Schema five edit points (both DDLs, both backfill decls, COLUMNS) with INTEGER nullable columns appended last; telemetry_schema_sql regenerates from the same constants untouched. Emitter forwards the pair from retry._emit on the success branch only (cancelled hedge loser rows stay NULL by construction); cache-hit and terminal rows pass explicit None. Both **fields recorders pick the columns up with zero signature change. --- src/polygateway/middleware/telemetry.py | 27 +++- src/polygateway/ports.py | 4 +- src/polygateway/telemetry/schema.py | 22 ++- src/polygateway/telemetry/sqlite.py | 2 +- tests/integration/test_postgres_telemetry.py | 9 +- tests/unit/test_hedge.py | 17 ++ tests/unit/test_ports.py | 4 +- tests/unit/test_telemetry.py | 160 +++++++++++++++++-- 8 files changed, 220 insertions(+), 25 deletions(-) diff --git a/src/polygateway/middleware/telemetry.py b/src/polygateway/middleware/telemetry.py index ef07fb4..ec730f0 100644 --- a/src/polygateway/middleware/telemetry.py +++ b/src/polygateway/middleware/telemetry.py @@ -216,6 +216,11 @@ class _AttemptUsage: # 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。 # 失败尝试无响应可言,默认 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 def of(cls, response: LLMResponse | None) -> _AttemptUsage: @@ -365,7 +370,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None: class TelemetryEmitter: - """从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。""" + """从请求与结果组装 38 字段并写入 recorder;一切写失败降级 warning。""" def __init__( self, @@ -405,6 +410,12 @@ class TelemetryEmitter: reasoning_applies: bool, operation: CallOperation, 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: """逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。 @@ -451,6 +462,8 @@ class TelemetryEmitter: event_kind="attempt", attempts=None, total_latency_ms=None, + stall_ms=stall_ms, + stall_count=stall_count, ) async def emit_cache_hit( @@ -498,6 +511,9 @@ class TelemetryEmitter: event_kind="cache_hit", attempts=None, total_latency_ms=None, + # 缓存命中行未产生 transport 观测: 无沉默可言,显式 None + stall_ms=None, + stall_count=None, ) async def emit_terminal_failure( @@ -550,6 +566,9 @@ class TelemetryEmitter: event_kind="terminal_failure", attempts=stats.attempts, total_latency_ms=stats.total_latency_ms, + # 终态行无具体源与 transport 观测: 显式 None + stall_ms=None, + stall_count=None, ) async def _record( @@ -592,6 +611,10 @@ class TelemetryEmitter: event_kind: EventKind, attempts: int | None, total_latency_ms: int | None, + # —— 1.3.8: 沉默归因两列(issue #27);各入口已按自己的事实定型, + # 本方法只搬运(同 reasoning_effort 先例)—— + stall_ms: int | None, + stall_count: int | None, ) -> None: try: # 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用); @@ -660,6 +683,8 @@ class TelemetryEmitter: error_body=errors.error_body, attempts=attempts, total_latency_ms=total_latency_ms, + stall_ms=stall_ms, + stall_count=stall_count, ) except asyncio.CancelledError: raise diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 6617509..88da72e 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -284,7 +284,7 @@ class TelemetryStatusProvider(Protocol): @runtime_checkable 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。 @@ -349,4 +349,6 @@ class TelemetryRecorder(Protocol): error_body: str | None, attempts: int | None, total_latency_ms: int | None, + stall_ms: int | None, + stall_count: int | None, ) -> None: ... diff --git a/src/polygateway/telemetry/schema.py b/src/polygateway/telemetry/schema.py index 1186e36..9ed9615 100644 --- a/src/polygateway/telemetry/schema.py +++ b/src/polygateway/telemetry/schema.py @@ -5,8 +5,8 @@ 多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。 **`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带 -`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 + -`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。 +`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 38 个 INSERT 字段 + +`created_at` = 39;列数断言一律按物理列数写,两套口径混用是最易错处。 本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且 互不依赖(import-linter 契约执法)。 @@ -62,7 +62,9 @@ CREATE TABLE IF NOT EXISTS llm_calls ( cause_type TEXT, error_body TEXT, 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, error_body TEXT, 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"), ("attempts", "INTEGER"), ("total_latency_ms", "INTEGER"), + # 1.3.8 沉默归因两列(issue #27): 同样可空无默认——旧行 NULL = "补列之前 + # 没记过这件事",与"观测过且零沉默"(0)是两回事 + ("stall_ms", "INTEGER"), + ("stall_count", "INTEGER"), ) # PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给 @@ -163,6 +171,9 @@ _PG_BACKFILL_DECLS = ( ("error_body", "TEXT"), ("attempts", "INTEGER"), ("total_latency_ms", "INTEGER"), + # 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上) + ("stall_ms", "INTEGER"), + ("stall_count", "INTEGER"), ) # 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。 @@ -212,6 +223,9 @@ COLUMNS = ( "error_body", "attempts", "total_latency_ms", + # —— 1.3.8 沉默归因(issue #27)—— + "stall_ms", + "stall_count", ) _COLUMN_SET = frozenset(COLUMNS) diff --git a/src/polygateway/telemetry/sqlite.py b/src/polygateway/telemetry/sqlite.py index c3e61dd..05f8107 100644 --- a/src/polygateway/telemetry/sqlite.py +++ b/src/polygateway/telemetry/sqlite.py @@ -143,7 +143,7 @@ class SQLiteRecorder: logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc) async def record_llm_call(self, **fields: object) -> None: - """写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。 + """写一行遥测;字段集合即 38 字段冻结签名(ports.TelemetryRecorder)。 取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的 占位符同序——两者必须一起改,分开改就是把值写进错位的列。 diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 3e5055b..5e7799f 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -69,6 +69,9 @@ _EXPECTED_COLUMNS = [ "error_body", "attempts", "total_latency_ms", + # —— 1.3.8 沉默归因两列(issue #27)—— + "stall_ms", + "stall_count", ] # 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片 @@ -769,7 +772,7 @@ class TestCallerDimensionsAcceptance: "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", schema, ) - # 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾 + # 22 → 38 个 recorder 字段(加 created_at 共 39 个物理列),且新列追加在末尾 assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS rows = await _fetch( schema_dsn, @@ -1142,7 +1145,7 @@ class TestCallObservabilityColumnsAcceptance: "WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position", schema, ) - # 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾: + # 26 → 38 个 recorder 字段(加 created_at 共 39 个物理列),新列追加在末尾: # 列序与新建库一致才不会让两条升级路径分叉 assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS @@ -1337,7 +1340,7 @@ class TestPublishedSchemaScript: await _execute_script(fresh_dsn, script) 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"} # 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉 assert actual == _EXPECTED_COLUMNS diff --git a/tests/unit/test_hedge.py b/tests/unit/test_hedge.py index 7db4980..2bd7ccc 100644 --- a/tests/unit/test_hedge.py +++ b/tests/unit/test_hedge.py @@ -142,6 +142,10 @@ class RecordingEmitter: error, reasoning_applies, operation, + # 1.3.8: `_emit` 显式转发 stall 两值(审查 B1)——显式全签名 fake 不加则 TypeError; + # 默认 None 只护调用方,护不住被调的显式签名 + stall_ms=None, + stall_count=None, ): self.rows.append( { @@ -151,6 +155,9 @@ class RecordingEmitter: if request.call_context is not None else None, "error": error, + # 1.3.8: 逐行捕获 stall 归因(取消输家行须恒 None,设计 §11-B2) + "stall_ms": stall_ms, + "stall_count": stall_count, } ) @@ -455,6 +462,16 @@ class TestHedgeSettlementAndSignals: assert winner["error"] is None 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): """输家取消不喂熔断失败计数、不喂健康分;赢家照常 record_success(④)。""" selector = RecordingSelector() diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 819eec0..0102553 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -147,6 +147,8 @@ class _DummyRecorder: error_body, attempts, total_latency_ms, + stall_ms, + stall_count, ) -> None: ... @@ -319,7 +321,7 @@ class TestTelemetryRecorderSignature: "attempts", "total_latency_ms", } <= set(params) - assert len(params) - 1 == 36 # 减掉 self + assert len(params) - 1 == 38 # 减掉 self @pytest.mark.parametrize( "name", diff --git a/tests/unit/test_telemetry.py b/tests/unit/test_telemetry.py index 75999d7..cd87404 100644 --- a/tests/unit/test_telemetry.py +++ b/tests/unit/test_telemetry.py @@ -89,6 +89,9 @@ _EXPECTED_COLUMNS = [ "error_body", "attempts", "total_latency_ms", + # —— 1.3.8 沉默归因两列(issue #27)—— + "stall_ms", + "stall_count", ] # 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用 @@ -181,6 +184,9 @@ async def _record_minimal(recorder, call_id="c1", **overrides): # 逻辑快照两列只属终态行 "attempts": None, "total_latency_ms": None, + # —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)—— + "stall_ms": None, + "stall_count": None, } fields.update(overrides) await recorder.record_llm_call(**fields) @@ -262,7 +268,7 @@ class TestSchemaModule: # COLUMNS 是 INSERT 字段序,不含数据库自填的 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 位) for ddl in (SQLITE_DDL, PG_DDL): 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", ) assert PG_BACKFILL[-1] == ( - "total_latency_ms", - "ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER", + "stall_count", + "ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER", ) 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("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT - # 全量 36 列: 占位符随列数增长,且不留空洞 - assert insert_sql("postgres", COLUMNS).count("$") == 36 - assert "$36)" in insert_sql("postgres", COLUMNS) - assert insert_sql("sqlite", COLUMNS).count("?") == 36 + # 全量 38 列: 占位符随列数增长,且不留空洞 + assert insert_sql("postgres", COLUMNS).count("$") == 38 + assert "$38)" in insert_sql("postgres", COLUMNS) + assert insert_sql("sqlite", COLUMNS).count("?") == 38 # 裁剪列表按位置占位符重新编号,不留空洞 assert insert_sql("postgres", ["call_id", "model"]) == ( "INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING" @@ -371,7 +377,130 @@ class TestBackendColumnParity: """新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。""" 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: @@ -647,7 +776,7 @@ class TestSQLiteCallerDimensionsAcceptance: conn = sqlite3.connect(db) 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()) assert rows["new-row"] == "tenant-a" assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉 @@ -697,7 +826,7 @@ class TestSQLiteSchemaMode: """issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。 列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23, - 补齐后 36 + `created_at` = 37。混用 INSERT 字段数与物理列数是本处最易错的地方。 + 补齐后 38 + `created_at` = 39。混用 INSERT 字段数与物理列数是本处最易错的地方。 """ def _physical_columns(self, db: Path) -> list[str]: @@ -736,7 +865,7 @@ class TestSQLiteSchemaMode: assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL 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" _make_pre_tenant_db(db) @@ -745,10 +874,10 @@ class TestSQLiteSchemaMode: recorder.close() 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): - """manual 只管 ALTER,不管 CREATE: 全新库照建,37 个物理列齐全(设计 §4.2)。""" + """manual 只管 ALTER,不管 CREATE: 全新库照建,39 个物理列齐全(设计 §4.2)。""" db = tmp_path / "manual_fresh.db" recorder = SQLiteRecorder(db, auto_migrate=False) await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a") @@ -985,6 +1114,9 @@ class TestPostgresBackfillDiscipline: "error_body", "attempts", "total_latency_ms", + # 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁 + "stall_ms", + "stall_count", ] def _recorder(self, conn): @@ -3353,7 +3485,7 @@ class TestRecorderShapeGate: class NarrowRecorder: async def record_llm_call(self, *, call_id, model) -> None: ... - # 若闸内硬编码了 36 个名字,这里必然拒绝 + # 若闸内硬编码了 38 个名字,这里必然拒绝 tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None) From 2ac05a20b6b60aa1dec05b84cc941db17eacfcae Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 01:52:10 -0400 Subject: [PATCH 08/12] feat: add opt-in mid-stream stall hedging for chat HEDGE__STALL_AFTER_S triggers a cross-source hedge when the primary leg stalls mid-stream (independent of HEDGE__AFTER_S); two-phase hedge window with event recheck discipline; watchdog-killed primary converges on the surviving hedge leg. STREAM__STALL_THRESHOLD_MS (1000ms default) adjustable via env/settings/constructor. Assembly guards: ValueError on never-opening windows, embedding/OCR scope warning (B4). Batch D 14 tests red-to-green, 1688 unit+contracts. --- src/polygateway/client.py | 23 +- src/polygateway/config.py | 180 ++++++++--- src/polygateway/middleware/retry.py | 101 +++++- tests/unit/test_config.py | 190 ++++++++++- tests/unit/test_stall_hedge.py | 479 ++++++++++++++++++++++++++++ 5 files changed, 899 insertions(+), 74 deletions(-) create mode 100644 tests/unit/test_stall_hedge.py diff --git a/src/polygateway/client.py b/src/polygateway/client.py index 9b2ea10..dd8ee18 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -239,6 +239,8 @@ class GatewayClient: call_deadline_s: float | None = None, hedge_after_s: float | None = None, hedge_max_extra: int = 1, + hedge_stall_after_s: float | None = None, + stall_threshold_ms: int | None = None, now: Any = time.monotonic, sleep: Any = asyncio.sleep, rng: Any = random.random, @@ -247,15 +249,25 @@ class GatewayClient: self._call_deadline_s = ensure_call_deadline( call_deadline_s, "GatewayClient(call_deadline_s=...)" ) - # 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4): 直接构造这条路 - # 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过 - self._hedge_after_s = check_hedge_assembly( + # 对冲守卫与 GatewaySettings 共用同一份(issue #24 H4 / 1.3.8 #28): 直接构造这条路 + # 不经过 settings,值域/交叉守卫若只挂在 settings 上就会被它绕过。 + # 返回契约(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_max_extra=hedge_max_extra, sources=sources, call_deadline_s=self._call_deadline_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 = ( TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap) if telemetry is not None @@ -280,6 +292,8 @@ class GatewayClient: emitter=emitter, # max_extra 不下传(issue #24 H5): v1 编排固定单路对冲,无消费者 hedge_after_s=self._hedge_after_s, + # 中段沉默对冲阈值(1.3.8 #28): 独立于 hedge_after_s 生效,守卫归一化值 + stall_after_s=self._hedge_stall_after_s, now=now, sleep=sleep, rng=rng, @@ -524,6 +538,9 @@ class GatewayClient: call_deadline_s=settings.call_deadline_s, hedge_after_s=settings.hedge_after_s, 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) client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过 diff --git a/src/polygateway/config.py b/src/polygateway/config.py index f2ff270..fef2a9d 100644 --- a/src/polygateway/config.py +++ b/src/polygateway/config.py @@ -54,7 +54,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = { "TRUST_ENV": ("trust_env", "bool"), "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"}) _QUOTA_FULL = frozenset({"wait", "fail_fast"}) # 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是 @@ -194,6 +194,16 @@ class GatewaySettings: # 由 `check_hedge_assembly` 单一定义点把关,直接构造/replace/env 三路一致 hedge_after_s: float | None = None 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: self._normalize() @@ -378,17 +388,17 @@ class GatewaySettings: env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等 空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。 """ - object.__setattr__( - self, - "hedge_after_s", - check_hedge_assembly( - hedge_after_s=self.hedge_after_s, - hedge_max_extra=self.hedge_max_extra, - sources=self.sources, - call_deadline_s=self.call_deadline_s, - origin="GatewaySettings.hedge_after_s", - ), + after_s, stall_after_s = check_hedge_assembly( + hedge_after_s=self.hedge_after_s, + hedge_max_extra=self.hedge_max_extra, + sources=self.sources, + call_deadline_s=self.call_deadline_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 def from_env( @@ -420,6 +430,7 @@ class GatewaySettings: circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"), call_deadline_s=_load_call_deadline(scope_u, env), **_load_hedge(scope_u, env), + **_load_stream(scope_u, 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]) +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]: - """读 `{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` 的段数判据天然 跳过它们;`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") 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( @@ -776,26 +814,32 @@ def check_hedge_assembly( sources: Sequence[SourceConfig], call_deadline_s: float | None, origin: str, -) -> float | None: - """对冲装配守卫的唯一事实源(issue #24 设计 §5 全表,H4 批准)。 + stall_after_s: float | None = None, + 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__` 调同一份,两条装配 - 路的值域/交叉守卫不漂移。返回归一化后的 `hedge_after_s`(None 或有限正数, - 复用 `ensure_call_deadline` 的值域校验);`hedge_after_s is None`(未启用)时 - 值域归一化后直接返回,交叉守卫不查——它们没有可校验的对象。 + 路的值域/交叉守卫不漂移。返回契约(1.3.8 审查 B2): + `tuple(归一化 hedge_after_s, 归一化 stall_after_s)`(各为 None 或有限正数, + 复用 `ensure_call_deadline` 的值域校验);**两阈值皆 None 才早退**——只对 + `hedge_after_s is None` 早退会让仅 stall 配置绕过交叉守卫(审查 B2 钉死)。 Args: - hedge_after_s: 对冲触发阈值(秒);None = 关闭。 + hedge_after_s: 首 token/整体耗时对冲触发阈值(秒);None = 关闭。 hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。 - sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft_timeout_s)。 + sources: 本 scope 的源集合(交叉守卫要读 timeout_s/ttft/inter_token)。 call_deadline_s: 调用期限(秒);与对冲的组合守卫见设计 §6。 origin: after_s 值域报错的定位串(env 键名 / `GatewaySettings.hedge_after_s` / `GatewayClient(hedge_after_s=...)`);跨字段守卫与 warning 沿用本模块先例, 消息自带字段名与 env 键型,不挂 origin。 + stall_after_s: 中段沉默对冲触发阈值(秒,1.3.8 issue #28);None = 关闭。 + stall_origin: stall_after_s 值域报错的定位串(同 origin 的三路纪律)。 Raises: - ValueError: max_extra 非 int/bool 或出 [1,3];阈值 ≥ 最小源 timeout_s(永不 - 可能触发);阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。 + ValueError: max_extra 非 int/bool 或出 [1,3];after_s ≥ 最小源 timeout_s(永不 + 可能触发);stall_after_s ≥ 最小源有效 inter-token 超时(回落同款,窗永不开); + 阈值 ≥ call_deadline_s(期限先于对冲触发,对冲形同虚设)。 """ if isinstance(hedge_max_extra, bool) or not isinstance(hedge_max_extra, int): raise ValueError( @@ -807,45 +851,80 @@ def check_hedge_assembly( "每次逻辑调用最多并发对冲路数,v1 仅单路生效" ) after = ensure_call_deadline(hedge_after_s, origin) - if after is None: - return None + stall = ensure_call_deadline(stall_after_s, stall_origin) + if after is None and stall is None: + return None, None if not sources: - # 直传路(GatewayClient(sources=[], hedge_after_s=...))没有 settings 的 - # 非空守卫先行拦截;报错必须定位到 hedge,而不是裸 min() 空序列异常 + # 直传路(GatewayClient(sources=[], ...))没有 settings 的 + # 非空守卫先行拦截;报错必须定位到 hedge 键族,而不是裸 min() 空序列异常。 + # 消息保留 "hedge_after_s" 字样: 既有用例据此定位键族(1.3.8 扩含 stall) 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) - if after >= min_timeout: - raise ValueError( - f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s" - f"({min_timeout});对冲永不可能触发,配置即错误" - ) - if call_deadline_s is not None and after >= call_deadline_s: - raise ValueError( - f"hedge_after_s({after})须 < call_deadline_s({call_deadline_s});" - "期限会先于对冲触发,对冲形同虚设(设计 §6)" - ) - ttfts = [s.ttft_timeout_s for s in sources if s.ttft_timeout_s is not None] - if ttfts and after >= min(ttfts): - logger.warning( - "hedge_after_s({}) ≥ 最小源 ttft_timeout_s({}): 流式挂起会被 TTFT 看门狗" - "先行切断,对冲对流式形同虚设(非流式仍有效)", - after, - min(ttfts), - ) + if after is not None: + if after >= min_timeout: + raise ValueError( + f"hedge_after_s({{SCOPE}}__HEDGE__AFTER_S={after})须 < 最小源 timeout_s" + f"({min_timeout});对冲永不可能触发,配置即错误" + ) + if call_deadline_s is not None and after >= call_deadline_s: + raise ValueError( + f"hedge_after_s({after})须 < call_deadline_s({call_deadline_s});" + "期限会先于对冲触发,对冲形同虚设(设计 §6)" + ) + ttfts = [s.ttft_timeout_s for s in sources if s.ttft_timeout_s is not None] + if ttfts and after >= min(ttfts): + logger.warning( + "hedge_after_s({}) ≥ 最小源 ttft_timeout_s({}): 流式挂起会被 TTFT 看门狗" + "先行切断,对冲对流式形同虚设(非流式仍有效)", + after, + 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: logger.warning( - "单源 scope 配置了对冲阈值 hedge_after_s={};运行期拿不到异源候选,对冲自然静默", + "单源 scope 配置了对冲阈值 hedge_after_s={}/hedge_stall_after_s={};" + "运行期拿不到异源候选,对冲自然静默", after, + stall, ) if hedge_max_extra > 1: logger.warning( "hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)", 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) @@ -868,6 +947,7 @@ class EmbeddingSettings: raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}") if self.expected_dim is not None and self.expected_dim < 1: raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}") + _warn_hedge_keys_on_non_stream_scope(self.gateway, "embedding") @classmethod def from_env( @@ -911,6 +991,10 @@ class OcrSettings: gateway: GatewaySettings + def __post_init__(self) -> None: + """内嵌 gateway 由 `GatewaySettings.__post_init__` 自己把关;此处只补 B4 对冲键 warning。""" + _warn_hedge_keys_on_non_stream_scope(self.gateway, "ocr") + @classmethod def from_env( cls, diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 5c01c29..8fbbbaa 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -201,6 +201,7 @@ class RetryMW: pacer: AdaptivePacer | None = None, emitter: TelemetryEmitter | None = None, hedge_after_s: float | None = None, + stall_after_s: float | None = None, now: Callable[[], float] = time.monotonic, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, rng: Callable[[], float] = random.random, @@ -222,6 +223,9 @@ class RetryMW: # 值域/交叉守卫在装配层(config.check_hedge_assembly),本类不重复校验; # hedge_max_extra 不下传——v1 编排固定单路对冲(H5) 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._sleep = sleep self._rng = rng @@ -272,8 +276,20 @@ class RetryMW: # (单路 = 成功那轮;对冲 = 赢家那一路),`_attempt` 只负责把本次 # transport 耗时与沉默观测投进来 generation_sink: list[_GenerationSample] = [] - if self._hedge_after_s is None: - # 默认关闭: 逐字 1.3.6 单路路径(默认关闭回归门据此成立) + if self._hedge_after_s is None and self._stall_after_s is None: + # 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立) + outcome = await self._attempt( + request, + *picked, + reasons, + attempt_fails, + 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, @@ -374,7 +390,15 @@ class RetryMW: self._feed_outcome(source.name, ok=True) self._pacer.on_success(source.name) 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 except RequestRejectedError as exc: actual, settlement_known = 0, True # 逐字保住 1.3.5: 坏请求全额退还 @@ -441,6 +465,11 @@ class RetryMW: # Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性) source, permit, entry = picked first_token: asyncio.Event = asyncio.Event() + # 中段沉默观测(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( self._attempt( @@ -451,14 +480,15 @@ class RetryMW: reasons, attempt_fails, first_token_event=first_token, - content_event=None, + content_event=content_event, generation_sink=sink_p, ) ) hedge: asyncio.Task[LLMResponse | _Failed] | None = None try: - # Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长) - if not await self._past_hedge_window(primary, first_token): + # Phase 2 触发窗: 两阶段(1.3.8)——首 token 前走 AFTER_S,首 token 后 + # 走 STALL_AFTER_S;只认 loop 相对时长,绝不读注入钟(设计 §4.1) + if not await self._past_hedge_window(primary, first_token, content_event): # 原路已了结/首 token 已至: 等价于未配置对冲 outcome = await primary if isinstance(outcome, LLMResponse): @@ -558,19 +588,32 @@ class RetryMW: await asyncio.gather(*tasks, return_exceptions=True) raise - async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool: - """对冲触发窗: 阈值到 + 首 token 未至 + 原路在途,三者齐备才放行对冲。 + async def _past_hedge_window( + 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`——测试 伪造注入钟跳变不得触发对冲(设计 §4.1)。waiter 任务必须收口,且**不得吞 外部取消**(finally 里 await 已取消的 waiter 会接住一个 CancelledError, 须靠 cancelling() 区分它是 waiter 自己的还是外面打进来的)。 """ + # 阶段 1 首 token 前: AFTER_S 已设才开窗(设计 §5.2);仅 stall 时此阶段不 + # 触发,但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall + # 窗误吞(计划 §3.6) waiter = asyncio.create_task(first_token.wait()) try: - await asyncio.wait( - {primary, waiter}, timeout=self._hedge_after_s, return_when=asyncio.FIRST_COMPLETED - ) + if self._hedge_after_s is not None: + await asyncio.wait( + {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: @@ -578,7 +621,35 @@ class RetryMW: except asyncio.CancelledError: if asyncio.current_task().cancelling(): # 外部取消,穿透 raise - return not primary.done() and not first_token.is_set() + 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: + waiter.cancel() + try: + await waiter + except asyncio.CancelledError: + if asyncio.current_task().cancelling(): # 外部取消,穿透 + raise + 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 def _succeeded(task: asyncio.Task, done: set[asyncio.Task]) -> bool: @@ -700,11 +771,15 @@ class RetryMW: *, response: LLMResponse | None = None, error: PolyGatewayError | str | None = None, + stall_ms: int | None = None, + stall_count: int | None = None, ) -> None: """逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。 异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常 类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。 + stall 两值(1.3.8)只在成功分支由 `_attempt` 显式传入(计划 §3.4); + 失败/取消分支恒 None——被取消输家无 TransportResult(设计 §11-B2)。 """ if self._emitter is None: return @@ -720,6 +795,8 @@ class RetryMW: reasoning_applies=True, # 公开方法四值之一;本中间件只服务 chat 洋葱 operation="chat", + stall_ms=stall_ms, + stall_count=stall_count, ) except asyncio.CancelledError: raise diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 396442e..e1920cb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1179,17 +1179,16 @@ class TestHedgeConfig: origin="GatewayClient(hedge_after_s=8)", ) assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键 - # 未启用对冲(None)时空 sources 直接放行: 交叉守卫没有可校验的对象 - assert ( - check_hedge_assembly( - hedge_after_s=None, - hedge_max_extra=1, - sources=[], - call_deadline_s=None, - origin="test", - ) - is None - ) + # 未启用对冲(两阈值皆 None)时空 sources 直接放行: 交叉守卫没有可校验的对象; + # 返回契约(1.3.8 审查 B2): tuple(归一化 after_s, 归一化 stall_after_s), + # 两阈值皆 None 才早退 + assert check_hedge_assembly( + hedge_after_s=None, + hedge_max_extra=1, + sources=[], + call_deadline_s=None, + origin="test", + ) == (None, None) def test_hedge_guard_below_min_timeout_raises(self): """阈值 ≥ 最小源 timeout_s = 对冲永不可能触发,装配期炸掉(ValueError)。""" @@ -1283,3 +1282,172 @@ class TestHedgeConfig: client = GatewayClient.from_settings(s) assert client._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) diff --git a/tests/unit/test_stall_hedge.py b/tests/unit/test_stall_hedge.py new file mode 100644 index 0000000..85f17bc --- /dev/null +++ b/tests/unit/test_stall_hedge.py @@ -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] From ca8743bf3334de8d3225443c713ff278ed70c29d Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 02:13:55 -0400 Subject: [PATCH 09/12] docs: document net generation time and mid-stream stall hedging --- .env.example | 7 ++ CHANGELOG.md | 47 ++++++++++ README.md | 32 ++++++- research-wiki/ARCHITECTURE.md | 8 +- research-wiki/ROADMAP.md | 2 + .../2026-09-11-streaming-stall-validation.md | 89 +++++++++++++++++++ 6 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 research-wiki/findings/2026-09-11-streaming-stall-validation.md diff --git a/.env.example b/.env.example index 076c85c..b853aa6 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,13 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S # ── 「挂起 vs 慢生成」物理不可分,建议取源 p50 的数倍)。对冲向异源并发再发一次,走完整限流/熔断准入, # ── 拿不到配额静默放弃。成本含义: 开启即用配额换延迟——触发窗口内 in-flight 翻倍,输家被取消后按 est # ── 保留预扣(不喂熔断),且输家可能已被上游计费、取消止不住。须与 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_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0c0a2..ca6973b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## 未发布 + +流式中段沉默的计量与应对(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) 给 chat 链路加了一条**默认关闭**的长尾对冲(issue #24):一次尝试挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢、输家取消。另给 `CallStats` 追加三个字段,其中 `generation_ms`(裸生成时间)对四条链路全部生效,与是否开启对冲无关。 diff --git a/README.md b/README.md index 99e4a0e..76f6d0e 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ | 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`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}__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 绕过(科研重采样) | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 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` 与遥测落库 | -| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`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-逻辑调用统计与失败诊断) | +| 遥测与成本 | 每次调用(含缓存命中与失败)必录 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`——**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` 配小了的唯一信号 | | 调用方维度 | 每次调用可带 `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` | @@ -111,6 +112,33 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重 自建 `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(公开包,匿名可装): diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index 88512d0..bea94bb 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -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 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。 +**生成时间净口径与沉默归因(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 限流 **语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。 @@ -562,7 +566,9 @@ flowchart TB ### 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。 diff --git a/research-wiki/ROADMAP.md b/research-wiki/ROADMAP.md index 03e0e1a..9b7f663 100644 --- a/research-wiki/ROADMAP.md +++ b/research-wiki/ROADMAP.md @@ -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-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. 开放决策依赖 | 决策 | 阻塞点 | 需拍板时间 | diff --git a/research-wiki/findings/2026-09-11-streaming-stall-validation.md b/research-wiki/findings/2026-09-11-streaming-stall-validation.md new file mode 100644 index 0000000..f87f87c --- /dev/null +++ b/research-wiki/findings/2026-09-11-streaming-stall-validation.md @@ -0,0 +1,89 @@ +--- +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 豁免索引补跑交集项)。 From 8489b8ea50308ca75e0876d293fef7feddf9b086 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 02:28:59 -0400 Subject: [PATCH 10/12] docs: resolve branch review note (final-state comment for content_event wiring) --- .../reviews/2026-09-11-138-branch-review.md | 43 +++++++++++++++++++ src/polygateway/middleware/retry.py | 4 +- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 research-wiki/reviews/2026-09-11-138-branch-review.md diff --git a/research-wiki/reviews/2026-09-11-138-branch-review.md b/research-wiki/reviews/2026-09-11-138-branch-review.md new file mode 100644 index 0000000..9e452ca --- /dev/null +++ b/research-wiki/reviews/2026-09-11-138-branch-review.md @@ -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 时序余量偏紧)。** \ No newline at end of file diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index 8fbbbaa..b048918 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -367,8 +367,8 @@ class RetryMW: # 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路, # 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token first_token_event=first_token_event, - # 中段沉默处置位(1.3.8): 本阶段(T1)所有调用点恒传 None = 不观测; - # T4 才在启用 stall 对冲的流式原路上接真事件(设计 §5.2 N3③) + # 中段沉默处置位(1.3.8): 未启用 stall 对冲时调用方传 None = 不观测; + # 启用时流式原路携真事件,对冲路恒 None(设计 §5.2 N3③) content_event=content_event, ) # 取整全库唯一处(设计 §4.2 N8): transport 的 float 沉默值在此 From a2a00c0ef2300808899b96e50f7027570cbe624b Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 03:01:40 -0400 Subject: [PATCH 11/12] test: sync PG telemetry integration helpers with 1.3.8 stall columns _minimal_fields and the pre_135 acceptance dicts missed the two new columns, KeyErroring against migrated 39-column physical tables (caught by the pre-merge integration gate; production path is signature-gated and unaffected). Verifier gaps P2-1/P2-2 closed: full suite 1764 passed, PG 30 passed, lint green. --- .../2026-09-11-streaming-stall-validation.md | 9 ++ .../reviews/2026-09-11-138-verifier.md | 94 +++++++++++++++++++ tests/integration/test_postgres_telemetry.py | 11 ++- 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 research-wiki/reviews/2026-09-11-138-verifier.md diff --git a/research-wiki/findings/2026-09-11-streaming-stall-validation.md b/research-wiki/findings/2026-09-11-streaming-stall-validation.md index f87f87c..1d303c2 100644 --- a/research-wiki/findings/2026-09-11-streaming-stall-validation.md +++ b/research-wiki/findings/2026-09-11-streaming-stall-validation.md @@ -87,3 +87,12 @@ T4 由前台委派 worker 执行,worker 在收尾阶段(证据落盘后、提交 ## 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 | diff --git a/research-wiki/reviews/2026-09-11-138-verifier.md b/research-wiki/reviews/2026-09-11-138-verifier.md new file mode 100644 index 0000000..c4f59cc --- /dev/null +++ b/research-wiki/reviews/2026-09-11-138-verifier.md @@ -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。 \ No newline at end of file diff --git a/tests/integration/test_postgres_telemetry.py b/tests/integration/test_postgres_telemetry.py index 5e7799f..3ce3b8c 100644 --- a/tests/integration/test_postgres_telemetry.py +++ b/tests/integration/test_postgres_telemetry.py @@ -170,6 +170,9 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object # 逻辑快照两列只属终态行 "attempts": None, "total_latency_ms": None, + # —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL —— + "stall_ms": None, + "stall_count": None, } fields.update(overrides) return fields @@ -1103,7 +1106,7 @@ class TestCallObservabilityColumnsAcceptance: """ 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 表达的是"补列之前根本没记过 这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行 @@ -1170,6 +1173,9 @@ class TestCallObservabilityColumnsAcceptance: "error_body": "upstream said 503", "attempts": None, "total_latency_ms": None, + # 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL + "stall_ms": None, + "stall_count": None, } assert dict(by_id["term"]) == { "call_id": "term", @@ -1183,6 +1189,9 @@ class TestCallObservabilityColumnsAcceptance: "error_body": None, "attempts": 3, "total_latency_ms": 4200, + # 1.3.8 两列: 终态行无 transport 观测 → NULL + "stall_ms": None, + "stall_count": None, } # 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红) assert dict(by_id["pre135"]) == { From cf6e3fa008f0b5271376abf11cdba0bf251d3796 Mon Sep 17 00:00:00 2001 From: iomgaa Date: Mon, 14 Sep 2026 03:03:39 -0400 Subject: [PATCH 12/12] chore: release 1.3.8 --- CHANGELOG.md | 2 +- README.md | 2 +- pyproject.toml | 2 +- src/polygateway/__init__.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca6973b..d529ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 未发布 +## 1.3.8(2026-09-14) 流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。 diff --git a/README.md b/README.md index 76f6d0e..e7f24ba 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重 ```bash 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: diff --git a/pyproject.toml b/pyproject.toml index 1a283d6..06cb836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.3.7" +version = "1.3.8" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 2df241a..8eaa381 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -52,7 +52,7 @@ from polygateway.types import ( ThinkingObservation, ) -__version__ = "1.3.7" +__version__ = "1.3.8" __all__ = [ "DEFAULT_PROFILES",