Files
PolyGateway/research-wiki/designs/2026-09-11-streaming-stall-design.md
T
iomgaa 929d60e501 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-13 23:46:34 -04:00

31 KiB
Raw Permalink Blame History

流式中段沉默:计量口径与对冲(已批准设计)

  • 状态: 已批准 + 独立审查补遗已并入(2026-09-11,reviews/2026-09-11-streaming-stall-design-review.md,B1-B3/N1-N9 全处置,见 §11);本文件只做设计,不含实现
  • 基线: main a28d944 / 1.3.7(已发布,含单路并发对冲与 CallStats.generation_ms/hedges/hedge_won)
  • 输入: findings/2026-09-11-streaming-stall-hedging-research.md(业界范式与来源,下称"调研",引用处标注 §x.y);下游 Terminal-Bench 实测 4407 次成功调用遥测(需求来源);1.3.7 设计 designs/2026-09-10-24-hedged-requests-design.md;远端 issue #27(P1)/#28(P2)
  • 关联: ARCH §6.4 取消语义、§7.2 重试;既有流式看门狗 streaming.py;1.3.7 对冲机制(本设计直接站在其编排与记账语义上)

1. 背景与问题陈述

两个问题都是口径/能力缺口,不是实现 bug:1.3.7 行为与其设计文档(§4.5 H8、对冲触发判据)完全一致,但下游实测暴露了设计时未覆盖的病态形态。

