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.
52 KiB
type, node_id, title, date
| type | node_id | title | date |
|---|---|---|---|
| plan | plan:2026-09-11-streaming-stall | 1.3.8 流式中段沉默:计量口径与对冲实施计划 | 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。 基线: maina28d944(1.3.7 已发布)+ 设计提交97de410;分支feature/1.3.8-streaming-stall。
范围纪律:不改四分类/熔断语义/429 分账/限流 Lua/deadline.py/Permit 端口/看门狗 streaming.py 语义;不做流式透传、内容拼接、自适应阈值、per-call 沉默阈值覆盖、embedding/OCR 对冲(设计 §8 非目标 A–F);不加墙钟/净双口径并存字段(active_generation_ms 否决);generation_ms 不进遥测列(YAGNI,墙钟 = generation_ms + stall_ms 还原);除设计 §7 已批准的三次端口变更外不动任何端口签名。
1. 边界、授权与执行纪律
| 项目 | 固定边界 |
|---|---|
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.8 合并/发布授权沿用 1.3.X 惯例;跨到 1.4 或设计 §9 之外的新公共面须停下确认 |
| 公共面 | 只做设计 §7/§9 已批准项:Transport.complete 加 content_event 必填 kw(第二次端口变更)、TransportResult 增 stall_ms/stall_count、TelemetryRecorder.record_llm_call 加两参(第三次端口变更)、CallStats 增 stall_ms/stall_count、两配置键 {SCOPE}__STREAM__STALL_THRESHOLD_MS(默认 1000,可调)/{SCOPE}__HEDGE__STALL_AFTER_S(未设=关闭)、GatewayClient.__init__ 对应 keyword-only 参数、遥测表 stall_ms/stall_count 两列(可空)。generation_ms 语义重定义为净口径(已批准例外,披露义务见 T5)。不新增其它键/端口方法/遥测列/异常类 |
| 三次端口变更 | ①Transport.complete 加 content_event(必填、无默认,与 first_token_event 同款约定);②TransportResult 增两字段(带默认 None);③record_llm_call 加 stall_ms/stall_count(无默认,装配闸判据即本签名)。全部同步点见 §2.2/§2.3 同步清单(真实 grep 结果,非凭记忆) |
| 口径钉死(N8) | TransportResult stall_ms: float | None;retry 层 wall_ms=int((now−gen_started)*1000) 照旧,net = max(0, wall_ms − int(stall_ms or 0))(clamp 防御);CallStats.stall_ms = int(stall_ms)(None 透传)——取整换算全库只此一处(retry 层),各实现不得另取整 |
| 钟同源纪律(N4) | 沉默序列(transport 内 time.monotonic)与墙钟(retry 注入 now,生产即 time.monotonic)生产同钟;FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟;对冲触发只用事件循环相对时长(asyncio.wait timeout),绝不读注入 now(1.3.7 §4.1 同款) |
| 取消铁律 | 外部取消到达时对冲两路同消并穿透;不 shield、不留后台任务;_past_hedge_window 两阶段重构逐字保留 waiter 收口 + cancelling() 穿透纪律(retry.py:556-568 既有范式);stall 等待每轮迭代自建 waiter |
| Event 并发纪律(N3) | ①超时到期后触发对冲前必须复检 is_set();②check→clear 之间不得有 await;③对冲路 content_event 恒 None(1.3.7 首 token 约定延伸,v1 不梯次);④等待者唯一(编排),Event 无需多等待者语义 |
| 排除条款 | 非流式 / 增量 chunk <2 → stall_ms/stall_count 为 None 且 generation_ms 不退减(= 原墙钟);chunk = 任何增量产出(content 或 reasoning_content 均计),空 chunk/usage 帧/SSE keepalive 不计时;对冲输家的沉默不进赢家快照(已完成落选者的沉默在其 attempt 遥测行;被取消输家无 TransportResult、hedge_cancelled 行 stall 恒 None) |
| 降级方向 | 准入失败 → 静默放弃对冲(不抛错不硬等);限流/熔断后端不可用照常冒泡(fail-closed);遥测写失败 warning 降级;遥测两列对既有实例幂等 ALTER(auto_migrate 先探测后补列,manual 档裁剪+点名告警) |
| 证据纪律 | 不打印 .env/token/Authorization;不提交 .pi/、tests/outputs/;测试事件驱动,禁 sleep 撞窗口;红绿证据落 tests/outputs/138/t<N>/(pytest 输出文件,不提交) |
Skill 纪律:T1–T4 行为变更执行 test-driven-development(先红后绿证据落本会话工具输出 + tests/outputs/138/t<N>/ 文件);每次提交执行 commit;T5 前执行 requesting-code-review 与 verification-before-completion(全新上下文 verifier);异常先 systematic-debugging。
保真校验:本计划不涉及 reference/ 迁移(沉默计量与中段对冲为库内自研口径,蓝本即本库 1.3.7 编排与 streaming.py 看门狗),保真校验不适用。
2. 文件职责、同步清单与不变接缝
2.1 文件职责
| 动作 | 精确路径 | 职责 |
|---|---|---|
| 修改 | src/polygateway/ports.py |
Transport.complete(:56-66)加 content_event 必填 kw + docstring;TelemetryRecorder.record_llm_call(:306-344)加 stall_ms/stall_count 两参 + docstring("36 字段"→"38 字段");TransportResult 在 types.py(见下) |
| 修改 | src/polygateway/types.py |
TransportResult(:560-586)增 stall_ms: float | None = None/stall_count: float | None = None(追加在 applied_effort 后);CallStats(:296-324)在 hedge_won 后增 stall_ms: int | None = None/stall_count: int | None = None;_CallContext(:328-388)__slots__/__init__ 增两私有位、record_generation 增 keyword-only stall_ms/stall_count(默认 None)、snapshot 填两字段;SourceConfig(:590-700)在 effort_fallback 后追加 stall_threshold_ms: int = 1000 + __post_init__ 值域校验(正整数、拒 bool) |
| 修改 | src/polygateway/transports/openai_compat.py |
complete(:427-473)加 content_event 透传两路径;_complete_stream(:553-616)chunk 循环(:580-590 邻域)记增量 chunk 时间戳 + 每 chunk set content_event(None 跳过),装配处(:597-616 邻域)由序列算 stall 两值;模块级纯函数 _stall_from_chunk_times;_complete_once(:660-705)接收 content_event 但永不置位(docstring 明写),stall 两值恒 None(默认) |
| 修改 | src/polygateway/middleware/retry.py |
__call__ 入口门(:273-283)改两阈值判定;_attempt(:304-400)加 content_event 私有 kw、sink 元素改三元组、成功分支取整换算 + _emit 传 stall;_attempt_hedged(: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 后,必填、不设默认值,同款约定):
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 后):
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):
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 后追加):
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 升级为三元组:
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 |
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):
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):
async def _past_hedge_window(self, primary, first_token, content_event) -> bool:
# 阶段 1 首 token 前: AFTER_S 已设才等(设计 §5.2;仅 stall 时此阶段不触发,
# 但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall 窗误吞)
if self._hedge_after_s is not None:
<既有 waiter 形态: asyncio.wait({primary, waiter(first_token)},
timeout=self._hedge_after_s, FIRST_COMPLETED),finally 收口 + cancelling 穿透>
if not primary.done() and not first_token.is_set():
return True # 首 token 超时未至 → AFTER_S 触发
else:
<同形态 waiter 但无 timeout: 只等 first_token 或 primary 了结>
if primary.done():
return False
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
if self._stall_after_s is None or content_event is None:
return False
while not primary.done():
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
try:
await asyncio.wait({primary, waiter}, timeout=self._stall_after_s,
return_when=asyncio.FIRST_COMPLETED)
finally:
<waiter.cancel() + await 收口 + cancelling() 穿透,逐字套用既有范式>
if primary.done():
return False
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
content_event.clear() # N3② check→clear 之间无 await
continue
return True # 复检仍未置位 = 真沉默 → 触发对冲
return False
红线:阶段 2 的 clear() 只许出现在 is_set() 为真且两者间无 await 的紧邻位置;每轮 waiter 必须进 finally 收口;外部取消在任一阶段穿透两路(_attempt_hedged 既有 except BaseException 收口块不动)。
4. 任务与提交点(T1→T2→T3→T4→T5;T3 可并 T2 后)
依赖顺序:T1(端口+原语)→ T2(记账,消费 T1 的 TransportResult 字段)→ T3(遥测,消费 T1/T2 的口径;可在 T2 后并行,因只碰 telemetry/ports/retry._emit 邻域)→ T4(编排+配置,消费 T1 的 content_event 与 T2 的快照口径)→ T5(文档,需前四者行为冻结)。每任务卡:目标 / 改动文件:行号邻域 / 先失败测试清单 / 证据出口 / 验收命令 / 回滚点。所有命令
conda run -n PolyGateway,禁接管道(退出码失真教训);证据文件不提交。
T0:设计 B4 补遗与本计划(本任务,无代码)
产出:设计 §6 配置表 STALL_AFTER_S 行修正 + §11 补 B4 行(已完成,2026-09-11 PM 裁决)+ 本计划。不提交代码。
T1 → 提交 1 feat: add a content event and stall accounting to the transport port
- 目标:观测原语(增量 chunk 时间戳 + content_event)+ 端口契约(
complete加 kw、TransportResult/SourceConfig增字段)+ stall 纯函数;§2.2 同步清单全量落地。 - 改动:
ports.py:56-66;types.py:560-586/590-700;openai_compat.py:427-473/553-616/660-705;retry.py:275/314/344/436/484(T1 阶段恒传 None);§2.2 表 11 实现 + 11 调用点全量同步。 - 先失败测试清单(批次 A;先红证据 = TypeError/ImportError/属性缺失,非断言值不符):
| 测试名 | 断言点 |
|---|---|
test_openai_compat.py::TestContentEvent::test_stream_sets_content_event_per_chunk |
流式 N 个增量 chunk → event 被置位(每 chunk 一次;计数/逐次 is_set 观测) |
…::test_reasoning_chunk_counts_as_liveness |
只流 reasoning_content 的流也置位 content_event 且计入 chunk 序列(B1:思考增量同样是存活证据) |
…::test_non_stream_never_sets_content_event |
非流式永不置位 |
…::test_none_content_event_keeps_behavior |
None = 不观测,行为逐字不变 |
…::test_content_event_is_required_keyword |
漏传 → TypeError(钉住必填) |
…::TestStallFromChunkTimes::test_gap_above_threshold_accumulates |
构造时间戳序列 [0, 0.3, 2.0, 2.1] 阈值 1000ms → stall_ms≈1700、stall_count==1(多段序列 → 总额/次数) |
…::test_gap_exactly_at_threshold_not_deducted |
间隔恰好 == 阈值 → 不减(严格大于) |
…::test_fewer_than_two_chunks_returns_none |
0/1 个 chunk → (None, None)(vLLM 排除条款) |
…::test_tail_not_counted |
末 chunk 之后的流结束间隔不进 stall(尾部归活跃侧,N1) |
…::test_empty_or_usage_frames_not_timed |
空 chunk/usage 帧/keepalive 不产生时间戳记录点 |
test_types.py::test_transport_result_stall_fields_default_none |
两字段默认 None,旧位置构造不炸 |
test_types.py::test_source_config_stall_threshold_validation |
默认 1000;0/负/bool → ValueError |
- 证据出口:
tests/outputs/138/t1/red-batch-a.log(先红)+green-unit.log(后绿)。 - 验收命令:
pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q→pytest tests/unit -q(全绿且不改一行既有断言,§2.2 同步遗漏会在此暴露为 TypeError)。 - 回滚点:
git revert <提交1>(同步清单全量在同一提交,revert 即全回)。
T2 → 提交 2 feat: redefine generation_ms as net generation time with stall attribution
- 目标:
generation_ms净口径 +CallStats/_CallContext两归因字段 + retry 三路径赢家记账(换算唯一处)。 - 改动:
types.py:296-324/328-388(§3.2);retry.py:283-288/332-360邻域 +_record_generation/_register_hedge共享换算(§3.2 表)。 - 先失败测试清单(批次 B;先红 = 字段不存在 TypeError / 断言仍得墙钟值):
| 测试名 | 断言点 |
|---|---|
test_types.py::test_callstats_stall_fields_default_none |
两字段默认 None;仅旧参数构造 CallStats(...) 不炸(1.3.7 及以前兼容) |
test_types.py::test_callcontext_record_generation_with_stall_and_snapshot |
stall 两值经 record_generation 进 snapshot;不传恒 None |
test_retry.py::test_generation_ms_net_of_stall |
FakeClock + 自控 stall 假 transport:wall 推进 5.0s、stall_ms=3200 → generation_ms == 1800 且 stall_ms == 3200、stall_count 透传;generation_ms + stall_ms == wall(还原公式) |
test_retry.py::test_generation_ms_clamps_negative_to_zero |
stall > wall(假 transport 报 6.0s stall、wall 5.0s)→ generation_ms == 0(clamp,N4) |
test_retry.py::test_non_stream_stall_none_no_deduction |
stall None → generation_ms == wall(不退减),快照两值 None(排除条款) |
test_retry.py::test_stall_attribution_none_when_no_winner |
两败轮次(复用 1.3.7 两败设施)→ snapshot stall 两值 None、generation_ms 不记 |
- 证据出口:
tests/outputs/138/t2/red-batch-b.log+green-unit.log。 - 验收命令:
pytest tests/unit/test_types.py tests/unit/test_retry.py -q→pytest tests/unit tests/contracts -q(全绿;对冲赢家口径断言在 T4 编排落地后补,见批次 D 末行)。 - 回滚点:
git revert <提交2>。
T3 → 提交 3 feat: add stall_ms and stall_count telemetry columns
- 目标:遥测两列全链路(schema 五编辑点 + 协议两参 + emitter 透传 + 两 recorder 自动拾取);§2.3 同步清单落地。
- 改动:
telemetry/schema.py五编辑点(§3.4 表)+ 模块头计数 docstring;ports.py:306-344;middleware/telemetry.py:199-236/396-447/449-540/560-665;retry.py:_emit(§3.4);sqlite.py:145/postgres.py:411docstring 计数;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::<SQLite 落行新用例> |
emit attempt(stall_ms=3200, stall_count=2)→ SQLite 行两列恰为该值;success 非流式行两列 NULL |
test_telemetry.py::<迁移幂等新用例> |
既有 1.3.5 形态表(37 物理列)开 auto_migrate recorder → ALTER 补两列不炸;重复初始化第二次幂等;manual 档缺列告警逐字含两条新 ALTER |
test_hedge.py::<取消输家行新用例> |
既有 AFTER_S 对冲触发,输家 hedge_cancelled 行 stall_ms/stall_count 恒 None(被取消输家无 TransportResult,B2) |
test_telemetry.py 装配闸负例三条(:3327/:3349/:3354) |
不改,协议加参后仍绿(负例仍旧不满足签名) |
- 证据出口:
tests/outputs/138/t3/red-batch-c.log+green-unit.log;PG 实库形态由tests/integration/test_postgres_telemetry.py覆盖(slow,发布门交集见 §6)。 - 验收命令:
pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q→pytest tests/unit tests/contracts -q。 - 回滚点:
git revert <提交3>(DDL 为可空加列,旧代码读新表兼容;回滚后新列闲置 NULL)。
T4 → 提交 4 feat: add opt-in mid-stream stall hedging for chat
- 目标:
HEDGE__STALL_AFTER_S独立生效的中段对冲 +STREAM__STALL_THRESHOLD_MS装配三层可调 + 全部守卫(B4 含 1.3.7 AFTER_S 同病补齐)。 - 改动:
config.py:57/192-196/382-392/398-427/745-769/772-849/865-869/OcrSettings 新 __post_init__(§3.5);client.py:240/252-257/264-283/485-526(§3.5);retry.py:__init__/:273-283/: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);无代码行为变更。
- 改动清单:
CHANGELOG.md未发布段:设独立"行为变更"段显著标注——generation_ms1.3.7(墙钟)→ 1.3.8(净生成时间 = 墙钟 − Σ 超阈值中段沉默,仍含 TTFT 与尾部),还原公式 墙钟 =generation_ms + stall_ms;正当性三行(字段发布仅 2 天/消费者唯一/遥测无账本,设计 §4.2);max_inter_token_ms原语义不变;新两字段/两键/遥测两列;对冲强制措辞三句(默认关闭;开启即用配额换延迟——实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲;输家可能已被上游计费);"中段沉默对冲无业界公开先例"诚实标注(调研 §A3);看门狗掐死喂熔断 vs 对冲输家取消不喂的不对称声明(N9)。README.md:口径表(generation_ms净口径定义/边界含 TTFT+尾部/还原公式/stall_ms is None分支条件/与 NVIDIA-vLLM 基准口径不扣除沉默的显式区隔)+ 配置表两键(值域/缺省/守卫/三层调整接口)+ 能力表"中段沉默对冲(可选,超业界现状能力)"行。.env.example:对冲两行注释后加# LLM__STREAM__STALL_THRESHOLD_MS=1000与# LLM__HEDGE__STALL_AFTER_S=(缺省关闭,语义与成本说明)。research-wiki/ARCHITECTURE.md:口径段(generation_ms净口径 + 归因两字段 + 换算唯一处纪律)与对冲触发段(两阈值独立生效/两阶段触发窗/与看门狗交互)登记。research-wiki/ROADMAP.md:1.3.8 里程碑登记。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 禁接管道):
make lint(ruff --fix + import-linter;ports/types/errors 不 import 实现的铁律由契约执法)。- 全套件:
pytest tests/unit tests/contracts tests/integration -q(integration 中 PG/Redis 用例按其既有标记;默认不含 slow)。 - 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 有效矩阵证据并如实登记)。 - T5 前:
requesting-code-review(整分支)+verification-before-completion(全新上下文 verifier subagent,前台)。
7. 阻塞矩阵与交接
| 触发条件 | 处置 |
|---|---|
| 需要新增设计 §9 之外的公共键/端口方法/遥测列/异常类 | 停下上报(边界之外即未批准) |
asyncio.current_task().cancelling() 语义在目标版本不符 |
3.12 语义同 1.3.6/1.3.7 已验证路径;若实测不符,改"取消标志位置于 _attempt_hedged 局部"方案并记 findings,不得吞取消 |
| 复检防误触发用例在 CI 抖动 | 只断"不触发 + attempts==1"结果,不断精确时序;仍不稳则调宽阈值比(0.45×阈值置位)并记 findings,不得删复检断言 |
| 想顺手让对冲路携带 content_event(梯次) | 不做(N3③:v1 单路;对冲路恒 None) |
| 想把看门狗掐死改成不喂熔断 / 把输家取消改成喂熔断 | 不改任一(N9 不对称声明:前者 1.3.6 起既有语义,后者 1.3.7 已批准语义);运维"挂起率"统计须知此不对称,落 README |
| 想给 LLMResponse 加顶层 stall 字段 | 不加(B2 裁决 (b):CallStats 已承载赢家口径,两处平铺必漂移) |
| 发现 embedding/OCR 也想用中段对冲 | 不加(非目标 E);B4 warning 已覆盖误配场景;登记为后续 issue |
| PG 实库迁移用例无沙箱可跑 | 记 findings 豁免索引,发布门 slow 交集时补跑;不得删批次 C 的 SQLite 幂等用例顶替 |
交接物:5 个提交(T0 文档并入首个提交或独立,由 commit skill 裁定)、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步不在本计划内,按 CLAUDE.md §4.4.1 另行执行(发布时按 §6 验收门 3 补 slow 交集)。
8. 自审
| 检查 | 结论 |
|---|---|
| 路径/行号/签名是否可执行无 TBD | 是——全部接入点本会话现读:ports.py:56-66/306-344、types.py:296-324/328-388/560-586/590-700、openai_compat.py:427-473/553-616/660-705、retry.py:239-300/304-400/371-562/665-700、config.py:57/192-196/382-392/398-427/745-769/772-849/854-910、client.py:217-310/485-526、middleware/telemetry.py:199-236/396-665、telemetry/schema.py:22-215/323-372、§2.2/§2.3 全部测试落点 |
| 同步清单是否真实 grep | 是——§2.2(11 实现 + 11 调用点)与 §2.3(8 类落点)均为 2026-09-11 本会话实跑结果,非凭记忆;执行时重跑复核已写进 §6 |
| 先失败后通过证据点 | 是——T1 批次 A(TypeError/ImportError)、T2 批次 B(字段缺失/仍得墙钟)、T3 批次 C(冻结清单先扩即红/装配闸)、T4 批次 D(键未识/hedges==0/超时)均先红;证据出口 tests/outputs/138/t<N>/ 逐卡列出 |
| 设计保真 | 是——净口径/排除条款/换算唯一处/两阶段触发窗/N3 并发纪律/N9 不对称/B2 裁决 (b)/B4 操作化均逐字落自设计;无另行发明语义 |
| 取消与降级铁律 | 取消穿透路径逐字保留既有收口;准入失败静默;后端不可用冒泡;遥测写失败降级;DDL 幂等;无 shield/后台任务 |
| 反 gold-plating | 四分类/熔断/429/Lua/streaming.py/embedding-OCR 链路/LLMResponse 顶层字段/梯次对冲/自适应阈值一律不碰;recorder **fields 零改动;约 50 处 emit_attempt 调用点零改动 |
| 跨任务签名一致 | content_event(T1 端口 → T4 接线)、stall 三元组 sink(T2 定义 → T4 赢家断言)、stall_after_s(config 定义 → client 透传 → RetryMW 消费)、stall_threshold_ms(SourceConfig 字段 → 装配 replace 分发 → transport 读)四处接缝均在 §3 写出实际代码 |
| 行数 | 约 400 行 ≤1000;表格/伪代码优先;无超 15 行代码块 |
| 残余诚实标注 | 中段对冲无业界公开先例(调研 §A3,CHANGELOG 必写);generation_ms 语义突变为人类"窗口期最小代价"裁决放行的例外(披露义务在 T5);复检竞速窗口只能收窄不能归零(N3① 已是设计批准的最强形态) |