P1:中段沉默计入 generation_ms,"裸生成时间"名不副实(issue #27)

CallStats.generation_ms 的口径是赢家 transport 调用的完整墙钟:middleware/retry.py:332 gen_started 紧贴 transport.complete() 调用前,:346 sink 在返回后立即记录。对流式,complete() 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。

下游实测(Terminal-Bench,4407 次成功调用):

指标 数值 含义
ttft 中位 1.63s(仅 3 次 >20s) 首 token 几乎从不慢
慢调用 generation_ms 中位 69.4s 墙钟长尾严重
其中 max_inter_token_ms 中位 54.9s 长尾的主体是一次中段沉默
沉默占全部模型时间 71.3% "在等不在生成"是主要成本
沉默与输出/输入量相关性 r = 0.05 / −0.06 沉默与生成量无关
reasoning_tokens 上报次数 129 / 4407 沉默不是模型在思考,是渠道/服务端排队(调研 §A4:prefill/decode 互踩是结构性现象)

结论:下游需要一个"扣除沉默段的活跃生成时间"口径,且带归因(沉默几次、共多长),不是只给一个净数(Gregg off-CPU 分解原则,调研 §B2)。

P2:中段沉默无对冲,现有两道闸门都接不住(issue #28)

既有机制 证据 为何接不住中段沉默
首 token 对冲 HEDGE__AFTER_S middleware/retry.py:542-562 _past_hedge_window 返回 not primary.done() and not first_token.is_set();非对冲模式 __call__ 传 first_token_event=None 根本不观测 首 token 到达后对冲窗永不打开;实测 ttft 中位 1.63s,AFTER_S=20 对流式调用恒不触发
看门狗 INTER_TOKEN_TIMEOUT_S streaming.py(budget = ttft_s 首 chunk / inter_token_s 后续);openai_compat.py:573 inter_token_s = source.inter_token_timeout_s or source.timeout_s;配置 config.py:46 能掐中段沉默,但掐掉即丢弃全部已生成内容、从头重试;重试可能再卡;每步成本 = 一次完整重新生成

调研结论(§A3):业界对中段沉默的落地形态收敛为"间隔看门狗 → 抛错 → 重试","中段沉默触发并发副本"在任何主流网关/库中均无公开先例——本设计的 P2 方案 2B 属超业界现状能力,须诚实标注。

2. 业界范式摘要(压缩自调研,不重查网络)

范式 出处(调研节) 对本设计的对应
中段沉默对冲无公开先例;主流 = 看门狗 → Transient → 重试 调研 §A3(LangChain stream_chunk_timeout 默认 120s 已合并;Codex CLI 静默 300s 整路重试;LiteLLM 提案默认关) P2 的 2A = 业界基线;2B 若做须自证语义
沉默扣除唯一先例 pi-tps:gap ≥500ms 判 stall,减所有超阈值段,连续停顿合并一次,样本不足返回 null 调研 §B3 P1 的 stall_ms/stall_count 口径直接照此;"只减最大一段"无任何先例,否决
Gregg off-CPU:墙钟分解须成对报告 + 归因,只报净数无法行动 调研 §B2 1B' 下由 generation_ms(净)+ stall_ms/stall_count 成对报告满足,墙钟 = generation_ms + stall_ms 可还原
NVIDIA TTFT/ITL/E2EL:排队计入 TTFT,基准口径不扣除沉默 调研 §B1 扣沉默是库自造口径,必须显式声明、不与基准口径混写
vLLM generation_time_ms 排除条款:token 数 <2 时间隔类字段 null;n>1 无法归因即抑制 调研 §B3 三新字段的 None 条款照抄:增量 chunk <2 / 非流式 → None
Envoy/gRPC:判赢在响应头即 commit 不回头;per_try_timeout 只在响应开始前有效,中段停顿不重新对冲 调研 §A2 P2 赢家裁定与"不回头"语义的参照;约束未来流式透传(§5.2)

另:配置命名先例对照见调研 §3.3,本设计键名与其对齐。

3. 共享观测原语:增量 chunk 单调时间戳序列

P1(计量)与 P2(中段触发)共用同一份底层观测,避免两套计时逻辑漂移:

项 设计
记录点 transport 层(openai_compat.py:580-590 现算 max_gap 的循环处已逐 chunk 迭代,时间戳序列在此追加记录,增量成本近零;:605 是 max_inter_token_ms 装配处)
记录内容 chunk = 任何增量产出(content 或 reasoning_content 都算),与看门狗/首 token 活性口径一致(openai_compat.py:585-588 注释"思考增量同样是存活证据"同款);仅空 chunk / usage 帧 / SSE keepalive 不计时(keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,本序列天然免疫,LangChain 语义见调研 §A3)
派生原则 一切计量(stall_ms 等)与中段触发信号(§5.2)都从此序列/其增量事件派生,不在热路径做判定(调研 §3.2-1);热路径只做"记一个时间戳 + set 一个 Event"
chunk ≠ token chunk 边界是传输/解析缓冲产物,非 token 边界(vLLM #27485 陷阱,调研 §B1);一切间隔类口径按 chunk 声明,不假装是 ITL

P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk 即刷新一个 asyncio.Event(语义 = "自上一增量 chunk 以来未再沉默"),编排侧以"等该事件超时"实现 stall 计时器——与 1.3.7 首 token 事件同构,零新机制类别。

4. P1 方案:沉默计量口径(1B' 已批准)

4.1 方案 1A:不改库,下游自算 —— 已否决

项 内容
语义 下游用既有 LLMResponse.max_inter_token_ms(types.py:406)自算 generation_ms − max_inter_token_ms,即只减最大一段;库仅澄清文档
改动面 README/CHANGELOG 口径澄清,零代码
优点 零实现、零契约变更;max_inter_token_ms 已在遥测表(telemetry/schema.py COLUMNS),存量数据即可分析(本次实测正是用它定位的病)
缺点 多段沉默场景系统性高估活跃时间(调研 §B3:只减最大一段无任何先例);口径责任散落每个下游,各家算法漂移后跨项目不可比;无 stall_count,违反 Gregg 归因原则

否决理由(人类裁决,2026-09-11):口径必须库内收敛,不得散落各下游自算。

4.2 方案 1B'(已批准):generation_ms 口径重定义

人类原话:"如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"。PM 澄清两字段本质不同(完整墙钟 vs 单次最大沉默段)后,人类意图收敛为:原草案 1B 的 active_generation_ms 净口径直接写进 generation_ms 本身,不新增派生字段。

项 内容
语义 generation_ms 重定义为净生成时间 = 赢家 transport 墙钟 − Σ(所有 > 阈值的相邻增量 chunk 沉默,pi-tps 式口径);CallStats 新增两归因字段(默认 None):stall_ms: int | None(沉默总额)、stall_count: int | None(= 超阈值相邻间隔个数;pi-tps"连续停顿合并一次"子句不移植——gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句是 pi-tps 事件流语义、在本口径下无操作定义,§11-N6);不新增 active_generation_ms——generation_ms 本身就是净口径
边界与换算(§11-N1/N4/N8 钉死) 净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部(末 chunk→流结束);尾部归活跃侧是有意选择(墙钟基底口径,尾部通常≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步。钟同源纪律:沉默序列(transport)与墙钟(retry 注入 now)生产同为 time.monotonic。换算只此一处:TransportResult stall_ms: float | None;retry 层 wall_ms=int((now−gen_started)*1000) 照旧,net = max(0, wall_ms − int(stall_ms or 0))(clamp 防御);CallStats.stall_ms = int(stall_ms)(None 透传)——各实现不得另取整。FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟
归因与还原 墙钟 = generation_ms + stall_ms;净数与沉默总额/次数成对报告,Gregg 成对报告原则(调研 §B2)在净口径下依然满足
排除条款(照 vLLM,与原案一致) 非流式 / 增量 chunk <2 → stall_ms/stall_count 为 None 且 generation_ms 不退减(= 原墙钟);增量口径与看门狗一致(content/reasoning_content 均计)——reasoning 模型长 thinking 阶段只流 reasoning_content,若只计 content 会把正常推理误计为沉默(P1 虚高、P2 误触发);非流式场景 net≈wall(无中段沉默概念),下游预算公式以 stall_ms is None 为分支条件;对冲输家的沉默不进赢家快照——已完成但竞速落选的输家其沉默在其 attempt 遥测行(可 join 还原);被取消的输家无 TransportResult、行是 hedge_cancelled error 行、stall 恒 None,其存在性由该行表达
阈值 新配置 {SCOPE}__STREAM__STALL_THRESHOLD_MS,默认 1000ms(§9-③ 拍板);人类附加要求"需要是一个可以调整的参数,对外提供调整接口"——调整接口 = 既有装配路径(env 键 / GatewaySettings 字段 / GatewayClient 构造 kwarg 三层均可调),不提供 per-call 覆盖(§8 非目标 F);不做自适应(防与对冲阈值耦出反馈环,调研 §3.2-3)
遥测 遥测表新增 stall_ms/stall_count 两列(§9-⑤ 拍板):telemetry/schema.py PG/SQLite 各一次可空 DDL + recorder/middleware/telemetry.py 透传;generation_ms 仍不进遥测列(YAGNI——下游要墙钟用 latency_ms 或 generation_ms + stall_ms 还原)
改动面 types.py:296-324(CallStats 增两字段);ports.py TransportResult 增 stall_ms/stall_count: float | None 默认 None;openai_compat.py:580-590(max_gap 循环邻域;:605 是装配处)从 chunk 时间戳序列计算两值(沿用 §3 原语);middleware/retry.py:332/346 邻域(赢家记账改为 record_generation(净值) + context 存 stall 两值进快照,口径与 H8 同款);遥测透传链路(PM 裁决选 (b):LLMResponse 不增顶层字段——CallStats 已承载赢家口径,避免同数据两处平铺漂移):middleware/telemetry.py _emit 显式从 TransportResult 传 stall 两值 + emitter _AttemptUsage 增两字段 + ports.py TelemetryRecorder.record_llm_call 签名加两参(第三次端口签名变更,§7 端口行补录;emitter 装配闸 signature.bind 以该协议签名为判据,漏改协议即装配期炸)+ 两 recorder 实现(PG/SQLite)透传;telemetry/schema.py 编辑点 = DDL 末尾(created_at 后) + SQLITE_BACKFILL/_PG_BACKFILL_DECLS 保序对齐 + COLUMNS + 列数断言测试 + telemetry_schema_sql 同源再生成;配置走 _load_hedge(config.py:745-769)同款 loader + GatewaySettings 字段;client.py 构造透传

契约变更的正当性与披露

语义突变在一般情形下违反"只增不删不改名"精神(§4.3 拒绝理由),本次由人类以"窗口期最小代价"裁决放行,依据:

依据 事实
字段极新 generation_ms 是 1.3.7(2026-09-10)引入的新字段,发布仅 2 天
消费者唯一 唯一消费者是提出本需求的下游 Terminal-Bench;三参考项目均未消费该字段
名实自相矛盾 字段名"裸生成时间"原本承诺的就是净口径,现实现(墙钟)与名字矛盾,本次重定义是兑现名字而非推翻承诺
无账本断裂 遥测 DB 从未存过 generation_ms(已验证 telemetry/schema.py 与 middleware/telemetry.py 均无此字段),库内无历史账本纵向比较断裂

披露义务:CHANGELOG 设独立"行为变更"段显著标注 1.3.7→1.3.8 口径变化 + README 口径表更新 + 本设计存档。

4.3 方案 1C:改 generation_ms 口径为扣沉默 —— 原拒绝理由被人类裁决覆盖(留档)

理由 依据
已发布契约突变:1.3.7 设计 §4.5 H8 明写墙钟口径并已被下游消费 铁律"字段只增不删不改名";语义突变等同改名
历史账本不可比:遥测 DB 存量 generation_ms 全是墙钟,口径切换后纵向比较断裂 遥测必录铁律的对账前提
NVIDIA/vLLM 基准口径均不扣沉默(调研 §B1),库把墙钟改掉会与业界定义脱钩 口径分层应靠新增字段,不是靠改旧字段

人类裁决(2026-09-11):上述理由在一般情形下成立,但人类以"窗口期最小代价"覆盖之(字段发布仅 2 天、消费者唯一、遥测无账本,见 §4.2 正当性表)——净口径以 1B' 形式落地;原 1B 三字段并存案随之被取代。

4.4 P1 对比与决策

维度 1A 下游自算(已否决) 1B 原三字段案(已被取代) 1B' 口径重定义(已批准) 1C 改原口径
多段沉默准确性 差(系统性高估) 好(减所有超阈段) 好(减所有超阈段) 好
归因能力 无(只有最大段) 有(总量 + 次数) 有(stall_ms/stall_count 成对) 无(只有净数)
口径唯一性 散落各下游 库内收敛 库内收敛 库内但破坏存量
契约风险 零 只增字段 + 遥测 DDL 语义突变,经人类以"窗口期最小代价"裁决放行(§4.2 正当性) 违反只增不删精神,拒绝
实现量 零 中(五文件邻域) 中(六文件邻域,含端口) 小但不可接受

决策:1B'(§9-① 拍板);1A 因"口径必须库内收敛"被人类否决;1B 原案被 1B' 取代;1C 拒绝理由被人类裁决覆盖(留档 §4.3)。

5. P2 备选方案:中段沉默的应对

5.1 方案 2A:不做中段对冲,看门狗链 + 文档

项 内容
语义 维持业界主流:中段沉默超 INTER_TOKEN_TIMEOUT_S → 掐流 → Transient → 既有重试/换源链;库补文档与配置指引(各源默认值、与 timeout_s 的回落关系 openai_compat.py:573、建议按源沉默分布取值)
改动面 README/wiki 配置指引,零代码
优点 零实现零契约变更;有 LangChain(默认 120s)与 Codex CLI(300s)生产先例(调研 §A3);掐死即 Transient,四分类不新增语义
缺点 掐掉即丢弃全部已生成内容、从头重试;重试可能再卡同一类排队;每步成本 = 一次完整重新生成;实测沉默 >20s 占 19.0%,这条链会频繁点火

5.2 方案 2B(已批准):中段沉默对冲

项 内容
触发 新配置 {SCOPE}__HEDGE__STALL_AFTER_S(独立于 HEDGE__AFTER_S 生效,§9-④ 拍板"否,独立生效";调研 §3.3 命名);语义:主路相邻增量 chunk 间隔超此阈值 → 触发。机制 = §3 原语派生的"增量 chunk Event 等待超时",与首 token 事件同构;_past_hedge_window(retry.py:542-562)扩为"首 token 未至走 AFTER_S,首 token 已至走 STALL_AFTER_S"。并发纪律(Event + 复检方案,§11-N3):①超时到期后触发对冲前必须复检 is_set()(chunk 与超时同一事件循环 pass 到达时防误触发);②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸声明);④stall 等待每轮迭代自建 waiter,逐字套用 _past_hedge_window 的 waiter 收口 + cancelling() 穿透纪律(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义
对冲执行 触发后主路不取消;并发异源副路走完整准入 + 熔断,无配额静默放弃(1.3.7 设计 §4.3 语义原样复用);FIRST_COMPLETED 赛跑;输家取消,est 保留不喂熔断、遥测 hedge_cancelled(1.3.7 设计 §4.4/§4.5 全部复用,零新记账语义)
赢家计量 赢家路为准:generation_ms(1B' 净口径)/stall_ms/stall_count(1B' 同批落地,§9-①)全部取赢家路;hedge_won 语义不变(赢家是否对冲路)
无拼接污染 chat() 是库内聚合流——调用方拿完整 LLMResponse,流在库内消费完毕才返回;中段切换赢家时两路内容从未交付调用方,无拼接问题。这与调研 §3.1 基于"流透传"假设的 commit 语义不同。约束明写:未来若做流式透传,commit 点须重审——一旦任一路首 chunk 已透传下游,即按 Envoy"判赢不回头"锁定该路,中段对冲对该次调用永久关闭(§8 非目标:本版不做透传)
与看门狗的交互 两参数语义独立:若主路沉默超 inter_token_timeout_s 被看门狗掐死,对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker record_failure/attempt_fails/遥测 error 行)照常;副路未起 → 照旧 Transient 重试。装配守卫升级为硬守卫(§6,§11-N5):stall_after_s ≥ 有效最小值 → ValueError,对冲窗永不开的情形在装配期直接拒绝。不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称
成本披露 实测沉默 >20s 占 19.0%——触发率远高于首 token 对冲(ttft 仅 3/4407 超 20s);每触发一次最多 +1 并发请求 + 一份 est 预扣滞留(1.3.7 §4.4 口径);默认必须关闭,文档明写"对冲 = 用配额换延迟"
改动面 retry.py:__call__ 入口门改为"两阈值(hedge_after_s/stall_after_s)任一已设即进对冲编排;仅 stall_after_s 且请求非流式 → 逐字走单路(等价未配置,中段沉默无非流式概念)"(§9-④ 独立生效的编排落点);_past_hedge_window(retry.py:542-562)从一次性等待重构为两阶段(首 token 前:AFTER_S 已设才等;首 token 后:STALL_AFTER_S 循环等待)+ 与看门狗掐死的汇合规则;ports.py + openai_compat.py:569-575/580-590 邻域(complete 加增量 chunk Event 参数——公共端口签名第二次变更,§9 整体批准覆盖);config.py 邻域(新键 + 交叉守卫);client.py 透传;遥测复用 hedge_cancelled,零新列

方案 2C 不单列,作为 2B 的裁定细节:看门狗掐死瞬间原子地并发补一路(掐与对冲合一)。评估:省一个参数但把"观测阈值"与"掐死阈值"耦合,掐死时副路从零开始(TTFT 全付),总延迟 = 沉默阈值 + 完整生成,严格劣于 2B 的"沉默中提前并发";且与 2A 链叠加时行为难推理。否决,仅留档。

5.3 P2 对比与决策

维度 2A 看门狗链 + 文档 2B 中段沉默对冲(已批准)
已生成内容 掐掉全丢 主路成果保留到最后一刻,输家才弃
长尾延迟 沉默阈值 + 完整重试耗时 ≈ 沉默阈值 + min(两路剩余)
业界先例 有(LangChain/Codex CLI) 无公开先例(调研 §A3),属超业界现状能力
成本 每次沉默 = 一次完整重新生成 触发率 19% 量级 × 一次额外请求
公共面变更 零 端口签名再变 + 新配置键
实现量 零(文档) 大(但编排/记账/遥测全复用 1.3.7)

决策:2B(§9-② 拍板,独立于 HEDGE__AFTER_S 生效)。诚实标注保留:它超出业界现状能力(调研 §A3),人类已明确接受;2A 链(看门狗 → Transient → 重试)作为不启用对冲时的默认行为继续存在,文档指引义务不变;P1(1B')同批落地后下游可直接量化沉默代价。

6. 配置汇总(新增两键,默认关闭/合理默认)

键 值域 缺省 守卫与既有键关系
{SCOPE}__STREAM__STALL_THRESHOLD_MS 正整数毫秒 1000(§9-③ 拍板,可调) 仅计量口径,不改任何行为;不设 = 用缺省;调整接口 = env 键 / GatewaySettings 字段 / GatewayClient 构造 kwarg 三层,无 per-call 覆盖;命名对齐调研 §3.3(pi-tps 500ms 先例,但场景不同)
{SCOPE}__HEDGE__STALL_AFTER_S 有限正数秒(复用 ensure_call_deadline 同款校验) 未设 = 关闭 独立于 HEDGE__AFTER_S;仅对流式 chat 请求生效;chat 非流式请求按 §5.2 入口门运行期静默退化——装配期无法判定 chat scope 的流式性(stream 是 per-call 参数,client.py:355/types.py:470),故 chat 侧无装配 warning(§11-B4);Embedding/OCR scope(装配期可证明纯非流式)检测到内嵌 gateway 的对冲键(AFTER_S/STALL_AFTER_S)非 None → 装配 warning(§11-B4,含 1.3.7 AFTER_S 同病顺带补齐);stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)(有效值,openai_compat.py:573 回落同款)→ ValueError(与 1.3.7 hedge_after_s ≥ min(timeout_s) 同款硬守卫,config.py:820-824——stall 场景无非流式残留收益,warning 论证不足);stall_after_s ≥ call_deadline_s → ValueError,落进 check_hedge_assembly(config.py:772);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款)

STREAM 段须加入 _RESERVED_SEGMENTS(config.py:57 邻域,1.3.7 为 HEDGE 做过同款);3 段键天然跳过 _load_sources(config.py:427-431)。装配路径与 1.3.7 对冲同款:GatewaySettings 字段 + __post_init__ 守卫 + GatewayClient.__init__ keyword-only 入口即校;不提供 per-call 覆盖(阈值是源/渠道特性,1.3.7 §5 同款判据)。

7. 与既有契约的关系

契约 处理
CallStats/LLMResponse 只增不删不改名 两新字段(stall_ms/stall_count)全 int | None、默认 None;1.3.7 及以前构造的位置调用不炸(1.3.7 §4.5 同款论证);generation_ms 语义重定义为净口径(已批准例外,§4.2 正当性与披露),max_inter_token_ms 原语义一字不动
遥测迁移 stall_ms/stall_count 两列可空,PG/SQLite 各一次 DDL;旧行 null;迁移纪律照 issue #12/#13/#15 教训与 1.3.7 §4.5 备选调先例;加列已拍板(§9-⑤)
公共端口签名 Transport.complete 随 2B 第二次加参(增量 chunk Event,必填 kw),与 1.3.7 first_token_event 同款约定;TransportResult 随 1B' 增 stall_ms/stall_count(默认 None);TelemetryRecorder.record_llm_call 签名随 1B' 加 stall_ms/stall_count 两参(第三次端口签名变更,emitter 装配闸 signature.bind 以该协议签名为判据)——三次变更均 §9 整体批准覆盖
文档登记义务 README 能力表 + CHANGELOG + ARCHITECTURE.md(口径段与对冲触发段)三处同步,发版按 CLAUDE.md §4.4.1 清单执行
版本归口 1.3.8 确认(人类拍板 P1 + P2 同批发);P1 含 generation_ms 口径变更,披露义务照 §4.2;P2 纯增量(新键 + 默认关闭行为)

8. 非目标

# 不做 理由
A 不做墙钟/净双口径并存字段(如 active_generation_ms) 1B' 拍板净口径直接写进 generation_ms;墙钟由 generation_ms + stall_ms 还原(§4.2),并存字段徒增口径漂移面
B 不做自适应分位对冲阈值(bhope/hedge DDSketch) 列未来方向:per-source 滚动分布跨进程要进 Redis,且与沉默计量阈值耦合有反馈环风险(调研 §3.2-3);本版绝对阈值已够用
C 不做流式透传 透传引入 commit 点与"判赢不回头"约束(§5.2),是独立大设计
D 不做内容拼接/续写 增量文本不可拼接,缝合即结果污染(调研 §3.1)
E embedding/OCR 不动 无流式中段概念,沿用 1.3.7 非目标 A
F 不做 per-call 沉默阈值覆盖 人类拍板(§9-③):调整接口走装配层(env 键 / GatewaySettings 字段 / GatewayClient 构造 kwarg 三层),per-call 覆盖超本版范围

9. 决策记录(2026-09-11 全部拍板)

# 决策点 拍板结果 人类原话 / 选项
① P1 选型 1B':generation_ms 口径重定义为净时间(原 1B 的 active_generation_ms 口径直接写进 generation_ms 本身,不新增派生字段);新增 stall_ms/stall_count 归因字段 "如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"(PM 澄清两字段本质不同后,意图收敛为口径重定义)
② P2 选型 2B:中段沉默对冲 选定 2B(已明确接受"超业界现状能力"标注)
③ STREAM__STALL_THRESHOLD_MS 默认值 1000ms,可调 "需要是一个可以调整的参数,对外提供调整接口"——接口 = 装配三层(env 键 / GatewaySettings 字段 / GatewayClient 构造 kwarg),不提供 per-call 覆盖
④ 中段对冲是否以 HEDGE__AFTER_S 已启用为前提 否,独立生效 原话"否,独立生效"
⑤ 遥测是否加 stall_ms/stall_count 两列 加 选定"加"(归因进 DB,可对账)

另(§9 之外拍板):P1 + P2 同批发 1.3.8(§7)。

10. 自查记录

  • 行数 231 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。
  • 方案改动面均列具体文件:行号(1B'、2B);行号以 2026-09-11 独立审查核实记录为准(全部实读源码,见 §11)。
  • 调研结论引用处标注(调研 §x.y);未重查网络。
  • 2026-09-11 批准确认:§9 五项 + 同批发 1.3.8 全部落地本文;1A/1C 保留为否决留档,1B 原案标注被 1B' 取代。
  • 2026-09-11 独立审查补遗:reviews/2026-09-11-streaming-stall-design-review.md 的 B1-B3/N1-N9 经 PM 逐条裁决全处置,修正直接并入正文对应小节,§11 只做索引;§9 五项拍板不变。

11. 独立审查补遗(2026-09-11)

审查报告 reviews/2026-09-11-streaming-stall-design-review.md 裁决"有条件实施"(3 阻断 + 9 建议);PM 逐条裁决如下,全部修正已并入正文对应小节(本节只做索引,正文为准)。§9 五项拍板不变。

发现 PM 裁决 / 处置 正文落点
B1 chunk 定义未钉死 chunk = 任何增量产出(content 或 reasoning_content 都算),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时;排除条款"内容 chunk <2"改"增量 chunk <2"(reasoning 模型长 thinking 只流 reasoning_content,只计 content 会误计沉默:P1 虚高、P2 误触发) §3 记录内容行、§4.2 语义/排除条款、§2 vLLM 行
B2 遥测透传链路遗漏 PM 裁决选 (b):_emit 显式从 TransportResult 传 stall 两值,LLMResponse 不增顶层字段(CallStats 已承载赢家口径,避免两处平铺漂移);ports.py record_llm_call 签名加两参(第三次端口签名变更,补进 §7 端口行);emitter _AttemptUsage + 两 recorder 实现 + schema.py 五处编辑点全列;输家表述修正:被取消输家无 TransportResult、行是 hedge_cancelled error 行、stall 恒 None,存在性由该行表达 §4.2 改动面/排除条款、§7 端口行
B3 编排入口遗漏 __call__ 入口门改"两阈值任一已设即进对冲编排;仅 stall_after_s 且请求非流式 → 逐字走单路(等价未配置)";_past_hedge_window 从一次性等待重构为两阶段(首 token 前 AFTER_S 已设才等 / 首 token 后 STALL_AFTER_S 循环等待);纯非流式 scope 设置给装配 warning §5.2 改动面、§6 配置表
N1 计时边界未明写 净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归活跃侧是有意选择(墙钟基底,尾部≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步 §4.2 边界与换算行
N2 非流式双口径 非流式 net≈wall(无中段沉默概念);下游预算公式以 stall_ms is None 为分支条件 §4.2 排除条款
N3 Event 并发纪律 采 Event + 复检方案:①超时到期后触发前复检 is_set();②check→clear 之间无 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮迭代自建 waiter,逐字套用 _past_hedge_window waiter 收口 + cancelling() 穿透;⑤等待者唯一(编排) §5.2 触发行
N4 钟同源与负值防御 沉默序列(transport)与墙钟(retry 注入 now)生产同为 time.monotonic;net = max(0, wall − stall) clamp;FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 §4.2 边界与换算行
N5 守卫三缺口 守卫用有效值 min over sources of (inter_token_timeout_s or timeout_s)(回落同款);stall_after_s ≥ 该有效最小值 → ValueError(与 1.3.7 hedge_after_s ≥ min(timeout_s) 同款硬守卫——stall 场景无非流式残留收益,warning 论证不足);≥ call_deadline_s → ValueError,点名落进 check_hedge_assembly §6 配置表、§5.2 与看门狗交互行
N6 stall_count 定义 = 超阈值相邻间隔个数;删"连续停顿合并一次"子句(gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句移植自 pi-tps 事件流语义、在此无操作定义) §4.2 语义行
N7 引用勘误 CallStats 在 types.py:296-324(原"417-418 邻域"错位,417-418 是 LLMResponse 邻域,max_inter_token_ms 在 :406);config.py:401-407 → :427-431;"config.py:46 邻域" → _load_hedge(config.py:745-769)同款 loader + GatewaySettings 字段(:46 是 per-source 四段键表 _SOURCE_FIELDS,与 §6 三段键矛盾);max_gap 循环在 openai_compat.py:580-590(:605 是装配处);遥测编辑点 = DDL 末尾(created_at 后) + SQLITE_BACKFILL/_PG_BACKFILL_DECLS 保序对齐 + COLUMNS + 列数断言测试 + telemetry_schema_sql 同源再生成;§5.2"须进批准项"改"§9 整体批准覆盖" §3、§4.1/§4.2/§5.2 改动面、§6、§7
N8 换算钉死 TransportResult stall_ms: float | None;retry 层 wall_ms=int((now−gen_started)*1000) 照旧,net = max(0, wall_ms − int(stall_ms or 0));CallStats.stall_ms = int(stall_ms)(None 透传)——换算只此一处,各实现不得另取整 §4.2 边界与换算行
N9 看门狗措辞 "主路之死不算失败"改"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker record_failure/attempt_fails/遥测 error 行)照常";补不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 §5.2 与看门狗交互行
B4(计划期补遗,2026-09-11 PM 裁决) "纯非流式 scope 设置时装配 warning"(B3/§6 原文)字面不可实现:装配期无法判定 chat scope 流式性(stream 是 per-call 参数)。操作化:①装配 warning 只落在 Embedding/OCR scope(装配期可证明纯非流式)——EmbeddingSettings/OcrSettings.__post_init__ 检测内嵌 gateway 的 hedge_after_s/hedge_stall_after_s 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化";AFTER_S 一并纳入(1.3.7 同病顺带补齐,属缺陷修复不扩公共面);②chat 侧装配 warning 明确不做,非流式请求按 §5.2 入口门运行期静默退化;③不新增任何 scope 级 stream 配置键(扩公共面且超 §9 键清单) §6 配置表 STALL_AFTER_S 行