Compare commits
53 Commits
v1.3.5
..
25a90ebd05
| Author | SHA1 | Date | |
|---|---|---|---|
| 25a90ebd05 | |||
| 1201a04449 | |||
| abcdf6930e | |||
| 31942f55f1 | |||
| 779a9b29a3 | |||
| 48ba36dd94 | |||
| 39a319e34e | |||
| 16743ca11d | |||
| e5ad67408a | |||
| ab0e924849 | |||
| 607523ef5c | |||
| 95104af2a5 | |||
| 5d2ad40336 | |||
| 22609c90d6 | |||
| a0cf8546dc | |||
| ce10881eb0 | |||
| bd82f16921 | |||
| 2c9c5a3aa7 | |||
| 9185eab8c9 | |||
| 0e29deac1a | |||
| b0232b7466 | |||
| cf6e3fa008 | |||
| a2a00c0ef2 | |||
| 8489b8ea50 | |||
| ca8743bf33 | |||
| 2ac05a20b6 | |||
| e0b0be09a7 | |||
| 53bcf43172 | |||
| 901a193514 | |||
| 6f3a620c73 | |||
| 929d60e501 | |||
| 97de4104c6 | |||
| 03cbf4c8db | |||
| a28d94451e | |||
| a04d87e8d7 | |||
| c60011b034 | |||
| 9f7d407120 | |||
| 0572611af7 | |||
| fe616cf91d | |||
| adc069447a | |||
| 463eca380d | |||
| 0a6d6225db | |||
| 166b2865d0 | |||
| b0ab39edbf | |||
| a07a6b096b | |||
| 53d2f089e1 | |||
| 5e2c35a812 | |||
| b1bc06e2b1 | |||
| da77b123ec | |||
| 9474c76ab0 | |||
| 1ff83bbfe0 | |||
| 6c640fcca3 | |||
| d2455e8fd4 |
@@ -41,10 +41,35 @@ for CODE_DIR in "${CODE_DIRS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 复杂度只对"本次改动触及的函数"硬拦: 全量拦截会让存量债务挡住一切提交
|
||||||
|
# (含纯文档提交),久之只剩绕过一条路;存量 ≥C 函数降为告警,随触及随还。
|
||||||
|
# 用 HEAD 对比工作区+暂存区: 钩子在 `git add && git commit` 同一命令里先于 add 运行
|
||||||
if command -v radon &> /dev/null; then
|
if command -v radon &> /dev/null; then
|
||||||
RADON_OUTPUT=$(radon cc "$CODE_DIR" -n C -s 2>&1 || true)
|
mapfile -t CHANGED_PY < <(git diff HEAD --name-only --diff-filter=AM -- "$CODE_DIR" | grep '\.py$' || true)
|
||||||
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then
|
if [[ ${#CHANGED_PY[@]} -gt 0 ]]; then
|
||||||
ERRORS+="[radon] 存在圈复杂度 ≥ C 的函数:\n$RADON_OUTPUT\n"
|
TOUCHED=$(git diff HEAD -U0 -- "${CHANGED_PY[@]}" | python3 -c '
|
||||||
|
import json, re, subprocess, sys
|
||||||
|
hunks, path = {}, None
|
||||||
|
for line in sys.stdin:
|
||||||
|
if line.startswith("+++ "):
|
||||||
|
path = line[6:].strip() if line.startswith("+++ b/") else None
|
||||||
|
m = re.match(r"@@ -\S+ \+(\d+)(?:,(\d+))? @@", line)
|
||||||
|
if m and path:
|
||||||
|
start, count = int(m.group(1)), int(m.group(2) or 1)
|
||||||
|
hunks.setdefault(path, []).append((start, start + max(count, 1) - 1))
|
||||||
|
data = json.loads(subprocess.run(["radon", "cc", "-j", "-n", "C", *hunks], capture_output=True, text=True).stdout or "{}")
|
||||||
|
for path, blocks in data.items():
|
||||||
|
for b in blocks if isinstance(blocks, list) else []:
|
||||||
|
if any(s <= b["endline"] and e >= b["lineno"] for s, e in hunks.get(path, [])):
|
||||||
|
print("%s:%s %s - %s (%s)" % (path, b["lineno"], b["name"], b["rank"], b["complexity"]))
|
||||||
|
' 2>&1 || true)
|
||||||
|
if [[ -n "$TOUCHED" ]]; then
|
||||||
|
ERRORS+="[radon] 本次改动触及的函数圈复杂度 ≥ C:\n$TOUCHED\n"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
LEGACY_COUNT=$(radon cc "$CODE_DIR" -n C 2>/dev/null | grep -cE '^\s+[FMC]\s' || true)
|
||||||
|
if [[ "$LEGACY_COUNT" -gt 0 ]]; then
|
||||||
|
WARNINGS+="[radon] $CODE_DIR/ 存量 ${LEGACY_COUNT} 个函数圈复杂度 ≥ C(未被本次改动触及,不阻塞)。\n"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -31,7 +31,7 @@ LLM__QWEN__1__TIMEOUT_S=120
|
|||||||
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
# 模型不支持所配档位时报错并列出它真正支持的档(库带能力表,含出处与实测日期)
|
||||||
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
# LLM__QWEN__1__EFFORT_FALLBACK=error # 档位打空时: error(默认,报错) | nearest(映射到最近的档)
|
||||||
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
# 默认报错的理由是钱: 静默的 medium→max 在部分模型上是数倍账单;nearest 等距取弱侧
|
||||||
# LLM__QWEN__1__MISSING_DONE=retry # SSE 缺 [DONE]: retry(默认) | salvage
|
# LLM__QWEN__1__MISSING_DONE=retry # SSE 真截断(无 [DONE] 且无 finish+其后实测 usage): retry(默认) | salvage;1.3.10 起 finish+实测 usage+干净关闭的无 DONE 流恒视为完整
|
||||||
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
# LLM__QWEN__1__TRUST_ENV=true # false = 绕过本地代理(LAN 直连)
|
||||||
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
# LLM__QWEN__1__EXTRA_BODY={"temperature":0} # 本源恒定的采样参数(JSON 对象串)
|
||||||
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
# 并入请求体,优先级低于 chat(overlay=...);受控实验固定解码用它,免得漏传
|
||||||
@@ -69,6 +69,24 @@ LLM_CIRCUIT_BREAKER_COOLDOWN=60 # 或 LLM__BREAKER__COOLDOWN_S
|
|||||||
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
|
# ── 在几毫秒内死掉且 MAX_ATTEMPTS 一格用不上。wait 不削弱保护(等待期照样
|
||||||
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
|
# ── 不发请求),只是把最坏墙钟拉长到 BACKPRESSURE__STALL_WINDOW_S ──
|
||||||
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
|
# LLM__CIRCUIT_OPEN=fail_fast # 熔断开路: fail_fast(默认) | wait
|
||||||
|
# LLM__CALL_DEADLINE_S= # 一次逻辑调用的墙钟硬边界(秒);缺省不设 = 不启用
|
||||||
|
# ── 治理对象是"等待"(退避/配额轮询/熔断冷却/结构化重问/embedding 分批共享一份),
|
||||||
|
# ── 不是单次 HTTP 超时(那是 TIMEOUT_S)。清理仍在 finally 跑完: 返回时刻 = 期限 + 清理耗时,
|
||||||
|
# ── 且到期 ≠ 未产出、≠ 未计费。到期抛 CallDeadlineExceeded(不属四分类、
|
||||||
|
# ── 不属 GatewayUnavailableError 族、无 retry_after_s);非法值(0/负/nan/inf)装配期报错 ──
|
||||||
|
# LLM__HEDGE__AFTER_S= # 长尾对冲触发阈值(秒);缺省不设 = 关闭(仅 chat 生效)
|
||||||
|
# LLM__HEDGE__MAX_EXTRA=1 # 每次逻辑调用最多对冲路数;v1 仅单路生效(>1 仅装配期 warning)
|
||||||
|
# ── 触发语义: 流式 = 超阈值且首 token 未至(不误杀慢生成);非流式 = 纯总时长阈值(无中途信号,
|
||||||
|
# ── 「挂起 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_*)══
|
||||||
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
|
PGW_LIMITER_BACKEND=memory # memory | redis(redis 需 REDIS_URL;多进程 worker 必须 redis)
|
||||||
@@ -182,3 +200,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填)
|
|||||||
# SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16
|
# SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16
|
||||||
# SOAK_OCR__GLOBAL__RPM=300
|
# SOAK_OCR__GLOBAL__RPM=300
|
||||||
# SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4
|
# SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4
|
||||||
|
|
||||||
|
# OpenAI 保真模式是 per-call response_mode="openai",无新增环境键。
|
||||||
|
# raw-only reasoning_effort 放调用 overlay;对应源不得设置 ENABLE_THINKING 或
|
||||||
|
# REASONING_EFFORT,请求 reasoning_effort=None 只继承源配置,不撤销源级意图。
|
||||||
|
|||||||
+173
@@ -1,5 +1,178 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.3.10(2026-10-15)
|
||||||
|
|
||||||
|
#30/#31 同根修复: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游的正常结局被当瞬时错误重试到耗尽(magpie/glm 不发 `[DONE]` 时单次调用挂 1216s;只有思考的 length 截断每次写满 max_tokens 后重抽)。本版改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存读写共用一份判定。设计 `research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md`。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **缺 `[DONE]` 但完整的流**(#31,**两种模式**): finish_reason 已到、其同帧或之后收到实测 usage、连接干净关闭 → 完整交回,用量记 measured,`stream_end="eof_after_finish"`。文本模式默认 `MISSING_DONE=retry` 原本同样会重试到耗尽;该键现在只管真截断(无 finish 或 finish 后无实测 usage)。
|
||||||
|
- **只有思考的 length 截断**(#30): 保真模式原样交回(content 空、思考完整、finish_reason=length),不再重抽。
|
||||||
|
- **`null` 与未知字段**(#30): 任意层 `null` 视为缺省(`refusal: null`、`tool_calls: null` 不再判失败,**非流式同样修复**);保真模式未知字段按 OpenAI 流式惯例无损合并(字符串拼接、对象递归、带 `index` 数组按 index 合并、`index/type/id/format` 同值幂等),Gemini `extra_content` 一类工具层扩展可原样回传。
|
||||||
|
- **`reasoning` 别名**: vLLM/OpenRouter 的 `reasoning` 字段计入活性(此前只有思考的流会被 TTFT 看门狗误杀),文本模式无非空 `reasoning_content` 时投影为 `thinking`。
|
||||||
|
- **截断结果不再写缓存**: 文本 salvage 结果此前会被缓存并在 TTL 内反复回放。
|
||||||
|
|
||||||
|
### 行为变更
|
||||||
|
|
||||||
|
- **文本模式 length/过滤且无正文 → `ResultInvalidError`**(原 `TransientError` 重试到耗尽): 不重试、不换源、熔断记成功不计窗口;**不经 `AllSourcesExhausted` 包装**,直接到调用方。原因码见异常消息(`length_without_content` / `filtered_without_content`)。文本模式"只有思考 + stop"维持瞬时(证据收集中)。
|
||||||
|
- **finish_reason 为 `network_error`/`error`**(GLM 等上游自报失败)即使有正文也判瞬时。
|
||||||
|
- **保真模式**: 只有思考/拒答/空白 + 明确 finish(非上游错误)→ 原样交回;length 截断的半截工具(缺 id/name)→ `ResultInvalidError` `tool_truncated`;无参函数全程只发 `arguments: null`(其余字段齐全)→ `ResultInvalidError` `tool_arguments_null`(确定性方言,交付值不改写)。
|
||||||
|
- 文本模式 `choice.index: null` 视为 0(此前整帧被丢弃);`tool_calls` 中全为 `null` 的元素按 `tool_calls: null` 处理,不产出会被上游拒收的空数组。
|
||||||
|
- **缓存**: 只有自然结束(stop/tool_calls/无 finish 的旧口径)且有正文的结果写入;length/过滤/未登记 finish 值、salvaged、空正文均交付但不缓存,读到此类条目按 miss。未登记 finish 值按源告警一次。
|
||||||
|
- **活性口径增补**: 未知字段的非空字符串片段(如 `reasoning`)算进展,`index/type/id/format` 等标签键不算;`stall_ms` 计量随之覆盖。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- `LLMResponse` / `TransportResult` 末尾追加 `finish_reason: str | None`、`stream_end: StreamEnd | None`(`done` / `eof_after_finish` / `salvaged`;非流式、缓存命中为 None);包根导出 `StreamEnd`。
|
||||||
|
- 遥测 `llm_calls` 末尾追加可空列 `stream_end`(INSERT 字段 38 → 39,物理列 39 → 40;旧行 NULL 不回填;auto 档幂等补列,manual 档裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取)。
|
||||||
|
|
||||||
|
### 破坏性(请先读)
|
||||||
|
|
||||||
|
- **`TelemetryRecorder.record_llm_call` 新增必填 keyword `stream_end`**: 自建 recorder 未跟进会在装配期被形状闸拒绝(`**fields` 形态不受影响)。
|
||||||
|
|
||||||
|
### 提醒
|
||||||
|
|
||||||
|
- 保真模式交付 `finish_reason=length` 的工具调用时 arguments 可能是半截 JSON: **执行工具前先查 `finish_reason`**。
|
||||||
|
- 未知字段合并冲突时会**删除该字段**并按(源, 路径)告警一次: 结果可能缺字段,以告警为准。典型形态: 带 `index` 的数组(如 `reasoning_details`)后续帧元素缺 `index` 即判形态改变,**整个字段被删**。
|
||||||
|
- 诚实边界: 中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分;防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
|
||||||
|
- 旧版本写入的 salvage/length 文本缓存无法辨认: 受影响集合须在升级后首次读写前更换 namespace/salt(同 1.3.9 提醒)。
|
||||||
|
|
||||||
|
## 1.3.9(2026-10-01)
|
||||||
|
|
||||||
|
- 新增 `chat(response_mode="openai")`,返回 `LLMResponse.completion`:assistant 消息、工具调用、finish_reason、上游 response_id、usage 快照;支持流式聚合及非流式。
|
||||||
|
- 修复 SSE 顶层 error 后接 DONE 被当成功、同帧 content/reasoning_content 丢失;工具实际进展纳入看门狗、首 token 与中段对冲。
|
||||||
|
- 保真仅支持单 choice,拒绝 structured 组合;工具参数逐字拼接,不执行或修复工具;缺 DONE 不允许 salvage。
|
||||||
|
- 保真缓存独立身份并完整回放;缺协议、畸形协议及无正文无工具的坏条目均按 miss 回源。**旧文本缓存可能含曾被吞错的半截结果,受影响集合须在升级首次读写前更换 namespace/salt(含 per-call 覆盖)**,库不会自动辨认旧污染。
|
||||||
|
- 新模式不绕过 managed/raw 冲突校验;raw-only 仍须源级/请求级均无受管意图。`Transport.complete` 新增必填 `response_mode`,自定义实现需适配。
|
||||||
|
- 分发采用 Apache-2.0;README 补充常驻事件循环线程桥接,不新增同步 API。
|
||||||
|
|
||||||
|
## 1.3.8(2026-09-14)
|
||||||
|
|
||||||
|
流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。
|
||||||
|
|
||||||
|
### 行为变更: `generation_ms` 口径重定义(1.3.7 墙钟 → 本版净生成时间)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **同一字段,两版口径不同。** 1.3.7 的 `generation_ms` 是赢家那次 transport 调用的完整**墙钟**(中段沉默全部计入);本版起是**净生成时间** = 墙钟 − Σ(所有 > `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 的相邻增量 chunk 沉默),**仍含 TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择)。还原公式: **墙钟 = `generation_ms + stall_ms`**(`stall_ms` 为 None 时墙钟 = `generation_ms`)。
|
||||||
|
|
||||||
|
语义突变在一般情形下违反「字段只增不删不改名」精神,本次由人类以「窗口期最小代价」裁决放行(2026-09-11),依据三行: 该字段 1.3.7(2026-09-10)才引入、**发布仅 2 天**;**消费者唯一**(提出本需求的下游 Terminal-Bench,三参考项目均未消费);**遥测 DB 从未存过** `generation_ms`,无历史账本纵向断裂。字段名「裸生成时间」原本承诺的就是净口径,本次重定义是兑现名字而非推翻承诺。`max_inter_token_ms`(单次最大相邻间隔)原语义一字不动——它与 `generation_ms` 是两个口径,不能互算。
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 计入沉默的间隔 | 相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗/首 token 活性口径一致)间隔**严格大于**阈值(恰好等于不减);空 chunk / usage 帧 / SSE keepalive 不计时;chunk 边界是传输/解析缓冲产物而非 token 边界,本口径不假装是 ITL |
|
||||||
|
| `stall_ms: int \| None` | 赢家路超阈值中段沉默总额(毫秒);与 `stall_count` **成对报告**(只报净数无法行动) |
|
||||||
|
| `stall_count: int \| None` | 超阈值相邻间隔个数 |
|
||||||
|
| None 条款(照 vLLM 排除条款) | 非流式 / 增量 chunk <2 → 两字段 None 且 `generation_ms` **不退减**(= 原墙钟);None = 「无沉默观测」而非 0,下游预算公式以 `stall_ms is None` 为分支条件 |
|
||||||
|
| 对冲赢家 | 三值全部取**赢家路**;已完成但落选的对冲输家其沉默在其 attempt 遥测行(可按 `logical_call_id` join 还原);被取消的输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None |
|
||||||
|
| 与业界基准口径的区隔 | NVIDIA TTFT/ITL/E2EL 与 vLLM 的基准口径**均不扣除**沉默——扣沉默是本库自造口径,与基准数字比较时不得混用 |
|
||||||
|
|
||||||
|
### 遥测两列(纯增量,可空)
|
||||||
|
|
||||||
|
遥测表 `llm_calls` 末尾追加 `stall_ms` / `stall_count` 两**可空**列(INSERT 字段 36 → 38,物理列 37 → 39),落库值与 `CallStats` 快照同源(retry 层已取整的 int)。旧行 NULL 不回填(NULL 表达「补列之前根本没记过这件事」);auto 档幂等补列(先探测后 ALTER),manual 档按现有列裁剪写入并点名缺列,补列 SQL 由 `telemetry_schema_sql()` 自取。`generation_ms` **仍不进遥测列**——要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原。`TelemetryRecorder.record_llm_call` 协议同步加两个**无默认值** keyword-only 参数: 自建 recorder 未跟进会在**装配期**被形状闸当场拒绝(不是运行期静默丢行),`**fields` 形态不受影响;`emit_cache_hit` / `emit_terminal_failure` 与失败/取消 attempt 行两列恒 NULL。
|
||||||
|
|
||||||
|
### 中段沉默对冲(默认关闭;无业界公开先例)
|
||||||
|
|
||||||
|
新键 `{SCOPE}__HEDGE__STALL_AFTER_S`: 主路**首 token 之后**相邻增量 chunk 间隔超此阈值 → 向异源并发再发一路,先回者赢、输家取消。编排/记账/遥测全部复用 1.3.7 对冲语义(异源排除、完整准入、拿不到配额静默放弃、`hedge_cancelled` 遥测行、两败只计一次重试预算)。**诚实标注: 业界对中段沉默的落地形态收敛为「看门狗掐流 → 重试」,「中段沉默触发并发副本」在任何主流网关/库中均无公开先例**——本能力属超业界现状能力,启用前请理解其语义。
|
||||||
|
|
||||||
|
**请先读这三句**:
|
||||||
|
|
||||||
|
| # | 承诺 | 展开 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **默认关闭,开启即用配额换延迟** | 不设该键时行为逐字等于 1.3.7。下游实测(Terminal-Bench 4407 次成功调用)沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft >20s 仅 3/4407),开启后约两成调用会在触发窗口内 in-flight 翻倍 |
|
||||||
|
| 2 | **输家可能已被上游计费,取消止不住** | 与 1.3.7 同一性质: 输家按 est 保留预扣(闸内保守记账,不是上游真实计费的计量);取消止得住等待,止不住上游已经烧掉的钱。对账靠 `hedge_cancelled` 遥测行按 `logical_call_id` join |
|
||||||
|
| 3 | **独立于 `HEDGE__AFTER_S` 生效,仅流式 chat** | 两阈值各自独立,可独设可组合;仅设 `STALL_AFTER_S` 时首 token 前的沉默**不触发**(窗在首 token 到达后才开);仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念,等价未配置);Embedding/OCR scope 配了任一对冲键 → 装配期 warning 后静默退化 |
|
||||||
|
|
||||||
|
**与看门狗的交互及不对称声明(运维「挂起率」统计须知)**: 主路沉默超 `INTER_TOKEN_TIMEOUT_S` 被看门狗掐死 → 照常进 Transient 重试链并**喂熔断失败计数**(1.3.6 起既有语义),对冲路已在跑时本轮不因主路之死而败(等副路裁定,主路 attempt 记账照常);对冲输家被取消 → **不喂**熔断(1.3.7 已批准语义)。同为源沉默事件、两种处置,本版不改任一。
|
||||||
|
|
||||||
|
### 配置面
|
||||||
|
|
||||||
|
| 键 | 值域 | 缺省 | 守卫 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;调整接口三层同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`),无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) |
|
||||||
|
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 装配守卫: `≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,对冲窗永不开)→ **ValueError**;`≥ call_deadline_s` → ValueError;单源 scope 设阈值 → warning 允许 |
|
||||||
|
|
||||||
|
`GatewayClient(...)` 直传 `hedge_stall_after_s` / `stall_threshold_ms` 两 keyword-only 参数走同一份装配守卫;`from_settings`/`from_env` 照常透传。
|
||||||
|
|
||||||
|
## 1.3.7(2026-09-10)
|
||||||
|
|
||||||
|
给 chat 链路加了一条**默认关闭**的长尾对冲(issue #24):一次尝试挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢、输家取消。另给 `CallStats` 追加三个字段,其中 `generation_ms`(裸生成时间)对四条链路全部生效,与是否开启对冲无关。
|
||||||
|
|
||||||
|
### 关于对冲,请先读这三句
|
||||||
|
|
||||||
|
| # | 承诺 | 展开 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **默认关闭,开启即用配额换延迟** | 不配 `{SCOPE}__HEDGE__AFTER_S` 时行为逐字等于 1.3.6(默认关闭回归门: 既有 unit/contracts 全套件一行断言未改)。开启后触发窗口内 in-flight 翻倍——两路各自走完整准入(配额闸/熔断门/pacer),拿不到配额**静默放弃**、原请求继续等,饱和期不添乱 |
|
||||||
|
| 2 | **输家可能已被上游计费,取消止不住** | 输家落既有取消路径按 est **保留预扣**(闸内保守记账,不是上游真实计费的计量);取消能止住等待,止不住上游已经烧掉的钱。对账靠遥测: 输家 attempt 行 `error="hedge_cancelled"`,与赢家行共享同一 `logical_call_id` 可 join |
|
||||||
|
| 3 | **对冲只对 chat 生效** | `EmbeddingClient`/`OcrClient` 不加对冲参数;它们本版的收益是 `CallStats.generation_ms` 计时(见下) |
|
||||||
|
|
||||||
|
### 触发语义
|
||||||
|
|
||||||
|
| 调用形态 | 触发判据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 流式 | 已过 `hedge_after_s` **且首 token 未至**——慢生成不会被误对冲 |
|
||||||
|
| 非流式 | 已过 `hedge_after_s`,纯总时长阈值。非流式无中途信号,「挂起 vs 慢生成」物理不可分,只能靠阈值取值控制误对冲(建议取源 p50 的数倍) |
|
||||||
|
|
||||||
|
输家取消**不喂熔断/健康分**(挂起 ≠ 源死亡);两路都失败才进既有重试循环且**只计一次重试预算**(对冲是一次尝试的加速形态,不是两次独立尝试;两路皆 429 时按既有规则免预算并退 stall 账,一路 429 一路真失败则计一次不退还)。
|
||||||
|
|
||||||
|
### CallStats 三新字段(全带默认值,1.3.6 的构造方式不炸)
|
||||||
|
|
||||||
|
| 字段 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| `generation_ms: int = 0` | 裸生成时间: 赢家/成功那次 transport 调用的墙钟,**排除** admission 排队、重试退避、对冲触发前等待与清理遥测;与 `total_latency_ms` 的差值即「在等不在生成」的波动开销。结构化重问取最后一轮(覆盖),embedding 为各批 transport 之和(累加),缓存命中恒 0(未产生 transport 调用,0 是实测) |
|
||||||
|
| `hedges: int = 0` | 实际并发发出的对冲路数(触发但准入失败静默不计);未启用对冲恒 0 |
|
||||||
|
| `hedge_won: bool = False` | 赢家是否为对冲路;无对冲恒 False |
|
||||||
|
|
||||||
|
### 与 `call_deadline_s` 的关系
|
||||||
|
|
||||||
|
两者正交、可独立配置: deadline 让长尾**更早失败**,hedge 让调用**更快成功**。组合时装配守卫强制 `hedge_after_s < call_deadline_s`(违反即 `ValueError`);`hedge_after_s ≥ min(源 timeout_s)` 同样装配期报错(对冲永不可能触发);`hedge_after_s ≥ min(已设 ttft_timeout_s)` 与单源 scope 设阈值降为装配期 **warning**(流式档已被看门狗先行切断 / 运行期自然静默)。per-call `chat(call_deadline_s=X)` 使 X < 对冲阈值时该次调用对冲不触发,属合法语义不告警。
|
||||||
|
|
||||||
|
### 配置面
|
||||||
|
|
||||||
|
| 键 | 值域 | 缺省 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `{SCOPE}__HEDGE__AFTER_S` | 有限正数秒(复用期限同款值域校验) | 未设 = 关闭 |
|
||||||
|
| `{SCOPE}__HEDGE__MAX_EXTRA` | int ∈ [1,3] | 1;**v1 仅单路对冲生效**,>1 接受但装配期 warning(梯次追加为预留) |
|
||||||
|
|
||||||
|
`GatewayClient(...)` 直传两 keyword-only 参数(`hedge_after_s` / `hedge_max_extra`)走同一份装配守卫;`from_settings`/`from_env` 照常透传。embedding/OCR 的 settings 嵌 `GatewaySettings` 故守卫照常跑,但对冲键对这两条链路不生效。
|
||||||
|
|
||||||
|
## 1.3.6(2026-09-10)
|
||||||
|
|
||||||
|
给一次逻辑调用加了一条**可选**墙钟硬边界(issue #22),并修好取消路径的 TPM 结算与 `Retry-After` 非有限值防御。
|
||||||
|
|
||||||
|
### 关于调用期限,请先读这三句
|
||||||
|
|
||||||
|
| # | 承诺 | 展开 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | **期限治理的是「等待」,不是「返回时刻」** | 到期后取消在飞的尝试,但清理(遥测写入、限流结算、缓存收尾)仍在 `finally` 里跑完,**允许超出期限**。实测构造(两次慢遥测写入)里返回时刻达期限的 **5–7 倍**;库只对「等待被切断」给承诺,对「多久返回」不给上界 |
|
||||||
|
| 2 | **到期 ≠ 未产出、≠ 未计费** | 上游可能已经算完并计费,只是结果在返回路上被丢弃(缓存写入慢于期限就是一例)。把 `CallDeadlineExceeded` 当成「这次没花钱」会低估成本 |
|
||||||
|
| 3 | **不配置就是 1.3.5 语义,逐字不变** | `call_deadline_s` 缺省 `None` 时根本不进 `asyncio.timeout` 上下文。故 1.3.5 的两条长等仍在: 纯 429 序列(429 不消耗重试预算)仍可能长时间等待;**有限大的 `Retry-After`(如 3600s)仍照睡**——库有意不用 `backoff_max_s` 去夹它,唯一制约手段就是本版这条期限 |
|
||||||
|
|
||||||
|
### 公共面新增(三项,全为纯新增)
|
||||||
|
|
||||||
|
| # | 位置 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `polygateway.CallDeadlineExceeded` | 新异常;带 `scope` / `deadline_s`,**无 `retry_after_s`**(到期不含「何时可再试」,给 `0.0` 会指示下游立刻重打饱和渠道) |
|
||||||
|
| 2 | 配置键 `{SCOPE}__CALL_DEADLINE_S` | 缺省不设 = 不启用;非法值(0/负/`nan`/`inf`/非数)在**装配期**当场 `ValueError` |
|
||||||
|
| 3 | 三个 client 构造参数 + 四个公开方法的 keyword-only 参数 | `GatewayClient` / `EmbeddingClient` / `OcrClient` 的 `call_deadline_s`;`chat` / `embed` / `recognize_text` / `parse_layout` 可 per-call 覆盖(`None` = 继承装配值,**不提供「本次关闭」**)。一次 `embed` 的 N 个批次共享同一份期限,不随批数放大 |
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **`except GatewayUnavailableError` 接不住 `CallDeadlineExceeded`。** 新异常直接继承 `PolyGatewayError`,既不属四分类,也不在 `GatewayUnavailableError` 族内——期限到期是**调用方自己设的边界**,不是网关不可用。只有显式配了期限的调用方才会遇到它,需要处理就单列一条 `except`。遥测侧无需改动: 三个边界既有的 `except PolyGatewayError` 会接住它并照常写一条 `terminal_failure` 行(`error_type='CallDeadlineExceeded'`),**零新增列**。
|
||||||
|
|
||||||
|
### 行为变更:取消路径的 TPM 结算口径
|
||||||
|
|
||||||
|
| 情形 | 1.3.5 | 本版 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 取消发生在**端口已开始、结算尚未确定**时 | `settle(0)`,入场预扣整笔退还 | 按 `est` **保留预扣**(方向是宁多扣不空退: 上游可能已计费) |
|
||||||
|
| 结算已确定(含真实 usage 恰为 0 的成功、已判 `SourceDead` 的 `0`) | 按已算出的值 | **一字不变**,取消不覆写 |
|
||||||
|
| 未被四分类接住的异常逃逸(`RuntimeError` 等) | `0` | **仍按 `0`**,本版不扩大语义(已登记为残留) |
|
||||||
|
|
||||||
|
启用期限后库自身会常规性触发取消路径,故这条记账修复与期限同版交付。OCR 的 `settle(0)` 不变——无 token 是事实而非「未知」。非取消路径的最终结算值与 1.3.5 逐字相同,只是算得更早(失败分支的结算决定前移到其第一个 `await` 之前)。
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
- `Retry-After: inf` / `1e999`(`float()` 会把它舍成 `inf`)此前会原样进入退避并让该次尝试睡到天荒地老;现按「无提示」处理,退回纯指数退避,并发**一条**带源名与判据词的 warning(不回显原始头,429 风暴下会淹掉真信号)。`nan`、空串、负数、HTTP-date 的既有值语义一字未动。
|
||||||
|
- 限流 Lua、`Permit` 端口签名、遥测 schema、缓存 key 公式、重试预算与退避算法、熔断语义**均未改动**。
|
||||||
|
|
||||||
## 1.3.5(2026-09-09)
|
## 1.3.5(2026-09-09)
|
||||||
|
|
||||||
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
|
把治理单位从「一次尝试」补齐到「一次逻辑调用」(issue #19、#23)。此前重试、换源、结构化重问、embedding 分批都各自独立可见,而「这一次调用总共打了几次、总共花了多久、最后为什么失败」在库外拼不出来;结构化耗尽、embedding/OCR 的无源与准入拒绝更是**一条遥测行都没有**。
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
> [!URGENT]
|
> [!URGENT]
|
||||||
> **实验室内部通用基础库(生产级、非 MVP、零业务假设)**
|
> **实验室内部通用基础库(生产级、非 MVP、零业务假设)**
|
||||||
|
>
|
||||||
> 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。
|
> 1. 本项目是被多个科研/生产项目依赖的**库**,不是应用:稳定性、并发性、防御性、可观测与测试不可为"简单"让步(YAGNI 仍适用,但不削减健壮性)。库的 bug 会同时击穿所有下游项目。
|
||||||
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
||||||
|
|
||||||
## 1. 项目元数据
|
## 1. 项目元数据
|
||||||
|
|
||||||
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
- **核心目标**: PolyGateway = 统一的大语言模型(LLM/VLM/OCR,音频预留)调度与中转库。治理单位是**一次模型调用**:请求封装、多源多账号、限流、错误分类与重试、熔断、Redis 响应缓存、流式看门狗、遥测(含成本)、结构化输出策略。全组件端口化可插拔。
|
||||||
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
- **架构权威文档**: `research-wiki/ARCHITECTURE.md`(架构单一事实源,含 D1-D14 决策及讨论过程、子系统设计、三项目迁移验收标准;**不受 400 行设计文档限制**,以无歧义传达既有讨论为准绳)。开发顺序见 `research-wiki/ROADMAP.md`;`research-wiki/designs/` 仅存放每次实现具体功能的设计文档。
|
||||||
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
- **参考项目**: `reference/` 下三个项目是本库的需求来源与代码蓝本(**只读,勿改**;M4 起"只读"指工作区文件与 main 检出不变——迁移实施经 `git worktree` 在 `~/Projects/m4-worktrees/` 的 feature 分支进行,worktree 的 git 操作会写 `reference/*/.git` 元数据,属预期);库必须能按 ARCHITECTURE.md §11 被它们迁移接入,否则即边界缺口。
|
||||||
@@ -35,11 +37,13 @@ make ci # 只读验证(check + test)
|
|||||||
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
> **同一理由适用于长跑命令**: 需要后台跑时(如全套件测试),命令末尾**不得接管道**,否则退出码失真;要判完成用 `wait`/轮询 PID,不要用会匹配到自身的 `pgrep -f "<完整命令串>"`。
|
||||||
|
|
||||||
### Phase 1: 规划与设计
|
### Phase 1: 规划与设计
|
||||||
|
|
||||||
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
1. 涉及**公共 API、端口签名、架构边界、新子系统**的变更**必须**调用 `brainstorming`(产出 2-3 备选方案+权衡)并经**人类确认**后实施;其余任务自判(判据: 是否改变库对下游的承诺)。动手前查阅 `research-wiki/`(单一事实源)。
|
||||||
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
2. 功能产生运行时数据时**必须**调用 `structured-logging`。
|
||||||
3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。
|
3. **里程碑级/跨多文件**功能编码前**必须**调用 `writing-plans`;小改动自判。审核门控: design 走 Claude 自审 → Codex 审 → **人类审**;plan 走 Claude 自审 → Codex 审 → 直接执行。
|
||||||
|
|
||||||
### Phase 2: 执行与验证
|
### Phase 2: 执行与验证
|
||||||
|
|
||||||
1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。
|
1. **测试结果门**: 合并前每个行为变更必须有"先失败后通过"的测试证据(`test-driven-development`);bug 修复必带回归测试;不规定中间怎么走。
|
||||||
2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
|
2. **独立验证**: 里程碑级/跨多文件/合并前**必须**派全新上下文的 verifier subagent(`verification-before-completion`);任何规模的完成声明都必须逐条对应本会话内的工具输出(证据化声明,禁止虚报)。
|
||||||
3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。
|
3. **反 gold-plating**: 不做任务外的重构、抽象与"顺手清理"。
|
||||||
@@ -47,6 +51,7 @@ make ci # 只读验证(check + test)
|
|||||||
## 4. 核心规则
|
## 4. 核心规则
|
||||||
|
|
||||||
### 4.1 核心原则(按优先级)
|
### 4.1 核心原则(按优先级)
|
||||||
|
|
||||||
- **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。
|
- **P1 YAGNI**: 不写当前用不到的代码;但并发控制、防御校验、可观测埋点、错误隔离与测试是"当前需要",不在削减之列。
|
||||||
- **P2 高可读性**: 领域术语命名;注释解释"为什么"。
|
- **P2 高可读性**: 领域术语命名;注释解释"为什么"。
|
||||||
- **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。
|
- **P3 单一职责**: 一句话说不清职责(需要"和")= 拆分。
|
||||||
@@ -69,6 +74,7 @@ make ci # 只读验证(check + test)
|
|||||||
| 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash |
|
| 无缓存毒化 | 缓存 key 必含 model + messages 摘要 + 命名空间/租户 + salt;多模态 content 先摘要再 hash |
|
||||||
|
|
||||||
### 4.3 代码开发规范
|
### 4.3 代码开发规范
|
||||||
|
|
||||||
- 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。
|
- 模块/类/方法必须有**中文 Docstring**;复杂逻辑用 `# Phase N` 注释组织。
|
||||||
- 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。
|
- 校验分层: 外部输入校验用显式异常(Python `-O` 移除 assert,禁止 assert 承担生产校验);assert 仅用于内部不变量。
|
||||||
- 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。
|
- 类名 `PascalCase`,私有前缀 `_`;导入顺序标准库→第三方→项目内。
|
||||||
@@ -76,6 +82,7 @@ make ci # 只读验证(check + test)
|
|||||||
- 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。
|
- 不考虑向后兼容,直接修改原文件。**例外**: `LLMResponse` 等已被三项目消费的公共类型,字段只增不删不改名(迁移兼容约束,见 ARCHITECTURE.md §5.1)。
|
||||||
|
|
||||||
### 4.4 Git 工作流
|
### 4.4 Git 工作流
|
||||||
|
|
||||||
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
|
- 一切开发在 feature 分支,严禁直改 main;频繁语义化提交;提交**必须**调用 `commit` skill;大改动前先提交回滚点。
|
||||||
|
|
||||||
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
|
### 4.4.1 发布流程(每步都是历史欠账换来的,不得跳步)
|
||||||
@@ -91,7 +98,7 @@ make ci # 只读验证(check + test)
|
|||||||
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
| 1 | **更新 README** | 打包会把当时的 README 固化进 sdist,**发布后再改就来不及了**(包里那份永远是旧的)。逐项核对: 安装命令的版本约束(`==1.1.*` 这类**极易漏改**,漏了下游就被锁在旧版)、能力表是否覆盖新行为、数字型断言是否仍成立(如遥测字段数,须用 `inspect.signature` 实测而非凭记忆) |
|
||||||
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
| 2 | CHANGELOG 定版 | "未发布" → `## X.Y.Z(日期)` |
|
||||||
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
| 3 | 版本号 | `pyproject.toml` + `src/polygateway/__init__.py` 两处必须一致 |
|
||||||
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加 `pytest -m slow`** ——真实网关 e2e 与 Redis 时间语义变体被 `addopts = "-m 'not slow'"` 默认排除,**不显式跑就等于没跑**(约 20-40 分钟,取决于网关快慢)。它们不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
|
| 4 | 合并 main + push | `--no-ff`;合并后在 main 上重跑 `make lint` 与全套件,**外加与本次 diff 有交集的 slow 子集**(2026-09-10 起): `pytest -m slow` 只跑被本版改动触及的模块——Redis 时间语义变体在动限流/退避/取消时跑,真实网关冒烟在动公开入口时跑;**`test_thinking_live.py` 全模型能力矩阵不再每次发布都跑**(付费且额度耗尽渠道会走完整超时链,一晚数小时无信号),仅在动 `thinking.py`/能力注册表/相关 e2e 设施或人类明确要求时跑,否则复用最近一次有效矩阵证据并如实登记。slow 不进日常提交是有意的: pre-commit 关卡跑全套件,网关一抖就挡住与之无关的提交,久了会把"测试红了先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉;代价是这道门必须由本清单兜住 |
|
||||||
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
|
| 5 | **打 tag 并 push** | `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`。历史上多个版本漏打 |
|
||||||
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
| 6 | 构建 | `rm -rf dist && python -m build && python -m twine check dist/*` |
|
||||||
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
| 7 | **上传 registry** | 凭据在 `~/.config/tea/config.yml`(tea CLI 的 Gitea token,**不在** `~/.pypirc`);token 走 `TWINE_PASSWORD` 环境变量,不进命令行<br>`TWINE_USERNAME=iomgaa TWINE_PASSWORD=$TOKEN python -m twine upload --repository-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi dist/*` |
|
||||||
@@ -106,16 +113,18 @@ Gitea 包 registry 是 **owner 级**(`/iomgaa/-/packages/`)不是仓库级;PyPI
|
|||||||
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
|
发布验证补充(2026-09-09 实测):私有索引不镜像 setuptools,下载 sdist 时 pip 即使带 `--no-deps` 仍可能尝试安装构建依赖;已具备构建工具的 conda 环境可加 `--no-build-isolation`,仍须核对下载来源与散列。包关联 POST 本次返回400,但匿名包页面和认证 GET 包 API 均证实已关联目标仓库;**400本身不算成功,也不能直接推断已关联**,必须读取实际 `repository.full_name` 与页面链接。包 API 可能要求认证(匿名401),页面仍应匿名亲查;不要为重试关联先解绑现有正确关系。
|
||||||
|
|
||||||
### 4.5 配置管理
|
### 4.5 配置管理
|
||||||
|
|
||||||
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
- 工程配置走 `pydantic-settings` + `.env`(模板 `.env.example`,敏感项不提交);严禁硬编码默认值;缺失关键配置直接报错。
|
||||||
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
- 多源命名约定 `{SCOPE}__{PROVIDER}__{N}__{FIELD}`;韧性参数键名沿用三项目习惯(`LLM_TIMEOUT` 等),降低迁移成本。
|
||||||
- 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。
|
- 装配只有两条路: `GatewayClient.from_env()`/`from_settings()`(工厂)或构造函数全量注入(测试/高级);库内部任何组件不得自读环境变量。
|
||||||
|
|
||||||
### 4.6 测试组织
|
### 4.6 测试组织
|
||||||
|
|
||||||
- `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。
|
- `tests/{unit,integration,e2e}`;真实场景优先(录制的真实网关响应二次构造优于凭空 mock)。
|
||||||
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
- 覆盖率目标 80%;并发/韧性行为是一等测试对象: 重试穿透取消、熔断开路半开、限流结算退款、Redis 掉线降级方向、缓存 key 隔离。
|
||||||
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
- Redis 相关测试用真实 Redis(integration),不 mock Lua 行为;限流契约测试随实现一起交付(参考 CHSAnalyzer `tests/contracts_limiter.py`)。
|
||||||
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
|
- 涉及真实 LLM 的测试输出结构化 Markdown 至 `tests/outputs/<module>/<test>_<ts>.md`。
|
||||||
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步统一跑。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
|
- **成败取决于外部服务当下状态的测试一律标 `slow`**(`tests/e2e/` 四个文件与 Redis 时间语义变体):它们默认不进日常套件,由发布清单第 4 步**按 diff 交集选子集**跑(全量 `pytest -m slow` 仅在交集不清或人类要求时用)。判据是"重跑一次可能就绿了"——这种测试留在提交关卡里会污染信号。同理,给它们的超时不得紧于 `.env` 的生产配置,否则是设计上就会间歇红。
|
||||||
|
|
||||||
## 5. 项目结构
|
## 5. 项目结构
|
||||||
|
|
||||||
@@ -154,6 +163,7 @@ project_root/
|
|||||||
| 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` |
|
| 第一次抽库尝试(教训与蓝本) | `reference/GovDoc-SaaS/packages/docagent-core/` |
|
||||||
|
|
||||||
## 7. 输出规范
|
## 7. 输出规范
|
||||||
|
|
||||||
- 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。
|
- 所有输出**中文**;文档优先表格/伪代码/Mermaid;禁止超 15 行代码块入文档、禁止连续超 5 条碎片列点;设计 ≤400 行、计划 ≤1000 行、报告 ≤300 行。
|
||||||
- **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。
|
- **例外**: `research-wiki/ARCHITECTURE.md` 不受行数限制——它的准绳是"后来的 AI/人类无需还原原始讨论即可准确理解全部决策及理由",宁详勿略(详细 ≠ 琐碎:记录论证与取舍,不堆砌实现细节)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -16,12 +16,17 @@
|
|||||||
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
| 熔断 | 双通道(连续失败 + 失败率窗口,健康证据抑制误熔);半开单探针带租约(持有者死亡自动回收);epoch fencing 拒绝迟到写回;开路时长指数递增;**开路时当场失败还是等冷却可配**(`CIRCUIT_OPEN`,单源 scope 应配 `wait`) |
|
||||||
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
| 自适应并发 | AIMD:429 削减、成功缓升,防止打爆上游 |
|
||||||
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
| 背压与判死 | 配额满与熔断开路**各自**可选等待或快速失败(`QUOTA_FULL` / `CIRCUIT_OPEN`,两键不可互相替代);等待期按双条件判死(本地非生产性等待与全局无进展**同时**超窗)。stall 窗口只计**非生产性**等待(429 退避/配额轮询/熔断冷却),与 `TIMEOUT_S` 无耦合 |
|
||||||
|
| 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** |
|
||||||
|
| 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__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-生成时间口径与中段沉默对冲) |
|
||||||
|
| OpenAI 消息保真(1.3.9) | `chat(response_mode="openai")` 返回完整 assistant 消息/工具调用、finish_reason、上游 ID 与 usage;单 choice,复用治理,不执行工具;默认仍为文本模式 |
|
||||||
|
| 完成判定(1.3.10) | 以上游 `finish_reason` 为准判定"生成结束、结果可交付":缺 `[DONE]` 但 finish 与其后的实测 usage 齐全即完整(两模式);length/过滤等确定性结局不再被当瞬时错误重试;未知/`null` 字段不再判失败;`LLMResponse.finish_reason` / `stream_end` 给出结束原因与方式,详见[1.3.10 统一完成判定](#1310-统一完成判定) |
|
||||||
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
| 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) |
|
||||||
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 |
|
| 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token(含 `reasoning` 别名)刷活性不计结果;截断流(无 `[DONE]` 且无 finish+其后实测 usage)默认判瞬时(`MISSING_DONE=salvage` 的文本打捞结果交付但不入缓存) |
|
||||||
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
| 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 |
|
||||||
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
| 推理档位 | 推理是**八档**(`none`/`auto`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`)而非开关:源级 `REASONING_EFFORT` + 请求级 `chat(reasoning_effort=...)`,`ENABLE_THINKING` 保留为语法糖;库带 24 条能力表(逐条 evidence 自报实测/文档推定),档位打空**默认报错并给出该模型最省的可用档与该配的键**,要静默映射需显式配 `EFFORT_FALLBACK=nearest`;实发档随 `LLMResponse.applied_effort` 与遥测落库 |
|
||||||
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 36 字段;三类行(`event_kind` = `attempt` / `cache_hit` / `terminal_failure`)加逐源诊断列(`http_status_code` / `error_type` / `cause_type` / `error_body`);SQLite / Postgres 后端(表已存在时**不需要** schema 建表权限,最小权限账号可直接用);按价格表折算成本落库(注意 `LLMResponse.cost` 本身恒为 `None`,成本只进遥测);多模态内容摘要落库不存原图 |
|
| 遥测与成本 | 每次调用(含缓存命中与失败)必录 39 字段;三类行(`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`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断) |
|
| 逻辑调用统计 | 治理单位是**一次逻辑调用**而非一次尝试:四种响应(chat / embedding / OCR 两种)带 `call_stats`(`logical_call_id` / `attempts` / `total_latency_ms`,及生成时间 `generation_ms`——**1.3.8 起为净口径**:赢家那次 transport 调用墙钟 − Σ超阈值中段沉默,仍含 TTFT 与尾部,排除准入排队/退避/触发前等待;1.3.7 的墙钟值由 `generation_ms + stall_ms` 还原——与沉默归因 `stall_ms` / `stall_count`(成对报告,None = 无沉默观测)、对冲计数 `hedges` / `hedge_won`),重试、换源、结构化重问、embedding 分批共享同一逻辑 ID;每次**领域失败**另落一条 `terminal_failure` 行,失败调用数从此是一条 `WHERE event_kind = 'terminal_failure'`,详见[1.3.5 逻辑调用统计与失败诊断](#135-逻辑调用统计与失败诊断)与[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) |
|
||||||
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
| 遥测的资源与降级 | Postgres 池**闲时占 0 条连接**、忙时上限可配(`PGW_TELEMETRY_PG_POOL_MAX`,缺省 4),每次写入有硬预算(`PGW_TELEMETRY_PG_WRITE_TIMEOUT_S`,缺省 5s);后端不可用是**可恢复的降级**(冷却 60s 后自动重试,DBA 建完表/放开权限即自愈),永久失能只留给 DSN 本身写错;降级状态可编程查询——`client.telemetry_status` 给出 `degraded`/`fatal`/`reason`/`dropped_rows` 等只读快照,不必再靠人工对账。**对账要同时看 `degraded` 与 `dropped_rows`**: 池饱和超预算丢的行走行级丢弃,`degraded` 保持 `False`(后端没挂,是本进程并发超了),只按 `degraded` 告警会看不见这一类丢行——而它恰是 `pool_max` 配小了的唯一信号 |
|
||||||
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
| 调用方维度 | 每次调用可带 `tenant_id`(遥测表的真实列,可挂 RLS、可建复合索引)与 `meta`(≤16 个自定义 KV);四个公共方法全覆盖,校验超限即报错;**库只交付列,不启用 RLS、不建索引** |
|
||||||
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
| 遥测表治理 | `llm_calls` 是**下游的表**:PG 侧缺省**不再自动 `ALTER` 补列**(`PGW_TELEMETRY_SCHEMA_MODE` 三态,不设则 sqlite→auto、postgres→manual),manual 档点名缺列并按现有列裁剪写入;`telemetry_schema_sql(backend)` 自取可粘进迁移文件的建表/补列 SQL;`PGW_TELEMETRY_TEXT_CAP` 限正文长度(**不设 = 存全文**);保留期与访问控制走[生产部署 DDL 模板](#生产部署-ddl-模板postgresql)加 `tools/telemetry_retention.py` |
|
||||||
@@ -109,13 +114,40 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重
|
|||||||
|
|
||||||
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
|
自建 `TelemetryRecorder` 的下游请同步补齐这 10 个 keyword-only 无默认值参数(或改成 `**fields` 形态),并同步自己的 schema 与 INSERT 字段。装配期形状闸只能证明形状能被接受,证不了函数体真的落这些列。
|
||||||
|
|
||||||
|
## 1.3.8 生成时间口径与中段沉默对冲
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **`generation_ms` 自 1.3.8 起是净生成时间,不再是墙钟。** 同一字段两版口径不同(放行依据:字段发布仅 2 天、消费者唯一、遥测无账本,人类「窗口期最小代价」裁决);1.3.7 的墙钟值 = 本版的 `generation_ms + stall_ms`。两段口径的存量快照不可直接纵比。
|
||||||
|
|
||||||
|
### `generation_ms` 口径表
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 定义 | **净生成时间** = 赢家那次 transport 调用的墙钟 − Σ(所有 > `STALL_THRESHOLD_MS`(缺省 1000ms)的相邻增量 chunk 沉默);仍**排除**准入排队/退避/对冲触发前等待(同 1.3.7) |
|
||||||
|
| 边界 | 仍含 **TTFT 与尾部**(末 chunk → 流结束归活跃侧,墙钟基底口径的有意选择) |
|
||||||
|
| 还原公式 | **墙钟 = `generation_ms + stall_ms`**(`stall_ms is None` 时墙钟 = `generation_ms`) |
|
||||||
|
| `stall_ms` / `stall_count` | 沉默总额(毫秒)与超阈值相邻间隔个数,**成对报告**(只报净数无法行动);chunk = 任何增量产出(`content`、`reasoning_content`、工具实际增量,1.3.10 起另含未知字段的非空字符串片段如 `reasoning`),空 chunk / usage 帧 / SSE keepalive / 仅标签键(`index`/`type`/`id`/`format`)不计时 |
|
||||||
|
| `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 1.3.8 段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
发布在实验室 Gitea PyPI(公开包,匿名可装):
|
发布在实验室 Gitea PyPI(公开包,匿名可装):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \
|
||||||
"polygateway[redis,postgres,structured]>=1.3.5,<2"
|
"polygateway[redis,postgres,structured]>=1.3.10,<2"
|
||||||
```
|
```
|
||||||
|
|
||||||
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
核心仅依赖 `httpx` + `pydantic`;按需选 extras:
|
||||||
@@ -185,13 +217,17 @@ vectors = (await embed.embed(["文本 a", "文本 b"])).vectors
|
|||||||
### 4. 业务侧异常处理
|
### 4. 业务侧异常处理
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from polygateway import GatewayUnavailableError, RequestRejectedError
|
from polygateway import CallDeadlineExceeded, GatewayUnavailableError, RequestRejectedError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await client.chat(messages)
|
resp = await client.chat(messages)
|
||||||
except GatewayUnavailableError as exc:
|
except GatewayUnavailableError as exc:
|
||||||
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
|
# 整个 scope 暂时无源可用: 延期重投,不消耗业务失败预算
|
||||||
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
|
schedule_retry(after_s=exc.retry_after_s) # exc.reason / exc.per_source_reasons 供诊断
|
||||||
|
except CallDeadlineExceeded as exc:
|
||||||
|
# 只在自己配了调用期限时出现: 不在 GatewayUnavailableError 族内,上一条接不住;
|
||||||
|
# 且无 retry_after_s(到期不含"何时可再试"),重投时机由业务侧定
|
||||||
|
schedule_retry(after_s=None) # exc.scope / exc.deadline_s 供诊断
|
||||||
except RequestRejectedError:
|
except RequestRejectedError:
|
||||||
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
... # 请求本身有问题(400/格式拒绝): 不重试,直接失败
|
||||||
```
|
```
|
||||||
@@ -463,6 +499,8 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
|
|
||||||
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
|
**网关拒绝的理由不会丢失**(1.2.0 起):非 2xx 的响应体经折叠与截断后同时进入异常 message 与 `exc.body_text`,故遥测表的 `error` 列里就能看到网关的原话——不必再为查一次 400 单独埋点。截断保头保尾(总长 2048 字符),JSON 错误体尾部的 `code` / `request_id` 不会被切掉。**经中转部署时请注意**:第三方中转服务自身抖动也会回 400,从状态码上与"你的输入有问题"无法区分;库仍按确定性失败处理(直连供应商时重试只会白烧配额),批处理下游宜据 `body_text` 自备兜底分类。
|
||||||
|
|
||||||
|
**有限大的 `Retry-After` 仍照睡**: 能力表那条「尊重 `Retry-After`」是字面意思——库**有意不用 `backoff_max_s` 去夹服务端给的提示**(夹住就是提前重打已明确说「还没好」的网关),服务端给 3600s 就真睡 3600s;**唯一的制约手段是 1.3.6 的调用期限**(上表「调用期限」行)。自 1.3.6 起,`inf` / `-inf` / `1e999` 这类**非有限**取值按「无提示」处理(退回纯指数退避 + 一条带源名的 warning),不再造成无限等待;`nan`、空串、负数、HTTP-date 的既有语义不变。
|
||||||
|
|
||||||
### 哪些异常会到达调用方
|
### 哪些异常会到达调用方
|
||||||
|
|
||||||
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
上表的"库内行为"一列描述的是**治理动作**,不是调用方要处理的东西。四类里有两类**根本到不了调用方**——它们被重试循环接住,预算耗尽时统一包成 `AllSourcesExhausted`。这个区分只看类型树和 docstring 是读不出来的,曾让下游据此写错整段设计文档,故在此列明:
|
||||||
@@ -473,9 +511,12 @@ SQLite 侧**不建议**对着一个大库文件跑 `DELETE` + `VACUUM`,而应**
|
|||||||
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
| `RequestRejectedError` | `SourceDeadError`(立即熔断该源并换源,同上) |
|
||||||
| `ResultInvalidError` | |
|
| `ResultInvalidError` | |
|
||||||
| `SourceNotConfiguredError` | |
|
| `SourceNotConfiguredError` | |
|
||||||
|
| `CallDeadlineExceeded`(1.3.6 起) | |
|
||||||
|
|
||||||
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
|
**`GovernanceBackendError` 属于第一列**: 限流/熔断的状态后端(如 Redis)自身故障时库 fail-closed——一个请求都发不出去,这就是"整个 scope 暂时不可用"。它继承 `GatewayUnavailableError`,所以 §4 那段 `except GatewayUnavailableError` 一条即覆盖完整,无需为它单列分支。`retry_after_s` 默认 5 秒(后端恢复时间不可知,取 0 会让积压任务零延迟冲击已挂掉的后端)。
|
||||||
|
|
||||||
|
**`CallDeadlineExceeded` 既不属四分类、也不属 `GatewayUnavailableError` 族**: 它直接继承 `PolyGatewayError`,表达的是「调用方自己设的墙钟边界到了」而非网关不可用,故**只有显式配了 `call_deadline_s` / `{SCOPE}__CALL_DEADLINE_S` 的调用方才可能遇到它**,不配就永远不会出现。它**无 `retry_after_s`**,且 §4 那段 `except GatewayUnavailableError` **接不住**它——要处理就得单列一条分支。
|
||||||
|
|
||||||
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
|
**`SourceNotConfiguredError` 有意不在第一列的族内**: 源名不在限流后端的配置字典中是**装配缺陷**而非暂时故障,它应当消耗失败预算、进死信、让人看见——归入可重投家族只会让配置写错的任务永远重投且无人告警。
|
||||||
|
|
||||||
## 配置参考
|
## 配置参考
|
||||||
@@ -571,3 +612,110 @@ make ci # 只读全量验证
|
|||||||
## 兼容性承诺
|
## 兼容性承诺
|
||||||
|
|
||||||
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
`LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。
|
||||||
|
|
||||||
|
## 1.3.9 OpenAI 工具消息保真(单 choice)
|
||||||
|
|
||||||
|
`chat(..., response_mode="openai")` 仍返回 `LLMResponse`,完整协议结果在
|
||||||
|
`response.completion`:`message`、`finish_reason`、`response_id`、`usage`。
|
||||||
|
`content/thinking` 是旧文本投影(可剥离 think 标签);回传代理或继续对话应使用
|
||||||
|
completion.message,不能从旧投影重建工具响应。嵌套 JSON 是独立快照但仍可修改。
|
||||||
|
|
||||||
|
```python
|
||||||
|
response = await client.chat(
|
||||||
|
messages,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"tools": tools, "tool_choice": "auto"},
|
||||||
|
)
|
||||||
|
completion = response.completion
|
||||||
|
messages.append(completion.message)
|
||||||
|
# 工具执行与 role=tool 结果由调用方添加;库不会执行工具。
|
||||||
|
```
|
||||||
|
|
||||||
|
保真是 JSON 字段和值保真,不是 HTTP 字节透明代理。仅支持单 choice(`n=1`),
|
||||||
|
可含多个并行 function tool_calls;arguments 逐字拼接,不解析、修复或执行。
|
||||||
|
SSE 的 role/content/reasoning_content/tool_calls 按原语义聚合;1.3.10 起 `null` 视为缺省,
|
||||||
|
未知增量字段按流式惯例无损合并(冲突删键并告警,见下节);
|
||||||
|
非流式保留整个 message 扩展字段,不返回顶层 logprobs/created 等其他字段。
|
||||||
|
上游实际模型仍读 `model_reported`,配置别名是 `model`。
|
||||||
|
|
||||||
|
| 边界 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 空文本且有完整工具 | 正常成功;不完整工具失败(length 截断的半截工具归 ResultInvalid),其余见 1.3.10 结局表 |
|
||||||
|
| error 帧 | 所有模式立即判失败并走已有重试;后续 DONE 不挽救 |
|
||||||
|
| 缺 DONE | 1.3.10 起 finish + 其后实测 usage + 干净 EOF 即完整;真截断仍不受 salvage 放宽,防截断工具被执行 |
|
||||||
|
| finish_reason=length | 原样返回(含只有思考的回答),不修补参数;**执行工具前先查 finish_reason**,arguments 可能是半截 JSON |
|
||||||
|
| structured | 不可与保真并用;自行传 overlay.response_format 不启用库的修复/重问阶梯 |
|
||||||
|
| raw 推理 | 保真不是校验逃生口;raw-only 要求源 ENABLE_THINKING/REASONING_EFFORT 均未设置,请求档为 None,再通过 overlay.reasoning_effort 传原值 |
|
||||||
|
|
||||||
|
工具实际增量与正文/推理共用首 token、inter-token、stall 对冲及沉默计量;注释、
|
||||||
|
usage、role-only、重复工具身份不算进展。非流式无中段事件,保持纯时长阈值对冲。
|
||||||
|
`Transport.complete` 的自定义实现须接收必填 `response_mode`;不支持保真时明确
|
||||||
|
RequestRejected,不能伪造 completion 或静默退回文本。
|
||||||
|
|
||||||
|
缓存按返回模式隔离;缺协议、畸形协议或无正文无工具的条目按 miss 回源。命中保留历史上游 response_id/usage,生成本次治理 call_id,
|
||||||
|
本次 attempts=0、cost=0。**旧文本缓存可能含修复前 error+DONE 的截断产物**:
|
||||||
|
受影响调用集合须在首次读写前更换从未承载旧语义的 namespace 或 salt,包含工厂、
|
||||||
|
全量注入和 per-call 覆盖,保留租户前缀与 epoch。未迁移仍可能读旧值,库不自动清除。
|
||||||
|
遥测继续单一出口,不新增工具全文列;既有 TEXT_CAP 不覆盖 tool_calls.arguments。
|
||||||
|
|
||||||
|
### 线程模型的异步桥接
|
||||||
|
|
||||||
|
库不新增同步客户端。socketserver 等工作线程应向**一个常驻事件循环线程**提交:
|
||||||
|
client 必须在该 loop 内创建、使用并关闭,不能每请求 asyncio.run 或跨 loop 共用
|
||||||
|
httpx/Redis 组件。下例的 loop 和 client 已由宿主完成初始化:
|
||||||
|
|
||||||
|
```python
|
||||||
|
future = asyncio.run_coroutine_threadsafe(
|
||||||
|
client.chat(messages, response_mode="openai", overlay=overlay), loop
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = future.result(timeout=timeout_s)
|
||||||
|
except TimeoutError:
|
||||||
|
future.cancel() # 断连也应取消;取消不等于上游未计费。
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
应用退出时先停止接单、取消/等待在途调用,在同一个 loop 中 await client.aclose(),
|
||||||
|
再停 loop 并 join 线程;注入组件仍由所有者关闭。不要从 loop 本身阻塞调用 future.result。
|
||||||
|
|
||||||
|
## 1.3.10 统一完成判定
|
||||||
|
|
||||||
|
#30/#31 同根: 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单作为成功判据,上游的正常结局
|
||||||
|
(不发 `[DONE]`、只有思考的 length 截断、`refusal: null`)被当瞬时错误重试到耗尽。1.3.10
|
||||||
|
改以上游 `finish_reason` 与传输完整性为准,流式/非流式/缓存四处共用一份判定。
|
||||||
|
|
||||||
|
| 结束方式(`stream_end`) | 条件 |
|
||||||
|
| --- | --- |
|
||||||
|
| `done` | 收到 `[DONE]` |
|
||||||
|
| `eof_after_finish` | 未发 `[DONE]`,但 finish 已到、其同帧或之后收到的 usage 为实测,连接干净关闭(用量记 measured) |
|
||||||
|
| `salvaged` | 文本模式 `MISSING_DONE=salvage` 打捞的截断结果(用量降 estimated,**不写缓存**) |
|
||||||
|
| `None` | 非流式 / 缓存命中 / 失败行 |
|
||||||
|
|
||||||
|
| 结局(首个匹配行生效) | `openai` 模式 | `text` 模式 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| finish 为 `network_error`/`error`(上游自报失败) | 瞬时错误 | 瞬时错误 |
|
||||||
|
| 工具快照不成立 | length → `ResultInvalidError`;其余瞬时 | — |
|
||||||
|
| 无任何产物 + length/过滤 | 原样交回 | `ResultInvalidError`(不重试、不计熔断) |
|
||||||
|
| 无任何产物 + 其余(零增量抖动) | 瞬时错误 `empty_completion` | 同左 |
|
||||||
|
| 有正文 | 交付 | 交付 |
|
||||||
|
| 只有思考/拒答/空白 + length/过滤 | 原样交回 | `ResultInvalidError` |
|
||||||
|
| 只有思考/拒答/空白 + stop 等 | 原样交回 | 瞬时错误(证据收集中) |
|
||||||
|
| 只有思考/拒答/空白 + 无 finish | 瞬时错误 | 同左 |
|
||||||
|
|
||||||
|
`ResultInvalidError` 不经 `AllSourcesExhausted` 包装,直接到调用方;原因码在异常消息里
|
||||||
|
(`length_without_content` / `filtered_without_content` / `tool_truncated` 等,遥测 `error`
|
||||||
|
列可检索)。只有自然结束(stop/tool_calls/function_call,或未上报 finish 的旧口径)且投影正文非空
|
||||||
|
(或工具完整)的结果才写缓存;未登记的 finish 值按正常交付、不缓存并告警一次。无参函数只发
|
||||||
|
`arguments: null` 的工具判 `ResultInvalidError`(`tool_arguments_null`,确定性方言,不改写不重试)。文本模式 `thinking` 在无非空 `reasoning_content` 时取
|
||||||
|
`reasoning`(vLLM/OpenRouter 新名)。保真模式未知字段:字符串拼接、对象递归、带 `index` 的
|
||||||
|
数组按 index 合并、`index/type/id/format` 同值幂等;冲突时**删除该字段**并按(源, 路径)
|
||||||
|
告警一次——结果可能缺字段,以告警为准。诚实边界:中转在上游出错后主动正常关闭连接与真
|
||||||
|
完整在传输层不可区分,防线是"finish 必须存在、不属上游错误、且其后有实测 usage"。
|
||||||
|
|
||||||
|
遥测 `llm_calls` 追加可空列 `stream_end`;`TelemetryRecorder.record_llm_call` 新增必填
|
||||||
|
keyword `stream_end`(**自建 recorder 须跟进,否则装配期被形状闸拒绝**)。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
PolyGateway 使用 [Apache License 2.0](LICENSE)。内部移植代码的原创 owner 已明确授权;
|
||||||
|
分发范围不包含 `reference/` 外部参考仓。
|
||||||
|
|||||||
+3
-1
@@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "polygateway"
|
name = "polygateway"
|
||||||
version = "1.3.5"
|
version = "1.3.10"
|
||||||
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测"
|
||||||
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
# registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告
|
||||||
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
# long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.27",
|
"httpx>=0.27",
|
||||||
@@ -82,6 +83,7 @@ layers = [
|
|||||||
"polygateway.middleware",
|
"polygateway.middleware",
|
||||||
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
"polygateway.transports | polygateway.backends | polygateway.telemetry | polygateway.structured",
|
||||||
"polygateway.thinking",
|
"polygateway.thinking",
|
||||||
|
"polygateway.deadline",
|
||||||
"polygateway.providers : polygateway.sources",
|
"polygateway.providers : polygateway.sources",
|
||||||
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
"polygateway.ports : polygateway.types : polygateway.errors : polygateway.streaming",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ flowchart TB
|
|||||||
| 值 | 含义 | 生产者 | cost |
|
| 值 | 含义 | 生产者 | cost |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
| `measured` | usage 帧完整可信 | 正常路径;OCR 成功行(0 token 是**事实**而非未知) | 按 token 换算 |
|
||||||
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1) | 按 token 换算 |
|
| `estimated` | 有实测数字但可信度降级 | 打捞路径(收到 usage 帧但流被截断,§7.1;1.3.10 起即 `stream_end="salvaged"`) | 按 token 换算 |
|
||||||
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
| `unavailable` | 用量信息不可得 | usage 帧缺失、失败尝试、终态失败 | **NULL** |
|
||||||
|
|
||||||
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
值域在 `types.py` 以模块级 frozenset 常量 `USAGE_SOURCES` 落地,**仅约束库内生产侧**(所有写入点从该常量取值),不在 `LLMResponse`/`Usage`/`TransportResult` 上加 `__post_init__` 值域校验——它们是运行时构造点,裸 `ValueError` 不属 §6 四分类、`RetryMW` 不捕会逃出 `chat()`;且 `LLMResponse` 是三项目已消费的公共类型,新增运行时校验属下游可见行为变更。历史库里既有的 `estimated` 行在新值域中依然合法可读。
|
||||||
@@ -470,7 +470,9 @@ flowchart TB
|
|||||||
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
| HTTP 429(body 无 insufficient_quota)、500/502/503/504 | `TransientError`(携 `Retry-After` 解析值,仅支持秒数形态) |
|
||||||
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
| HTTP 429 + body 含 insufficient_quota、401、403 | `SourceDeadError` |
|
||||||
| HTTP 400 | `RequestRejectedError` |
|
| HTTP 400 | `RequestRejectedError` |
|
||||||
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应) |
|
| **空补全**: 200 且流程完整([DONE]/usage 正常)但 content 为空(2026-07-20 M1 验证发现,人类裁决) | `TransientError`(服务抖动,重试/换源;绝不缓存空响应)。1.3.10 起仅限**零产物**或无法区分抖动与结局的情形;上游明确的 length/过滤结局见 §15 |
|
||||||
|
| finish_reason 为 `network_error`/`error`(上游自报失败,1.3.10) | `TransientError` |
|
||||||
|
| 文本模式 length/过滤且无正文;保真模式 length 截断的半截工具(1.3.10) | `ResultInvalidError`(确定性结局,不重试、不计熔断) |
|
||||||
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
| 解析层失败(结构化输出/OCR ZIP) | `ResultInvalidError` |
|
||||||
|
|
||||||
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
**响应体留存(2026-08-16,Gitea issue #10;设计 `designs/2026-08-16-issue10-error-body-retention-design.md`)**: 上表每一条 HTTP 翻译**都必须携带响应体摘要**——摘要同时进入异常 message 与 `PolyGatewayError.body_text`(1.2.0 新增基类字段)。两者都要,因为逐次遥测写的是 `str(exc)`,只加字段进不了遥测表,而"事后可查"正是这条要求的目的。摘要口径由 `transports/_http_errors.summarize_body` 单点实现(折叠空白 → 限长 2048 字符 → 超长保留头 1400 + 尾 600 并记省略字数),两个 transport 共用,**不得各写一份**——issue #10 的成因正是"只有 429 那一支用了响应体"。`body_text` 是旁路数据,不参与任何治理判定;`_translate_429` 的类型细分仍解析未截断原文(摘要会破坏 JSON,改用它会让超长 body 的 `insufficient_quota` 退化成普通限速)。
|
||||||
@@ -501,6 +503,10 @@ flowchart TB
|
|||||||
|
|
||||||
指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。
|
指数退避 + jitter: `delay = min(base * 2^attempt, max_delay) * uniform(0.5, 1.5)`,与 `Retry-After` 提示取较大值。**单层重试原则**: 库内只有 RetryMW 一层重试;Video-Tree/GovDoc 现存的"治理层重试 + Agent 步级二次重试"双层结构(异常集合不同、职责重叠)不复制——业务侧如需任务级重试,自行在库外包,且语义是"任务重试"不是"调用重试"。
|
||||||
|
|
||||||
|
**生成时间净口径与沉默归因(2026-09-11,1.3.8,issue #27;设计 `designs/2026-09-11-streaming-stall-design.md` §4)**: `CallStats.generation_ms` 自 1.3.8 起为**净生成时间** = 赢家那次 transport 调用墙钟 − Σ(所有 > `stall_threshold_ms`(默认 1000,`SourceConfig` 字段,env 键 / `GatewaySettings` 字段 / 构造 kwarg 三层可调,无 per-call 覆盖)的相邻**增量 chunk**(`content` 或 `reasoning_content` 均计,与看门狗活性口径一致;空 chunk/usage 帧/SSE keepalive 不计时)沉默,**仍含 TTFT 与尾部**(末 chunk→流结束归活跃侧,墙钟基底口径的有意选择);1.3.7 的墙钟口径由 `generation_ms + stall_ms` 还原,语义突变经人类"窗口期最小代价"裁决放行(字段发布仅 2 天/消费者唯一/遥测无账本)。归因两字段 `stall_ms`/`stall_count`(沉默总额/超阈值相邻间隔个数,成对报告——Gregg off-CPU 原则);非流式/增量 chunk <2 → None 且不退减(None = 无沉默观测,下游以 `stall_ms is None` 分支);对冲输家的沉默不进赢家快照(落选者在其 attempt 遥测行,被取消者 `hedge_cancelled` 行恒 None)。**换算唯一处纪律(N8)**: TransportResult 携带未取整 `float` stall 值,取整与净额计算(`net = max(0, wall_ms − int(stall_ms or 0))`,clamp 防御)全库只在 retry 层一处,各实现不得另取整;钟同源(N4)——沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)同钟,FakeClock 测试配自控 stall 值的假 transport。观测原语: transport 热路径只做"记时间戳 + set `content_event`",一切计量与触发判定在热路径外从该序列派生。扣沉默是库自造口径,NVIDIA/vLLM 基准口径均不扣沉默,不得混写。
|
||||||
|
|
||||||
|
**中段沉默对冲(2026-09-11,1.3.8,issue #28;设计同上 §5)**: `{SCOPE}__HEDGE__STALL_AFTER_S`(默认关闭)与 1.3.7 `{SCOPE}__HEDGE__AFTER_S` **两阈值独立生效**(可独设可组合);`_past_hedge_window` 为**两阶段触发窗**——阶段 1 首 token 前: AFTER_S 已设才计时触发(仅设 stall 时此阶段不触发,但仍须等首 token 到达才进阶段 2,否则 pre-TTFT 沉默会被 stall 窗误吞);阶段 2 首 token 后: STALL_AFTER_S 循环等待 `content_event`,超时到期复检 `is_set()` 防 chunk 与超时同 pass 误触发、check→clear 之间无 await、每轮自建 waiter 并逐字保留 waiter 收口 + `cancelling()` 穿透纪律;仅设 stall 且请求非流式 → `__call__` 入口门逐字走单路(中段沉默无非流式概念)。触发后编排/记账/遥测全复用 1.3.7(异源、完整准入、准入失败静默放弃、输家取消 est 保留不喂熔断、`hedge_cancelled` 行、两败计一次预算);对冲路 `content_event` 恒 None(v1 不梯次)。**与看门狗的交互**: 主路沉默超 `inter_token_timeout_s` 被掐死时,对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路裁定),attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常。**不对称声明(N9)**: 看门狗掐死**喂**熔断失败(1.3.6 起既有语义)vs 对冲输家取消**不喂**熔断(1.3.7 已批准语义)——同为源沉默事件不同处置,本版不改任一,运维"挂起率"统计须知此不对称。装配硬守卫: `stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,对冲窗永不开)或 `≥ call_deadline_s` → ValueError;单源 scope → warning 允许;Embedding/OCR scope 配任一对冲键 → `__post_init__` warning(B4,装配期可证明纯非流式;chat 的流式性是 per-call 参数,装配期无信号,故 chat 侧无此 warning)。**诚实标注: 中段沉默触发并发副本无业界公开先例**(主流收敛为看门狗掐流 → 重试),属超业界现状能力,人类已明确接受。
|
||||||
|
|
||||||
### 7.3 限流
|
### 7.3 限流
|
||||||
|
|
||||||
**语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。
|
**语义契约**(两后端同一套): `try_acquire(source, est_tokens) -> Permit | 拒绝(含等待提示)`;`Permit.settle(actual_usage)` 按实际 usage 结算(预扣保守入场,多退少补);`Permit.release()` 在 finally 必然执行;permit 带 TTL 租约防进程死亡泄漏。
|
||||||
@@ -562,7 +568,9 @@ flowchart TB
|
|||||||
|
|
||||||
### 7.8 遥测与成本
|
### 7.8 遥测与成本
|
||||||
|
|
||||||
**必录字段**(继承三项目 15 字段规范;当前 36 个 INSERT 字段,物理表列 37 = 36 + 数据库自填的 `created_at`,两套口径的区分见 `telemetry/schema.py` 模块 docstring): call_id、parent_call_id、session_id、model、provider、source_name、messages(JSON)、response、thinking、prompt_tokens、completion_tokens、usage_source、latency_ms、ttft_ms、max_inter_token_ms、cache_hit、error、**cost**、**cached_prompt_tokens**、**model_reported**、**sampling**、**reasoning_tokens**、**tenant_id**、**meta**、**thinking_observation**、**reasoning_effort**、**scope**、**operation**、**logical_call_id**、**event_kind**、**http_status_code**、**error_type**、**cause_type**、**error_body**、**attempts**、**total_latency_ms**。
|
**必录字段**(继承三项目 15 字段规范;当前 39 个 INSERT 字段,物理表列 40 = 39 + 数据库自填的 `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**、**stream_end**(1.3.10,§15)。
|
||||||
|
|
||||||
|
**`stall_ms`/`stall_count` 两列(2026-09-11,1.3.8,issue #27,端口 36 → 38)**: 沉默归因进库,口径见 §7.2 净口径段。取 INTEGER 而非 REAL——落库值是 retry 层已取整的 int,与换算唯一处纪律(N8)一致。追加在 `total_latency_ms` 之后,可空无默认、旧行 NULL 不回填;`emit_cache_hit`/`emit_terminal_failure` 与失败/取消 attempt 行恒 NULL(缓存命中与终态行无 transport 观测;被取消的对冲输家无 TransportResult,其 `hedge_cancelled` 行 stall 恒 NULL,存在性由该行表达)。`generation_ms` **仍不进遥测列**(YAGNI)——墙钟由 `generation_ms + stall_ms` 还原。
|
||||||
|
|
||||||
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
**`sampling` 列(2026-07-31,issue #4,端口 20 → 21)**: 列语义 = 「调用方采样意图 ⊎ 生效源 `extra_body`」的 canonical JSON,空则 NULL。**不含**结构化注入的 `response_format`——列名是采样参数,schema 不是,且数 KB schema 逐行落库会让审计表无谓膨胀。三个 emit 入口口径必须各自定死,否则同一列在不同行含义不同: `emit_attempt`(RetryMW 调用,**唯一**有生效源者)并上 `source.extra_body`;`emit_cache_hit` / `emit_terminal_failure`(前者由 TelemetryMW 最外层调用,后者自 1.3.5 起由三个 client 的公开边界经 `emit_terminal_once` 统一写出)无 source 可言,只记调用级——与 `model`/`source_name` 在终态行置空是同一先例,且缓存命中行无损(`sampling` 已进缓存 key,能命中即意味调用级参数与历史那次逐字相同)。三者统一读 `request.sampling` 而非 `request.overlay`(后者在 RetryMW 处已被结构化注入污染、在 TelemetryMW 处未被污染,直接用必然三行分叉)。OCR/embedding 路径因决策 G 剥离 `extra_body`,该列恒 NULL。
|
||||||
|
|
||||||
@@ -783,3 +791,36 @@ src/polygateway/
|
|||||||
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
| Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) |
|
||||||
| Q4 | conda 环境名 | `PolyGateway` |
|
| Q4 | conda 环境名 | `PolyGateway` |
|
||||||
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |
|
| Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 |
|
||||||
|
|
||||||
|
|
||||||
|
## 14. OpenAI 单 choice 消息保真(2026-10-01,#29 已批准增量)
|
||||||
|
|
||||||
|
本节明确修订 §5/§6.2/§7.1/§7.2/§7.5/§7.6 中原仅文本契约,详细边界见 [设计](designs/2026-10-01-issue29-openai-fidelity-design.md)。不新增 Agent Loop 或第二套治理。
|
||||||
|
|
||||||
|
| 接缝 | 增量契约 |
|
||||||
|
|---|---|
|
||||||
|
| 公共返回 | chat 追加 response_mode=text/openai,默认 text;LLMResponse/TransportResult 末尾追加 completion,内含 assistant message、finish_reason、response_id、usage;旧字段继续文本兼容投影 |
|
||||||
|
| 请求所有权 | messages 字段保真;overlay 仍受 model/messages/stream/stream_options 保护;raw-only 复用既有路径,模式不绕过 managed 校验 |
|
||||||
|
| 协议 | openai 单 choice;工具按 index 排序,name/arguments 逐片拼接;SSE 支持 role/content/reasoning_content/tool_calls,未知增量拒绝;非流式保留消息扩展字段;成功必含 assistant role |
|
||||||
|
| 失败 | 所有 chat 模式 error 帧立即 TransientError;openai 缺 DONE 一律重试、不 salvage;tool-only 成功,纯空/不完整工具失败;text salvage 原语义不变 |
|
||||||
|
| 活性 | 同帧正文/推理/工具实际进展只记一次;role/usage/finish/空帧/重复身份不算;沿既有看门狗、首 token、stall 对冲与唯一取整点 |
|
||||||
|
| 缓存 | 仅 openai key 新增 response_mode 语义维度,默认 text key 不变;完整快照显式重建,缺 completion/role 或无有效正文/工具按 miss(与 transport 共用内核纯值判据,不依赖 transport 实现);历史上游 id 回放,本次治理 id 重建 |
|
||||||
|
| 迁移 | 旧 text 污染无法自动判断,受影响集合必须迁移 namespace/salt;新模式天然隔离;无新遥测列/表,统一 helper 与取消结算不变 |
|
||||||
|
|
||||||
|
本增量不会执行工具;length 结束原因与参数原样交回,由下游判断可执行性。嵌套 JSON 是独立可变快照,不承诺深冻结。许可已由原创 owner 明确批准 Apache-2.0。
|
||||||
|
|
||||||
|
## 15. 统一完成判定(2026-10-14,#30/#31 已授权增量)
|
||||||
|
|
||||||
|
本节修订 §6.2 空补全行、§7 `usage_source` 映射与 §14 协议/失败/缓存三行,详细边界见 [设计](designs/2026-10-14-issue30-31-completion-verdict-design.md)。
|
||||||
|
|
||||||
|
| 接缝 | 增量契约 |
|
||||||
|
|---|---|
|
||||||
|
| 根因 | 1.3.9 以 `[DONE]` 存在、正文非空、字段白名单判定成功,上游正常结局落 Transient 被重试放大;本版以上游 `finish_reason` 与传输完整性为准 |
|
||||||
|
| 内核 | `types.py` 纯函数 `_stream_is_complete` / `_classify_finish` / `_judge_outcome` / `_is_cacheable`,流式、非流式、缓存读写四处共用;只返回判定值,错误由调用点翻译 |
|
||||||
|
| 流完整性 | `[DONE]` → `done`;无 DONE 但 finish 已到且其同帧或之后的 usage 为实测、干净 EOF → `eof_after_finish`;其余为截断:保真不打捞,文本按 `missing_done` |
|
||||||
|
| 结局 | 首匹配表:上游错误 finish → Transient;坏工具(length → ResultInvalid);零产物 + length/过滤 → 保真交付、文本 ResultInvalid;零产物其余 → Transient(保 2026-07-20 裁决);有正文交付;有产物无正文按 finish 分模式 |
|
||||||
|
| 字段 | 五层 null 视缺省;保真未知字段无损合并(字符串拼接、index 数组按 index、标签键幂等),冲突删键 + 有界告警;未知字符串片段计活性 |
|
||||||
|
| 缓存 | 可缓存 ⊂ 可交付:salvaged、无正文、length/过滤/未知 finish 不写,读到即 miss;判定先于命中覆盖 |
|
||||||
|
| 公共面 | `LLMResponse`/`TransportResult` 追加 `finish_reason`、`stream_end`;遥测加 `stream_end` 列,`record_llm_call` 加必填参数 |
|
||||||
|
|
||||||
|
`usage_source` 与 `stream_end` 分工:前者答"账单可信吗",后者答"内容完整吗"。`eof_after_finish ⇒ measured`;`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。诚实边界:中转在上游出错后主动正常关闭连接,与真完整在传输层不可区分。
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
|
|
||||||
音频端口实现(D10)、SDK transport(openai/anthropic 原生协议,D2 预留)、GLM OCR invoker(D9 预留)、内网 pip index(Q1)、多项目共用 Redis 的 namespace 治理、harness-eval 评估流水线激活。
|
音频端口实现(D10)、SDK transport(openai/anthropic 原生协议,D2 预留)、GLM OCR invoker(D9 预留)、内网 pip index(Q1)、多项目共用 Redis 的 namespace 治理、harness-eval 评估流水线激活。
|
||||||
|
|
||||||
|
**已交付增补(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 对冲(现不做)。
|
||||||
|
|
||||||
|
**已交付增补(2026-10-15,1.3.10 已发布)**: 统一完成判定(issue #30/#31,1.3.10)——以上游 `finish_reason` 与传输完整性判定流完成与结局,流式/非流式/缓存读写共用内核纯函数;缺 `[DONE]` 但 finish+实测 usage 完整即成功(两模式),文本 length/过滤无正文改 `ResultInvalidError`,保真模式 null/未知字段无损合并;`LLMResponse` 增 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。设计 designs/2026-10-14-issue30-31-completion-verdict-design.md(人类授权 Claude 与独立 subagent 两轮审查后直接执行),实施 plans/2026-10-14-issue30-31-completion-verdict.md。后续储备:文本 n>1 混流修复、文本"只有思考+stop"证据收集。
|
||||||
|
|
||||||
## 7. 开放决策依赖
|
## 7. 开放决策依赖
|
||||||
|
|
||||||
| 决策 | 阻塞点 | 需拍板时间 |
|
| 决策 | 阻塞点 | 需拍板时间 |
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# 1.3.6:可选调用期限(issue #22),兼 `Retry-After` 有限值防御
|
||||||
|
|
||||||
|
- 状态: **人类已于 2026-09-10 批准**(§9 七项批准项全数获批,H3 取 (a′) 档);实施计划见 `research-wiki/plans/2026-09-10-136-call-deadline.md`
|
||||||
|
- 基线: main `ab00aa4` / 1.3.5,工作区 HEAD `d2455e8`;分支 `feature/1.3.6-call-budgets`
|
||||||
|
- 范围收敛(2026-09-09 人类定夺,2026-09-10 追认): **只做方案 B 的后半**——保留 chat 的 429 免次数预算,新增**可选**整体调用期限,缺省不启用;**不做** 429 次数预算(原 D2/D3)、**不做** issue #24(未批,不得捆绑)
|
||||||
|
- 已批准契约(2026-09-10 逐条定案): `call_deadline_s` 缺省 `None`;单调用参数 `None` = 继承装配值;`CallDeadlineExceeded` 为 `PolyGatewayError` **直接**子类;合作式清理会超出期限、**到期可能丢弃已计费的成功**;chat 429 免次数预算保留原样
|
||||||
|
- 取消结算(TPM)修复**并入本版**: 不再作为「先立独立 issue、修好再启用期限」的前置阻塞,改为本版第一个原子提交(§6.3 矩阵即其精确契约)
|
||||||
|
- 输入: issue #22 原文;设计审查 `a544b789/design136/review.md`(BL1-BL5)与独立复审 B(B1-B5,含离线 asyncio 探针实测);本轮独立探针 `/tmp/pgw_deadline_probe.py`、`/tmp/pgw_deadline_probe2.py`(3.12.13 实测,§5.2);1.3.5 源码逐行现读
|
||||||
|
- 关联: ARCHITECTURE §7.2/§7.3、`designs/2026-08-06-issue8-stall-budget-design.md`、`designs/2026-09-09-135-call-observability-design.md`
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 目标 1 | 让调用方能对**一次逻辑调用**设墙钟上限;不配置时,库行为逐字保持 1.3.5 |
|
||||||
|
| 目标 2 | 修 `Retry-After` **非有限值**防御缺口(`inf`/`1e999` → `sleep(inf)` 永久挂起) |
|
||||||
|
| 目标 3 | 期限是**单一硬边界**,覆盖缓存 IO/准入排队/退避 sleep/transport/结构化重问/embedding 分批,不是轮首软检查 |
|
||||||
|
| 非目标 A | 不新增 429 次数预算、不改 429 退避指数分账、不收紧任何缺省(原 §4.1/§4.2 已删除) |
|
||||||
|
| 非目标 B | 不改三条循环各自的既有差异(chat 429 免预算 + stall 退还;embed/OCR 无条件计数) |
|
||||||
|
| 非目标 C | ~~本设计不含取消路径 `settle(0)` 结算修复~~ → **2026-09-10 改判**: 该修复**已获批并并入本版**,是期限落地前的第一个原子提交(§6.3 给出精确结算矩阵)。它只改**取消路径**的结算取值,不动成功/拒绝/失败三条既有分支的口径 |
|
||||||
|
| 非目标 D | 不做 issue #24(长尾对冲);不新增遥测列;不新增后台任务/`shield` |
|
||||||
|
|
||||||
|
## 2. 1.3.5 现状核实(现读源码,不引用旧报告)
|
||||||
|
|
||||||
|
| 事实 | 证据 | 对本设计的意义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| chat 429 免重试预算并退还 stall 账 | `middleware/retry.py:158-164`、`:250-257` | **保留不动**;期限是正交的第二层 |
|
||||||
|
| embed/OCR 无条件 `fails += 1` | `embedding.py:319`、`ocr.py:339` | **保留不动**;两条循环次数有界、时长无界,由期限兜 |
|
||||||
|
| 退避与 `Retry-After` 取大且不夹上限 | `retry.py:64-77` `max(delay, retry_after)` | 有限大值**照睡**(§6.2 说明为何不夹) |
|
||||||
|
| `_parse_retry_after` 放行 `inf`,但 `nan` **已被忽略** | `transports/openai_compat.py:111-120`:`float()` 成功后 `seconds > 0`;`nan > 0` 恒假 → `nan` 现已返回 `None` | 真实缺口只有 `inf`/`1e999` 一族(唯一一处;`monkey_ocr.py:58` 不解析该头) |
|
||||||
|
| 三个公开边界均在**输入校验之后**建 `_CallContext` | `client.py:381`、`embedding.py:192`、`ocr.py:272` | 期限起点与 `total_latency_ms` 同口径,校验时间不计入 |
|
||||||
|
| 洋葱与两条循环全在同一 `await` 树下 | `client.py:398`、`embedding.py:209`、`ocr.py:274` | 一个 `asyncio.timeout` 即可覆盖全部等待,**无须**改 `backoff_delay`/`SourceAdmission._nap` 签名(解 BL3) |
|
||||||
|
| 终态行唯一出口 + 去重 | `telemetry.py:670-716` `emit_terminal_once` / `types.py:355` `claim_terminal` | 到期终态复用该出口,`error_type` 列自动落新类名,**无新增列** |
|
||||||
|
| 取消路径 attempt 行写 `error="cancelled"` 后穿透 | `retry.py:320-323` | 到期时 attempt 行仍记 cancelled(保留),**终态行**必须记 deadline |
|
||||||
|
| 库内已有 `asyncio.timeout` + `cm.expired()` 范式 | `streaming.py:43-58`、`telemetry/postgres.py:419-423` | 本设计沿用同一范式,不发明新写法 |
|
||||||
|
| `asyncio.timeout` 的**公共契约**: 到期投递取消并在退出时转 `TimeoutError`,外部取消原样上抛,擦边成功不遗留游离取消 | 标准库文档 + 受支持 Python 矩阵上的行为测试(§10"形态区分"批次;复审 B 在 3.12.13 上以离线探针复核) | 外部取消优先与"擦边成功"竞态由运行时判定,库不自判(§5.2);**不依据 CPython 私有实现细节立论**,跨版本保证由测试矩阵给 |
|
||||||
|
|
||||||
|
## 3. 实现范式的三个备选
|
||||||
|
|
||||||
|
| 方案 | 做法 | 判定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **A 单一硬边界(推荐)** | 在三个公开边界各用一次 `asyncio.timeout(d)` 包住整条 `await` 树;到期由运行时取消在途 await,边界处转成 `CallDeadlineExceeded` | 覆盖面天然完整(缓存/准入/sleep/transport/重问/分批);零签名扩散;代价是**到期即取消在途尝试**(§6.3) |
|
||||||
|
| B 轮首软检查 + sleep 夹紧 | 三条循环轮首判剩余预算,退避 `min(delay, 剩余)` | **否决**: 名为期限实为"轮次粒度上限"——一次 300s 的慢尝试或一次准入排队即可整体越限,且要改 `backoff_delay`、`_nap`、三处轮首共 5 个点(BL3 原形态)。用软检查冒充硬期限正是 issue #8 "不要用一个预算冒充另一个预算"的同形错误 |
|
||||||
|
| C 每层各自超时 | 缓存、准入、transport 各配一份超时 | **否决**: N 个键相加才是总时长,调用方仍拿不到"最多等 N 秒"的承诺;配置面爆炸 |
|
||||||
|
|
||||||
|
**推荐 A**,且缺省 `None` = 不启用:opt-in 才能保证存量下游行为逐字不变(1.3.x 内不做默认行为变更)。
|
||||||
|
|
||||||
|
## 4. 方案 A 的具体形态
|
||||||
|
|
||||||
|
### 4.1 新模块 `src/polygateway/deadline.py`(约 50 行,只依赖 stdlib + `errors.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ensure_call_deadline(value: object, origin: str) -> float | None:
|
||||||
|
"""全装配路径共用的值域校验: None 或有限正数,否则 ValueError(消息含 origin)。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ValueError(...) # bool 先判:不得把 True 当成 1 秒
|
||||||
|
v = float(value)
|
||||||
|
if not math.isfinite(v) or v <= 0:
|
||||||
|
raise ValueError(...) # NaN / inf / 0 / 负
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
|
||||||
|
"""给一次逻辑调用施加单一硬边界;None = 不进任何上下文,逐字走旧路径。
|
||||||
|
|
||||||
|
入参已由调用方在**构造 `aw` 之前**校验(见 §4.3),本函数不再校验。
|
||||||
|
"""
|
||||||
|
if deadline_s is None:
|
||||||
|
return await aw
|
||||||
|
inner_timeout: BaseException | None = None
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(deadline_s) as cm:
|
||||||
|
try:
|
||||||
|
return await aw
|
||||||
|
except TimeoutError as exc:
|
||||||
|
inner_timeout = exc # 体内(含清理路径)自抛,不是本层期限
|
||||||
|
raise
|
||||||
|
except TimeoutError as exc:
|
||||||
|
if cm.expired() and exc is not inner_timeout:
|
||||||
|
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
|
||||||
|
raise # 内层失败原样上抛,绝不贴 deadline 标签
|
||||||
|
```
|
||||||
|
|
||||||
|
**为何不能只用 `cm.expired()`(本轮独立探针实测,3.12.13)**: `Timeout.expired()` 在 `EXPIRING`/`EXPIRED` 两态都返 True(`inspect.getsource` 现读),而计时器触发后的**清理路径**若自抛 `TimeoutError`(第三方端口实现自抛,或其内部另一个 `asyncio.timeout` 到期),该异常会被只看 `expired()` 的写法**改标成 `CallDeadlineExceeded`**(探针 E1/E2 实测均为误标)。`__cause__` 启发式也不够: 内层 `asyncio.timeout` 抛的 `TimeoutError` 其 `__cause__` 同样是 `CancelledError`(E1 实测仍误标)。故采用**局部变量身份比较**这一最小辅助机制: 本层 `asyncio.timeout` 转出的是 `raise TimeoutError from exc_val` 新建对象,与体内那个实例必不同一,判据确定、不依赖任何 CPython 私有实现。它**不新增公共配置、不开后台任务、不改异常对象**;`cm.expired()` 作为第二道守卫保留。
|
||||||
|
|
||||||
|
```text
|
||||||
|
探针证据(/tmp/pgw_deadline_probe2.py, python 3.12.13):
|
||||||
|
D1 到期命中 → CallDeadlineExceeded 耗时 0.050s cancelling=0
|
||||||
|
D2 清理内层 timeout → TimeoutError(原样) 耗时 0.060s ← 只看 expired() 会误标
|
||||||
|
D3 清理裸 TimeoutError→ TimeoutError(原样) 耗时 0.050s ← 只看 expired() 会误标
|
||||||
|
D4 未到期内层自抛 → TimeoutError(原样) 耗时 0.010s
|
||||||
|
D5 到期窗口内领域异常 → 领域异常(期限让位) 耗时 0.200s
|
||||||
|
D6 慢清理 → CallDeadlineExceeded 耗时 0.251s(期限 5×)
|
||||||
|
D7 清理期外部取消 → CancelledError cancelling=1(外部取消优先)
|
||||||
|
D8 外部取消先到 → CancelledError
|
||||||
|
D9 未启用(None) → 逐字旧路径
|
||||||
|
```
|
||||||
|
|
||||||
|
写成**接收 awaitable 的函数**而非 `@asynccontextmanager`: 后者要把 `yield` 包进 timeout,取消经 `athrow` 回注生成器,语义正确但绕(`streaming.py:60-70` 的 docstring 已记录同类陷阱);函数形态只有一条直路。`None` 分支**不进** `asyncio.timeout`,故未启用时连"必须在 Task 内运行"这一新约束都不引入。
|
||||||
|
|
||||||
|
**两个实现约束(实施时不得变形)**:
|
||||||
|
|
||||||
|
1. **校验先于构造 awaitable**: 公开方法入口先跑 `ensure_call_deadline`,通过后才构造 `self._handler(request)` 等协程。若把校验放进 `with_call_deadline`,非法参数抛错时会遗留**未 await 的协程**(RuntimeWarning + 未释放资源)。
|
||||||
|
2. **分层合法**: `deadline.py` 只 import stdlib 与 `errors.py`,并作为**新一层**进 import-linter 契约(`pyproject.toml` layers 中置于 `polygateway.thinking` 之下、内核行之上)。`config.py` 位在更上层,import 它合法且**不会依赖任何具体实现**(transports/backends/telemetry 一律不引入)。
|
||||||
|
|
||||||
|
### 4.2 三处接入点(唯一三处;严禁在循环内层再建 scope)
|
||||||
|
|
||||||
|
| 文件:行 | 现状 | 改后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client.py:398` | `response = await self._handler(request)` | `await with_call_deadline(self._handler(request), deadline_s=d, scope=self._scope)` |
|
||||||
|
| `embedding.py:209` | `return await self._embed_all(...)` | 同款包住 `_embed_all(...)`(**整次调用一份**,分批共享) |
|
||||||
|
| `ocr.py:274` | `return await self._run(...)` | 同款包住 `_run(...)` |
|
||||||
|
|
||||||
|
三处均在既有 `try` 之内、`_CallContext` 之后,故到期路径照走 `except PolyGatewayError → emit_terminal_once`(§7)。`StructuredMW._run_ladder`(`structured.py:67-98`)与 `_embed_batch`(`embedding.py:295`)**不得**新建 scope:同级重试/重问/分批共享同一期限,否则期限被轮数放大 N 倍即等于没有。
|
||||||
|
|
||||||
|
### 4.3 装配路径与 per-call 覆盖(实际签名)
|
||||||
|
|
||||||
|
| 层 | 签名变化 | 语义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 配置键 | `{SCOPE}__CALL_DEADLINE_S`,经 `_first` 读(**不用** `_require`,否则是破坏性配置变更) | 未设 = `None` = 不启用 |
|
||||||
|
| `GatewaySettings` | 末尾追加 `call_deadline_s: float \| None = None` + `_validate_call_deadline()` 进 `__post_init__`(见 `config.py:186-193`) | 有默认值,不扰动既有位置构造;校验覆盖 env/直接构造/`dataclasses.replace` 三条路 |
|
||||||
|
| 值域校验 | **全装配路径共用** `deadline.ensure_call_deadline`(§4.1):`GatewaySettings.__post_init__`、三个 client `__init__`、四个公开方法各调一次,实现只一份 | 拒收: `bool`(True 不得当 1 秒)、非数值类型、`NaN`、`inf`、`0`、负数 → `ValueError`(消息含来源)。**不与 `timeout_s` 耦合**:期限短于单次超时是合法选择 |
|
||||||
|
| 三个 client `__init__` | 追加 keyword-only `call_deadline_s: float \| None = None`,**入口即校** | 与 `now`/`sleep`/`rng` 同款注入位;全量注入是正式装配路,不得只靠 `GatewaySettings` 守门(否则 `inf` 静默失效、`NaN` 每次调用当场失败) |
|
||||||
|
| 三条 `from_settings` | 传 `settings.call_deadline_s`(embed/OCR 取 `settings.gateway.call_deadline_s`) | `from_env` 无签名变化(经 settings 透传) |
|
||||||
|
| 四个公开方法 | `chat`/`embed`/`recognize_text`/`parse_layout` 追加 keyword-only `call_deadline_s: float \| None = None` | `None` = **继承装配值**;正数 = 本次覆盖;**不提供"本次关闭"**(需要不同期限就装配两个 client;三态哨兵不值这个公共面复杂度) |
|
||||||
|
| 校验时点 | per-call 值在 `_CallContext` 创建**之前**、也在构造被包裹协程之前校验(与既有三项校验同列;OCR 两个入口经 `_call` 两级透传,与 `image` 校验同列) | 输入校验边界保持:非法期限抛裸 `ValueError`,不进统计边界、不写终态行、不遗留未 await 协程 |
|
||||||
|
|
||||||
|
### 4.4 新错误类型
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CallDeadlineExceeded(PolyGatewayError):
|
||||||
|
"""调用方设定的整体期限到期;不是网关不可用、也不是源故障。"""
|
||||||
|
def __init__(self, *, scope: str, deadline_s: float) -> None:
|
||||||
|
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
|
||||||
|
self.scope, self.deadline_s = scope, deadline_s
|
||||||
|
```
|
||||||
|
|
||||||
|
| 决策 | 取法 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 父类 | `PolyGatewayError` **直接**子类 | 不用 `TransientError`: 那是"退避后可重试、计熔断"的源级瞬时故障,下游按它无限外层重试只会把同一份期限再等一遍;不用 `GatewayUnavailableError`: 其消息硬编码"{scope} 网关暂时不可用"(`errors.py:156`),把调用方自选的期限报告成 scope 死亡,正是要避免的"用一个时钟冒充另一个"(BL4) |
|
||||||
|
| `scope` 字段 | **有** | 多 scope 部署时的诊断分组,免得下游从消息串里解析 |
|
||||||
|
| `retry_after_s` 字段 | **无** | 期限到期不含"何时可再试"的信息;给 `0.0` 会按 `errors.py` 既定语义指示下游**立刻重打仍饱和的渠道** |
|
||||||
|
| `SCOPE_REASONS` | **不新增值** | 它不是 `GatewayUnavailableError` 家族成员,与 `reason` 无关 |
|
||||||
|
| 四分类 | 不变 | 它是**调用方策略**的终止信号,不是四分类里的失败;文档须明写 |
|
||||||
|
| 导出 | 进 `__init__.py` 的 `__all__` | 下游要能 `except CallDeadlineExceeded` |
|
||||||
|
|
||||||
|
**醒目**: `except GatewayUnavailableError` / `except AllSourcesExhausted` 的存量代码**接不住**本异常——这是有意设计,且只在显式配置期限后才可能出现。CHANGELOG / wiki / README 必须以此措辞列出。
|
||||||
|
|
||||||
|
## 5. 时钟与失败模式的边界
|
||||||
|
|
||||||
|
### 5.1 两个时钟不混用
|
||||||
|
|
||||||
|
| 时钟 | 用途 | 纪律 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 事件循环时钟(`loop.time()`,`asyncio.timeout` 内部) | 期限的**唯一**计时源 | 只传**相对时长** `deadline_s`;严禁把 `_CallContext._started`(注入 `now`)加上偏移当绝对截止时刻传进去 |
|
||||||
|
| 注入 `now`(`types.py:335`、三个 client) | `CallStats.total_latency_ms`、`StallClock`、退避 | 不读、不改;测试替换它不会影响期限判定,这一点必须在测试里明确 |
|
||||||
|
|
||||||
|
代价写实: 两者不同源,故 `total_latency_ms` 与 `deadline_s` 之间存在微小偏差(注入钟被伪造时可任意大)。这是**有意**的——统一它们要么强迫调用方注入 loop 钟,要么自建定时器,两者都比这点偏差贵。
|
||||||
|
|
||||||
|
### 5.2 四种"到期周边形态"必须分开
|
||||||
|
|
||||||
|
| 现象 | 判据 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 本层期限到期 | `except TimeoutError` 且 `cm.expired()` | `CallDeadlineExceeded`,终态行记 deadline |
|
||||||
|
| 内层自抛 `TimeoutError`(未到期) | `cm.expired()` 为假 | 原样上抛,不吞不改判(同 `streaming.py:52-58`);探针 D4 |
|
||||||
|
| **到期后清理路径自抛 `TimeoutError`** | `cm.expired()` 为真但异常对象**就是体内那一个**(身份比较) | 原样上抛该 `TimeoutError`,**不改标成 deadline**(探针 D2/D3;只看 `expired()` 的写法在此会误标)。代价: 该异常不是 `PolyGatewayError`,三个边界的 `except PolyGatewayError` 接不住→**无终态行**(与 1.3.5 已有的裸 `TimeoutError` 穿透行为同口径,非本版新增) |
|
||||||
|
| 外部取消 | `asyncio.timeout` 契约:不是本层计时器造成的取消 → `CancelledError` 原样上抛 | 走既有 `except asyncio.CancelledError` 分支,终态行记 `"cancelled"`;**不会**同时出现两条终态行(`claim_terminal()` 去重)。探针 D7/D8 实测: 外部取消**无论先于还是晚于到期**(含清理期到达)都胜出 |
|
||||||
|
| **到期窗口内体内先抛领域异常** | 计时器已触发、取消尚未投递到达时,体内先 `raise AllSourcesExhausted(...)` 等 | **领域异常原样逐层上抛,期限静默让位**;终态行记该领域异常而非 deadline。该窗口在重试循环真实存在(一次尝试刚结束与计时器同刻),复审 B 探针 E2/E8 已实测 |
|
||||||
|
|
||||||
|
故本设计只承诺"到期**通常**得 `CallDeadlineExceeded`",**不承诺 100%**;实现与测试均不得写成无条件断言。擦边竞态(计时器已触发但调用体已成功返回)不会遗留游离取消——这是 `asyncio.timeout` 的公共行为,库不自判形态、也不依赖任何 CPython 私有实现;跨版本保证由受支持 Python 矩阵上的行为测试提供(§10)。
|
||||||
|
|
||||||
|
### 5.3 期限治理的是"等待",不是返回时刻(必须写进 wiki,不得含糊)
|
||||||
|
|
||||||
|
asyncio 是**合作式取消**: 到期只是向任务投递一次取消,真正返回的时刻取决于在途 `await` 何时到达取消点,以及**清理路径**跑多久。已知会在期限之后继续跑的三段:
|
||||||
|
|
||||||
|
| 段 | 位置 | 性质 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| permit 结算与释放 | `retry.py:341` → `admission.py:46-60` | Redis 后端是两次网络往返;不受期限管辖 |
|
||||||
|
| 取消路径的 attempt 遥测行 | `retry.py:320-323` | 一次落库;丢了就丢了本次现场 |
|
||||||
|
| 终态遥测行 | 三个边界的 `emit_terminal_once` | **有意留在期限之外**:诊断行如果自己被期限切掉,期限到期这件事就没有台账 |
|
||||||
|
|
||||||
|
**量级不是毫秒级**: 复审 B 探针 E4 以上述真实形态(取消分支 0.2s 遥测 + `finally` 0.1s 结算)实测:期限 0.05s → 返回时刻 0.351s(**约 7 倍**);本轮独立探针 D6(清理 0.2s)复现同一形态: 0.251s(**5 倍**)。故对外措辞必须是"**返回时刻 = 期限 + 清理耗时**",而不是"最多 N 秒返回";清理耗时取决于 permit/遥测后端,可远超期限本身。§10 以量化断言把越限量变成可观测、可回归的量。
|
||||||
|
|
||||||
|
**反向代价(同等重要)**: 成功之后的旁路 IO **在期限之内**——`middleware/cache.py:199` 的 `_safe_set` 在 `call_next` 返回之后执行,`middleware/telemetry.py:734` 的缓存命中写同理。期限落在这两步 → 一个已完成、**已计费**的 `LLMResponse` 被丢弃,调用方只拿到 `CallDeadlineExceeded`。故 wiki 必须写明"期限到期**不等于**未产出、未计费";本版**不引入 `shield`** 去抢救它。
|
||||||
|
|
||||||
|
取舍是显式的: **宁可超出期限也要留下资源清理与诊断**,而不是引入 `shield`/后台任务去"抢救"(那会把取消语义弄脏,违反"取消可穿透"铁律)。若下游端口实现(自实现 transport / 遥测)在清理里长时间阻塞,期限的超出量就是那段阻塞时长——库不为第三方实现兜底。
|
||||||
|
|
||||||
|
## 6. 与既有算法的关系(明确不改的三件事)
|
||||||
|
|
||||||
|
### 6.1 F1:`Retry-After` 非有限值(纯 bug 修复)
|
||||||
|
|
||||||
|
`_parse_retry_after` 加判据: **解析成功但为无穷**(`inf`/`-inf`/`1e999`,判据 `math.isinf(seconds)`)时显式忽略并记一条 `warning`,按"服务端没给提示"处理,不伪造缺省值。`nan` 维持现状——它被既有的 `seconds > 0` 恒假拦下,**静默 `None` 且不告警**(§2),本版**不给它加告警、不改判据顺序**。
|
||||||
|
|
||||||
|
告警要有主语但不得回显不可信输入,故函数签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`:`source_name` 是**必填 keyword-only 私有参数**(带前导下划线的模块内函数,不属公共面,无需 keyword 默认值兜底),唯一调用处 `_translate_429`(`transports/openai_compat.py:140`)传 `source_name=source.name`。warning 只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串**。其余形态(HTTP-date、空串、负数、不可解析)**维持静默返回 `None`**--HTTP-date 是 RFC 7231 合法形态、空/负是常见噪声,逐次 warning 会在 429 风暴时把真缺陷的信号淡掉。不抛 `RequestRejectedError`:一个坏响应头不该把一次可重试的 429 判死。
|
||||||
|
|
||||||
|
### 6.2 有限大值的 `Retry-After` 不夹上限
|
||||||
|
|
||||||
|
审查 BL1 建议 `min(retry_after, backoff_max_s)`,**本设计不采纳**:夹小的直接后果是提前重打一个明确说了"3600 秒后再来"的饱和渠道,把服务端调度指令改写成库的猜测。有限大值的处置只有一条正路——调用方设期限,由 §4 的硬边界在到期时切断那次 sleep。未配期限即维持 1.3.5 语义(等满 `Retry-After`),这一残余必须在 wiki 明写。
|
||||||
|
|
||||||
|
### 6.3 取消路径结算修复(**已获批,本版第一个原子提交**)
|
||||||
|
|
||||||
|
现状(现读): `retry.py:281 actual = 0` → `:320` 取消分支 → `:341 finally` → `admission.py:46-60 settle_and_release(permit, actual)`;`settle` 算的是 `delta = actual - est`(`backends/memory/limiter.py:48-55`、`backends/redis/limiter.py:136-158`),故 `actual=0` = **把入场预扣的 TPM 整笔退还**。取消发生在 transport 在途时,上游可能已经计费——退款就是把已消耗的额度退回闸里。启用期限后库自己会常规性触发该路径,故先修后启用。
|
||||||
|
|
||||||
|
**精确结算矩阵**(以“这一刻库到底知道什么”为唯一判据;`est` 指 `source.effective_est_tokens()`):
|
||||||
|
|
||||||
|
| # | 取消落点(精确位置) | 库此时知道的事实 | `settle()` 取值 | 本版是否改变 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| S1 | 准入阶段(`admission.pick`:`try_enter` 异常、开路分支) | **确定未调用 transport** | `0`(全额退) | 否(既有行为即此) |
|
||||||
|
| S2 | 退避 `sleep` / 配额轮询 / 熝断等待 | 本轮未持 permit(上一轮已在 `finally` 结清) | 无 permit 可结 | 否 |
|
||||||
|
| S3 | `_attempt` 内、transport 在途(`retry.py:288` 的 await 未返回) | **端口已开始但用量未知** | `est`(delta==0,保留预扣) | **是**——原为 `0` |
|
||||||
|
| S4 | transport 已返回、`actual` 已算出后的任一 await(记账写回/逐次遥测) | **完整 usage 已知**(measured/estimated) | 保留已算出的真实 `actual`,**不得被取消分支覆盖回 `est`** | 否(现行为已正确,修复不得弄坏) |
|
||||||
|
| S5 | **已处理领域失败**分支内的 await(`record_failure`/`_emit`)中途取消 | 已知失败类型,结算决定已算出 | 该分支的决定值:瞬时 = `est`;`SourceDead` = **`0`**;`RequestRejected`/`ResultInvalid` = `0` | 是——决定移到该分支同步处理之后、紧贴首个 await 之前,故 `SourceDead` 的既有 `0` 在取消下**被保住**而不再退化成 `est` |
|
||||||
|
| S6 | OCR 任何位置(`ocr.py:449 settle_and_release(permit, 0)`) | **OCR 无 token 是事实**,不是“未知” | `0` | 否(事实即 0,不得改成 `est`) |
|
||||||
|
| S7 | embedding 与 chat 同构两处(`embedding.py:392-407` 取消分支) | 同 S3/S4 | 同 S3/S4 | **是**(与 chat 同口径同时改) |
|
||||||
|
| S8 | **未被任何 except 接住**的异常(`RuntimeError`、`KeyError` 等未分类逃逸) | 库对用量一无所知,且**不在本版批准范围** | `0`(与 1.3.5 逐字一致) | 否——本版**不**把"端口开始 = 可能已计费"推广到未分类异常 |
|
||||||
|
|
||||||
|
**实现形态**(实施时不得变形;人类只批准了"取消路径"这一条,故语义扩大**必须**被限制在取消分支内):
|
||||||
|
|
||||||
|
`actual` 初值**保持 `0` 不动**;另设一个**局部阶段变量** `settlement_known: bool = False`(纯函数内局部,不是新公共面、不进任何签名、不进配置)。置位规则只有两条——成功路径拿到用量(真实 usage 或 `usage_source == "unavailable"` 的 `est`)后置 `True`;三个**已处理领域失败**分支在**进入分支后的第一条语句**算出结算值并置 `True`。取消分支只在 `settlement_known` 仍为 `False` 时才赋 `actual = est`。
|
||||||
|
|
||||||
|
```python
|
||||||
|
actual = 0
|
||||||
|
settlement_known = False # 局部阶段变量: 该刻库是否已算出确定结算
|
||||||
|
# 成功: actual = 真实 usage 或 est → settlement_known = True
|
||||||
|
# RequestRejected / ResultInvalid: actual = 0; settlement_known = True(分支首句)
|
||||||
|
# SourceDead / Transient: actual = 0 if dead else est; settlement_known = True(分支首句)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if not settlement_known: # 端口已开始、结算未定 → 保守保留预扣
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
三条不变量(同级 `except CancelledError` 接不住其他 `except` 块内的取消,故必须在其首个 await 前先确定结算,而非只靠标志位):① **已确定的值一律不覆写**,包括真实 usage 恰为 `0`(源真返回 0 token 是事实,不是"未知");② "确定结算"的界桩按**当前代码里 failure 记账 await 的前后位置**划定——失败分支的结算决定被前移到 `record_failure` / `_emit` **之前**,故取消无论落在这两个 await 的哪一侧,拿到的都是该失败类型本来的值;③ 未被任何 `except` 接住的异常不经上述任一分支,`actual` 保持 `0` 原样传播(S8)。
|
||||||
|
|
||||||
|
**明确收窄**: 本版**不再**把 S5 泛化成"一切失败按 `est` 结算"。1.3.5 的 `SourceDead` 退全款(`0`)是**有意**的语义(源已判死,不该继续占额度),取消恰好落在它之后时必须保留那个 `0`;"端口开始 = 可能已计费"只是**取消且结算未定**这一格的兜底取值,不是全局通则。
|
||||||
|
|
||||||
|
这样只有取消路径的取值发生改变,其余四条既有路径与未分类异常路径逐字不变(验收矩阵 §10 "结算"批次逐条钉,并新增一条**防越界回归**)。
|
||||||
|
|
||||||
|
**保守结算的诚实边界**(不得写成“修对了”): `await transport.complete(...)` 返回前被取消,只能证明**端口协程已被进入**,不能证明 HTTP 字节已发出、更不能证明上游已计费。故 S3 是一个**保守选择**(宁可多扣不可凭空退款),不是事实性计量;它与既有的“瞬时失败按 `est` 结算”(`retry.py:337`)同一口径、同一理由。库**不新增任何 wire 事件协议**(如“transport 上报字节已发出”)来缩小这个不确定区——那是新公共端口面,且 httpx 层面也给不出可靠信号;不确定性写进文档而不是藏起来。
|
||||||
|
|
||||||
|
**不改且不被本版解决的相邻缺口**(列出以免被读成已修): ① `ResultInvalidError` 路径(如 `embedding.py:350-355` 维度不符、`transports/openai_compat.py:280-308` 响应形态异常)——响应真实返回过(已计费)但仍按 `0` 退全款;② `RequestRejectedError` 同理。两者与取消无关,属另一族记账语义变更,**未获批准即不动**,另行立 issue。
|
||||||
|
|
||||||
|
**共享状态不变**: `permit.release()`、`pacer.leave()`、`breaker.release_probe()`(探针归还)、`mark_progress` 全部保持原样且仍在 `finally`;本修复**只改 `settle()` 的入参取值**,不动限流 Lua、不动端口签名、不动幂等语义。
|
||||||
|
|
||||||
|
## 7. 遥测(无新增列,无新增 DDL)
|
||||||
|
|
||||||
|
| 行 | 到期时的取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| attempt 行 | 被取消的那次仍记 `error="cancelled"`(`retry.py:320-323`)——它描述的是**那次尝试**的真实结局,保留 |
|
||||||
|
| 终态行 | 经既有 `emit_terminal_once` 写出;到期**通常** `error_type='CallDeadlineExceeded'`(例外见 §5.2 第 4 行:体内先抛领域异常时记该异常)、`error` 为其消息串(`telemetry.py:274-305` 既有取值逻辑,零改动),`logical_call_id`/`attempts`/`total_latency_ms` 照旧 |
|
||||||
|
| 计数 | 每逻辑调用至多一条终态行,由 `claim_terminal()` 保证;**不会**同时出现 cancelled 与 deadline 两条 |
|
||||||
|
|
||||||
|
## 8. 变更点清单(反 gold-plating)
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新增文件 | `src/polygateway/deadline.py`(1 个校验函数 + 1 个包裹函数) |
|
||||||
|
| 改动文件 | `errors.py`(1 个类)、`__init__.py`(1 个导出)、`config.py`(1 个键 + 1 个 loader + 1 个守卫 + 1 个字段,守卫直调 `deadline.ensure_call_deadline`)、`client.py`/`embedding.py`/`ocr.py`(各 1 处包裹 + 构造参数 + 公开方法参数 + 入口校验 + `from_settings` 透传)、`middleware/retry.py` 与 `embedding.py` 的 `_attempt`(§6.3 结算矩阵:只改 `actual` 取值 + 1 个**局部**阶段变量 `settlement_known`)、`transports/openai_compat.py`(F1 一行判据 + warning + `source_name` 必填私有 kw 及其唯一调用处)、`pyproject.toml`(import-linter layers 新增 `polygateway.deadline` 一层) |
|
||||||
|
| 直接复用 | `_CallContext`、`emit_terminal_once`、`claim_terminal`、`asyncio.timeout` + `cm.expired()` 范式、`settle_and_release` 单一出口、既有 FakeClock/假 transport 测试设施、限流契约套件(Lua 不改) |
|
||||||
|
| **明确不做** | 不改 `backoff_delay`/`SourceAdmission._nap`/`StallClock`/429 分账/限流 Lua/端口签名;不改 `ResultInvalid`/`RequestRejected` 的结算口径;不加遥测列;不加 `shield`/后台任务;不加 429 次数键;不做 #24 |
|
||||||
|
|
||||||
|
## 9. 集中人类批准项(2026-09-10 全数获批)
|
||||||
|
|
||||||
|
| # | 决策 | 结果 | 不采纳的代价 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| H1 | 采纳 §3 方案 A(单一 `asyncio.timeout` 硬边界),缺省 `None` | **已批** | B/C 只能给轮次粒度或多键相加的"伪期限" |
|
||||||
|
| H2 | `CallDeadlineExceeded` 为 `PolyGatewayError` 直接子类、无 `retry_after_s`、不进 `SCOPE_REASONS` | **已批** | 挂进 `GatewayUnavailableError` 会把调用方的期限报告成网关不可用 |
|
||||||
|
| H3 | 取消结算修复的位置 | **已批 (a′)**: 不再另立前置 issue,改为 **1.3.6 内的第一个原子提交**,口径按 §6.3 矩阵(S1-S8,含 S8 未分类异常维持 `0` 的收窄)逐格定死 | 选 (b) = 把已知记账缺口变成常规路径,正是本项目反复吃过的亏 |
|
||||||
|
| H4 | per-call 覆盖形态: §4.3 的"`None` 继承、无单次关闭" | **已批** | 三态哨兵扩大公共面;完全不给 per-call 则长短调用必须装两个 client |
|
||||||
|
| H5 | §6.2 不夹有限大 `Retry-After`(与审查 BL1 建议相反) | **已批**,残余写进 wiki | 夹小即提前重打饱和渠道 |
|
||||||
|
| H6 | §5.3/§11 的对外承诺形态: 期限治理**等待**,返回时刻 = 期限 + 清理耗时(实测可达 5-7 倍),且到期可能丢弃已计费成功 | **已批**,不引入 `shield` | 写成"最多 N 秒返回"会让下游上层超时被整片击穿 |
|
||||||
|
| H7 | issue #22 关闭判据 = "调用方**能配置**上限";#24 保持 open、**本版不实现** | **已批** | 见 §11 |
|
||||||
|
|
||||||
|
实施边界不得再扩: 本表之外的任何公共面变化(新配置键、新端口方法、新遥测列、其它记账口径变更)均属**未批准**,需停下来报。
|
||||||
|
|
||||||
|
## 10. 离线验收矩阵(实施时须先失败后通过;全部不触网、不付费)
|
||||||
|
|
||||||
|
| 批次 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 未启用回归 | `call_deadline_s=None` 时三条链路行为逐字不变:现有 `tests/unit/test_retry.py`、`test_backpressure.py`、`test_embedding.py`、`test_ocr_client.py`、`test_client.py` 全绿(不改一行断言) |
|
||||||
|
| 期限命中 | 假 transport 真实 `await asyncio.sleep(0.3)`、`call_deadline_s=0.05` → 抛 `CallDeadlineExceeded`,`scope`/`deadline_s` 正确;计时范式沿用 `tests/unit/test_streaming.py` 的真实 loop 时钟 + 4-10× 余量(已验证稳定,不标 slow) |
|
||||||
|
| 覆盖面 | 分别令 ①退避 sleep(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 各自超期 → 均抛 `CallDeadlineExceeded`;embedding 断言 N 批**共享一份**期限(总时长不随批数放大) |
|
||||||
|
| 形态区分 | ①内层自抛 `TimeoutError`(假 transport 直抛)且未到期 → 原样上抛,不变成 deadline;②外部 `task.cancel()` → 仍抛 `CancelledError`,终态行 `error="cancelled"`;③擦边成功(transport 耗时略小于期限)→ 正常返回,无游离取消;④**到期窗口内体内先抛领域异常**(自旋构造确定性窗口)→ 上抛该领域异常、终态行记它,**不断言必为 deadline**;⑤**到期后清理自抛 `TimeoutError`**(假端口在取消分支里 `raise TimeoutError`)→ 原样上抛该 `TimeoutError`,**断言不是 `CallDeadlineExceeded`**(钉住身份比较机制;只看 `cm.expired()` 的写法在此变红) |
|
||||||
|
| 结算(§6.3 矩阵逐格) | S1 准入取消 → `tpm_used` 回到 0;S3 transport 在途取消 → `tpm_used == est`(**先失败后通过**的核心红绿);S4 取消落在 usage 已知之后 → `tpm_used == 真实 usage`(不被 `est` 覆盖);S6 OCR 取消 → `tpm_used == 0`;S5 取消落在 `SourceDead` 的 `record_failure` await 中途 → `tpm_used == 0`(**不得**变成 `est`);**S8 防越界回归**: 假 transport 抛 `RuntimeError`(未分类)→ `tpm_used == 0` 且异常原样上抛;成功/`SourceDead`/`RequestRejected`/`ResultInvalid` 四条既有路径结算值逐字不变;真实 usage 恰为 0 的成功 → `tpm_used == 0`;permit `release`/`pacer.leave`/`release_probe` 调用次数不变 |
|
||||||
|
| 终态遥测 | 到期恰好一条 `event_kind='terminal_failure'` 行,`error_type='CallDeadlineExceeded'`;被取消的 attempt 行仍为 `cancelled`;两者 `logical_call_id` 一致;列数不变 |
|
||||||
|
| 清理不可越过(含量化) | 到期时 permit 的 `settle`/`release` 与 pacer `leave` 仍被调用(假 permit 记账断言);`CancelledError` 未被吞;**量化断言**: 注入已知 sleep 的假 permit/假 emitter → 返回时刻 ≈ 期限 + 已知清理时长(可远大于期限本身) |
|
||||||
|
| 成功旁路被切 | 假缓存后端 `set` 慢于期限 → 抛 `CallDeadlineExceeded` 且断言上游 transport **已成功调用一次**(已计费成功被丢弃,§5.3),把该行为钉住而非留作偶然 |
|
||||||
|
| 注入钟无关 | 伪造注入 `now`(跳变 10^6 秒)不触发期限;反之期限触发时 `total_latency_ms` 仍来自注入钟 |
|
||||||
|
| 配置(全装配路径) | 键未设 → `None`;`0`/负/`nan`/`inf`/非数/`True`(bool 不得当 1 秒)→ `ValueError` 且消息含来源——**四条路各测一遍**: env、`GatewaySettings` 直接构造、`dataclasses.replace`、**三个 client `__init__` 直传**;per-call 非法值在构造协程前抛错且**无 "coroutine was never awaited" 警告**;`call_deadline_s < timeout_s` **不报错**(允许) |
|
||||||
|
| F1 | `Retry-After` 为 `inf`/`-inf`/`1e999` → 按无提示处理且各记一条 warning(**断言日志含源名、不含原始字符串**);`nan`/空/负/HTTP-date → 静默 `None` 且**无 warning**;`_parse_retry_after` 的 `source_name` 为必填 kw(漏传即 `TypeError`);有限正数仍取大;`insufficient_quota` 仍归 `SourceDead` |
|
||||||
|
|
||||||
|
## 11. issue 闭环判据
|
||||||
|
|
||||||
|
| issue | 判据 | 措辞纪律 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #22 | **可关闭**: 调用方通过 `{SCOPE}__CALL_DEADLINE_S` 或 per-call 参数即可给一次调用设上限 | 措辞必须是"**期限治理的是等待,不是返回时刻**;返回时刻 = 期限 + 清理耗时(取决于 permit/遥测后端,实测可达数倍)",不得写成"最多等 N 秒"的硬保证;同时写明"到期不等于未产出、未计费"(§5.3)与"**不配置就保持 1.3.5 旧语义**"(纯 429 序列仍可能长时间等待、有限大 `Retry-After` 仍照睡)——三句均需出现在 CHANGELOG、wiki 与 issue 关闭说明的显要位置 |
|
||||||
|
| #24 | **保持 open、本版不实现**(未获批准),另行设计 | 期限只让长尾**更早失败**,与"更快成功"是两件事;任何文档不得把前者写成后者,也不得因本版而声称 #24 缓解 |
|
||||||
|
|
||||||
|
## 12. 待补证据与残余风险(诚实标注)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 取消是否让上游停止生成与计费 | 无一手证据 → §6.3 的 S3 只能是**保守选择**而非事实计量;不做任何"取消即省钱"或"已修准"的表述 |
|
||||||
|
| 保守结算的反向偏差 | S3 在“transport 确实未发出字节”时会**多扣** `est`(直到窗口滞后自然过期);与“凭空退款击穿网关”相比这是有意选定的方向(降级方向铁律),但必须写进 CHANGELOG |
|
||||||
|
| 未分类异常的结算 | `RuntimeError` 等未被四分类接住的异常仍按 `0` 退全款(S8)——人类只批准了取消路径,本版**不**将保守口径扩到它们;它们理论上同样可能发生在 transport 在途之后,属已知残留,需时另立 issue |
|
||||||
|
| `ResultInvalid`/`RequestRejected` 的退全款 | 本版**不改**(§6.3 末段);它们与取消无关,属未批准的另一族记账语义变更 |
|
||||||
|
| 跨 Python 版本的取消/超时语义 | 本设计只依赖 `asyncio.timeout` 的**公共行为**与异常对象身份比较,不引 CPython 私有实现作保证;保证由 §10"形态区分"在受支持 Python 矩阵上的测试提供(本轮探针仅覆盖 3.12.13) |
|
||||||
|
| 清理自抛 `TimeoutError` 时无终态行 | 该异常不是 `PolyGatewayError`,三个边界的 `except` 接不住(§5.2);与 1.3.5 已有的裸 `TimeoutError` 穿透同口径,本版不扩大也不修补 |
|
||||||
|
| #22 现场 46.7s/20min 数字 | 未复跑;本设计不依赖其数值,只依赖路径成立性(已由源码证明) |
|
||||||
|
| 第三方端口实现的清理耗时 | 不可控,直接构成期限超出量(§5.3) |
|
||||||
|
| 期限与 `stall_window_s` 的联合调参建议 | 本版不给推荐值:两者治理对象不同(调用方意志 vs scope 活性),给一个"经验公式"就是把两个预算再次绑死 |
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# 长尾对冲请求(issue #24)设计
|
||||||
|
|
||||||
|
- 状态: **已批准**(2026-09-10 人类批准 §9 全部批准项 H1–H7,含增补 H8:`CallStats` 增 `generation_ms`/`hedge_won` 裸生成时间字段);本文件只做设计与权衡,不含实现
|
||||||
|
- 基线: main `166b286` / 1.3.6(已发布,含 `call_deadline_s` 与取消结算修复)
|
||||||
|
- 输入: issue #24 原文(非流式挂起后正常 200:20 次里 5 次超 60s、中位 15.1s、真实负载 13% 调用吃掉 71% 模型总时间、慢调用输出中位 186 token——在等不在生成、90–96s 窄峰疑似源侧固定机制)
|
||||||
|
- 关联: `designs/2026-09-09-136-call-budgets-design.md`(期限与取消结算,本设计直接站在其 S3 格上);ARCH §6.4 取消语义、§7.2 重试、§7.3 限流;`designs/2026-08-06-issue8-stall-budget-design.md`
|
||||||
|
|
||||||
|
## 1. 目标与非目标
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 目标 1 | 非流式请求挂起超过阈值时,并发向**另一个等价源**再发一次,先回者赢,输家取消——把 p99 从"挂起时长"压到"阈值 + 健康源耗时" |
|
||||||
|
| 目标 2 | 流式请求以 **TTFT 未至**为触发判据(不误杀慢生成);非流式无 TTFT 可观测,用总时长阈值 |
|
||||||
|
| 目标 3 | 默认关闭;开启后的一切行为(配额、熔断、遥测、结算)可观测、可对账 |
|
||||||
|
| 非目标 A | embedding / OCR 不做对冲(无 TTFT 概念、issue 未涉、无配置面) |
|
||||||
|
| 非目标 B | 不做滚动分位数触发(`AFTER_PERCENTILE`);不做同源对冲;不加遥测新列(默认档) |
|
||||||
|
| 非目标 C | 不改 `call_deadline_s`/`deadline.py`/限流 Lua/429 分账;不改既有四分类 |
|
||||||
|
|
||||||
|
## 2. 现状核实(现读 1.3.6 源码,不引用旧报告)
|
||||||
|
|
||||||
|
| 事实 | 证据 | 对本设计的意义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 非流式路径是单 JSON 响应,**仅 total 超时**,无中途进度信号 | `transports/openai_compat.py:646-654`(`_complete_once` docstring 原文)、`:684 ttft_ms=None` | 非流式的对冲触发**物理上只有总时长阈值**一种;"挂起 vs 慢生成"在非流式不可分,只能靠阈值取值与成本上限控制误对冲 |
|
||||||
|
| 流式首 token 观测点已存在 | `openai_compat.py:569-575`(`ttft_ms` 首次赋值处) | TTFT 事件信号只需在该点 `event.set()`,探测成本近零 |
|
||||||
|
| `Transport.complete` 是公共端口签名,库内唯一实现 | `ports.py:51-60` | 加首 token 事件参数 = **公共端口签名变更**,必须进批准项(H2) |
|
||||||
|
| 每次尝试 = 选源 → 熔断门 → 限流 permit → transport,全在 `_attempt` 内 | `middleware/retry.py:276-353`;准入编排 `middleware/admission.py:171-213`(`pick`) | 对冲 = **并发跑第二次 `_attempt`**,配额/熔断/结算/pacer 全部复用,无需发明第二套准入 |
|
||||||
|
| 取消路径结算: `settlement_known=False` 时保留预扣 est | `retry.py:327-331`(1.3.6 S3 格);finally 结算 `:352-353` → `admission.py:46-60` | 对冲输家走取消路径,**结算语义现成**:额外成本上限 = 一份 est 预扣滞留 |
|
||||||
|
| 取消的 attempt 行记 `error="cancelled"` 后穿透 | `retry.py:332-334` | 输家遥测只需换一个区分字符串,零新列(§4.5) |
|
||||||
|
| `_CallContext` 承诺"每调用一个实例的**单任务**对象,计数无需锁" | `types.py:321-337`;`register_attempt` `:339-345`、`claim_terminal` `:354-360` 均为无 await 同步方法 | 对冲引入第二个并发任务,该 docstring 承诺须修订;同步方法在事件循环内天然任务安全(无 await 间隙),机制零改动 |
|
||||||
|
| 成功响应在**返回前**冻结 `call_stats` 快照 | `client.py:442`(`dataclasses.replace(response, call_stats=context.snapshot())`) | 输家取消收口必须**先于**快照,否则 `attempts` 漏计输家(§4.5) |
|
||||||
|
| 期限包整棵树,缺省 None 不进上下文 | `deadline.py:64-86`;接入点 `client.py:419-421`;配置 `config.py:190`、`:695-715` | deadline 与 hedge 正交组合,`deadline.py` 零改动(§6) |
|
||||||
|
| 429 免重试预算且耗时退 stall 账 | `retry.py:158-164`、`:250-257` | 对冲轮内某任务 429 的免预算语义沿用 attempt 级既有机制(§4.6) |
|
||||||
|
| 配置键两段/三段式天然跳过 `_load_sources`;保留段防撞名 | `config.py:401-407`(len==4 判定)、`:57`(`_RESERVED_SEGMENTS`) | 新键 `{SCOPE}__HEDGE__AFTER_S` 为 3 段,天然不被当源字段;`HEDGE` 须加进保留段(§5) |
|
||||||
|
|
||||||
|
## 3. 备选方案与权衡
|
||||||
|
|
||||||
|
| 维度 | **方案 A:并发对冲(推荐)** | 方案 B:取消式投机重试 | 方案 C:仅流式对冲,非流式只靠 deadline |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 做法 | 阈值到 → 并发向异源发第二次 `_attempt`,`asyncio.wait(FIRST_COMPLETED)`,赢家返回、输家 `cancel()` 并 await 收口 | 阈值到 → 取消在途 attempt,按可重试失败走既有换源重试循环 | 只对 `stream=True` 做 TTFT 对冲;非流式维持 1.3.6 现状(期限切长尾) |
|
||||||
|
| 挂起请求的信号处理 | 输家只是"被取消",**不喂熔断/健康分**——挂起的请求最终正常 200,记 failure 是错误信号(源没坏,是这一跳排队) | 必须新造一类"挂起失败":复用 Transient 会把未死源喂进熔断失败计数(`retry.py:340/346-348`),污染熔断与健康分;新造免预算类别 = 又一类四分类外特例 | 同 A(但只覆盖流式) |
|
||||||
|
| 重试预算 | 对冲不消耗 `max_attempts`——它是"一次尝试的加速形态" | 消耗预算(3 次挂起即 `AllSourcesExhausted`),除非新造免预算类 | 同 A |
|
||||||
|
| 尾部赢面 | 原请求"后发先至"时仍可用其成果;尾部的尾部 = min(两路) | 原请求成果恒被丢弃;延迟恒 = 阈值 + 重试耗时 | 流式同 A;非流式尾部 = 期限(更晚失败,不是更快成功) |
|
||||||
|
| 成本 | 对冲窗口内两路并发,输家可能被上游计费 + 一份 est 预扣滞留 | 取消更早(阈值即取消),已计费浪费**更少** | 最低(覆盖面也最小) |
|
||||||
|
| 实现量 | 大:对冲编排 + 端口加参 + 并发收口 + 遥测区分 | 约为 A 的 1/3:阈值计时器 + 取消 + 失败归类 | 中:同 A 但免非流式分支 |
|
||||||
|
| 解决 issue 现场 | 是(issue 复现即非流式) | 是 | **否**——issue 的现场就是非流式,等于没解决 |
|
||||||
|
|
||||||
|
**关键判断**: B 的性价比看似更高(issue 数据显示挂起峰在 90s+,原请求几乎不可能后发先至),但它要回答一个 A 不用回答的问题——"挂起中的源该不该记失败"。记,则熔断/健康分被一次排队事件污染(双峰窄峰指向源侧固定机制,不是源死亡);不记,则要在四分类外新造语义。A 让输家落进 1.3.6 已有的取消路径,**零新分类语义**,且对冲拿不到配额时自然静默(饱和期不添乱)。C 不解决原问题,仅列为范围收缩的退路。
|
||||||
|
|
||||||
|
**推荐 A**;B 作为"预算敏感且接受熔断语义代价"的降级备选保留在批准项(H1)中由人类定夺。
|
||||||
|
|
||||||
|
## 4. 方案 A 的具体形态
|
||||||
|
|
||||||
|
### 4.1 触发条件(设计问题 1)
|
||||||
|
|
||||||
|
| 调用形态 | 触发判据 | 机制 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 流式 | 已过 `hedge_after_s` **且首 token 事件未置位** | `Transport.complete` 加 keyword-only 参数 `first_token_event: asyncio.Event \| None`(必填,不设默认值,与端口既有约定同款);`OpenAICompatTransport` 在 `openai_compat.py:573-575` 首 token 处 `set()`。阈值计时器 = 等待该事件,超时即触发 |
|
||||||
|
| 非流式 | 已过 `hedge_after_s`(纯总时长阈值) | `_complete_once` 物理上无中途信号(`openai_compat.py:646-654`),事件**永不置位**直到完成——同一套"等事件超时"机制自然退化为时间阈值,**零分支** |
|
||||||
|
| 分位数触发 | **v1 不做** | 滚动分位数需要 per-source 状态窗口,跨进程部署还得进 Redis;issue 的双峰形态(主峰 0–10s vs 挂起峰 90s+)用绝对阈值区分度已足够。保留为未来扩展 |
|
||||||
|
|
||||||
|
- "非流式不对冲只做 deadline"已被方案 C 覆盖并否决(不解决 issue 现场);但**配置层面允许只对流式生效**——`stream=False` 的调用方若不接受误对冲成本,可不配阈值。
|
||||||
|
- 误对冲的代价有界:最多 `hedge_max_extra` 次额外请求/逻辑调用,输家记账见 §4.4。
|
||||||
|
- 时钟纪律同 deadline(136 设计 §5.1):只用**相对时长 + 事件循环钟**,不读注入 `now`——测试伪造注入钟跳变不得触发对冲,验收矩阵钉住。
|
||||||
|
|
||||||
|
### 4.2 对冲目标 = 异源(设计问题 2)
|
||||||
|
|
||||||
|
| 决策 | 取法 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 同源 or 异源 | **异源,且仅异源** | issue 观测的 90–96s 固定窗口窄峰指向源侧机制;同源对冲 = 给同一队列再排一个号,徒增成本 |
|
||||||
|
| 等价源定义 | 同 scope 内 `SourceAdmission.pick` 正常排序选出的下一个候选——等价性由 **scope 语义**承诺(同 scope 源本就可互换,同 model 集合是常态),对冲层不发明新的等价概念 | 复用既有选源排序、冷却备忘、调用内降权(`admission.py:63-127`),不新建"等价类"配置维度 |
|
||||||
|
| 排除当前源 | `pick` 加**私有**排除参数(如 `exclude: frozenset[str]`);对冲任务以在途源名为排除集 | 改动收敛在 middleware 内部,不碰公共端口 |
|
||||||
|
| 无候选可用 | 单源 scope / 其余源全冷却、开路、配额满 → **放弃本次对冲**,继续等原请求 | 对冲是优化不是权利;单源 scope 配了阈值 = 装配期 warning、运行期自然静默(§5) |
|
||||||
|
| 同源对冲开关 | 不做(YAGNI) | 配置面少一个维度;真出现"源内分片排队"形态再立 issue |
|
||||||
|
|
||||||
|
### 4.3 准入不独立:对冲走完整准入(设计问题 3)
|
||||||
|
|
||||||
|
对冲请求**照常走** QuotaGate + BreakerGate + pacer + 冷却备忘(`admission.py:171-213` 的完整 `pick` 路径),**不给旁路**:
|
||||||
|
|
||||||
|
| 情形 | 行为 | 对齐 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 配额满 / 被熔断 / pacer 超限 | 放弃本次对冲,原请求继续等(不抛错、不排队硬等) | 对冲若绕闸,源挂起风暴时并发翻倍打进正在排队的网关——正是限流铁律要防的击穿;拿不到配额时自然静默,饱和期不添乱 |
|
||||||
|
| 限流/熔断后端不可用 | `try_acquire`/`try_enter` 抛 `GovernanceBackendError`,照常冒泡 | 铁律"后端不可用 → 报错而非放行",对冲分支不新增降级面 |
|
||||||
|
| permit 持有 | 赢家输家各持各的 permit,各自 `finally` 结算释放(`retry.py:352-353`) | 与两个独立并发调用完全同构,限流契约零改动 |
|
||||||
|
|
||||||
|
### 4.4 成本与取消记账(设计问题 4)
|
||||||
|
|
||||||
|
| 角色 | 结算 | 依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 赢家(先成功) | 正常成功路径:`settle(实际 usage)`;`usage_source="unavailable"` 时按 est | `retry.py:300-308` 既有分支,零改动 |
|
||||||
|
| 输家(被取消) | 落 1.3.6 取消 S3 格:transport 在途、结算未定 → `settle(est)`(**保留预扣,不退款**) | `retry.py:327-331`;上游可能已对输家计费,est 保留是保守下限——与期限到期同口径,文档明写"对冲掉的那次可能已计费" |
|
||||||
|
| 输家(取消前已真失败) | 走既有失败分支结算(dead=0/瞬时=est) | `retry.py:346-347`,取消落点决定取值,S5 机制已覆盖 |
|
||||||
|
| 对冲轮内 429 | attempt 级免预算 + stall 退还照既有机制 | `retry.py:158-164` |
|
||||||
|
|
||||||
|
**计费对账口径**:一次逻辑调用对冲一次的最大额外成本 = 一份 est 预扣滞留(窗口过期自动释放)+ 输家已被上游计费的不可观测部分。下游要能算出"对冲浪费多少钱"——靠 §4.5 的遥测区分,而不是新记账通道。
|
||||||
|
|
||||||
|
### 4.5 并发安全与遥测区分(设计问题 5)
|
||||||
|
|
||||||
|
| 关注点 | 设计 |
|
||||||
|
| --- | --- |
|
||||||
|
| `_CallContext` 共享 | 两个对冲任务共享同一个 context(同一逻辑调用)。`register_attempt`/`claim_terminal`/`snapshot` 均为**无 await 同步方法**(`types.py:339-360`),事件循环内任务并发调用天然安全,机制零改动;但 `types.py:321-337` docstring 的"单任务对象"承诺须修订为"单逻辑调用、可多任务并发登记"。增补 H8 后 context 再持 `_hedges`/`_generation_ms`/`_hedge_won` 三个计数与 `register_hedge`/`record_generation` 两个同步方法,任务安全性与 `register_attempt` 同款 |
|
||||||
|
| 逻辑调用 ID | 不变:两任务共享 `logical_call_id`;各 attempt 独立 `call_id`(uuid4,`retry.py:279`) |
|
||||||
|
| 快照时点 | 赢家产生 → 输家 `cancel()` 并 **await 收口完毕**(输家 finally 的结算/遥测跑完)→ 才允许 `client.py:442` 的快照返回。`attempts` 因此恒含输家(=2),`total_latency_ms` 含输家清理耗时——与 deadline"返回时刻 = 期限 + 清理耗时"同口径 |
|
||||||
|
| 任务泄漏 | 编排用 `asyncio.wait(FIRST_COMPLETED)` + 显式收口;取消优先铁律不变:外部取消到达时两任务都被取消并穿透,不 shield、不留后台任务(ARCH §6.4) |
|
||||||
|
| 遥测行区分(默认档,零新列零 DDL) | 输家 attempt 行 `error="hedge_cancelled"`(与既有 `"cancelled"` 同通道,`retry.py:327-334` 同款字符串);赢家 attempt 行照常;`CallStats` **只增**三字段(`types.py:296-318` 既有快照对象,经 `LLMResponse.call_stats` 既有通道带出,`types.py:425`;三字段全带默认值,1.3.6 及以前构造的 `CallStats(...)` 位置调用不炸):`hedges: int = 0`(本次调用**实际并发发出**的对冲路数;触发但准入失败静默不计)、`generation_ms: int = 0`(裸生成时间,口径见下行)、`hedge_won: bool = False`(赢家是否对冲路) |
|
||||||
|
| `generation_ms` 口径(增补 H8) | **赢家那次 transport 调用的墙钟时长**(HTTP 发出到响应收完):chat/对冲 = 赢家那次;无对冲 = 成功那次 attempt;结构化重问 = 最后一轮(覆盖语义,每轮成功覆写);embedding = 各批 transport 时长之和(累加语义);OCR = 单次;缓存命中 = 0(未产生 transport 调用,0 是实测而非"未知")。**排除** admission 排队/backoff/对冲触发前等待/清理遥测;计时点收敛在三条链路 `_attempt` 的 transport 调用两侧,用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义);对冲编排裁定赢家后才写入 context,输家(含两路同时完成的竞速落选者)的值一律丢弃 |
|
||||||
|
| 对前端有用的对冲参数(增补 H8 取舍) | **纳入** `hedges` + `hedge_won` + `generation_ms`(经 CallStats 既有通道带出,零遥测新列);**不纳入**每路 attempt 分别耗时/输家身份——那是运维诊断面,遥测 DB attempt 行已有 `hedge_cancelled` 标签与同 `logical_call_id` 可 join 还原,不重复进公开响应。`generation_ms` 与 `total_latency_ms` 的**差值即波动开销**(等待/退避/准入/对冲触发前耗损),前端可直接展示"在等不在生成" |
|
||||||
|
| `hedge_cancelled` 标签机制 | 编排在 `cancel()` **之前**给输家任务置位标记(如 `task._polygateway_hedge_loser = True`);`_attempt` 的 CancelledError 分支读标记选 `"hedge_cancelled"`/`"cancelled"`。外部取消与对冲取消竞速时可能误贴——两任务同消、记账方向一致(est 保留),标签误贴不造成结算或熔断错误,属可接受并明写 |
|
||||||
|
| 遥测行区分(备选调) | attempt 表加 `hedge_role` 列(`NULL/'primary'/'hedge'`,PG/SQLite 各一次 DDL)——遥测列变更代价有 issue #12/#13/#15 教训,v1 不推荐;列进批准项(H3)由人类定夺 |
|
||||||
|
| 熔断/健康信号 | 输家取消**不喂**失败、赢家照常记成功——挂起不是源死亡证据(§3 关键判断) |
|
||||||
|
|
||||||
|
### 4.6 编排形态与失败汇合
|
||||||
|
|
||||||
|
- 对冲轮 = 一次"超级尝试":首个成功即本轮结果;**两任务都失败**才进既有重试循环,且 `fails += 1` 只计一次(对冲是加速形态,不是两次独立尝试;429 的免预算/refund 仍在 attempt 级生效)。
|
||||||
|
- 原 attempt 先失败、对冲在途 → 直接等对冲结果,不重试;对冲先失败、原 attempt 在途 → 继续等原 attempt(等价于未触发对冲)。
|
||||||
|
- `retry` 循环骨架(`retry.py:228-263`)、退避、stall 判定全部不变;变化收敛在"单轮尝试的内部从单任务变任务组"。
|
||||||
|
|
||||||
|
## 5. 配置面(设计问题 6;默认必须关闭)
|
||||||
|
|
||||||
|
| 键 | 值域 | 缺省 | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `{SCOPE}__HEDGE__AFTER_S` | 有限正数秒,复用 `ensure_call_deadline` 同款值域校验(`deadline.py:20-42`) | **未设 = 关闭** | 3 段键天然跳过 `_load_sources`(`config.py:401-407`);`HEDGE` 加进 `_RESERVED_SEGMENTS`(`config.py:57`)防 provider 段撞名 |
|
||||||
|
| `{SCOPE}__HEDGE__MAX_EXTRA` | int ∈ [1,3] | 1 | 每次逻辑调用最多并发对冲几路;>1 仅对冲再挂起时梯次追加 |
|
||||||
|
|
||||||
|
装配路径与期限同款(136 设计 §4.3 形态):`GatewaySettings` 末尾追加两字段 + `__post_init__` 新守卫(`config.py:190-201` 同列);`GatewayClient.__init__` keyword-only 参数**入口即校**(`client.py:239-241` 同列);`from_settings` 透传,`from_env` 无签名变化。
|
||||||
|
|
||||||
|
| 守卫 | 判定 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError` | 对冲永不可能触发,配置即错误(与 `_validate_probe` 同款装配期炸掉哲学,`config.py:346-355`) |
|
||||||
|
| `hedge_after_s ≥ min(ttft_timeout_s)`(仅设有该键的源) | 装配期 **warning** | 流式档挂起已被 TTFT 看门狗先行切断(`openai_compat.py:561-566`),对冲形同虚设;非流式仍有效,故不升 ValueError(与 `stall_window_s ≥ max(ttft_timeout_s)` 同型交叉守卫先例,`config.py:336-344`) |
|
||||||
|
| 单源 scope 设了阈值 | 装配期 **warning**,允许 | 源集合可运行期之外的配置演进;运行期拿不到候选自然静默(§4.2) |
|
||||||
|
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError` | 期限先于对冲触发,对冲形同虚设(§6) |
|
||||||
|
| chat() per-call 覆盖参数 | **不提供** | 对冲阈值是源/渠道特性,不是任务特性(期限有 per-call 是因为任务耐心不同);需要不同阈值就装配两个 client |
|
||||||
|
|
||||||
|
## 6. 与 `call_deadline_s` 的关系(设计问题 7)
|
||||||
|
|
||||||
|
| 维度 | hedge | deadline |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 语义 | **提前换路**:提高 deadline 内拿到结果的概率 | **最终保险**:超过耐心即终止(治理等待) |
|
||||||
|
| 层级 | RetryMW 单轮尝试内部 | 公开边界包整棵树(`client.py:419-421`),对冲编排在树内,`deadline.py` 零改动 |
|
||||||
|
| 独立配置 | 可只配 hedge(无期限) | 可只配 deadline(1.3.6 现状) |
|
||||||
|
| 组合 | `hedge_after_s < call_deadline_s`(装配守卫强制);典型:`timeout_s=300, hedge_after_s=8, call_deadline_s=120` | 输家取消的清理耗时不受期限管辖,沿用"返回时刻 = 期限 + 清理耗时"措辞(136 设计 §5.3);**per-call 覆盖** `chat(call_deadline_s=X)` 使 X < hedge_after_s 时,该次调用对冲不触发(deadline 先切整棵树),属合法语义不告警——装配守卫只管默认值,per-call 是调用方的当次选择 |
|
||||||
|
|
||||||
|
两者回答不同问题:deadline 让长尾**更早失败**,hedge 让调用**更快成功**——文档不得混写(136 设计 §11 已立此措辞纪律)。
|
||||||
|
|
||||||
|
## 7. 变更点清单(反 gold-plating;实施前置零)
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 改动 | `middleware/retry.py`(单轮尝试 → 任务组编排 + 对冲计时 + 输家 `hedge_cancelled` 遥测 + `_attempt` transport 级计时点);`middleware/admission.py`(`pick` 加私有排除参数);`ports.py` + `transports/openai_compat.py`(`complete` 加 `first_token_event` 必填 kw,流式首 token 处置位);`config.py`(两键 + loader + 两守卫 + 保留段);`types.py`(`CallStats` 增三字段 `hedges`/`generation_ms`/`hedge_won` + `_CallContext` docstring 修订与计数方法);`client.py`(构造参数 + 透传);`embedding.py`/`ocr.py`(`_attempt` 加 transport 级计时点——只计时不对冲,非目标 A 不变) |
|
||||||
|
| 新增文件 | 无(编排收敛在 retry.py;若超 150 行可拆 `middleware/hedge.py`,实施期定) |
|
||||||
|
| 直接复用 | 取消结算 S3 格、`settle_and_release` 单一出口、准入全链路、`asyncio.timeout` 范式、假 transport/FakeClock 测试设施、限流契约套件(Lua 不改) |
|
||||||
|
| 明确不做 | 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`;不加遥测列(默认档);不做 embedding/OCR/分位数/同源对冲/per-call 参数;不引入 shield/后台任务 |
|
||||||
|
|
||||||
|
## 8. 测试策略(设计问题 8:事件驱动,不 sleep 撞窗口)
|
||||||
|
|
||||||
|
| 原则 | 做法 |
|
||||||
|
| --- | --- |
|
||||||
|
| 事件驱动假 transport | 两个 `asyncio.Event`(`first_token`/`complete`)精确控制 TTFT 与完成时刻;触发判定 = "事件未置位且计时器到期",从不真睡出长尾 |
|
||||||
|
| 真实 loop 钟 + 余量 | 对冲阈值取 0.05s 级、断言容差 4–10×(`tests/unit/test_streaming.py` 既有范式,136 设计 §10 已验证稳定,不标 slow) |
|
||||||
|
| 先失败后通过 | 同一挂起场景:无对冲 → 总时长 = 挂起时长(红);开启 → 总时长 ≈ 阈值 + 快源耗时(绿) |
|
||||||
|
| 注入钟纪律 | 伪造注入 `now` 跳变 10^6 秒不得触发对冲(对冲计时只用 loop 相对时长) |
|
||||||
|
|
||||||
|
验收矩阵(离线、不触网):① 触发两形态(流式 TTFT 未至触发/已至不触发;非流式纯时间触发);② 异源排除(断言第二请求落在另一源;无候选静默);③ 准入失败静默(配额满 → 不对冲,原请求照等);④ 赢输记账(赢家 settle 实际 usage、输家 settle est、`tpm_used` 断言);⑤ 并发安全(`attempts==2`、终态行恰 1 条、`logical_call_id` 一致、无任务泄漏告警);⑥ 默认关闭回归(现有全套件不改一行断言全绿);⑦ 外部取消穿透(两任务同消、`CancelledError` 上抛);⑧ 与 deadline 组合(期限切断含对冲的整棵树);⑨ 配置守卫四路(env/直接构造/replace/client 直传);⑩ 裸生成时间断言(增补 H8):**赢家计时不含等待**(对冲赢家的 `generation_ms` ≈ 赢家路 transport 时长,不含触发前等待/admission/backoff);**对冲赢家取快者**(对冲路赢 → `hedge_won=True` 且为对冲路时长;原路后发先至 → `hedge_won=False` 且为原路时长);**embedding 为批次和**(N 批各自 transport 时长累加);**缓存命中为 0**(第二次同 key 调用 `generation_ms == 0` 且 `attempts == 0`)。
|
||||||
|
|
||||||
|
## 9. 集中人类批准项
|
||||||
|
|
||||||
|
**状态: H1–H7 与增补 H8 全部已于 2026-09-10 获人类批准**(H1 取方案 A;H3 取零新列档;H5 取"v1 只允许 1")。
|
||||||
|
|
||||||
|
| # | 决策 | 推荐 | 备选代价 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| H1 | 方案选型 | **A(并发对冲)** | B 省 2/3 实现量,但须新造"挂起失败"语义且污染或不污染熔断二选一;C 不解决 issue 现场 |
|
||||||
|
| H2 | `Transport.complete` 加 `first_token_event` 必填 kw(公共端口签名变更) | 批准 | 不加则流式只能用纯时间阈值,误对冲慢生成(issue 明示的反面) |
|
||||||
|
| H3 | 遥测区分档位 | 零新列(`hedge_cancelled` 字符串 + `CallStats` 三字段,含 H8) | `hedge_role` 列更规整但要 PG/SQLite 双 DDL + 迁移纪律 |
|
||||||
|
| H4 | 配置键名/值域/守卫(§5 全表,含交叉守卫 ValueError) | 按 §5 | 交叉守卫降为 warning 则错配静默 |
|
||||||
|
| H5 | `hedge_max_extra > 1` 的梯次对冲 | v1 只允许 1(键存在但上限 1 也接受) | 直接放开到 3 省一次版本,但多路对冲洗掉信号 |
|
||||||
|
| H6 | 两败计一次重试预算 | 批准 | 计两次会让对冲调用更快耗尽预算,语义说不过去 |
|
||||||
|
| H7 | embedding/OCR/分位数/同源对冲/per-call 参数全部不进本版 | 批准 | 任一纳入都是公共面扩大,需单独论证 |
|
||||||
|
| H8(增补) | `CallStats` 再增 `generation_ms`(裸生成时间,口径见 §4.5)与 `hedge_won` 两字段;retry/embedding/ocr 三条链路 `_attempt` 加 transport 级计时点 | 批准(2026-09-10,随 H1–H7 同日) | 不加则前端拿不到"在等不在生成"的量化口径,issue #24 的现场观测(13% 调用吃掉 71% 模型总时间)无法在产品面复现;每路 attempt 分别耗时与输家身份走遥测 DB join 还原,不进公开响应 |
|
||||||
|
|
||||||
|
## 10. 残余风险(诚实标注)
|
||||||
|
|
||||||
|
| 项 | 状态 |
|
||||||
|
| --- | --- |
|
||||||
|
| 输家取消能否止住上游计费 | 无一手证据(与 136 设计 §12 同款):est 保留只是闸内保守记账,**不是**上游真实计费的计量;文档只写"可能已计费",不写"对冲浪费上限 = est" |
|
||||||
|
| 非流式误对冲慢生成 | 物理不可分(无中途信号);只能靠阈值取值(建议 > 源 p50 数倍)与 `hedge_max_extra` 上限控制;分位数触发是未来缓解 |
|
||||||
|
| 对冲流量放大 | 开启后挂起窗口内 in-flight 翻倍;准入全走闸意味着饱和期自然静默,但**配置者须理解**对冲 = 用配额换延迟 |
|
||||||
|
| "挂起不喂熔断"的反向代价 | 一个持续挂起的源不会因对冲输家而被熔断标记;源级淘汰仍靠既有失败/超时路径——这是有意选择(§3),但运维上"挂起率"只能靠 `hedge_cancelled` 遥测行统计 |
|
||||||
|
| 两任务共享 `_CallContext` 的承诺修订 | docstring 级变更;若未来给 context 加带 await 的方法,须重审任务安全 |
|
||||||
|
| 多路对冲(H5 若放开) | 信号冲刷与成本上界均未论证,v1 不碰 |
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# 流式中段沉默:计量口径与对冲(已批准设计)
|
||||||
|
|
||||||
|
- 状态: **已批准 + 独立审查补遗已并入(2026-09-11,`reviews/2026-09-11-streaming-stall-design-review.md`,B1-B3/N1-N9 全处置,见 §11)**;本文件只做设计,不含实现
|
||||||
|
- 基线: main `a28d944` / 1.3.7(已发布,含单路并发对冲与 `CallStats.generation_ms/hedges/hedge_won`)
|
||||||
|
- 输入: `findings/2026-09-11-streaming-stall-hedging-research.md`(业界范式与来源,下称"调研",引用处标注 §x.y);下游 Terminal-Bench 实测 4407 次成功调用遥测(需求来源);1.3.7 设计 `designs/2026-09-10-24-hedged-requests-design.md`;远端 issue #27(P1)/#28(P2)
|
||||||
|
- 关联: ARCH §6.4 取消语义、§7.2 重试;既有流式看门狗 `streaming.py`;1.3.7 对冲机制(本设计直接站在其编排与记账语义上)
|
||||||
|
|
||||||
|
## 1. 背景与问题陈述
|
||||||
|
|
||||||
|
两个问题都是**口径/能力缺口,不是实现 bug**:1.3.7 行为与其设计文档(§4.5 H8、对冲触发判据)完全一致,但下游实测暴露了设计时未覆盖的病态形态。
|
||||||
|
|
||||||
|
### P1:中段沉默计入 `generation_ms`,"裸生成时间"名不副实(issue #27)
|
||||||
|
|
||||||
|
`CallStats.generation_ms` 的口径是赢家 transport 调用的完整墙钟:`middleware/retry.py:332` `gen_started` 紧贴 `transport.complete()` 调用前,`:346` sink 在返回后立即记录。对流式,`complete()` 返回 = 整个流读完,故中段沉默全部计入——这是 1.3.7 设计明写的口径,但"裸生成时间"这个名字误导了下游把它当活跃生成时间用。
|
||||||
|
|
||||||
|
下游实测(Terminal-Bench,4407 次成功调用):
|
||||||
|
|
||||||
|
| 指标 | 数值 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ttft 中位 | 1.63s(仅 3 次 >20s) | 首 token 几乎从不慢 |
|
||||||
|
| 慢调用 `generation_ms` 中位 | 69.4s | 墙钟长尾严重 |
|
||||||
|
| 其中 `max_inter_token_ms` 中位 | 54.9s | 长尾的主体是**一次中段沉默** |
|
||||||
|
| 沉默占全部模型时间 | 71.3% | "在等不在生成"是主要成本 |
|
||||||
|
| 沉默与输出/输入量相关性 | r = 0.05 / −0.06 | 沉默与生成量无关 |
|
||||||
|
| reasoning_tokens 上报次数 | 129 / 4407 | 沉默**不是**模型在思考,是渠道/服务端排队(调研 §A4:prefill/decode 互踩是结构性现象) |
|
||||||
|
|
||||||
|
结论:下游需要一个"扣除沉默段的活跃生成时间"口径,且带归因(沉默几次、共多长),不是只给一个净数(Gregg off-CPU 分解原则,调研 §B2)。
|
||||||
|
|
||||||
|
### P2:中段沉默无对冲,现有两道闸门都接不住(issue #28)
|
||||||
|
|
||||||
|
| 既有机制 | 证据 | 为何接不住中段沉默 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 首 token 对冲 `HEDGE__AFTER_S` | `middleware/retry.py:542-562` `_past_hedge_window` 返回 `not primary.done() and not first_token.is_set()`;非对冲模式 `__call__` 传 `first_token_event=None` 根本不观测 | 首 token 到达后对冲窗**永不打开**;实测 ttft 中位 1.63s,`AFTER_S=20` 对流式调用恒不触发 |
|
||||||
|
| 看门狗 `INTER_TOKEN_TIMEOUT_S` | `streaming.py`(budget = ttft_s 首 chunk / inter_token_s 后续);`openai_compat.py:573` `inter_token_s = source.inter_token_timeout_s or source.timeout_s`;配置 `config.py:46` | 能掐中段沉默,但**掐掉即丢弃全部已生成内容**、从头重试;重试可能再卡;每步成本 = 一次完整重新生成 |
|
||||||
|
|
||||||
|
调研结论(§A3):业界对中段沉默的落地形态收敛为"间隔看门狗 → 抛错 → 重试",**"中段沉默触发并发副本"在任何主流网关/库中均无公开先例**——本设计的 P2 方案 2B 属超业界现状能力,须诚实标注。
|
||||||
|
|
||||||
|
## 2. 业界范式摘要(压缩自调研,不重查网络)
|
||||||
|
|
||||||
|
| 范式 | 出处(调研节) | 对本设计的对应 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 中段沉默对冲**无公开先例**;主流 = 看门狗 → Transient → 重试 | 调研 §A3(LangChain `stream_chunk_timeout` 默认 120s 已合并;Codex CLI 静默 300s 整路重试;LiteLLM 提案默认关) | P2 的 2A = 业界基线;2B 若做须自证语义 |
|
||||||
|
| 沉默扣除唯一先例 pi-tps:gap ≥500ms 判 stall,**减所有超阈值段**,连续停顿合并一次,样本不足返回 null | 调研 §B3 | P1 的 `stall_ms/stall_count` 口径直接照此;"只减最大一段"无任何先例,否决 |
|
||||||
|
| Gregg off-CPU:墙钟分解须**成对报告 + 归因**,只报净数无法行动 | 调研 §B2 | 1B' 下由 `generation_ms`(净)+ `stall_ms`/`stall_count` 成对报告满足,墙钟 = `generation_ms + stall_ms` 可还原 |
|
||||||
|
| NVIDIA TTFT/ITL/E2EL:排队计入 TTFT,基准口径**不扣除**沉默 | 调研 §B1 | 扣沉默是库自造口径,必须显式声明、不与基准口径混写 |
|
||||||
|
| vLLM `generation_time_ms` 排除条款:token 数 <2 时间隔类字段 null;`n>1` 无法归因即抑制 | 调研 §B3 | 三新字段的 None 条款照抄:增量 chunk <2 / 非流式 → None |
|
||||||
|
| Envoy/gRPC:判赢在**响应头**即 commit 不回头;per_try_timeout 只在响应开始前有效,中段停顿不重新对冲 | 调研 §A2 | P2 赢家裁定与"不回头"语义的参照;约束未来流式透传(§5.2) |
|
||||||
|
|
||||||
|
另:配置命名先例对照见调研 §3.3,本设计键名与其对齐。
|
||||||
|
|
||||||
|
## 3. 共享观测原语:增量 chunk 单调时间戳序列
|
||||||
|
|
||||||
|
P1(计量)与 P2(中段触发)共用同一份底层观测,避免两套计时逻辑漂移:
|
||||||
|
|
||||||
|
| 项 | 设计 |
|
||||||
|
| --- | --- |
|
||||||
|
| 记录点 | transport 层(`openai_compat.py:580-590` 现算 `max_gap` 的循环处已逐 chunk 迭代,时间戳序列在此追加记录,增量成本近零;:605 是 `max_inter_token_ms` 装配处) |
|
||||||
|
| 记录内容 | **chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致**(`openai_compat.py:585-588` 注释"思考增量同样是存活证据"同款);**仅空 chunk / usage 帧 / SSE keepalive 不计时**(keepalive 字节会重置 httpx read timeout,对"有字节无内容"的卡顿失效,本序列天然免疫,LangChain 语义见调研 §A3) |
|
||||||
|
| 派生原则 | 一切计量(`stall_ms` 等)与中段触发信号(§5.2)都从此序列/其增量事件派生,**不在热路径做判定**(调研 §3.2-1);热路径只做"记一个时间戳 + set 一个 Event" |
|
||||||
|
| chunk ≠ token | chunk 边界是传输/解析缓冲产物,非 token 边界(vLLM #27485 陷阱,调研 §B1);一切间隔类口径按 chunk 声明,不假装是 ITL |
|
||||||
|
|
||||||
|
P2 触发所需的活体信号由此派生:transport 每记一个增量 chunk 即刷新一个 `asyncio.Event`(语义 = "自上一增量 chunk 以来未再沉默"),编排侧以"等该事件超时"实现 stall 计时器——与 1.3.7 首 token 事件同构,零新机制类别。
|
||||||
|
|
||||||
|
## 4. P1 方案:沉默计量口径(1B' 已批准)
|
||||||
|
|
||||||
|
### 4.1 方案 1A:不改库,下游自算 —— 已否决
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语义 | 下游用既有 `LLMResponse.max_inter_token_ms`(`types.py:406`)自算 `generation_ms − max_inter_token_ms`,即**只减最大一段**;库仅澄清文档 |
|
||||||
|
| 改动面 | README/CHANGELOG 口径澄清,零代码 |
|
||||||
|
| 优点 | 零实现、零契约变更;`max_inter_token_ms` 已在遥测表(`telemetry/schema.py` COLUMNS),存量数据即可分析(本次实测正是用它定位的病) |
|
||||||
|
| 缺点 | 多段沉默场景**系统性高估**活跃时间(调研 §B3:只减最大一段无任何先例);口径责任散落每个下游,各家算法漂移后跨项目不可比;无 `stall_count`,违反 Gregg 归因原则 |
|
||||||
|
|
||||||
|
**否决理由(人类裁决,2026-09-11)**:口径必须库内收敛,不得散落各下游自算。
|
||||||
|
|
||||||
|
### 4.2 方案 1B'(已批准):`generation_ms` 口径重定义
|
||||||
|
|
||||||
|
人类原话:"如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"。PM 澄清两字段本质不同(完整墙钟 vs 单次最大沉默段)后,人类意图收敛为:原草案 1B 的 `active_generation_ms` 净口径**直接写进 `generation_ms` 本身**,不新增派生字段。
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语义 | `generation_ms` 重定义为**净生成时间** = 赢家 transport 墙钟 − Σ(所有 > 阈值的相邻**增量 chunk** 沉默,pi-tps 式口径);`CallStats` 新增两归因字段(默认 None):`stall_ms: int \| None`(沉默总额)、`stall_count: int \| None`(= **超阈值相邻间隔个数**;pi-tps"连续停顿合并一次"子句不移植——gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句是 pi-tps 事件流语义、在本口径下无操作定义,§11-N6);**不新增 `active_generation_ms`**——`generation_ms` 本身就是净口径 |
|
||||||
|
| 边界与换算(§11-N1/N4/N8 钉死) | 净值 = 墙钟 − 中段沉默,**仍含 TTFT 与尾部**(末 chunk→流结束);尾部归活跃侧是有意选择(墙钟基底口径,尾部通常≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步。钟同源纪律:沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`。换算只此一处:TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——各实现不得另取整。FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 |
|
||||||
|
| 归因与还原 | 墙钟 = `generation_ms + stall_ms`;净数与沉默总额/次数成对报告,Gregg 成对报告原则(调研 §B2)在净口径下依然满足 |
|
||||||
|
| 排除条款(照 vLLM,与原案一致) | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` **不退减**(= 原墙钟);增量口径与看门狗一致(`content`/`reasoning_content` 均计)——reasoning 模型长 thinking 阶段只流 reasoning_content,若只计 content 会把正常推理误计为沉默(P1 虚高、P2 误触发);非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件;对冲输家的沉默**不进**赢家快照——已完成但竞速落选的输家其沉默在其 attempt 遥测行(可 join 还原);被取消的输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,其存在性由该行表达 |
|
||||||
|
| 阈值 | 新配置 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`,**默认 1000ms**(§9-③ 拍板);人类附加要求"需要是一个可以调整的参数,对外提供调整接口"——调整接口 = 既有装配路径(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层均可调),**不提供 per-call 覆盖**(§8 非目标 F);**不做自适应**(防与对冲阈值耦出反馈环,调研 §3.2-3) |
|
||||||
|
| 遥测 | 遥测表新增 `stall_ms`/`stall_count` 两列(§9-⑤ 拍板):`telemetry/schema.py` PG/SQLite 各一次可空 DDL + recorder/`middleware/telemetry.py` 透传;`generation_ms` **仍不进遥测列**(YAGNI——下游要墙钟用 `latency_ms` 或 `generation_ms + stall_ms` 还原) |
|
||||||
|
| 改动面 | `types.py:296-324`(`CallStats` 增两字段);`ports.py` `TransportResult` 增 `stall_ms`/`stall_count: float \| None` 默认 None;`openai_compat.py:580-590`(max_gap 循环邻域;:605 是装配处)从 chunk 时间戳序列计算两值(沿用 §3 原语);`middleware/retry.py:332/346` 邻域(赢家记账改为 `record_generation`(净值) + context 存 stall 两值进快照,口径与 H8 同款);遥测透传链路(PM 裁决选 (b):LLMResponse 不增顶层字段——CallStats 已承载赢家口径,避免同数据两处平铺漂移):`middleware/telemetry.py` `_emit` 显式从 TransportResult 传 stall 两值 + emitter `_AttemptUsage` 增两字段 + `ports.py` `TelemetryRecorder.record_llm_call` 签名加两参(**第三次端口签名变更**,§7 端口行补录;emitter 装配闸 `signature.bind` 以该协议签名为判据,漏改协议即装配期炸)+ 两 recorder 实现(PG/SQLite)透传;`telemetry/schema.py` 编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;配置走 `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段;`client.py` 构造透传 |
|
||||||
|
|
||||||
|
#### 契约变更的正当性与披露
|
||||||
|
|
||||||
|
语义突变在一般情形下违反"只增不删不改名"精神(§4.3 拒绝理由),本次由人类以"窗口期最小代价"裁决放行,依据:
|
||||||
|
|
||||||
|
| 依据 | 事实 |
|
||||||
|
| --- | --- |
|
||||||
|
| 字段极新 | `generation_ms` 是 1.3.7(2026-09-10)引入的新字段,发布仅 2 天 |
|
||||||
|
| 消费者唯一 | 唯一消费者是提出本需求的下游 Terminal-Bench;三参考项目均未消费该字段 |
|
||||||
|
| 名实自相矛盾 | 字段名"裸生成时间"原本承诺的就是净口径,现实现(墙钟)与名字矛盾,本次重定义是**兑现名字**而非推翻承诺 |
|
||||||
|
| 无账本断裂 | 遥测 DB 从未存过 `generation_ms`(已验证 `telemetry/schema.py` 与 `middleware/telemetry.py` 均无此字段),库内无历史账本纵向比较断裂 |
|
||||||
|
|
||||||
|
披露义务:CHANGELOG 设独立"行为变更"段显著标注 1.3.7→1.3.8 口径变化 + README 口径表更新 + 本设计存档。
|
||||||
|
|
||||||
|
### 4.3 方案 1C:改 `generation_ms` 口径为扣沉默 —— 原拒绝理由被人类裁决覆盖(留档)
|
||||||
|
|
||||||
|
| 理由 | 依据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 已发布契约突变:1.3.7 设计 §4.5 H8 明写墙钟口径并已被下游消费 | 铁律"字段只增不删不改名";语义突变等同改名 |
|
||||||
|
| 历史账本不可比:遥测 DB 存量 `generation_ms` 全是墙钟,口径切换后纵向比较断裂 | 遥测必录铁律的对账前提 |
|
||||||
|
| NVIDIA/vLLM 基准口径均不扣沉默(调研 §B1),库把墙钟改掉会与业界定义脱钩 | 口径分层应靠新增字段,不是靠改旧字段 |
|
||||||
|
|
||||||
|
**人类裁决(2026-09-11)**:上述理由在一般情形下成立,但人类以"窗口期最小代价"覆盖之(字段发布仅 2 天、消费者唯一、遥测无账本,见 §4.2 正当性表)——净口径以 1B' 形式落地;原 1B 三字段并存案随之被取代。
|
||||||
|
|
||||||
|
### 4.4 P1 对比与决策
|
||||||
|
|
||||||
|
| 维度 | 1A 下游自算(已否决) | 1B 原三字段案(已被取代) | **1B' 口径重定义(已批准)** | 1C 改原口径 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 多段沉默准确性 | 差(系统性高估) | 好(减所有超阈段) | 好(减所有超阈段) | 好 |
|
||||||
|
| 归因能力 | 无(只有最大段) | 有(总量 + 次数) | 有(`stall_ms`/`stall_count` 成对) | 无(只有净数) |
|
||||||
|
| 口径唯一性 | 散落各下游 | 库内收敛 | 库内收敛 | 库内但破坏存量 |
|
||||||
|
| 契约风险 | 零 | 只增字段 + 遥测 DDL | 语义突变,经人类以"窗口期最小代价"裁决放行(§4.2 正当性) | **违反只增不删精神,拒绝** |
|
||||||
|
| 实现量 | 零 | 中(五文件邻域) | 中(六文件邻域,含端口) | 小但不可接受 |
|
||||||
|
|
||||||
|
**决策:1B'(§9-① 拍板)**;1A 因"口径必须库内收敛"被人类否决;1B 原案被 1B' 取代;1C 拒绝理由被人类裁决覆盖(留档 §4.3)。
|
||||||
|
|
||||||
|
## 5. P2 备选方案:中段沉默的应对
|
||||||
|
|
||||||
|
### 5.1 方案 2A:不做中段对冲,看门狗链 + 文档
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语义 | 维持业界主流:中段沉默超 `INTER_TOKEN_TIMEOUT_S` → 掐流 → Transient → 既有重试/换源链;库补文档与配置指引(各源默认值、与 `timeout_s` 的回落关系 `openai_compat.py:573`、建议按源沉默分布取值) |
|
||||||
|
| 改动面 | README/wiki 配置指引,零代码 |
|
||||||
|
| 优点 | 零实现零契约变更;有 LangChain(默认 120s)与 Codex CLI(300s)生产先例(调研 §A3);掐死即 Transient,四分类不新增语义 |
|
||||||
|
| 缺点 | 掐掉即**丢弃全部已生成内容**、从头重试;重试可能再卡同一类排队;每步成本 = 一次完整重新生成;实测沉默 >20s 占 19.0%,这条链会频繁点火 |
|
||||||
|
|
||||||
|
### 5.2 方案 2B(已批准):中段沉默对冲
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 触发 | 新配置 `{SCOPE}__HEDGE__STALL_AFTER_S`(**独立于 `HEDGE__AFTER_S` 生效**,§9-④ 拍板"否,独立生效";调研 §3.3 命名);语义:主路**相邻增量 chunk 间隔**超此阈值 → 触发。机制 = §3 原语派生的"增量 chunk Event 等待超时",与首 token 事件同构;`_past_hedge_window`(`retry.py:542-562`)扩为"首 token 未至走 AFTER_S,首 token 已至走 STALL_AFTER_S"。并发纪律(Event + 复检方案,§11-N3):①超时到期后触发对冲前必须复检 `is_set()`(chunk 与超时同一事件循环 pass 到达时防误触发);②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸声明);④stall 等待每轮迭代自建 waiter,逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义 |
|
||||||
|
| 对冲执行 | 触发后主路**不取消**;并发异源副路走**完整准入 + 熔断**,无配额静默放弃(1.3.7 设计 §4.3 语义原样复用);`FIRST_COMPLETED` 赛跑;输家取消,est 保留不喂熔断、遥测 `hedge_cancelled`(1.3.7 设计 §4.4/§4.5 全部复用,零新记账语义) |
|
||||||
|
| 赢家计量 | 赢家路为准:`generation_ms`(1B' 净口径)/`stall_ms`/`stall_count`(1B' 同批落地,§9-①)全部取赢家路;`hedge_won` 语义不变(赢家是否对冲路) |
|
||||||
|
| 无拼接污染 | `chat()` 是**库内聚合流**——调用方拿完整 `LLMResponse`,流在库内消费完毕才返回;中段切换赢家时两路内容从未交付调用方,**无拼接问题**。这与调研 §3.1 基于"流透传"假设的 commit 语义不同。**约束明写**:未来若做流式透传,commit 点须重审——一旦任一路首 chunk 已透传下游,即按 Envoy"判赢不回头"锁定该路,中段对冲对该次调用永久关闭(§8 非目标:本版不做透传) |
|
||||||
|
| 与看门狗的交互 | 两参数语义独立:若主路沉默超 `inter_token_timeout_s` 被看门狗掐死,**对冲路已在跑 → 本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常**;副路未起 → 照旧 Transient 重试。装配守卫升级为硬守卫(§6,§11-N5):`stall_after_s ≥ 有效最小值` → ValueError,对冲窗永不开的情形在装配期直接拒绝。不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 |
|
||||||
|
| 成本披露 | 实测沉默 >20s 占 **19.0%**——触发率远高于首 token 对冲(ttft 仅 3/4407 超 20s);每触发一次最多 +1 并发请求 + 一份 est 预扣滞留(1.3.7 §4.4 口径);**默认必须关闭**,文档明写"对冲 = 用配额换延迟" |
|
||||||
|
| 改动面 | `retry.py`:`__call__` 入口门改为"两阈值(`hedge_after_s`/`stall_after_s`)任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置,中段沉默无非流式概念)"(§9-④ 独立生效的编排落点);`_past_hedge_window`(`retry.py:542-562`)从一次性等待重构为两阶段(首 token 前:`AFTER_S` 已设才等;首 token 后:`STALL_AFTER_S` 循环等待)+ 与看门狗掐死的汇合规则;`ports.py` + `openai_compat.py:569-575/580-590` 邻域(`complete` 加增量 chunk Event 参数——公共端口签名第二次变更,§9 整体批准覆盖);`config.py` 邻域(新键 + 交叉守卫);`client.py` 透传;遥测复用 `hedge_cancelled`,零新列 |
|
||||||
|
|
||||||
|
**方案 2C 不单列,作为 2B 的裁定细节**:看门狗掐死瞬间原子地并发补一路(掐与对冲合一)。评估:省一个参数但把"观测阈值"与"掐死阈值"耦合,掐死时副路从零开始(TTFT 全付),总延迟 = 沉默阈值 + 完整生成,严格劣于 2B 的"沉默中提前并发";且与 2A 链叠加时行为难推理。否决,仅留档。
|
||||||
|
|
||||||
|
### 5.3 P2 对比与决策
|
||||||
|
|
||||||
|
| 维度 | 2A 看门狗链 + 文档 | **2B 中段沉默对冲(已批准)** |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 已生成内容 | 掐掉全丢 | 主路成果保留到最后一刻,输家才弃 |
|
||||||
|
| 长尾延迟 | 沉默阈值 + 完整重试耗时 | ≈ 沉默阈值 + min(两路剩余) |
|
||||||
|
| 业界先例 | 有(LangChain/Codex CLI) | **无公开先例**(调研 §A3),属超业界现状能力 |
|
||||||
|
| 成本 | 每次沉默 = 一次完整重新生成 | 触发率 19% 量级 × 一次额外请求 |
|
||||||
|
| 公共面变更 | 零 | 端口签名再变 + 新配置键 |
|
||||||
|
| 实现量 | 零(文档) | 大(但编排/记账/遥测全复用 1.3.7) |
|
||||||
|
|
||||||
|
**决策:2B(§9-② 拍板,独立于 `HEDGE__AFTER_S` 生效)**。诚实标注保留:它超出业界现状能力(调研 §A3),人类已明确接受;2A 链(看门狗 → Transient → 重试)作为不启用对冲时的默认行为继续存在,文档指引义务不变;P1(1B')同批落地后下游可直接量化沉默代价。
|
||||||
|
|
||||||
|
## 6. 配置汇总(新增两键,默认关闭/合理默认)
|
||||||
|
|
||||||
|
| 键 | 值域 | 缺省 | 守卫与既有键关系 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000**(§9-③ 拍板,可调) | 仅计量口径,不改任何行为;不设 = 用缺省;调整接口 = env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层,无 per-call 覆盖;命名对齐调研 §3.3(pi-tps 500ms 先例,但场景不同) |
|
||||||
|
| `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒(复用 `ensure_call_deadline` 同款校验) | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S`;仅对流式 chat 请求生效;chat 非流式请求按 §5.2 入口门运行期静默退化——装配期**无法**判定 chat scope 的流式性(`stream` 是 per-call 参数,`client.py:355`/`types.py:470`),故 chat 侧无装配 warning(§11-B4);Embedding/OCR scope(装配期可证明纯非流式)检测到内嵌 gateway 的对冲键(`AFTER_S`/`STALL_AFTER_S`)非 None → 装配 warning(§11-B4,含 1.3.7 `AFTER_S` 同病顺带补齐);`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效值,`openai_compat.py:573` 回落同款)→ **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫,config.py:820-824——stall 场景无非流式残留收益,warning 论证不足);`stall_after_s ≥ call_deadline_s` → ValueError,落进 `check_hedge_assembly`(config.py:772);单源 scope 设阈值 → warning 允许(1.3.7 §5 同款) |
|
||||||
|
|
||||||
|
`STREAM` 段须加入 `_RESERVED_SEGMENTS`(`config.py:57` 邻域,1.3.7 为 `HEDGE` 做过同款);3 段键天然跳过 `_load_sources`(`config.py:427-431`)。装配路径与 1.3.7 对冲同款:`GatewaySettings` 字段 + `__post_init__` 守卫 + `GatewayClient.__init__` keyword-only 入口即校;不提供 per-call 覆盖(阈值是源/渠道特性,1.3.7 §5 同款判据)。
|
||||||
|
|
||||||
|
## 7. 与既有契约的关系
|
||||||
|
|
||||||
|
| 契约 | 处理 |
|
||||||
|
| --- | --- |
|
||||||
|
| `CallStats`/`LLMResponse` 只增不删不改名 | 两新字段(`stall_ms`/`stall_count`)全 `int \| None`、默认 None;1.3.7 及以前构造的位置调用不炸(1.3.7 §4.5 同款论证);`generation_ms` 语义重定义为净口径(**已批准例外**,§4.2 正当性与披露),`max_inter_token_ms` 原语义一字不动 |
|
||||||
|
| 遥测迁移 | `stall_ms`/`stall_count` 两列可空,PG/SQLite 各一次 DDL;旧行 null;迁移纪律照 issue #12/#13/#15 教训与 1.3.7 §4.5 备选调先例;**加列已拍板**(§9-⑤) |
|
||||||
|
| 公共端口签名 | `Transport.complete` 随 2B 第二次加参(增量 chunk Event,必填 kw),与 1.3.7 `first_token_event` 同款约定;`TransportResult` 随 1B' 增 `stall_ms`/`stall_count`(默认 None);`TelemetryRecorder.record_llm_call` 签名随 1B' 加 `stall_ms`/`stall_count` 两参(**第三次端口签名变更**,emitter 装配闸 `signature.bind` 以该协议签名为判据)——三次变更均 §9 整体批准覆盖 |
|
||||||
|
| 文档登记义务 | README 能力表 + CHANGELOG + ARCHITECTURE.md(口径段与对冲触发段)三处同步,发版按 CLAUDE.md §4.4.1 清单执行 |
|
||||||
|
| 版本归口 | **1.3.8 确认**(人类拍板 P1 + P2 同批发);P1 含 `generation_ms` 口径变更,披露义务照 §4.2;P2 纯增量(新键 + 默认关闭行为) |
|
||||||
|
|
||||||
|
## 8. 非目标
|
||||||
|
|
||||||
|
| # | 不做 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A | 不做墙钟/净双口径并存字段(如 `active_generation_ms`) | 1B' 拍板净口径直接写进 `generation_ms`;墙钟由 `generation_ms + stall_ms` 还原(§4.2),并存字段徒增口径漂移面 |
|
||||||
|
| B | 不做自适应分位对冲阈值(bhope/hedge DDSketch) | 列未来方向:per-source 滚动分布跨进程要进 Redis,且与沉默计量阈值耦合有反馈环风险(调研 §3.2-3);本版绝对阈值已够用 |
|
||||||
|
| C | 不做流式透传 | 透传引入 commit 点与"判赢不回头"约束(§5.2),是独立大设计 |
|
||||||
|
| D | 不做内容拼接/续写 | 增量文本不可拼接,缝合即结果污染(调研 §3.1) |
|
||||||
|
| E | embedding/OCR 不动 | 无流式中段概念,沿用 1.3.7 非目标 A |
|
||||||
|
| F | 不做 per-call 沉默阈值覆盖 | 人类拍板(§9-③):调整接口走装配层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg 三层),per-call 覆盖超本版范围 |
|
||||||
|
|
||||||
|
## 9. 决策记录(2026-09-11 全部拍板)
|
||||||
|
|
||||||
|
| # | 决策点 | 拍板结果 | 人类原话 / 选项 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| ① | P1 选型 | **1B'**:`generation_ms` 口径重定义为净时间(原 1B 的 `active_generation_ms` 口径直接写进 `generation_ms` 本身,不新增派生字段);新增 `stall_ms`/`stall_count` 归因字段 | "如果 generation_ms 与 max_inter_token_ms 本质是一样的那能不能改一下 generation_ms,减去所有大于 stall_ms 的中段沉默得到一个纯净的时间"(PM 澄清两字段本质不同后,意图收敛为口径重定义) |
|
||||||
|
| ② | P2 选型 | **2B**:中段沉默对冲 | 选定 2B(已明确接受"超业界现状能力"标注) |
|
||||||
|
| ③ | `STREAM__STALL_THRESHOLD_MS` 默认值 | **1000ms,可调** | "需要是一个可以调整的参数,对外提供调整接口"——接口 = 装配三层(env 键 / `GatewaySettings` 字段 / `GatewayClient` 构造 kwarg),不提供 per-call 覆盖 |
|
||||||
|
| ④ | 中段对冲是否以 `HEDGE__AFTER_S` 已启用为前提 | **否,独立生效** | 原话"否,独立生效" |
|
||||||
|
| ⑤ | 遥测是否加 `stall_ms`/`stall_count` 两列 | **加** | 选定"加"(归因进 DB,可对账) |
|
||||||
|
|
||||||
|
另(§9 之外拍板):P1 + P2 **同批发 1.3.8**(§7)。
|
||||||
|
|
||||||
|
## 10. 自查记录
|
||||||
|
|
||||||
|
- 行数 231 ≤400;无超 15 行代码块(全文无代码块,仅表格与伪语义表述)。
|
||||||
|
- 方案改动面均列具体文件:行号(1B'、2B);行号以 2026-09-11 独立审查核实记录为准(全部实读源码,见 §11)。
|
||||||
|
- 调研结论引用处标注(调研 §x.y);未重查网络。
|
||||||
|
- 2026-09-11 批准确认:§9 五项 + 同批发 1.3.8 全部落地本文;1A/1C 保留为否决留档,1B 原案标注被 1B' 取代。
|
||||||
|
- 2026-09-11 独立审查补遗:`reviews/2026-09-11-streaming-stall-design-review.md` 的 B1-B3/N1-N9 经 PM 逐条裁决全处置,修正直接并入正文对应小节,§11 只做索引;§9 五项拍板不变。
|
||||||
|
|
||||||
|
## 11. 独立审查补遗(2026-09-11)
|
||||||
|
|
||||||
|
审查报告 `reviews/2026-09-11-streaming-stall-design-review.md` 裁决"有条件实施"(3 阻断 + 9 建议);PM 逐条裁决如下,全部修正已并入正文对应小节(**本节只做索引,正文为准**)。§9 五项拍板不变。
|
||||||
|
|
||||||
|
| 发现 | PM 裁决 / 处置 | 正文落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| B1 chunk 定义未钉死 | chunk = 任何增量产出(`content` 或 `reasoning_content` 都算),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时;排除条款"内容 chunk <2"改"增量 chunk <2"(reasoning 模型长 thinking 只流 reasoning_content,只计 content 会误计沉默:P1 虚高、P2 误触发) | §3 记录内容行、§4.2 语义/排除条款、§2 vLLM 行 |
|
||||||
|
| B2 遥测透传链路遗漏 | PM 裁决选 (b):`_emit` 显式从 TransportResult 传 stall 两值,LLMResponse 不增顶层字段(CallStats 已承载赢家口径,避免两处平铺漂移);`ports.py` `record_llm_call` 签名加两参(第三次端口签名变更,补进 §7 端口行);emitter `_AttemptUsage` + 两 recorder 实现 + schema.py 五处编辑点全列;输家表述修正:被取消输家无 TransportResult、行是 `hedge_cancelled` error 行、stall 恒 None,存在性由该行表达 | §4.2 改动面/排除条款、§7 端口行 |
|
||||||
|
| B3 编排入口遗漏 | `__call__` 入口门改"两阈值任一已设即进对冲编排;仅 `stall_after_s` 且请求非流式 → 逐字走单路(等价未配置)";`_past_hedge_window` 从一次性等待重构为两阶段(首 token 前 AFTER_S 已设才等 / 首 token 后 STALL_AFTER_S 循环等待);纯非流式 scope 设置给装配 warning | §5.2 改动面、§6 配置表 |
|
||||||
|
| N1 计时边界未明写 | 净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归活跃侧是有意选择(墙钟基底,尾部≈0),与 pi-tps"止于最后更新"的差异显式声明,README 口径表同步 | §4.2 边界与换算行 |
|
||||||
|
| N2 非流式双口径 | 非流式 net≈wall(无中段沉默概念);下游预算公式以 `stall_ms is None` 为分支条件 | §4.2 排除条款 |
|
||||||
|
| N3 Event 并发纪律 | 采 Event + 复检方案:①超时到期后触发前复检 `is_set()`;②check→clear 之间无 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮迭代自建 waiter,逐字套用 `_past_hedge_window` waiter 收口 + `cancelling()` 穿透;⑤等待者唯一(编排) | §5.2 触发行 |
|
||||||
|
| N4 钟同源与负值防御 | 沉默序列(transport)与墙钟(retry 注入 `now`)生产同为 `time.monotonic`;`net = max(0, wall − stall)` clamp;FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟 | §4.2 边界与换算行 |
|
||||||
|
| N5 守卫三缺口 | 守卫用有效值 `min over sources of (inter_token_timeout_s or timeout_s)`(回落同款);`stall_after_s ≥ 该有效最小值` → **ValueError**(与 1.3.7 `hedge_after_s ≥ min(timeout_s)` 同款硬守卫——stall 场景无非流式残留收益,warning 论证不足);`≥ call_deadline_s` → ValueError,点名落进 `check_hedge_assembly` | §6 配置表、§5.2 与看门狗交互行 |
|
||||||
|
| N6 `stall_count` 定义 | = 超阈值相邻间隔个数;删"连续停顿合并一次"子句(gap 口径下任意两超阈 gap 间至少隔一个 chunk,无可合并对象,该子句移植自 pi-tps 事件流语义、在此无操作定义) | §4.2 语义行 |
|
||||||
|
| N7 引用勘误 | CallStats 在 `types.py:296-324`(原"417-418 邻域"错位,417-418 是 LLMResponse 邻域,`max_inter_token_ms` 在 :406);`config.py:401-407` → `:427-431`;"`config.py:46` 邻域" → `_load_hedge`(`config.py:745-769`)同款 loader + `GatewaySettings` 字段(:46 是 per-source 四段键表 `_SOURCE_FIELDS`,与 §6 三段键矛盾);max_gap 循环在 `openai_compat.py:580-590`(:605 是装配处);遥测编辑点 = DDL 末尾(created_at 后) + `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"改"§9 整体批准覆盖" | §3、§4.1/§4.2/§5.2 改动面、§6、§7 |
|
||||||
|
| N8 换算钉死 | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`;`CallStats.stall_ms = int(stall_ms)`(None 透传)——换算只此一处,各实现不得另取整 | §4.2 边界与换算行 |
|
||||||
|
| N9 看门狗措辞 | "主路之死不算失败"改"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常";补不对称声明:看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件不同处置——前者是 1.3.6 起既有看门狗语义、后者是 1.3.7 已批准语义,本设计不改任一;运维"挂起率"统计须知此不对称 | §5.2 与看门狗交互行 |
|
||||||
|
| B4(计划期补遗,2026-09-11 PM 裁决) | "纯非流式 scope 设置时装配 warning"(B3/§6 原文)字面不可实现:装配期无法判定 chat scope 流式性(`stream` 是 per-call 参数)。操作化:①装配 warning 只落在 Embedding/OCR scope(装配期可证明纯非流式)——`EmbeddingSettings`/`OcrSettings.__post_init__` 检测内嵌 gateway 的 `hedge_after_s`/`hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化";`AFTER_S` 一并纳入(1.3.7 同病顺带补齐,属缺陷修复不扩公共面);②chat 侧装配 warning 明确不做,非流式请求按 §5.2 入口门运行期静默退化;③不新增任何 scope 级 stream 配置键(扩公共面且超 §9 键清单) | §6 配置表 STALL_AFTER_S 行 |
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
title: OpenAI消息保真设计
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# #29 OpenAI 消息保真设计
|
||||||
|
|
||||||
|
> **修订指针(2026-10-14)**: §4.1"未知 delta 拒绝"、§4.2"reasoning-only 不算成功"与"缺 DONE 一律失败"、§5 活性口径、§6 缓存判据已由 [统一完成判定设计](2026-10-14-issue30-31-completion-verdict-design.md)(1.3.10)修订,以后者为准。
|
||||||
|
|
||||||
|
> 状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;按人类最终指定准备 1.3.9,取代未发布的 1.4.0;尚未发布。
|
||||||
|
> 基线:`0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8;日期:2026-10-01。
|
||||||
|
> 需求:[issue #29](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/29)。
|
||||||
|
> 权威约束:ARCHITECTURE §2.3、§5、§6、§7.1–7.8;实现前按本文修订对应架构段落。
|
||||||
|
|
||||||
|
## 1. 批准记录与范围
|
||||||
|
|
||||||
|
用户已明确同意:现有 `chat()` 增加显式 OpenAI 保真模式,`LLMResponse` 增加完整协议结果,复用治理链,修复工具聚合、活性、error 帧和缓存隔离;首版单 choice、不执行工具、不增加 Agent Loop。用户随后确认内部移植代码均为其原创且本人 owner,并明确授权 PolyGateway 使用 Apache-2.0;dissect3 也是其项目。以上不再重复请示。
|
||||||
|
|
||||||
|
Codex 不可用,人类已授权原生 subagent 替代独立审查。具体 API 拼写与防御约束由本设计落实,经独立审查后实施;若审查要求改变已批准边界,才重新讨论。设计阶段已结束;实现验证记录见计划末尾,独立代码审查/发布前禁止宣称 issue 已解决。
|
||||||
|
|
||||||
|
“保真”是受支持 OpenAI Chat Completions 消息字段和值保真,不是 HTTP 字节级透明代理,不覆盖任意供应商增量方言。model、stream、stream_options 仍由库控制;没有逐块回调、工具执行、同步客户端、美元预算或失败用量取证新功能。
|
||||||
|
|
||||||
|
## 2. 源码核验与方案比较
|
||||||
|
|
||||||
|
| 已核实接缝 | 现状与修复目标 |
|
||||||
|
| --- | --- |
|
||||||
|
| `transports/openai_compat.py::_sse_delta` | 只读首 choice;content 早返回丢同帧 reasoning;忽略 tool_calls/error |
|
||||||
|
| `_complete_stream` / `_complete_once` | 纯文本判空;raw 只存 usage;需新增协议快照与 tool-only 成功语义 |
|
||||||
|
| `client.py::chat` / `_build_payload` | messages 已直传,overlay 已合并,无需白名单重写 |
|
||||||
|
| `thinking.py::validate_thinking_raw` | raw-only 已支持;受管与 raw 冲突是既定保护,不得绕过 |
|
||||||
|
| `middleware/retry.py::_build_response` | 唯一响应构造出口,新增字段在此搬运,不新增治理循环 |
|
||||||
|
| `middleware/cache.py` | 已有完整消息/overlay 身份,但嵌套新类型需重建、模式需隔离 |
|
||||||
|
|
||||||
|
| 方案 | 优势 | 代价/结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A:chat 显式模式 + LLMResponse 内嵌协议结果 | 生命周期、取消、缓存、遥测和对冲单一实现;兼容旧投影 | 需定义组合约束和缓存身份;**选定** |
|
||||||
|
| B:新增 chat_completion 入口,复用私有核心 | 接口像 OpenAI | 重复公共签名、输入校验和终态封装,收益不足,否决 |
|
||||||
|
| C:独立 SDK 客户端或代理自聚合 | 局部改动直观 | 无法自动解决响应上浮/缓存/线路错误,复制治理,否决 |
|
||||||
|
|
||||||
|
## 3. 公共契约
|
||||||
|
|
||||||
|
### 3.1 模式与类型
|
||||||
|
|
||||||
|
在 `types.py` 定义并从包根导出 `ResponseMode` 和 `OpenAIChatCompletion`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 的协议消息快照;嵌套 JSON 并非深度不可变。"""
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
`GatewayClient.chat` 末尾追加 keyword-only `response_mode: ResponseMode = "text"`;`ChatRequest` 末尾同字段同默认。`TransportResult`、`LLMResponse` 末尾追加 `completion: OpenAIChatCompletion | None = None`,已有字段不改名、不改位置。保真模式成功时 completion 必非 None;文本模式不承诺提供 completion。
|
||||||
|
|
||||||
|
`Transport.complete` 追加必填 keyword-only `response_mode: ResponseMode`,依照现有端口纪律不使用默认值掩盖漏传。默认 transport 实现和测试 transport 一并适配;不新增 SDK 适配器(当前文件树尚无该实现)。自定义 transport 须实现新参数与返回契约,不支持模式时抛 RequestRejectedError,不静默回退。
|
||||||
|
|
||||||
|
completion 是独立于上游容器的深拷贝 JSON 快照;frozen 只保证字段不能重绑,不伪称深度不可变。调用方可用 `copy.deepcopy(response.completion.message)` 构造下一轮;其修改不得污染别次调用、缓存条目或另一路对冲。不上 MappingProxyType,避免与既有 dataclasses.asdict/JSON 序列化冲突。
|
||||||
|
|
||||||
|
### 3.2 请求与组合校验
|
||||||
|
|
||||||
|
| 条件 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 未知模式 | 公共入口 ValueError,尚未创建逻辑调用上下文 |
|
||||||
|
| openai + structured 非 None | 公共入口 ValueError;不注入 schema、不自动重问;overlay 的 response_format 仍可直传 |
|
||||||
|
| openai + n | 仅允许缺省或严格整数 1(不接受 bool);调用 overlay 在入口校验,选中源 extra_body 及最终 payload 在 HTTP 前校验,不接受被 overlay 遮蔽的非法源级 n |
|
||||||
|
| text + n | 保留旧行为,本次不扩大旧文本多 choice 语义;明确只投影第一 choice |
|
||||||
|
| 消息 | 保留 assistant/tool/reasoning_content/扩展字段,JSON 值不改写;保真入口校验可序列化并深拷贝,避免并发/重试期间调用方改动 |
|
||||||
|
| overlay | 复用既有保护键、源级→请求级优先级;保真调用对快照作深拷贝,不原地改调用方容器 |
|
||||||
|
|
||||||
|
源级 n 错误属选源后的请求拒绝,抛 RequestRejectedError,finally 结算规则不变。保真响应必须恰好一个 choice;SSE choice.index 必须为整数 0,usage-only choices=[] 合法;非流式一个 choice 可省略 index,存在时必须为 0。上游返回多 choice 是协议失败 TransientError,不能静默截取。
|
||||||
|
|
||||||
|
### 3.3 推理所有权不随返回模式变化
|
||||||
|
|
||||||
|
复用 `effective_effort`、`resolve_thinking`、`validate_thinking_raw`,不改能力表与档位映射。raw-only 示例前提:源级 enable_thinking=None、reasoning_effort=None,请求级 reasoning_effort=None,然后 overlay 传 `{"reasoning_effort": "max"}`。`chat(reasoning_effort=None)` 是继承源,不是撤销源意图;raw 与任何有效受管意图(含 NONE)并存仍拒绝。raw-only 的 applied_effort 仍 None,不编造受管档位。
|
||||||
|
|
||||||
|
### 3.4 兼容投影与上游身份
|
||||||
|
|
||||||
|
完整协议 `message` 不过 `_finalize_text`,保留 content 的缺省/null/空串区别、空白、think 标签;非流式保留整个 message 的 JSON 扩展字段。`LLMResponse.content/thinking` 仍是旧 `_finalize_text` 投影,content 为 null/缺省时投影空串;只用于旧消费面和现有遥测,代理回传以 completion 为准。
|
||||||
|
|
||||||
|
response_id 是上游 id,允许未上报为 None,绝不拿 call_id 补齐;finish_reason 原样保存,缺失为 None,不从工具存在与否猜测;usage 保存原对象快照(最后一个非 null usage),不因 token 归一或 salvage 修改原文。字段存在但类型不符是协议失败。`length` 原样返回,不修复工具参数;下游决定是否执行。
|
||||||
|
|
||||||
|
## 4. 协议聚合与完成规则
|
||||||
|
|
||||||
|
新增内部模块 `transports/_openai_completion.py`,只处理 chat 单 choice 协议校验、每尝试聚合与快照;不做 I/O、重试、计时、provider 推理或缓存。现有 openai_compat 保留 HTTP、看门狗、投影、usage 归一化与领域上下文。
|
||||||
|
|
||||||
|
### 4.1 SSE 聚合状态
|
||||||
|
|
||||||
|
每次 complete 独立创建聚合器,不放 transport 实例共享状态。逐 JSON 帧先校验对象并查 error,再读取 choices/delta、元信息和 usage;坏类型统一转 TransientError,不让 AttributeError/TypeError 逃出治理。
|
||||||
|
|
||||||
|
| 字段 | 合并规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| content / reasoning_content | 非 null 必为 str,逐片拼接;同帧同时收集;不 trim;从未出现则不造键,仅 null 则保留 null |
|
||||||
|
| role | openai 模式若出现必须 assistant;只出现 role 不算活性;最终成功必须含 assistant role;缺失失败,不伪造字段 |
|
||||||
|
| tool_calls | 必为列表;每项 index 必为非负严格整数;按 index 分桶,末尾按 index 排序,索引不输出到最终工具对象 |
|
||||||
|
| id / type | 每工具的身份字段,缺失片段不覆盖;相同重复幂等,冲突拒绝;不把重复完整 id 拼两次 |
|
||||||
|
| function.name / function.arguments | 字符串增量按到达顺序逐字拼接,允许空片段;arguments 不 JSON 解析/修复/重排 |
|
||||||
|
| response id / finish_reason | 首个非 null 固定,后续相同值允许、冲突拒绝;model_reported 沿用既有首有效值口径 |
|
||||||
|
|
||||||
|
最终有效工具需非空 id、type="function"、非空 function.name,以及至少出现一次字符串 arguments(允许空串);重复工具 id、缺字段、非 function 工具类型都拒绝,不伪造或丢弃。index 可非连续,按排序输出,不按列表位置猜 index。JSON 参数内容及工具业务 schema 不由库验证。
|
||||||
|
|
||||||
|
SSE、非流式及缓存最终快照均要求 role="assistant";缺失或其他 role 为协议失败/缓存 miss;合法 role-only 开头不算活性。
|
||||||
|
|
||||||
|
非流式工具是完整列表:保留条目及扩展字段,仅校验上述必要字段/类型/唯一 id,不要求 index;存在的扩展字段不删除。SSE 保真仅支持 role/content/reasoning_content/tool_calls;未知 delta 字段或未知工具增量字段无法推定合并语义,明确拒绝为协议失败,避免宣称任意扩展字段无损;元数据如 logprobs 不属于本协议结果字段,文档明确不返回。
|
||||||
|
|
||||||
|
### 4.2 成功、截断与错误
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| openai:空正文 + 有完整工具 | 成功,不触发空补全重试 |
|
||||||
|
| openai:非空投影正文、无工具 | 成功;存在任何不完整工具则仍拒绝,不用正文掩盖坏工具 |
|
||||||
|
| openai:无正文且无有效工具 | 维持 empty_completion 瞬时失败;reasoning-only 不算成功 |
|
||||||
|
| openai:EOF 无 DONE | 无论 missing_done=salvage、是否已有正文/工具/finish,都失败;不打捞潜在可执行结果 |
|
||||||
|
| text:EOF 无 DONE | 保持已有 early_eof/retry/salvage 及 estimated/unavailable 行为 |
|
||||||
|
| 任一 chat 模式出现顶层 error 键 | 立即 TransientError,即使值 null/空对象;不读取后续 usage/DONE 挽救,不缓存 |
|
||||||
|
| 非流式 200 error / 非 JSON / 坏 envelope | TransientError;HTTP 非 200 分类沿用现有规则 |
|
||||||
|
|
||||||
|
text 仍不接收 tool-only 成功,不承诺工具结果;共用解析路径的 error 检查、同帧双文本采集和真实工具进展活性修复适用于两模式。纯文本消费不新增工具完整性要求,以免把未承诺的工具结果变成默认请求失败。
|
||||||
|
|
||||||
|
## 5. 活性、治理、取消和可观测
|
||||||
|
|
||||||
|
一帧有非空正文、非空推理、非空工具 name/arguments 增量或首次建立工具身份时,产生**一次**进展事件。重复相同身份、空 arguments、空 delta、role-only、usage-only、finish-only、注释不刷新活性。同帧多个字段和两个工具仍只记录一个时间戳,避免制造零间隔或虚增 stall_count。
|
||||||
|
|
||||||
|
`stream_with_liveness_timeouts` 继续包进展迭代器;openai_compat 用同一事件更新时间戳、TTFT、max_gap、first_token_event、content_event。保持 `_stall_from_chunk_times` 与 retry 唯一取整点不变;真正沉默照常触发中段对冲。非流式不置两事件,stall=None,只保留纯时长阈值对冲。
|
||||||
|
|
||||||
|
请求经原 Telemetry→Cache→Structured(None 直通)→Retry→准入→Transport 链。重试、异源对冲、失败预算、源健康、限流结算均原样复用;输家取消仍保守 est 结算、不喂熔断,赢家携自身完整快照。CancelledError 全栈穿透,连接与 permit finally 释放,无新的 shield 或后台任务。
|
||||||
|
|
||||||
|
遵循 structured-logging:不新增数据库列/表/Recorder 参数。`_build_response` 搬运 completion;错误由 transport 抛出,经过既有 RetryMW._emit→TelemetryEmitter 单出口记 attempt/terminal 行。SSE error 使用现有 summarize_body/compose_message 有界摘要,携 source_name/operation=chat,最多记录有界错误体,不在日志新增完整工具参数、密钥或成功消息。
|
||||||
|
|
||||||
|
现有 `llm_calls` schema(`research-wiki/schemas/llm-calls.md`)及覆盖指标复用。确定性验收指标:error→成功恰好 attempts=2;tool-only attempts=1;命中 attempts=0;失败不写缓存;无孤儿 permit/task。失败 usage 仍 unavailable,费用仍未知,不删除失败事件;不扩展遥测全文覆盖,tool_calls.arguments 不受现有 TEXT_CAP 截断的已知边界须继续声明。
|
||||||
|
|
||||||
|
## 6. 缓存语义
|
||||||
|
|
||||||
|
`build_cache_key` 追加 `response_mode: ResponseMode = "text"`;仅非 text 时将 response_mode 写入 key 对象。默认文本 key 逐字不变;这是请求语义维度,不引入包版本/revision。tools、tool_choice、raw effort 已在 sampling/source fingerprint;assistant/tool 消息全字段已在 digest_messages,直接复用。
|
||||||
|
|
||||||
|
CacheMW 显式序列化和重建 OpenAIChatCompletion,读时验证容器、单消息/工具必要字段与元信息类型。openai 条目 completion 缺失/null/畸形或无有效产物视 miss+warning,不能补默认伪造协议成功;text 旧条目允许缺新字段。transport 与缓存共用 types 内部纯值判据:完整工具或非空投影正文;无工具时还要求协议原正文非空,不能靠缓存旧投影伪装成功;不收紧快照构造用途。错误条目回源成功后覆写,失败不写。
|
||||||
|
|
||||||
|
缓存中的 message/usage 与调用结果独立;命中保留历史上游 id/finish/usage,生成本次 call_id、清除历史 call_stats,最终返回本次 attempts=0;cost=0 延续原口径。缓存读写和序列化失败仅 warning,不冒泡,取消例外穿透;将 `_serialize` 异常纳入缓存降级边界,避免新增嵌套字段扩大失败面。
|
||||||
|
|
||||||
|
旧文本缓存可能已被 error+DONE 毒化,不能自动判别。发布文档要求受影响调用集合首次读写前迁移 namespace/salt,保留租户前缀与 epoch;覆盖工厂和 per-call 身份。未迁移仍会回放旧值,不能宣称修复自动清除历史缓存;openai 新身份天然不命中旧文本。
|
||||||
|
|
||||||
|
## 7. 旧行为审计与非功能约束
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 旧字段/默认文本/structured 阶梯/受管推理 | 保留;默认文本同帧漏 reasoning 属修复 |
|
||||||
|
| HTTP 分类、retry/backoff、两类对冲、TPM/熔断 | 保留,不改算法与预算 |
|
||||||
|
| 空补全、missing_done salvage | 文本保留;openai tool-only 放行、缺 DONE 强制失败 |
|
||||||
|
| Redis TTL、命名空间、遥测幂等与单 helper | 保留;新增模式隔离与嵌套协议结果回放 |
|
||||||
|
| 并发与取消 | 每尝试状态隔离,重试/对冲不得复用聚合器,CancelledError 穿透 |
|
||||||
|
| 降级方向 | 缓存/遥测 warning;准入后端 fail-closed;不新增后端 |
|
||||||
|
| 持久化/原子性/恢复 | 仅成功后一次写完整缓存值;沿用后端原子 set;无新的任务持久化或断点状态 |
|
||||||
|
| 幂等与重复 | 重试/对冲可能产生重复上游计费,库不执行工具,因此不产生本地工具副作用;不承诺上游生成幂等 |
|
||||||
|
|
||||||
|
## 8. 测试、许可证与交付门
|
||||||
|
|
||||||
|
单元测试复用 MockTransport/SSE/受控事件设施;新增两工具交错分片、同帧 content+reasoning、原样下一轮、raw=max、stream=False、缺 DONE、安全判错、缓存和治理组合。每个行为组须保留基线红、新实现绿日志;不以本阶段基线通过冒充新功能证据。
|
||||||
|
|
||||||
|
真实 Redis 只测完整协议缓存往返/隔离与取消关联契约,不 mock Lua。真实模型跑专用通用函数工具双轮冒烟(标 slow,输出 Markdown),复用 `.env` 配置与预算,不在源码硬编码凭据;网络/额度失败如实失败,不改成无覆盖成功。不默认重跑全模型推理矩阵;本次不改 thinking 能力算法。
|
||||||
|
|
||||||
|
许可证:采用 Apache-2.0 已授权;实施前核查实际分发文件及注明移植/参考的片段,区分参考协议事实与复制代码。reference/ 只读且不打包;AGPL 仓存在不自动污染本库,也不由授权覆盖第三方权利。发现实际复制的不兼容片段须上报,不擅自改许可证。加入标准 LICENSE 与 pyproject 元数据;只有确有通知义务才加 NOTICE,不捏造第三方来源。
|
||||||
|
|
||||||
|
源码 docstring、README、CHANGELOG、ARCHITECTURE 同步;无新 env 键,`.env.example` 仅补 raw-only 源配置提示(不改默认)。Gitea Wiki 当前下线,不重建过期页面。线程代理文档说明:client 在同一常驻事件循环线程内创建/使用/关闭;工作线程 run_coroutine_threadsafe,等待超时/断连取消 Future;不逐请求 asyncio.run,不跨 loop 共享 client。
|
||||||
|
|
||||||
|
发布由父会话在独立代码审查、全套件、相关 slow、构建包 LICENSE/元数据核验之后执行;沿 CLAUDE 发布九步,外部验证 registry/Release/包页和下游下载,不仅 bump。issue 只有在可安装产物及验收证据齐备后关闭;本阶段不触发这些动作。
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
type: design
|
||||||
|
node_id: design:2026-10-14-issue30-31-completion-verdict-design
|
||||||
|
title: 统一完成判定设计(#30/#31)
|
||||||
|
date: 2026-10-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# #30/#31 统一完成判定设计
|
||||||
|
|
||||||
|
> 状态: 方向已获人类批准(2026-10-14,四项取舍与五个拍板点按推荐);人类授权"Claude 与独立 subagent 确认无误后直接执行",不再走人类设计审阅。两轮独立审查已闭合(R1 3 阻塞 + 9 应修;R2 通过,2 应修已并入),见 §11。
|
||||||
|
> 基线: `22609c9`(1.3.9);目标版本 **1.3.10**;分支 `fix/issue30-31-completion-verdict`。
|
||||||
|
> 需求: [#30](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/30)、[#31](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/31)。
|
||||||
|
> 修订对象: [1.3.9 保真设计](2026-10-01-issue29-openai-fidelity-design.md) §4.1/§4.2/§5/§6、ARCHITECTURE §6.2/§7/§14。
|
||||||
|
|
||||||
|
## 1. 问题与根因
|
||||||
|
|
||||||
|
两条 issue 是**同一根因**: 库判定"生成结束了、结果可交付"时,依赖库自己的推断(`[DONE]` 存在、正文非空、字段在白名单内、快照字段类型),而不是上游的权威声明(`finish_reason`)与传输完整性(干净 EOF + 结尾 usage)。推断失败一律落 `TransientError` → 重试/换源/计熔断,把确定性结局放大成 `max_attempts` 次付费重抽并推高健康源的熔断计数。
|
||||||
|
|
||||||
|
| 表现 | 位置(1.3.9) | 后果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| #31 缺 `[DONE]` 但 finish+usage 完整 | `openai_compat.py:668-671`(保真无条件拒);`_check_done:714`(文本 `missing_done=retry` 默认同样拒) | magpie/glm 两模式均重试到耗尽;实测 1216s 挂起、dissect3 38 次终败 |
|
||||||
|
| #30-1 reasoning-only + `length` | `types.py:478-490`、`_reject_empty_completion` | 每次写满 max_tokens 后丢弃重抽 |
|
||||||
|
| #30-2 未知键 / `tool_calls:null` | `_openai_completion.py:97-98,112-114,139-140,146-147`;**非流式也中**: `OpenAIChatCompletion` 拒 `tool_calls: None`(`types.py:451-453`,已实测) | 渠道一换 100% 失败,且以"瞬时"形态出现 |
|
||||||
|
| 隐患: 只有 `reasoning` 字段的流 | 活性只认 content/reasoning_content | 两模式被看门狗当无进展 → TTFT 超时 |
|
||||||
|
| 隐患: 文本 salvage 的截断结果被缓存 | `cache.py:197-204` 写路径无判定 | 半截结果 TTL 内反复回放 |
|
||||||
|
|
||||||
|
补充事实: GLM 的 finish_reason 含 `network_error`/`sensitive`;2026-07-20 空补全裁决针对的是 stop/无 finish 的"零增量"抖动(`findings/2026-07-21-m25-acceptance.md:48`);vLLM 等实现每帧带累计 usage,finish 之前的 usage 不证明走完。
|
||||||
|
|
||||||
|
## 2. 方案比较
|
||||||
|
|
||||||
|
| 方案 | 内容 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A 局部放行 | 按 issue 字面在三处各加例外 | 否决: 流式/非流式/缓存判据继续分叉;文本 #31 残留;`network_error` 被当成功 |
|
||||||
|
| B 统一完成判定(选定) | 内核纯函数分两步(流完整性 → 结局分类),流式/非流式/缓存读写四处共用;"可交付"与"可缓存"拆两判据;未知字段无损合并 | 一次消除根因;代价是公共字段追加与遥测端口加参 |
|
||||||
|
| C 透明透传 | 保真模式不做结局判定,原样交回 | 否决: 零增量抖动回归(违背 2026-07-20 裁决),截断流也被交出 |
|
||||||
|
|
||||||
|
## 3. 公共契约变更(只增不改)
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
| --- | --- |
|
||||||
|
| `types.py` | 新增 `StreamEnd = Literal["done", "eof_after_finish", "salvaged"]`,包根导出 |
|
||||||
|
| `TransportResult` / `LLMResponse` 末尾 | 追加 `finish_reason: str \| None = None`、`stream_end: StreamEnd \| None = None` |
|
||||||
|
| `OpenAIChatCompletion` 校验 | `tool_calls: null` 合法(视同无工具,原样保留键) |
|
||||||
|
| `TelemetryRecorder.record_llm_call` | 追加**无默认值** keyword-only `stream_end: str \| None`(**破坏性**: 自建 recorder 装配期被形状闸拒绝,CHANGELOG 单列) |
|
||||||
|
| `llm_calls` 表 | 末尾追加可空列 `stream_end TEXT`(落点见 §7) |
|
||||||
|
| `Transport.complete` 签名 | **不变** |
|
||||||
|
|
||||||
|
`finish_reason` 取值: 流式取 choice 0(保真已强制单 choice;文本取 `index` 缺省或为 0 的 choice)首个非 null 值;非流式取 `choices[0].finish_reason`。保真模式非字符串维持 1.3.9 拒绝(Transient);文本模式非字符串记 None。`finish_reason` 不进遥测列(未获批)。
|
||||||
|
|
||||||
|
| 场景 | `stream_end` |
|
||||||
|
| --- | --- |
|
||||||
|
| 流式收到 `[DONE]` | `"done"` |
|
||||||
|
| 干净 EOF + finish + 结尾 measured usage | `"eof_after_finish"` |
|
||||||
|
| 文本 `missing_done=salvage` 打捞 | `"salvaged"` |
|
||||||
|
| 非流式 / 缓存命中 / 失败行 | `None`(与 `ttft_ms`/`stall_ms` 同"无从观测"口径) |
|
||||||
|
|
||||||
|
与 `usage_source` 分工: 后者答"账单可信吗",前者答"内容完整吗"。`eof_after_finish ⇒ measured`(由 §4.2 条件保证);`salvaged ⇒ estimated|unavailable`;`estimated ⇒ salvaged`,逆命题不成立。
|
||||||
|
|
||||||
|
## 4. 统一完成判定
|
||||||
|
|
||||||
|
### 4.1 位置与形态
|
||||||
|
|
||||||
|
全部放 `types.py` 内部纯函数(下划线前缀,不导出): CacheMW 要复用,import-linter 禁止 middleware 依赖 transports,而内核层 `ports : types : errors : streaming` 允许。函数只返回判定值,**不抛领域错误**(错误需 `source_name` 等上下文,由调用点翻译),不捕获任何异常,取消天然穿透。
|
||||||
|
|
||||||
|
| 函数 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `_tool_snapshot_ok(message)` | 工具快照是否成立(id/type/function.name/arguments 规则 + 唯一 id);`OpenAIChatCompletion.__post_init__` 复用 |
|
||||||
|
| `_classify_finish(value)` | 映射到 finish 类别(下表) |
|
||||||
|
| `_stream_is_complete(...)` | 第一步,返回 `StreamEnd \| None`(None = 截断) |
|
||||||
|
| `_judge_outcome(...)` | 第二步,返回 `deliver` 或 `(reject_transient \| reject_result, 原因码)` |
|
||||||
|
| `_is_cacheable(...)` | §6 判据 |
|
||||||
|
|
||||||
|
| finish 类别 | 值(大小写敏感) |
|
||||||
|
| --- | --- |
|
||||||
|
| natural | `stop`、`tool_calls`、`function_call` |
|
||||||
|
| truncated | `length` |
|
||||||
|
| filtered | `content_filter`、`sensitive` |
|
||||||
|
| upstream_error | `network_error`、`error` |
|
||||||
|
| unknown | 其余非空字符串: 按 natural 交付、**不可缓存**、按源+值告警一次(文案写明"该值不缓存",便于下游定位命中率下降);`end_turn`/`stop_sequence` 等无证据不预先归类 |
|
||||||
|
| absent | None |
|
||||||
|
|
||||||
|
### 4.2 第一步: 流完整性(仅流式)
|
||||||
|
|
||||||
|
| 条件 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 读取异常 / 活性超时 / error 帧 / 畸形帧 | 照旧 Transient(不动) |
|
||||||
|
| 收到 `[DONE]` | `done` |
|
||||||
|
| 干净 EOF、无 DONE、finish 已见、finish 帧**同帧或之后**收到的 usage 被 `_resolve_usage` 判为 measured | `eof_after_finish`,usage 记 measured(不降级) |
|
||||||
|
| 干净 EOF、其余情形 | 截断: openai → Transient `missing_done`(仍不打捞);text → 原 `_check_done` 语义(零产物 early_eof、`retry` 抛、`salvage` 记 `salvaged`) |
|
||||||
|
|
||||||
|
聚合器每尝试记录 `finish_seen` 与 `usage_after_finish`: 收到非 null usage 时,若 `finish_seen` 已真或同帧带 finish,则置后者为真;是否 measured 以**最后一个**非 null usage(与计费所用 `sink["usage"]` 同一对象)在流结束时判定;usage-only 帧(`choices: []`)在 finish 之后到达是常规形态,正确置位。状态随重试/对冲每尝试新建。
|
||||||
|
|
||||||
|
诚实边界: 中转站在上游出错后主动正常结束响应,与真完整在传输层不可区分;防线是"finish 必须存在、不属 upstream_error、且其后有 measured usage",文档如实写明。
|
||||||
|
|
||||||
|
### 4.3 第二步: 结局分类
|
||||||
|
|
||||||
|
产物定义——**保真模式**: 任何产物 = 消息中任一非空字符串片段(content、reasoning_content、reasoning、refusal、合并保留的未知字符串,空白也算)或至少一个非 null 工具条目;**文本模式**: 任何产物 = 投影 content 或 thinking(含 §4.4 别名)非空串(含空白),未知字段与工具增量只计活性不计产物,`_check_done` 零产物判据同口径。两模式**正文产物** = 投影 content strip 后非空,或(仅保真)工具快照成立且非空。
|
||||||
|
|
||||||
|
表格**自上而下取首个匹配行**:
|
||||||
|
|
||||||
|
| # | 条件 | openai 模式 | text 模式 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | finish 为 upstream_error | Transient `upstream_finish_error` | 同左 |
|
||||||
|
| 2 | (仅保真)存在工具条目但 `_tool_snapshot_ok` 不成立 | truncated → ResultInvalid `tool_truncated`;其余 → Transient `tool_incomplete` | 不适用 |
|
||||||
|
| 3 | 无任何产物 + truncated/filtered | 交付(#30;隐藏推理模型写满预算的形态) | ResultInvalid `length_without_content` / `filtered_without_content` |
|
||||||
|
| 4 | 无任何产物 + natural/unknown/absent | Transient `empty_completion`(保 2026-07-20 裁决) | 同左 |
|
||||||
|
| 5 | 有正文产物 | 交付 | 交付 |
|
||||||
|
| 6 | 有产物无正文 + truncated | 交付(#30) | ResultInvalid `length_without_content` |
|
||||||
|
| 7 | 有产物无正文 + filtered | 交付 | ResultInvalid `filtered_without_content` |
|
||||||
|
| 8 | 有产物无正文 + natural/unknown | 交付 | Transient `empty_completion`(待证据;消息带 finish 与 thinking 长度) |
|
||||||
|
| 9 | 有产物无正文 + absent(流式仅 DONE 收尾,或非流式 body finish 为 null) | Transient `empty_completion` | 同左 |
|
||||||
|
|
||||||
|
ResultInvalid 理由: `retry.py:409-414` 对其"不重试、不换源、熔断记成功不计窗口";length/过滤是预算或内容策略的确定性结局,不是源坏了。对冲下原路 ResultInvalid 等对冲路裁定,两败原样上抛(`retry.py:548-560`);StructuredMW 只捕 parse 抛出的 ResultInvalid,不会误重问(`structured.py:79`)。已知代价: 该分支 TPM 全额退款、用量不落成本(`actual=0`),与 Transient 丢成本同级,登记为已知边界。文本 length 改抛 ResultInvalid 后不再经 `AllSourcesExhausted` 包装,§10 要求核对下游 catch 点。
|
||||||
|
|
||||||
|
原因码以固定格式 `"{源} {code}: …"` 写进异常 message,从而进遥测 `error` 列(LIKE 可检索);不新增列、不改 `_failure_reason`(Transient 一族熔断原因记 `network_error` 属已知口径)。
|
||||||
|
|
||||||
|
保真模式行 2 之外的工具快照成立 + `length`: 交付,arguments 可能是半截 JSON——docstring 与 CHANGELOG 提醒调用方先查 `finish_reason` 再执行工具。非流式跳过第一步,直接以 body 的 finish_reason 走第二步。
|
||||||
|
|
||||||
|
### 4.4 文本模式 reasoning 别名
|
||||||
|
|
||||||
|
投影 `thinking`: 本次尝试出现过**非空** `reasoning_content` 则只用它,否则用 `reasoning`。保真 `completion.message` 保留上游原键不改名。两模式活性、产物摘要、`thinking_observation` 均基于该投影。
|
||||||
|
|
||||||
|
## 5. 字段合并与活性
|
||||||
|
|
||||||
|
### 5.1 null 与已知字段
|
||||||
|
|
||||||
|
null 规则**优先于**已知字段类型校验: chunk、choice、delta、工具条目、function 五层任意键值为 null 视为缺省,数组中的 null 元素(如 `tool_calls: [null]`)跳过;键从未出现时保留 `key: null`(含 `tool_calls`、`refusal`、`role`、`function.arguments`)。保真成功仍须最终 role=assistant(全程只有 `role: null` 即缺 role,照旧失败)。其余已知字段(content/reasoning_content/role/tool_calls 及工具 id/type/function.name/arguments)非 null 时语义逐字沿用 1.3.9。
|
||||||
|
|
||||||
|
### 5.2 未知字段(delta、`tool_calls[]` 条目、`function` 三层统一)
|
||||||
|
|
||||||
|
| 值 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标签键 `index`/`type`/`id`/`format`(任意未知层级) | 同值幂等,不拼接;不同值 = 冲突 |
|
||||||
|
| 其他字符串 | 按到达顺序拼接(OpenAI SDK 流式累加惯例) |
|
||||||
|
| 数字/布尔 | 同值幂等;不同值 = 冲突(不学 SDK 相加) |
|
||||||
|
| 对象 | 递归套用本表 |
|
||||||
|
| 数组且元素均为带整数 `index` 的对象 | 按 index 合并(`reasoning_details` 一类);合并策略由该键首次出现形态决定,之后形态改变 = 冲突 |
|
||||||
|
| 其他数组 | 追加 |
|
||||||
|
| 冲突 | 删除该键路径,本尝试内不再接收;按 (源, 归一路径) 告警一次 |
|
||||||
|
|
||||||
|
冲突删键是有损的: docstring 与 CHANGELOG 写明"可能缺字段,以告警为准"。上游方言确定,重拉同样结果,不视为缓存毒化。
|
||||||
|
|
||||||
|
### 5.3 活性
|
||||||
|
|
||||||
|
已知字段活性逐字沿用 1.3.9(非空 content/reasoning_content、工具 name/arguments 非空增量、首次建立工具身份)。未知字段仅当本帧在非标签键上并入非空字符串片段(含追加型数组中的字符串元素)时算进展(`reasoning` 由此计入);同帧只记一次。残余风险如实登记: 上游每帧重复下发的非标签字符串(如 `status`)会被拼接并持续刷新看门狗。文本模式同口径。README 中"增量 chunk = content 或 reasoning_content"的 stall/TTFT 口径表述同步更新为本节口径。
|
||||||
|
|
||||||
|
### 5.4 告警节流
|
||||||
|
|
||||||
|
节流集合放 transport 实例(与 `_warned_mismatches` 分立,纯 asyncio 中立),键为 (源, 归一路径) 与 (源, 未知 finish 值):路径中数组下标归一为 `[]`,finish 值截断至 64 字符;集合上限 1024,饱和后记一次"告警已饱和"不再增长。日志只写源名与路径,不回显值(可能含工具参数)。聚合器以只读集合交出冲突路径,transport 负责告警。
|
||||||
|
|
||||||
|
## 6. 可交付 vs 可缓存
|
||||||
|
|
||||||
|
可缓存 ⊂ 可交付。两模式同一判据 `_is_cacheable`,读写共用:
|
||||||
|
|
||||||
|
| 条件(任一成立即不可缓存) | 理由 |
|
||||||
|
| --- | --- |
|
||||||
|
| `stream_end == "salvaged"` | 截断结果,修现存毒化 |
|
||||||
|
| 无正文产物 | 不把空回答固化进 TTL |
|
||||||
|
| finish 类别为 truncated/filtered/unknown | 截断有随机性,同 key 换时可成功;未知值不可证明是自然结束 |
|
||||||
|
| openai 模式 completion 原文 content 为空且无工具(沿 1.3.9) | 不信任旧投影 |
|
||||||
|
|
||||||
|
| 路径 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| 写 | 不可缓存 → 跳过,debug 日志 |
|
||||||
|
| 读 | `_is_cacheable` 在命中覆盖(`cache.py:224` `fields.update`)**之前**、对存储原值判定;不可缓存 → miss + warning |
|
||||||
|
| 读: 非法 `stream_end` | 不在 Literal 域内 → miss + warning(关乎内容完整性,故与 `_coerce_observation` 的宽容方向相反) |
|
||||||
|
| 读: 顶层无 finish_reason | 回退 `completion.finish_reason`(1.3.9 保真 length 条目因此可识别);文本旧条目两字段缺省 → None → 仍可命中 |
|
||||||
|
| 命中返回 | `stream_end=None`;`finish_reason` 取判定所用值(顶层缺省时为回退值),非字符串视 miss |
|
||||||
|
| 序列化 | `_RESPONSE_FIELDS` 由 `dataclasses.fields` 自动派生,无需改动 |
|
||||||
|
|
||||||
|
旧 salvage/length 文本污染无法辨认: CHANGELOG 继续要求受影响集合迁移 namespace/salt。
|
||||||
|
|
||||||
|
## 7. 遥测落点
|
||||||
|
|
||||||
|
| 位置 | 变更 |
|
||||||
|
| --- | --- |
|
||||||
|
| `middleware/telemetry.py` `_AttemptUsage.of(response)` | 读 `response.stream_end`(本次 LLMResponse 有字段,不像 1.3.8 走 `emit_attempt` kw,避免约 50 处调用点) |
|
||||||
|
| `_record` | 新增参数,单一 helper 下沉 recorder |
|
||||||
|
| `emit_cache_hit` / `emit_terminal_failure` / 失败与取消 attempt 行 | 显式 None |
|
||||||
|
| `telemetry/schema.py` | SQLite/PG 两份 CREATE 末尾、`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS`(列序对齐)、`COLUMNS`;`telemetry_schema_sql()` 随之输出 |
|
||||||
|
| sqlite/postgres recorder | 按 `COLUMNS` 写入;auto 档幂等补列、manual 档裁剪写入并点名缺列(沿 1.3.8) |
|
||||||
|
| tests | 约 20 处测试替身 `record_llm_call` 补参数 |
|
||||||
|
| 文档 | README "必录字段数"按 `inspect.signature` 实测更新;`schemas/llm-calls.md` 加列并修正陈旧计数 |
|
||||||
|
|
||||||
|
## 8. 旧行为审计
|
||||||
|
|
||||||
|
| 既有行为 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 保真缺 DONE 一律失败 | **替换**: §4.2 判完整时成功;其余仍失败、仍不打捞 |
|
||||||
|
| 文本 `missing_done=retry` 遇缺 DONE 即抛 | **替换**: 同上;`missing_done` 只管真截断 |
|
||||||
|
| 文本 salvage + estimated | 保留,另标 `salvaged`,不再写缓存 |
|
||||||
|
| 零增量 + DONE → Transient | **保留**(`test_stream_zero_content_with_done_is_transient` 红线回归) |
|
||||||
|
| 保真 reasoning-only/空产物失败 | **替换**: §4.3 行 3/6/7/8 |
|
||||||
|
| 未知 delta/工具/function 字段拒绝;null 已知字段拒绝 | **替换**: §5 |
|
||||||
|
| 非流式 `tool_calls: null` 拒绝 | **替换**: 合法 |
|
||||||
|
| 多 choice/index≠0 拒绝、最终 role 必须 assistant、error 帧立即失败 | 保留 |
|
||||||
|
| 工具快照强校验 | 保留;仅 length 截断时改归 ResultInvalid |
|
||||||
|
| finish_reason 冲突拒绝(Transient) | 保留(登记: 确定性方言,将来可改 ResultInvalid) |
|
||||||
|
| 已知字段活性口径 | 保留;未知字段按 §5.3 增补 |
|
||||||
|
| 缓存写路径无判定 | **替换**: 按 `_is_cacheable` |
|
||||||
|
| 文本 n>1 混流 | 不在范围,另开 issue |
|
||||||
|
|
||||||
|
## 9. 非功能维度
|
||||||
|
|
||||||
|
| 维度 | 回答 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并发与取消 | 新状态在每尝试聚合器/sink 内;判定为纯函数,不新增 try/except,`CancelledError` 原路穿透;finally 结算与 permit 释放不变 |
|
||||||
|
| 降级方向 | 缓存跳写/视 miss 只 debug/warning;遥测新列缺失按 manual 档裁剪 + 告警,不冒泡;限流/熔断不涉及 |
|
||||||
|
| 幂等与重复 | 减少重复付费重抽;标签键与身份字段幂等;告警节流有界 |
|
||||||
|
| 持久化与原子性 | 仅新增一可空列,补列沿既有探测后 ALTER 幂等流程;缓存仍成功后单次原子 set |
|
||||||
|
| 错误分类 | 仅落 Transient 与 ResultInvalid 两类,不新增类;原因码见 §4.3 |
|
||||||
|
| 可观测 | `stream_end` 字段 + 遥测列;冲突路径与未知 finish 有界告警;遵 structured-logging 更新 `schemas/llm-calls.md` |
|
||||||
|
|
||||||
|
## 10. 测试、文档与交付
|
||||||
|
|
||||||
|
全部用假 SSE / MockTransport 复现,每条先红后绿;真实中转冒烟标 slow 按发布清单 diff 交集跑。
|
||||||
|
|
||||||
|
| 组 | 用例 |
|
||||||
|
| --- | --- |
|
||||||
|
| #31 验收 | 1 stop+usage+EOF 成功、`eof_after_finish`、measured、attempts=1;2 tool_calls 同上工具完整;3 无 finish 即关 → 失败;4 finish 无 usage → 失败;5 中途 `RemoteProtocolError` → 失败;6 带 DONE → `done` 不变 |
|
||||||
|
| #31 补充 | usage 早于 finish(累计 usage)+EOF → 不完整;finish 后 usage 为 `{}` → 不完整;`network_error`+usage+EOF → Transient;文本模式 1/3/4(`retry`、`salvage` 两档) |
|
||||||
|
| #30 验收 | reasoning-only+length → 交付、content 空、思考完整、attempts=1;流式 `refusal:null`+`tool_calls:null` 成功;非流式 message 带 `tool_calls:null`+`refusal:null` 成功 |
|
||||||
|
| 结局表 | §4.3 每行至少一例,两模式、流式与非流式;文本 length → ResultInvalid 且熔断未计失败;零增量+DONE(带 stop/不带 finish)仍 Transient;content 非空 + 坏工具 + stop → Transient |
|
||||||
|
| 合并/活性 | 只有 `reasoning` 刷新活性不超时;二次构造 OpenRouter `reasoning_details` 往返(`format`/`id` 不拼接);工具层嵌套对象原样保留可回传;数值冲突删键、告警一次、调用成功;节流饱和;`role:null`/`arguments:null` 视缺省 |
|
||||||
|
| 缓存 | 四类不可缓存均不写、注入后读视 miss;非法 `stream_end` 视 miss;1.3.9 旧文本条目仍命中;1.3.9 保真 length 条目经回退识别为不可缓存;命中 `stream_end=None` |
|
||||||
|
| 遥测 | sqlite/pg(integration)新列写入、旧库补列、manual 档裁剪;形状闸拒绝缺参 recorder |
|
||||||
|
| 改写 | `test_openai_never_salvages`;`test_invalid_delta_is_domain_error` 中 `{"audio": {}}`、`{"index":0,"extra":1}`、null 已知字段参数;提交说明列出 |
|
||||||
|
|
||||||
|
同步: 源码 docstring、README(能力表、`1.3.*` 安装约束核对、活性口径、字段数)、CHANGELOG 1.3.10(行为变更段、破坏性段: recorder 端口、namespace/salt 提醒、半截工具参数提醒、冲突删键提醒)、ARCHITECTURE §6.2/§7/§14、1.3.9 设计加修订指针、`schemas/llm-calls.md`、`research-wiki/migrations/` 三份核对下游对 ResultInvalid 的 catch 点、`docs-convention.md` §2 清单。另开 issue: 文本 n>1 混流;文本"有产物无正文 + stop"证据收集。发布按 CLAUDE.md §4.4.1 九步,#30/#31 在 registry 可安装且验收证据齐备后关闭。
|
||||||
|
|
||||||
|
证据: `retry.py:404-450,548-560`;`cache.py:197-236`;`structured.py:79`;[openai-python `_streaming.py`](https://github.com/openai/openai-python/blob/main/src/openai/_streaming.py) 与 `lib/streaming/_deltas.py`;[vLLM reasoning outputs](https://docs.vllm.ai/en/latest/features/reasoning_outputs/);GLM finish 枚举来自第三方镜像,实现前以真实中转探针再核;Gemini `extra_content` 路径未核实原文,测试按通用嵌套对象构造。
|
||||||
|
|
||||||
|
## 11. 审查修订记录
|
||||||
|
|
||||||
|
| 轮次 | 意见 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| R1 | B1 `tool_calls:null` 被快照拒(非流式现存) | 采纳: §3/§5.1/§10 |
|
||||||
|
| R1 | B2 结局表行重叠 | 采纳: 首匹配行序 + `_tool_snapshot_ok` 前置 |
|
||||||
|
| R1 | B3 文本"任何产物"不可计算 | 采纳: §4.3 分模式定义 |
|
||||||
|
| R1 | S1 eof_after_finish 但 usage 非 measured | 采纳: 条件加 measured |
|
||||||
|
| R1 | S2/S3 活性冲突与标签拼接 | 采纳: §5.2/§5.3 |
|
||||||
|
| R1 | S4 告警集合无界 | 采纳: §5.4 |
|
||||||
|
| R1 | S5 无产物 + length 与 #30 原文冲突 | 采纳(人类授权代决): 保真交付、文本 ResultInvalid、stop/absent 仍 Transient |
|
||||||
|
| R1 | S6-S9 缓存顺序、遥测落点、原因码、null 优先 | 采纳: §6/§7/§4.3/§5.1 |
|
||||||
|
| R1 | 建议 1-7 | 采纳 1/2/3/5/6/7;建议 4 不标不可缓存(上游确定性,非毒化) |
|
||||||
|
| R2 | 结论通过;N1 measured 以最后 usage 判定、N2 非空 reasoning_content | 采纳: §4.2/§4.4 |
|
||||||
|
| R2 | 建议 1-4: 命中回退值、未知 finish 告警文案、数组字符串活性与残余风险、null 五层与数组 null 元素 | 全部采纳: §6/§4.1/§5.3/§5.1 |
|
||||||
|
| 代码审查 | I1 保真可缓存须同时要求投影正文非空(think 标签剥净后为空不缓存) | 采纳: `_is_cacheable` |
|
||||||
|
| 代码审查 | I2 无参函数 `arguments` 全程 null → 快照不成立被判 Transient 重试 | 人类授权代决: 归 ResultInvalid `tool_arguments_null`(确定性方言);不改写交付值(改写破坏保真,重试救不回) |
|
||||||
|
| 代码审查 | I3 文本 `index:null` 整帧丢弃、`index:False` 冒充 0 | 采纳: null 视 0,bool 拒 |
|
||||||
|
| 代码审查 | Minor: `[null]` 产出空 `tool_calls`、非流式缺 role 先于判定、告警路径长度有界、README 表述 | 采纳;index 数组缺 index 整键删除登记于 CHANGELOG 提醒 |
|
||||||
@@ -7,6 +7,9 @@ date: 2026-09-09
|
|||||||
|
|
||||||
# 1.3.5 T2/T3 验收证据
|
# 1.3.5 T2/T3 验收证据
|
||||||
|
|
||||||
|
> 最新状态(2026-09-09):**1.3.5 已发布**,main 与 `v1.3.5` 同指 `ab00aa4`;§8.6 所列「尚未执行」已由
|
||||||
|
> [发布完成与外部验收](2026-09-09-135-release-completion.md)逐项关闭。以下为分阶段历史,不追改当时结论。
|
||||||
|
>
|
||||||
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
|
> 范围:**仅 T2(遥测 10 列 / 诊断保真 / scope+operation / 装配闸)与 T3(终态行 / 取消 / 统一出口)**,
|
||||||
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
|
> 外加计划 T4 中"签名与列数机械迁移"那一片(与 schema 同批完成,避免先提交 schema 却留写入缺键)。
|
||||||
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
|
> **不含** T4 的 PG 集成、变异矩阵与文档同步——另任务承接,缺口见 §5。
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-09-135-release-completion
|
||||||
|
title: "1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查"
|
||||||
|
date: 2026-09-09
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.5 发布完成与外部验收
|
||||||
|
|
||||||
|
> 状态:**1.3.5 已发布并完成外部验收,#19 / #23 已评论关闭**。main 与 `v1.3.5` 同指
|
||||||
|
> `ab00aa44576bd4cd47d0680d107d5191679dc1ee`。发布准备阶段的证据见
|
||||||
|
> [1.3.5 验收证据](2026-09-09-135-call-observability-validation.md)(其 §8.6 列的"尚未执行"由本文逐项关闭,
|
||||||
|
> 该文当时结论不追改)。本轮无生产/测试代码改动、未派子代理;原始日志在
|
||||||
|
> `tests/outputs/135/publish-20260909/`(gitignore,不入库)。
|
||||||
|
|
||||||
|
## 1. 合并与合并后门
|
||||||
|
|
||||||
|
长跑全部在 tmux `pgw135-publish` 串行执行,`PYTHONUNBUFFERED=1`,命令末尾**不接管道**,每门独立 `.exit`。
|
||||||
|
|
||||||
|
| 步骤 | 结果与证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 远端占用复查(改动前) | `git ls-remote` 最高 `v1.3.4`;registry simple 索引按完整字面量 `polygateway-1.3.5` 无命中;Release API `/releases/tags/v1.3.5` 与包页面 1.3.5 均 **404**。origin/main = 本地 main = `af57f93`,无未审变更 |
|
||||||
|
| 合并 | `--no-ff` 得 `ab00aa4`;`git diff HEAD feature/1.3.5-call-observability` **空**,即合并树与父会话已审的 `433039b` 逐字一致;`merge.log/.exit` |
|
||||||
|
| CLAUDE.md 纯格式 diff | 合并前仅对该文件 `git stash push`(未 stash 全部),合并后 `stash pop` 回原样;全程未提交、未覆盖 |
|
||||||
|
| `make lint` | **exit 0**,ruff 无自动改动,import-linter **1 kept / 0 broken**;`lint.log/.exit` |
|
||||||
|
| `make test` | **1605 passed / 23 skipped / 108 deselected,95% 覆盖,282.48s,exit 0**;`daily.log/.exit`。skip 与 deselected **不计通过** |
|
||||||
|
| 真实网关 e2e 冒烟(显式 slow) | `pytest tests/e2e/test_smoke_gateway.py -m slow -v`:**4 passed,98.00s,exit 0**(流式/非流式/结构化 json/结构化模型阶梯);`smoke-gateway.log/.exit` |
|
||||||
|
| Redis 时间语义(显式 slow) | `pytest tests/contracts tests/integration -m slow -q`:**18 passed / 159 deselected,1138.67s,exit 0**;`redis-time.log/.exit` |
|
||||||
|
|
||||||
|
未补全模型矩阵(用户已批准):本版对 `thinking.py`、能力表、wire 片段、缓存 key 公式、重试/限流/熔断语义一字未改,
|
||||||
|
故 1.3.4 的型号级结论在其**原有边界**内继续成立,连同其 FAIL/UNKNOWN/不可达/缺轮一并继承,不因本版升格。
|
||||||
|
本版**已变更**的统计/SQL/PG 口径不复用旧统计,全部由 1.3.5 新测试承担(36 列契约、终态行、真实 PG 30 项)。
|
||||||
|
|
||||||
|
## 2. push / tag / 构建 / 上传
|
||||||
|
|
||||||
|
| 步骤 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| push main | `af57f93..ab00aa4`,exit 0;`push-main.log/.exit` |
|
||||||
|
| tag | `git tag -a v1.3.5`,注释 tag 对象 `ab780cf36a8c6b2de316c0b194dcaa9485e8172f`,解引用 = `ab00aa4`;push exit 0;`remote-refs-after.log` |
|
||||||
|
| 构建 | 确认 `dist` 是项目内真实目录(非符号链接)后清掉旧 1.3.3/1.3.4 产物;`python -m build` exit 0,wheel + sdist 各一份 |
|
||||||
|
| `twine check` | 两份均 **PASSED**,exit 0 |
|
||||||
|
| 上传 | exit 0。凭据只从 tea 配置读入 `TWINE_PASSWORD` 环境变量,不进 argv/日志;**未重传任何已存在版本的字节** |
|
||||||
|
|
||||||
|
产物 SHA-256:
|
||||||
|
wheel `c017599ea6bef69a485e1d35eab2ca89154cd33180ebd6dc759647097221a6b4`;
|
||||||
|
sdist `87fd4151b77f55829b5d3206c5b75ac6c1c6bb8c94faa194955bf0db33ad0211`。
|
||||||
|
|
||||||
|
## 3. 独立下载、解包与安装后行为
|
||||||
|
|
||||||
|
全部在仓库外临时目录(`/tmp/pgw135-registry-*`)进行,不引入新依赖。
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| registry 下载 | wheel exit 0;sdist 因私有索引不镜像 setuptools 需 `--no-build-isolation`(沿用 1.3.4 已记录的处置)exit 0 |
|
||||||
|
| 字节一致 | 下载的两份 SHA-256 与本地构建**逐字相同** |
|
||||||
|
| 包内源码 | `__init__.py` / `types.py` / `ports.py` / `telemetry/schema.py` / `middleware/telemetry.py` / `client.py` / `embedding.py` / `ocr.py` 八个文件 wheel、sdist 与已发布提交**散列全等**;sdist 内 README == 仓库 README |
|
||||||
|
| 元数据 | wheel METADATA `Version: 1.3.5`、`Description-Content-Type: text/markdown`、正文 30465 字符,含《1.3.5 逻辑调用统计与失败诊断》与安装下界 `>=1.3.5,<2`;Project-URL 三项齐全 |
|
||||||
|
| 安装后公共面(`pip install --target` + 仅从该目录导入) | `CallStats` 在 `__all__`、frozen、字段 `logical_call_id/attempts/total_latency_ms`;四类响应 `call_stats` 均为**末尾**字段且默认 `None`;`record_llm_call` 实测 **36 参**,10 个新参全为 keyword-only 且**无默认值**;`len(schema.COLUMNS) == 36` |
|
||||||
|
| 安装后终态行行为(离线,httpx MockTransport 固定 503,不发真实请求) | 3 条 `attempt` + **恰 1 条** `terminal_failure`;终态 `attempts=3`、`total_latency_ms` 非空、`cost IS NULL`、`usage_source='unavailable'`、`http_status_code` 为 NULL;尝试行三条均记真实 **503** 与 `error_body`,`error_type='TransientError'`;四行共享同一 `logical_call_id`;物理列 **37**(36 写入列 + `created_at`) |
|
||||||
|
|
||||||
|
## 4. 外部产物亲查(匿名,看到什么算什么)
|
||||||
|
|
||||||
|
| 目标 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| Release 创建 | POST **201**,id 22,正文 1898 字符逐字取自 CHANGELOG 1.3.5 段 |
|
||||||
|
| [包页面](https://gitea.iomgaa.online/iomgaa/-/packages/pypi/polygateway/1.3.5) | 匿名 **200**;可见正文含安装命令与《1.3.5 逻辑调用统计与失败诊断》;页面有指向 `iomgaa/PolyGateway` 的仓库锚点 |
|
||||||
|
| [v1.3.5 Release](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.3.5) | 匿名 **200**;可见文本含 `WHERE event_kind = 'terminal_failure'` 与「26 → 36」迁移说明 |
|
||||||
|
| [Releases 列表](https://gitea.iomgaa.online/iomgaa/PolyGateway/releases) | 匿名 **200**,含 v1.3.5 |
|
||||||
|
| simple 索引 | 匿名 **200**,1.3.5 的 wheel 与 sdist 均列出 |
|
||||||
|
|
||||||
|
## 5. issue 收尾
|
||||||
|
|
||||||
|
仅动 **#19 / #23**:各评论一条后关闭,随后**以 GET 读到的 state 为准**复核(不拿 PATCH 返回码推断),两者均 `closed`。
|
||||||
|
评论内容点名:自建 recorder 需补 10 参或改 `**fields`(**装配期报错**)、PG manual 档补列、
|
||||||
|
计失败改 `WHERE event_kind = 'terminal_failure'`、`AVG(latency_ms)` 按 `event_kind` 分组;
|
||||||
|
并明确「新增失败统计列与终态行是**常规口径变更、不是异常字段**」,`http_status_code` 不可当成败判据;
|
||||||
|
同时声明**未做全模型矩阵、不宣称所有模型或渠道可用**。#22 / #24 未触碰(复核仍 `open`、评论数 0)。
|
||||||
|
|
||||||
|
## 6. 真实失败与处置(原件保留,不改写成功)
|
||||||
|
|
||||||
|
| 失败 | 根因与处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 首次安装后终态验证 exit 1 | `sqlite3.OperationalError: no such column: id` —— 是**我的验证脚本**臆断了主键列名,实际第 37 个物理列是 `created_at`。改脚本 `ORDER BY rowid` 并把多出的物理列打印出来自证,**未改库代码**;失败日志保留 |
|
||||||
|
| 首次 Release 创建 exit 1 | `KeyError: 'TOKEN'` —— 变量未 export 到子进程,**未发出任何 API 请求**(不是服务端拒绝)。改为 export 后 201 |
|
||||||
|
| 包关联 POST **400** | 返回 `invalid argument`,与 1.3.4 同款。**不把 400 当成功、也不据此猜已关联**:另以认证 GET 包 API 读到 `repository.full_name = iomgaa/PolyGateway`,并匿名读到包页面上的仓库链接,两条实测证据才是关联成立的依据;既有正确关系不为重试而解绑 |
|
||||||
|
|
||||||
|
## 7. 残余缺口(不自称已关)
|
||||||
|
|
||||||
|
| 缺口 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| live 覆盖基线 | `metrics/call-telemetry-coverage.md` 的实际基线列仍待首次生产运行填入 |
|
||||||
|
| 下游自建 recorder | 装配闸只证形状可被接受,证不了函数体真的落这些列(已写进 README/CHANGELOG/issue 评论) |
|
||||||
|
| 模型矩阵 | 未重跑;1.3.4 的 FAIL/UNKNOWN/不可达/缺轮例外原样继承,不外推为「全模型可用」 |
|
||||||
|
| 其余 e2e | 本轮 slow 只跑了 `test_smoke_gateway.py` 与 contracts/integration 的 Redis 时间语义,`tests/e2e/` 其余文件未跑 |
|
||||||
|
| 用户文档站(Gitea Wiki) | 本轮未同步;`docs-convention.md` §2 清单待另行执行 |
|
||||||
|
|
||||||
|
conda 启动器既有 `RequestsDependencyWarning` 保留,不宣称零告警。本记录落在独立
|
||||||
|
`docs/1.3.5-release-evidence` 分支,**不改动已发布的 main/tag/registry 产物**。
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-10-136-call-deadline-validation
|
||||||
|
title: "1.3.6 可选调用期限与取消结算验证记录"
|
||||||
|
date: 2026-09-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.6 可选调用期限与取消结算验证记录
|
||||||
|
|
||||||
|
> 范围:分支 `feature/1.3.6-call-budgets` 上的 T1–T3 三个行为提交(`1ff83bb` / `9474c76` / `da77b12`)与 T4 文档提交。设计 `research-wiki/designs/2026-09-09-136-call-budgets-design.md`(人类已批准),计划 `research-wiki/plans/2026-09-10-136-call-deadline.md`。
|
||||||
|
> 本文件是**证据索引**:原始输出在 `tests/outputs/136/`(按纪律**不提交**),此处只记路径、命令、退出码与结论。
|
||||||
|
> 环境:conda 环境 `PolyGateway`,**Python 3.12.13**(`conda run -n PolyGateway python -V` 实测)。所有 pytest/lint 命令均**不接管道**,退出码直取。
|
||||||
|
> 版本号未 bump、未 tag、未发布——发布清单(CLAUDE.md §4.4.1)不在本轮范围。
|
||||||
|
|
||||||
|
## 1. 红绿证据索引
|
||||||
|
|
||||||
|
| 任务 | 阶段 | 证据文件 | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T1 取消结算 | 红/绿 | **未落盘**(见 §1.1 诚实说明) | 见 §1.1 |
|
||||||
|
| T1 真实 Redis | 绿(本轮在 HEAD `da77b12` 上复跑) | `tests/outputs/136/t4/redis_cross_connection.txt` | `8 passed`,`exit=0` |
|
||||||
|
| T2 期限 | 红(值域+形态) | `tests/outputs/136/t2/red_deadline.txt` | 收集期 `1 error`(`deadline.py` 缺席),`exit=2` |
|
||||||
|
| T2 期限 | 绿(`test_deadline.py`) | `tests/outputs/136/t2/green_deadline.txt` | `22 passed`,`exit=0` |
|
||||||
|
| T2 接线中途 | 绿 | `tests/outputs/136/t2/unit_contracts_midway.txt`、`unit_contracts_after_wiring.txt` | 各 `1560 passed, 17 skipped` |
|
||||||
|
| T2 入口冒烟 | 绿 | `tests/outputs/136/t2/entry_smoke.txt` | 四个方法签名含 `call_deadline_s`;到期异常 `has retry_after_s: False`,`exit=0` |
|
||||||
|
| T2 回归门 | 绿 | `tests/outputs/136/t2/final_unit_contracts.txt` | `1572 passed, 17 skipped`,`pytest_exit=0` |
|
||||||
|
| T2 lint | 红→绿 | `tests/outputs/136/t2/lint.txt`(`Found 3 errors`,`lint_exit=2`)→ `lint_final.txt`(`All checks passed!` + `Contracts: 1 kept, 0 broken.`,`lint_exit=0`) | 修后绿 |
|
||||||
|
| T2c 补测 | 红 pass1 | `tests/outputs/136/t2c/red_pass1_import_absent.txt` | `3 errors in 0.29s`(三个模块收集期 ImportError) |
|
||||||
|
| T2c 补测 | 红 pass2 | `tests/outputs/136/t2c/red_pass2_real_reasons.txt` + `red_method_and_reasons.txt` | `32 failed`;分布见 §1.2 |
|
||||||
|
| T2c 补测 | 绿 | `green_unit_after_hardening.txt`(`1530 passed`)、`green_client_recheck.txt`(`110 passed`,`EXIT=0`)、`green_recheck_client_embedding.txt`(`165 passed`)、`green_unit_contracts.txt`(`1592 passed, 17 skipped`)、`final_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 |
|
||||||
|
| T2c lint | 绿 | `tests/outputs/136/t2c/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` |
|
||||||
|
| T3 `Retry-After` | 红 | `tests/outputs/136/t3/red.txt` | `6 failed, 8 passed, 129 deselected` |
|
||||||
|
| T3 `Retry-After` | 绿 | `green_file.txt`(`143 passed`)、`green_unit_contracts.txt`(`1606 passed, 17 skipped`) | 全绿 |
|
||||||
|
| T3 lint | 绿 | `tests/outputs/136/t3/lint.txt` | `All checks passed!` + `Contracts: 1 kept, 0 broken.` |
|
||||||
|
|
||||||
|
`tests/outputs/136/t2/lsp_noise_refutation.txt` 与 `t2c/lsp_noise_refutation.txt` 记录编辑器 LSP 报的 import/属性告警属环境噪声(`pydantic` 在 conda 环境可解析),不是代码缺陷。
|
||||||
|
|
||||||
|
### 1.1 T1 的证据形态(诚实说明)
|
||||||
|
|
||||||
|
T1(`1ff83bb`)的**先红后通过证据产生于当时的会话工具输出,未落盘为 `tests/outputs/136/t1/` 文件**。本文件不追认那次输出,只登记两项**当下可复核**的替代证据:
|
||||||
|
|
||||||
|
| 替代证据 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 提交 `1ff83bb` 的 diff | 三个源文件 + 四个测试文件共 213 插入;测试侧含 S3/S7(取消结算按 `est`)、S5-dead(仍 `0`)、S8(`RuntimeError` 逃逸仍 `0`)、真实 usage 恰为 0 的成功仍 `0` 四组断言 |
|
||||||
|
| 本轮在 HEAD `da77b12` 上重跑真实 Redis | `pytest tests/integration/test_redis_cross_connection.py -q` → `8 passed`,`exit=0`(`tests/outputs/136/t4/redis_cross_connection.txt`) |
|
||||||
|
|
||||||
|
结论口径:**T1 的“红”只有会话内证据、无归档文件**;T1 的“绿”在当前 HEAD 上已被真实 Redis 复现证实。
|
||||||
|
|
||||||
|
### 1.2 T2c 两趟红证据的方法说明(诚实说明)
|
||||||
|
|
||||||
|
T2c 的红证据是在**基线 `1ff83bb`(T2 之前)**的 `git worktree --detach` 检出上取的,用 `PYTHONPATH=<worktree>/src` 覆盖 editable `.pth`(已实测 `polygateway` 加载自 worktree 且 `deadline.py` 缺席):
|
||||||
|
|
||||||
|
| 趟次 | 做法 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| pass1 | 用例原样跑 | 三个测试模块**收集期** ImportError(`CallDeadlineExceeded` 不存在)→ `3 errors`。只证明符号缺席,**没有执行到函数体** |
|
||||||
|
| pass2 | 仅把缺失的**导入符号**替换成**本地占位异常类**(shim),让函数体真正跑起来 | `32 failed`:**27 条“参数/属性不存在”**(`chat()` 9、`embed()` 4、`GatewaySettings.__init__()` 3、`GatewaySettings.call_deadline_s` 属性 3、`recognize_text()` 2、`GatewayClient.__init__()` 2、`parse_layout()`/`OcrClient.__init__()`/`EmbeddingClient.__init__()`/`GatewayClient._call_deadline_s` 各 1)+ **5 条 `Failed: DID NOT RAISE ValueError`** |
|
||||||
|
|
||||||
|
**该 shim 是一次性本地脚手架,未提交、不在任何分支上**;它只替换导入符号,不改被测源码。故 pass2 的红**是针对预 T2 源码的真实失败原因分布**,而非构造错误——但读者需知这份红**无法从仓库检出复现**,只能从上表与 `red_method_and_reasons.txt` 复核。
|
||||||
|
|
||||||
|
## 2. 命令与退出码
|
||||||
|
|
||||||
|
| 命令(前缀均为 `conda run -n PolyGateway python -m`,lint 为 `make lint`) | 何时 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pytest tests/unit/test_deadline.py -q` | T2 红 | `exit=2`(collection error,符合预期) |
|
||||||
|
| `pytest tests/unit/test_deadline.py -q` | T2 绿 | `22 passed`,`exit=0` |
|
||||||
|
| `pytest tests/unit tests/contracts -q` | T2 门 | `1572 passed, 17 skipped`,`exit=0` |
|
||||||
|
| `pytest tests/unit/test_client.py tests/unit/test_embedding.py tests/unit/test_ocr_client.py tests/unit/test_config.py -q -rf -k "…deadline…"` | T2c 红 | `32 failed`(基线 worktree,见 §1.2) |
|
||||||
|
| `pytest tests/unit tests/contracts -q` | T2c 门 | `1606 passed, 17 skipped`,`exit=0` |
|
||||||
|
| `pytest tests/unit/test_openai_compat.py -q -rf -k RetryAfterNonFinite` | T3 红 | `6 failed, 8 passed, 129 deselected` |
|
||||||
|
| `pytest tests/unit/test_openai_compat.py -q` | T3 绿 | `143 passed` |
|
||||||
|
| `pytest tests/unit tests/contracts -q` | T3 门 | `1606 passed, 17 skipped`,`exit=0` |
|
||||||
|
| `pytest tests/integration/test_redis_cross_connection.py -q` | T1/T4 复跑 | `8 passed`,`exit=0` |
|
||||||
|
| `make lint`(ruff + import-linter) | T2/T2c/T3 收尾 | `All checks passed!`;`Contracts: 1 kept, 0 broken.`(新 `polygateway.deadline` 层在内) |
|
||||||
|
|
||||||
|
T4(本次文档提交)**不含行为变更**,故未跑测试;仅新增上表最后一行的真实 Redis 复跑作为 §1.1 的替代证据。
|
||||||
|
|
||||||
|
## 3. 豁免索引(哪些门没跑,为什么,谁来兜)
|
||||||
|
|
||||||
|
| 未执行项 | 原因 | 兜底责任 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `pytest -m slow`(真实网关 e2e、Redis 时间语义变体) | 成败取决于外部服务当下状态,默认被 `addopts = "-m 'not slow'"` 排除;计划 §5 明确本轮不跑 | **发布清单(CLAUDE.md §4.4.1)第 4 步**,合并 main 后统一执行 |
|
||||||
|
| `tests/e2e/` 四个文件 | 同上,本版零付费调用 | 同上 |
|
||||||
|
| 真实网关的期限行为实测 | 期限用例用真实事件循环时钟+假 transport 构造,余量 4–10 倍,不依赖网关 | 发布清单第 4 步的 e2e 顺带覆盖;**本轮无真实网关证据** |
|
||||||
|
| 模型能力矩阵复验 | 本版未触碰推理/能力表 | 不适用 |
|
||||||
|
| 跨 Python 版本验证 | 见 §4 残余三 | 未兜底,登记为残余 |
|
||||||
|
|
||||||
|
真实 Redis **不在豁免之列**:T1 已证、本轮在 HEAD 上复跑(`8 passed`),未以 memory 后端冒充。
|
||||||
|
|
||||||
|
## 4. 残余风险(三条,逐条复述设计 §12)
|
||||||
|
|
||||||
|
| # | 残余 | 诚实口径 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 取消结算按 `est` 保留预扣 | 这是**保守选择,不是“上游已计费”的证明**。库无法知道端口已开始的那次调用是否真的产生了计费用量;方向定为宁多扣不空退(多扣只损失本窗口一点额度,空退会让已计费用量绕过闸门)。真实 usage 已知(含恰为 0)与已判 `SourceDead` 的 `0` 不被覆写;未分类异常逃逸仍按 `0`,属**已知残留,本版不动** |
|
||||||
|
| 2 | 清理期自抛 `TimeoutError` 时**无终态遥测行** | 与 1.3.5 的裸 `TimeoutError` 穿透**同一口径**(这条路径一直存在、一直没有终态行),本版没有让它变坏;**但期限把这条路径常态化了**——启用期限后触发清理的频率上升,其可达性随之上升。`with_call_deadline` 的局部变量身份比较保证这种 `TimeoutError` **不会**被误标成 `CallDeadlineExceeded`(`test_deadline.py` 有断言钉住) |
|
||||||
|
| 3 | 跨 Python 版本仅 3.12.13 有实证 | `asyncio.timeout` 的 `cm.expired()`/`uncancel()` 行为与清理期异常传播是探针在 **3.12.13 单一版本**上实测的;3.13+的行为未验证。库声明 3.12+,故这是**真实的验证缺口**,不是理论担忧 |
|
||||||
|
|
||||||
|
## 5. 发布清单第 4 步结果(2026-09-10,main `b0ab39e`)
|
||||||
|
|
||||||
|
| 门 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `make lint`(合并后 main) | 通过(ruff + import-linter 1 kept 0 broken) | 会话内输出 |
|
||||||
|
| 全套件(unit+contracts+integration) | **1683 passed, 23 skipped, exit=0** | `tests/outputs/136/release/full-gate.log` + `.exit` |
|
||||||
|
| slow 交集子集(Redis 时间语义变体 + 真实网关冒烟) | **22 passed, exit=0**(21 分钟) | `tests/outputs/136/release/slow-scoped.log` + `.exit` |
|
||||||
|
| `test_thinking_live.py` 全模型能力矩阵 | **未跑——按 2026-09-10 人类批准的新规则豁免**:本版 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施,复用 1.3.4/1.3.5 已登记矩阵证据;当晚多渠道额度耗尽,全矩阵只会产出超时链噪声。规则变更已写入 CLAUDE.md §4.4.1 第 4 步与 §4.6(提交 `b0ab39e`);被中途终止的全量尝试日志留存于 `slow-gate.log` 备查 | 本表 |
|
||||||
|
|
||||||
|
## 6. 未在本轮做的事
|
||||||
|
|
||||||
|
- 不实现 issue #24(长尾对冲):未获批准,代码与文档均无 hedge 机制,本版**不缓解 #24**。
|
||||||
|
- 不修 `ResultInvalid`/`RequestRejected` 已计费坏结果仍退全款:属另一族记账语义,未批准,登记待立 issue。
|
||||||
|
- 不 bump 版本号、不打 tag、不构建、不上传 registry、不同步 wiki——全部留给发布清单。
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-10-24-hedged-requests-validation
|
||||||
|
title: "issue #24 长尾对冲请求与裸生成时间验证报告"
|
||||||
|
date: 2026-09-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #24 长尾对冲请求与裸生成时间验证报告
|
||||||
|
|
||||||
|
> 计划:`research-wiki/plans/2026-09-10-24-hedged-requests.md`;设计:`research-wiki/designs/2026-09-10-24-hedged-requests-design.md`(H1–H8 全数获批)。
|
||||||
|
> 分支 `feature/1.3.7-hedged-requests`;基线 main `166b286`(1.3.6);代码提交 `0a6d622`(T1)/`463eca3`(T2)/`adc0694`(T3),文档提交见本文件 git 历史。
|
||||||
|
> 所有命令在 `PolyGateway` conda 环境执行,未接管道(退出码不失真)。
|
||||||
|
|
||||||
|
## 1. 红绿证据索引
|
||||||
|
|
||||||
|
证据目录 `tests/outputs/137/{t1,t2,t3}`(不提交,本地留存);每份日志末尾带 `EXIT_CODE=` 行。
|
||||||
|
|
||||||
|
| 任务 | 相位 | 证据文件 | 退出码 | 结果与失败形态 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| T1 端口事件 | 红(批次 A) | `t1/red-batch-a.log` | 1 | 4 failed,143 deselected——失败均为 `TypeError`(端口签名无 `first_token_event` 必填 kw),非断言值不符 |
|
||||||
|
| T1 | 绿(批次 A) | `t1/green-batch-a.log` | 0 | 147 passed |
|
||||||
|
| T1 | 绿(unit 全套) | `t1/green-unit-full.log` | 0 | 1550 passed,既有断言一行未改 |
|
||||||
|
| T2 CallStats 三字段 | 红(批次 B–F) | `t2/red-batch-b-f.log` | 1 | 10 failed,373 deselected——字段不存在(`TypeError`/`AttributeError`)与计时字段恒 0 |
|
||||||
|
| T2 | 绿(批次 B–F) | `t2/green-batch-b-f.log` | 0 | 10 passed,373 deselected |
|
||||||
|
| T2 | 绿(unit+contracts) | `t2/green-unit-contracts.log` | 0 | 1621 passed,17 skipped |
|
||||||
|
| T3 对冲编排 | 红(编排+配置守卫,计划批次 G/H) | `t3/red_batches_d_h.txt` | 1 | 25 failed——14×RetryMW 缺 `hedge_after_s`、2×GatewayClient 缺参、4×GatewaySettings 缺属性、4×守卫未抛 ValueError、1×client 缺属性,均为未实现形态 |
|
||||||
|
| T3 | 绿(同上 25 用例) | `t3/green_batches_d_h_run1.txt` | 0 | 25 passed,2.55s |
|
||||||
|
| T3 | 绿(批次 I 默认关闭回归) | `t3/green_full_unit_contracts.txt` | 0 | **1646 passed** = 基线 1621 + 新增 25,17 skipped,48s;既有断言一行未改 |
|
||||||
|
| T3 | lint | `t3/lint.txt` | 0 | `make lint`(ruff --fix + import-linter)通过,Contracts: 1 kept,0 broken |
|
||||||
|
|
||||||
|
T3 各相位命令原文与退出码另见 `t3/commands.md`(该文件表头"批次 D–H"为执行批次流水号,对应计划 §5 的批次 G 对冲编排 + H 配置守卫,25 用例 = 15 编排 + 8 配置 + 2 client 入口校验)。
|
||||||
|
|
||||||
|
## 2. 批次与提交映射
|
||||||
|
|
||||||
|
| 提交 | 计划任务 | 覆盖批次(计划 §5) | 关键断言 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `0a6d622` | T1 端口事件(H2) | A | 流式首 token 置位事件;非流式永不置位;`None` 不观测行为不变;漏传必填 kw 即 `TypeError` |
|
||||||
|
| `463eca3` | T2 三字段与计时(H3+H8) | B–F | 三字段默认 0/0/False;chat 计时排除退避与准入;结构化重问取最后一轮;embedding 批次累加;OCR 单次;缓存命中恒 0 |
|
||||||
|
| `adc0694` | T3 对冲编排(H1/H4/H5/H6) | G、H、I | 触发两形态;异源排除;准入失败静默;赢家 settle 实际/输家 settle est;`hedge_cancelled` 标签;输家不喂熔断;`attempts==2` 无任务泄漏;外部取消两路穿透;deadline 切断对冲树;原路后发先至;两败计一次预算;两 429 免预算退 stall;混合失败计一次;配置守卫四路 |
|
||||||
|
|
||||||
|
## 3. 实测核对(文档承诺 vs 运行实测,本文件交付时复核)
|
||||||
|
|
||||||
|
| 承诺 | 核对方式 | 实测结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CallStats` 三字段默认值 | `CallStats(logical_call_id='x', attempts=1, total_latency_ms=5)` 仅旧三参构造 | `hedges=0`、`generation_ms=0`、`hedge_won=False`,构造不炸 |
|
||||||
|
| `hedge_won` 语义 | 现读 `middleware/retry.py:498-578` | 赢家裁定后 `hedge_won=winner is hedge`;两败轮次照登 `hedge_won=False` 且裸生成时间无归属不记;触发但准入失败不计 `hedges` |
|
||||||
|
| 两键 env 解析 | `GatewaySettings.from_env(env=...)` 注入两源 + `LLM__HEDGE__AFTER_S=8`/`MAX_EXTRA=2` | 解析出 `hedge_after_s=8.0`/`hedge_max_extra=2`,两源照常加载(3 段键未被当源字段);`MAX_EXTRA=2` 触发装配期 warning「v1 仅单路对冲生效」;不设两键时 `None`/`1` |
|
||||||
|
| 版本号不动 | `pyproject.toml` 与 `src/polygateway/__init__.py` | 两处均 `1.3.6`,本计划不 bump、不 tag、不发布 |
|
||||||
|
| 文档行号 | README/CHANGELOG/.env.example 接入点 | 均按交付时现读行号接入,未沿用计划旧行号 |
|
||||||
|
|
||||||
|
## 4. 豁免索引(未跑项与去向)
|
||||||
|
|
||||||
|
| 未跑项 | 理由 | 去向 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/e2e/`(slow) | 本计划不新增、不跑真实网关用例;`tests/e2e/conftest.py` 包装 transport 已同步转发 `first_token_event` | 发布清单第 4 步按 diff 交集选子集(本 diff 触及公开入口与 e2e 设施,e2e 冒烟届时在交集内) |
|
||||||
|
| Redis 契约/integration(slow) | T1–T3 未触碰限流 Lua、`Permit` 端口、`backends/**` 与 `tests/contracts/**`(计划 §2 不改清单);对冲结算复用既有 `settle_and_release` 路径,无新 Lua 行为可测 | 同按发布交集规则判断;diff 已触及 retry/限流结算路径,**Redis 时间语义变体届时在交集内**(计划 §5 末句已登记) |
|
||||||
|
| `test_thinking_live.py` 全模型矩阵 | 未动 `thinking.py`/能力注册表 | 不在交集,复用最近一次有效矩阵证据 |
|
||||||
|
|
||||||
|
## 5. 残余复述(设计 §10 与计划 §6 的已批准口径)
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 非流式「挂起 vs 慢生成」 | 物理不可分(无中途信号),只能靠阈值取值(建议源 p50 数倍)与 `hedge_max_extra` 上限控制误对冲;分位数触发为未来扩展 |
|
||||||
|
| 输家取消止不住上游计费 | est 保留只是闸内保守记账,不是上游真实计量的计量;文档只写「可能已计费」,不写「浪费上限 = est」 |
|
||||||
|
| 竞速误贴标签 | 外部取消与对冲取消同时到达时,输家行可能误贴 `cancelled`/`hedge_cancelled`;两任务同消、记账方向一致(est 保留),不造成结算或熔断错误,可接受 |
|
||||||
|
| `hedge_max_extra` v1 单路 | 值域 [1,3] 接受,>1 仅装配期 warning,运行期恒单路(对冲任务恒传 `first_token_event=None`,不再触发梯次);若未来审定应为 `ValueError`,改 `check_hedge_assembly` 一处 + 批次 H 一条断言 |
|
||||||
|
| 挂起源不喂熔断的反向代价 | 持续挂起的源不会因对冲输家被熔断标记;「挂起率」只能靠 `hedge_cancelled` 遥测行统计(同 `logical_call_id` join 还原) |
|
||||||
|
|
||||||
|
## 6. 结论
|
||||||
|
|
||||||
|
## 7. 发布清单第 4 步结果(2026-09-10,main `a04d87e`)
|
||||||
|
|
||||||
|
| 门 | 结果 | 证据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `make lint`(合并后 main) | 通过(ruff + import-linter 1 kept 0 broken) | 会话内输出 |
|
||||||
|
| 全套件(unit+contracts+integration) | **1727 passed, 23 skipped, exit=0** | `tests/outputs/137/release/full-gate.log` + `.exit` |
|
||||||
|
| slow 交集子集(Redis 时间语义变体 + 真实网关冒烟;交集判据:本版动了重试/退避/取消路径与公开入口) | **22 passed, exit=0**(21 分钟) | `tests/outputs/137/release/slow-scoped.log` + `.exit` |
|
||||||
|
| `test_thinking_live.py` 全模型能力矩阵 | **未跑——按 2026-09-10 生效的交集规则豁免**:本版 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施,复用 1.3.4/1.3.5 已登记矩阵证据 | 本表 |
|
||||||
|
|
||||||
|
T1–T3 全部行为变更具备先红后绿证据(§1),默认关闭回归门成立(1646 passed 且既有断言一行未改),lint 与 import-linter 契约通过。文档承诺经运行实测核对(§3)。版本 bump、tag、发布与 slow/e2e 交集子集**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# 流式中段卡顿对冲与"净生成时间"口径调研
|
||||||
|
|
||||||
|
> 调研日期: 2026-09-11。对应缺口: ①对冲触发判据只盯"首 token 未到",真实故障是"首 token 快(中位 1.6s)但流中段长时间沉默(中位 55s)";②`generation_ms` 把中段沉默包进墙钟,下游需要"净生成时间"。
|
||||||
|
|
||||||
|
## 〇、总览: 结论先行
|
||||||
|
|
||||||
|
### 主题 A: 流式中段卡顿(stream started but stalls mid-way)的对冲/恢复范式
|
||||||
|
|
||||||
|
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Hedged/Tied requests(原始定义) | Dean & Barroso, CACM 2013 | 论文语境是一元(unary)完整响应的 leaf 扇出,"first result"指整个响应,**未讨论增量/流式响应** | 延迟派发(等 p95 再发第二路)+ 首个结果到达即取消其余 + 额外负载可低至 ~2%;tied request 的跨副本取消协议 |
|
||||||
|
| 自适应对冲(TTFT 竞速) | llms.blog 生产架构文;bhope/hedge; InfoQ | LLM 网关语境把赢家裁定从"响应头"前移到"首 token";但该文断言"卡顿几乎只发生在排队/prefill"——**与我们实测(中段沉默中位 55s)相矛盾**,SGLang decode 饿死 issue 证明中段卡顿真实存在 | 按模型×渠道维护滚动 P90/P95 TTFT(DDSketch)作动态对冲阈值;token bucket 限制对冲放大率 |
|
||||||
|
| Envoy hedge policy | Envoy 官方文档 | 对冲赢家裁定在**响应头**("first response with acceptable headers"),一旦裁定即 commit;**响应体中段停顿不会重新对冲**,只能靠 stream idle timeout 兜底 | "判赢即 commit、不回头"的语义参考;`hedge_on_per_try_timeout` 命名;per_try_timeout 只在响应开始前有效(PR #10082) |
|
||||||
|
| gRPC hedging | gRPC A6 提案 + 官方指南 | hedgingDelay 以"收到非错误响应(头)"为节拍;收到 nonFatal 状态码立即补发下一路;server pushback(`grpc-retry-pushback-ms`)可推迟/禁止对冲;retryThrottling 用 token bucket 压对冲率 | `maxAttempts`/`hedgingDelay`/`nonFatalStatusCodes` 配置三元组;上限钳到 5;deadline 覆盖整个对冲链 |
|
||||||
|
| 流内不活跃看门狗(inactivity watchdog) | LiteLLM PR #29767、LangChain PR #36949(已合并) | **业界对中段沉默的主流应对是"看门狗→超时→抛错/重试",不是并发副本**;关键细节: 以**解析后的内容 chunk 间隔**计时,SSE keepalive 注释不重置计时器 | `stream_chunk_timeout`(LangChain, 默认 120s) / `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(LiteLLM 提案,默认关);首 chunk 不受看门狗约束(TTFT 由请求超时管) |
|
||||||
|
| 沉默检测→整路重试 | OpenAI Codex CLI issue #33051 | Responses 流静默 300s 后判定 `stream disconnected - retrying sampling request (1/5)`,是"中段沉默触发重试"的真实生产先例 | 沉默超阈值 → 归为 Transient → 走既有重试/换源链,而非保活等待 |
|
||||||
|
| serving 层 ITL 劣化成因 | vLLM disagg prefill 文档、vLLM PR #33743、SGLang #32549/#29879 | 中段卡顿的服务端根因: prefill 与 decode 同卡互踩、chunked prefill 调度饿死 decode(实测 decode 被压到 ~1 batch/24s) | 中段沉默是**结构性现象**而非异常个例,网关层必须有对策;观测指标参考 `sglang:decode_step_latency_seconds` |
|
||||||
|
|
||||||
|
### 主题 B: "净生成时间"(扣除沉默段)观测口径范式
|
||||||
|
|
||||||
|
| 范式名称 | 出处 | 与我们问题的对应关系 | 可直接借鉴的做法 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| TTFT / ITL(=TPOT) / E2EL 标准定义 | NVIDIA NIM/AIPerf 指标文档、NVIDIA 技术博客 | E2EL = TTFT + Generation_time(首 token→末 token,剔除 `[DONE]`);ITL = (E2EL−TTFT)/(输出 token 数−1);**排队、prefill、网络全部计入,无任何沉默扣除** | 口径分层: 排队/prefill 归 TTFT,纯解码段单独计量;工具间定义不一致(TTFT 是否计入 ITL 均值)必须显式声明 |
|
||||||
|
| TPOT vs ITL 聚合权重 | Anyscale 指标文档 | 单请求 TPOT = ITL 均值;多请求聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权 | 库内聚合统计要声明权重口径,否则跨实现不可比 |
|
||||||
|
| vLLM 阶段化计时 | vLLM metrics 设计文档 + per-request metrics 特性 | `request_queue_time / prefill_time / decode_time` 三段直方图;响应体 `metrics` 对象给 `generation_time_ms`(= decode time,排除排队与 prefill/TTFT) | **generation_time_ms 命名先例**(注意: vLLM 的 decode time 仍含引擎侧中段停顿,沉默扣除在服务端的口径也没人做) |
|
||||||
|
| off-CPU / wall-time 分解 | Brendan Gregg off-CPU analysis | 墙钟 = on-CPU + off-CPU(阻塞);方法论要求给阻塞段**归因**(stack/原因),而非只减总量 | 沉默段扣除必须同时记录沉默总量与归属(哪段、多长、几次),否则下游无法审计 |
|
||||||
|
| 沉默扣除实战先例 | pi-tps(pi 编辑器扩展) | **唯一直接对应"从生成速率里扣除沉默段"的实现**: gap ≥500ms 判为 stall,累计 stallMs/stallCount,从 streaming window 中扣除后算 TPS;连续停顿算一次事件;burst 交付/样本不足时输出 `—` 不硬算 | 阈值式扣除(减所有超阈值的段,而非只减最大一段);三分支防御(主口径/保守兜底/不可辨识时返回 null) |
|
||||||
|
| OpenRouter 供应商口径 | OpenRouter 路由/延迟文档 | 供应商对比口径: latency = TTFT(含网络+排队+prefill),throughput = 解码 TPS;Total = TTFT + tokens/TPS | "延迟=TTFT、速率=解码段"的二元口径是用户侧最广泛认知的形态 |
|
||||||
|
|
||||||
|
## 一、主题 A 详细发现
|
||||||
|
|
||||||
|
### A1. Tail at Scale 原始定义与流式后续工作
|
||||||
|
|
||||||
|
- 原始定义: "客户端先向其认为最合适的副本发送请求,短暂延迟后再发第二路;首个结果返回后取消其余在途请求"。额外负载可控: 等 95 分位预期延迟再发对冲,只惠及少量请求;更激进的对冲需要更快的取消(跨服务器取消的 tied requests)。([CACM 原文](https://cacm.acm.org/research/the-tail-at-scale/)、[作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf))
|
||||||
|
- 论文场景是搜索 leaf 扇出的一元响应(各 leaf 返回 top-k 部分结果给聚合器),**"partial"在文中指分片结果,不是增量 token 流**;全文无 streaming/incremental 响应的对冲讨论。([semicolony 批注版](https://semicolony.dev/papers/tail-at-scale))
|
||||||
|
- 后续工作的共识补充: 高负载下对冲会级联放大(每个慢请求都对冲→负载↑→更慢),生产系统需要熔断/自适应阈值等反馈回路。([semicolony 批注版·批评节](https://semicolony.dev/papers/tail-at-scale))
|
||||||
|
- LLM 语境的适配文(llms.blog): 把对冲裁定前移到 TTFT 竞速,主张"首 token 一旦产出,prefill 已完成、decode 高度确定,卡顿几乎只发生在排队/prefill"。**此断言与我们实测不符**(首 token 中位 1.6s、中段沉默中位 55s),且被 serving 层证据推翻(见 A4),引用时须警惕。([llms.blog](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols))
|
||||||
|
- bhope/hedge(Go 库): 按 host 用 DDSketch 学延迟分布,主路超 p90 估计即发备路,token bucket 压对冲率(防故障期放大),p99 从 64ms→17ms(~74%),开销 ~9%;**明确支持流式负载(按 TTFT 而非响应头计量)**。([GitHub](https://github.com/bhope/hedge)、[InfoQ 解读](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/))
|
||||||
|
|
||||||
|
### A2. 基础设施层: Envoy / gRPC / NGINX
|
||||||
|
|
||||||
|
- **Envoy**: 启用 hedge policy 后"竞速多个并发上游请求,把**首个带可接受响应头的响应**返回下游;由 retry policy 判定响应是否可接受"。当前对冲**只能由 per-try timeout 触发**(`hedge_on_per_try_timeout`: per-try 超时不取消原请求,直接补发)。([HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html)、[HedgePolicy API](https://javadoc.io/static/io.envoyproxy.controlplane/api/1.0.37/io/envoyproxy/envoy/config/route/v3/HedgePolicy.html)、[PR #6228](https://github.com/envoyproxy/envoy/pull/6228))
|
||||||
|
- **Envoy 对"响应已开始但中途停顿"**: per_try_timeout "只在响应开始前有效"(PR #10082 明确允许全局 route timeout 禁用时配 per_try_timeout 给流式端点用)——即**判赢发生在响应头,中段停顿不会触发新的对冲**;流式响应的停顿只能靠 stream idle timeout 兜底(route timeout 默认 15s 与流式不兼容,须禁用改用 idle_timeout)。([PR #10082](https://github.com/envoyproxy/envoy/pull/10082)、[超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html))
|
||||||
|
- **gRPC(A6)**: `hedgingPolicy = {maxAttempts, hedgingDelay, nonFatalStatusCodes}`;原请求立即发,每过 hedgingDelay 未收到非错误响应就补发一路;收到 nonFatal 状态码立即补发下一路;任一请求收到非错误响应→取消全部其余并返回该响应;fatal 状态码→取消全部并报错;server pushback(`grpc-retry-pushback-ms`,负值=禁止再发)可推迟/终止对冲;`retryThrottling{maxTokens, tokenRatio}` 按失败/成功比用 token bucket 压制对冲与重试;maxAttempts 客户端钳到 5;deadline 覆盖整条对冲链。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md)、[官方指南](https://grpc.io/docs/guides/request-hedging/))
|
||||||
|
- gRPC 对冲对 server-streaming: listener 把 `onNext/onClose` 转发给所有在途对冲 RPC——流式场景赢家仍按"首个非错误响应(头)"裁定,**没有按消息流中段进度重新裁定的语义**。([A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md))
|
||||||
|
- **NGINX**: 无对冲原语;`ngx_http_mirror_module` 只做多副本镜像且**忽略镜像响应**(fire-and-forget),不构成 FIRST_COMPLETED 竞速。([NGINX mirror 模块文档](https://nginx.org/en/docs/http/ngx_http_mirror_module.html))
|
||||||
|
|
||||||
|
### A3. LLM 网关/库层: 中段 stall 的真实实现与讨论
|
||||||
|
|
||||||
|
| 项目 | 机制 | 关键语义 | 来源 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| LiteLLM(提案, PR 已 stale 关闭) | `_StreamInactivityGuard` + `LITELLM_STREAM_INACTIVITY_TIMEOUT_SECONDS`(默认关) | 以 `asyncio.wait_for` 约束 chunk 间隔;**首 chunk 不受看门狗约束**(TTFT 归请求 timeout, reasoning 模型首 token 慢);触发抛 `litellm.Timeout`(408) | [PR #29767](https://github.com/BerriAI/litellm/pull/29767) |
|
||||||
|
| LiteLLM(已发) | `ttft_timeout` 配置;`stream_timeout` 修为"整流墙钟上限" | `ttft_timeout`: 无首 token 超时即 `litellm.Timeout`(非流式内部提升为流式实现);httpx read timeout 被 keepalive 字节重置,**对"有字节无内容"的卡顿完全失效** | [config 文档](https://docs.litellm.ai/docs/proxy/config_settings)、[PR #31238](https://github.com/BerriAI/litellm/pull/31238) |
|
||||||
|
| LiteLLM(bug 证据) | 中段超时在 200 已发后抛在生成器内 | 被误记为成功(遥测语义教训);另有反向机制: 上游沉默时主动注入 SSE ping 保活(PR #36024, Anthropic 每 ~30s ping, Claude Code 20s 看门狗) | [Issue #29602](https://github.com/BerriAI/litellm/issues/29602)、[PR #36024](https://github.com/BerriAI/litellm/pull/36024) |
|
||||||
|
| LangChain(已合并, 1.2.0) | `stream_chunk_timeout`(默认 **120s**) + `LANGCHAIN_OPENAI_STREAM_CHUNK_TIMEOUT_S` | 计**解析后内容 chunk 的间隔**, SSE keepalive 不重置;触发抛 `StreamChunkTimeoutError`(TimeoutError 子类,携带 timeout_s/model/chunks_received 结构化字段);配套 TCP keepalive/`TCP_USER_TIMEOUT` socket 选项 | [PR #36949](https://github.com/langchain-ai/langchain/pull/36949)、[API 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) |
|
||||||
|
| LangChain JS(诉求) | 用户要"不活跃超时"而非"总时长超时" | 总时长超时误杀长流;间隔计时才是正确语义 | [langchainjs #9088](https://github.com/langchain-ai/langchainjs/issues/9088) |
|
||||||
|
| OpenAI Codex CLI | 流静默检测 + 整路重试 | 静默 300s 判 `stream disconnected`,重试 `retrying sampling request (1/5)`;用户报告 5–10 分钟级卡顿 | [issue #33051](https://github.com/openai/codex/issues/33051) |
|
||||||
|
| Portkey | request_timeout 曾用 `AbortSignal.timeout()` | bug: 流已开始仍被总超时杀掉;修复为收到首个响应即清定时器——"总时长 vs 不活跃"同一教训 | [Issue #471](https://github.com/Portkey-AI/gateway/issues/471)、[streaming 文档](https://portkey-ai-gateway.mintlify.app/features/streaming) |
|
||||||
|
| Bifrost / Helicone | 重试+fallback+自适应 LB | **未见公开的中段沉默触发重试/对冲实现**;Bifrost 生态文章把 "parallel hedged requests" 列为路由模式之一但无流式细节 | [Bifrost retries](https://bifrost.mintlify.app/features/retries-and-fallbacks)、[Helicone retries](https://docs.helicone.ai/features/advanced-usage/retries) |
|
||||||
|
|
||||||
|
**结论**: 业界对"流中段沉默"的落地形态收敛为 **间隔式不活跃看门狗 → 抛错(Timeout 子类)→ 交给既有重试/换源链**;"中段沉默触发并发副本(hedging)"在任何主流网关/库中**均未找到公开实现**——我们的 FIRST_COMPLETED 异源对冲若覆盖中段沉默,属于超业界现状的能力,但语义上可组合"看门狗 + 已取消输家不可复活"来实现。
|
||||||
|
|
||||||
|
### A4. serving 层: inter-token stall 的观测与成因
|
||||||
|
|
||||||
|
- **成因**: 连续批处理引擎把新请求的 prefill(算力密集)与在途请求的 decode(带宽密集)交错;vLLM 官方文档承认未分离时"decode 中插入 prefill 会导致 tail ITL 尖刺",故推 P/D 分离分别调 TTFT 与 ITL。([vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/))
|
||||||
|
- **实测极端案例**: SGLang 严格 prefill-first 调度下,持续 prompt 负载使 decode 饿死到 **~1 batch/24s**——即中段沉默数十秒是调度器结构性产物,非异常。([SGLang #32549](https://github.com/sgl-project/sglang/issues/32549))
|
||||||
|
- **观测指标先例**: vLLM `vllm:inter_token_latency_seconds` 直方图(llm-d 运维文档建议用 `histogram_quantile` 盯分位,"ITL 高→输出断断续续");SGLang 新增 `forward_pass_duration_seconds`/`decode_step_latency_seconds`(按 prefill 桶×decode 请求数打标,专测 decode 被干扰)。([vLLM metrics](https://docs.vllm.ai/en/stable/design/metrics/)、[llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md)、[SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879))
|
||||||
|
- **缓解手段**: chunked prefill(vLLM 默认预算 2048 tokens/迭代,防长 prompt 冻结在途会话)、inter-prefill-budget(中位 TTFT 降 37%,防 HoL 阻塞)。([LeCompute 调度调优](https://lecompute.fr/en/runtimes/latency-against-latency/)、[vLLM PR #33743](https://github.com/vllm-project/vllm/pull/33743))
|
||||||
|
|
||||||
|
## 二、主题 B 详细发现
|
||||||
|
|
||||||
|
### B1. LLM 推理基准的标准时间口径
|
||||||
|
|
||||||
|
- **NVIDIA NIM/AIPerf 官方定义**: TTFT = 提交查询→收到首个 token(忽略空内容的首个响应;含排队+prefill+网络);E2E 请求延迟 = TTFT + Generation_time(首 token→末 token,剔除末尾 `[DONE]`/空响应);ITL =(E2E−TTFT)/(输出 token 数−1),**显式排除 TTFT**,分母减 1 使其只刻画 decode 段。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
|
||||||
|
- NVIDIA 技术博客同口径: "TTFT 含请求排队、prefill 与网络延迟";"E2E = TTFT + 生成时间;ITL 为相邻输出 token 间隔均值(不含首 token)"。([NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/))
|
||||||
|
- **排队与网络如何处理**: 基准口径**不扣除**——排队计入 TTFT/E2EL,网络计入 TTFT;跨工具可比的前提是定义对齐("Tool implementations vary, so compare results only when definitions align")。([NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html))
|
||||||
|
- **Anyscale/vLLM 口径**: 单请求 TPOT = ITL 均值 =(E2E−TTFT)/(token 数−1);聚合时 avg TPOT 按请求等权、avg ITL 按 token 加权——两者回答不同问题(单请求体感 vs 系统稳态流率)。([Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md))
|
||||||
|
- **MLPerf Inference**: LLM server 场景收 TTFT + TPOT 两指标,TPOT 定义为"所有生成 token 间的平均间隔";延迟自 LoadGen 调度时刻起算(含排队)。([inference_rules.adoc](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc)、[MLPerf 5.1 说明](https://mlcommons.org/2025/09/small-llm-inference-5-1/))
|
||||||
|
- **陷阱先例**: vLLM bench serve 的 ITL 在 chunk 与 token 非 1:1 时(reasoning/tool-call parser 缓冲)算错——**chunk 边界 ≠ token 边界**,任何按 chunk 计时的口径都要声明这一点。([vLLM #27485](https://github.com/vllm-project/vllm/issues/27485));另有"ITL 低于 TPOT"的历史 bug(均值口径混淆)。([vLLM #6531](https://github.com/vllm-project/vllm/issues/6531))
|
||||||
|
|
||||||
|
### B2. wall-time vs on-CPU: 扣除外部阻塞时间的标准做法与陷阱
|
||||||
|
|
||||||
|
- **off-CPU analysis**(Brendan Gregg): CPU profiling 只看在 CPU 上执行的线程;off-CPU 分析测量线程被阻塞(off-CPU)的时间并带上下文(stack trace),两者互补以解释 100% 线程时间。off-CPU 时间 = 调度器 off-cpu→on-cpu 探针的时间戳差。([Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html)、[offcputime 工具](https://github.com/brendangregg/bpf-perf-tools-book/blob/master/originals/Ch06_CPUs/offcputime.bt))
|
||||||
|
- **方法论要点**: ①墙钟分解必须成对报告(on-CPU + off-CPU flame graph 同看),只报"净时间"会丢失归因;②阻塞时间若发生在请求的同步路径上,则直接等比影响延迟——即"扣除"是分析手段,不改事实;③归因需要上下文(stack/wakeup stack),单给一个总数无法行动。([Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html)、[Wakeup/Off-Wake](https://www.brendangregg.com/blog/2016-02-01/linux-wakeup-offwake-profiling.html))
|
||||||
|
- 映射到我们的场景: `generation_ms`(墙钟)= 活跃生成(on-CPU 类比)+ 中段沉默(off-CPU 类比);Gregg 范式要求的"带归因的分解"对应"沉默总量 + 沉默次数 + 各段位置",而非只给一个净数。
|
||||||
|
|
||||||
|
### B3. 从 token 到达序列扣沉默段的计量学先例与陷阱
|
||||||
|
|
||||||
|
- **唯一找到的成体系先例 — pi-tps**: 每个流式更新测量与上一更新的间隔;**gap ≥ 500ms 判为 stall**,整段累计为 `stallMs`、连续停顿算一次 `stallCount`;算 TPS 时把 stall 从 streaming window 中扣除;每个 message 边界重置 stall 时钟(工具调用间隙不误计);三分支防御: 主口径(≥5 次更新、平均间隔 ≥1ms、stall < 活跃时间)、保守兜底(用含 TTFT 的全窗口,宁可低估)、**结构性不可辨识时返回 null 显示 `—`**(burst 交付/样本不足)。([pi-tps 包页](https://pi.dev/packages/@logicouo/pi-tps?page=49))
|
||||||
|
- **vLLM 服务端口径**: `generation_time_ms` = 首 token→末 token 的 decode 时间,**已排除排队与 prefill/TTFT,但仍包含引擎侧中段停顿**(preempt/调度等待);`mean_itl_ms` 单 token 响应为 null;`n>1` 时整个 metrics 抑制(无法归因)——口径排除条款的范本。([vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/))
|
||||||
|
- **OpenRouter 用户侧口径**: latency = TTFT(网络+排队+prefill),throughput = 解码 TPS;`Total = TTFT + tokens/TPS`——二元口径,不做沉默扣除,但把"等"与"生成"分开呈现是行业用户心智。([OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance)、[供应商评估](https://openrouter.ai/blog/insights/evaluate-llm-provider-performance/))
|
||||||
|
- **三种扣除形态的判定**(综合以上先例):
|
||||||
|
|
||||||
|
| 形态 | 评价 |
|
||||||
|
| --- | --- |
|
||||||
|
| 只减最大一段 | 无任何先例;多段沉默场景系统性高估活跃时间,不推荐 |
|
||||||
|
| 减所有超阈值段(pi-tps 式) | 有唯一直接先例;阈值外小停顿计入活跃时间,偏差有界且方向保守,**推荐** |
|
||||||
|
| 减所有段(纯 token 间隔求和) | 等价于重定义 ITL;chunk≠token 时不可算(vLLM #27485),放弃 |
|
||||||
|
|
||||||
|
- **尾部归属**: AIPerf 显式剔除末尾 `[DONE]`/空响应(E2E 止于末 token);pi-tps 的 stream span 止于最后一次 message_update;**最后一个 token 的间隔属于生成段内,流终止信号之后的时间一律不计**。vLLM 单 token 响应 `mean_itl_ms = null` 提示: token 数 <2 时一切间隔类口径应输出 null 而非 0。
|
||||||
|
|
||||||
|
## 三、对库设计的具体建议
|
||||||
|
|
||||||
|
### 3.1 中段对冲的赢家裁定语义
|
||||||
|
|
||||||
|
1. **裁定事件分两级**: 现行"首 token 竞速"保留(命名对齐业界: TTFT race);新增**中段沉默竞速**——主路沉默超过 `stall_after_s` 触发异源副路,副路同样按"首个内容 chunk"裁定。
|
||||||
|
2. **commit 点**: 借鉴 Envoy/gRPC"首个可接受响应即 commit":一旦某一路的**首个内容 chunk 已向下游交付**,该路成为 committed winner,其余路取消、不得复活——chat completion 的增量内容**不可拼接**(两路文本分叉,缝合即结果污染),这是 Env名"判赢不回头"语义在 LLM 场景的必然推论。
|
||||||
|
3. **commit 前摇摆**: 若副路先出 chunk 而主路恰好恢复,以**先交付者**为准(FIRST_DELIVERED),不做内容比车。
|
||||||
|
4. **committed winner 再沉默**: 不再对冲(无副本可换语义),走看门狗 → `stall_timeout` 抛错 → 错误分类 Transient → 交既有重试/换源链(Codex CLI 先例);已在下游的增量内容如实保留并标记截断。
|
||||||
|
5. **成本与放大控制**: 借鉴 gRPC `retryThrottling`(token bucket 压对冲率)与 Tail at Scale 级联警告: 对冲预算独立计量,故障期自动收敛;输家 est 不喂熔断的现行设计保持。
|
||||||
|
|
||||||
|
### 3.2 沉默扣除口径的推荐形态
|
||||||
|
|
||||||
|
1. **记录原语**: 传输层记录每个内容 chunk 的单调时间戳序列(空 chunk/SSE keepalive 不计,LangChain 语义),遥测侧由时间戳派生,不在热路径做判定。
|
||||||
|
2. **派生字段**(CallStats 只增不删):
|
||||||
|
|
||||||
|
| 字段 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| `generation_ms`(已有) | 首内容 chunk→末内容 chunk 墙钟(vLLM `generation_time_ms` 同口径) |
|
||||||
|
| `stall_ms` | Σ 所有 > `stall_threshold_ms` 的相邻 chunk 间隔(pi-tps 式,非只减最大段) |
|
||||||
|
| `stall_count` | 超阈值间隔个数(连续停顿合并为一次) |
|
||||||
|
| `active_generation_ms` | `generation_ms − stall_ms`,**派生值、永不覆盖墙钟原值**(Gregg 分解原则) |
|
||||||
|
|
||||||
|
3. **阈值形态**: 固定默认值(pi-tps 用 500ms,Anthropic ping 间隔 ~30s 提示阈值需 ≫ keepalive 周期,建议默认 2–5s 量级、可配),不做自适应分位阈值(避免与对冲阈值耦合出反馈环)。
|
||||||
|
4. **排除条款照抄 vLLM**: chunk 数 <2 时间隔类字段为 null;非流式调用三个新字段全 null;对冲输家的沉默数据保留在输路记录但不进赢家 CallStats。
|
||||||
|
5. **对冲与沉默的关系**: `stall_after_s`(对冲触发)与 `stall_threshold_ms`(计量口径)是**两个独立参数**——前者触发动作、后者只做观测,命名与语义都必须分开。
|
||||||
|
|
||||||
|
### 3.3 配置命名先例对照
|
||||||
|
|
||||||
|
| 语义 | 业界先例 | 建议沿用风格 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 首 token 超时 | LiteLLM `ttft_timeout`、`stream_timeout` | `HEDGE__AFTER_S` 已符合;看门狗类用 `STREAM__TTFT_TIMEOUT_S` |
|
||||||
|
| 中段不活跃看门狗 | LangChain `stream_chunk_timeout`(默认 120s)、LiteLLM 提案 `...STREAM_INACTIVITY_TIMEOUT_SECONDS` | `STREAM__STALL_TIMEOUT_S` |
|
||||||
|
| 中段对冲触发阈值 | 无直接先例(gRPC `hedgingDelay` 是按响应头的节拍) | `HEDGE__STALL_AFTER_S` |
|
||||||
|
| 沉默计量阈值 | pi-tps 固定 500ms(代码常量) | `STREAM__STALL_THRESHOLD_MS` |
|
||||||
|
| 对冲并发上限 | gRPC `maxAttempts`(钳 5) | 现有单副路设计已等价 maxAttempts=2 |
|
||||||
|
|
||||||
|
## 四、来源清单
|
||||||
|
|
||||||
|
**保留(关键证据)**
|
||||||
|
|
||||||
|
- [The Tail at Scale (CACM 2013)](https://cacm.acm.org/research/the-tail-at-scale/) / [作者 PDF](https://barroso.org/publications/TheTailAtScale.pdf) — 对冲原始定义
|
||||||
|
- [semicolony 批注版 Tail at Scale](https://semicolony.dev/papers/tail-at-scale) — 论文未覆盖流式响应、级联放大批评
|
||||||
|
- [Envoy HTTP routing 文档](https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_routing.html) / [超时 FAQ](https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html) / [PR #10082](https://github.com/envoyproxy/envoy/pull/10082) — 判赢在响应头、流式停顿不重新对冲
|
||||||
|
- [gRPC A6 提案](https://github.com/grpc/proposal/blob/master/A6-client-retries.md) / [官方 hedging 指南](https://grpc.io/docs/guides/request-hedging/) — hedgingDelay/非致命码/pushback/throttle 语义
|
||||||
|
- [LiteLLM PR #29767](https://github.com/BerriAI/litellm/pull/29767) / [config_settings](https://docs.litellm.ai/docs/proxy/config_settings) / [Issue #29602](https://github.com/BerriAI/litellm/issues/29602) / [PR #36024](https://github.com/BerriAI/litellm/pull/36024) — 中段看门狗形态、keepalive 重置 read timeout 失效、遥测误记教训
|
||||||
|
- [LangChain PR #36949](https://github.com/langchain-ai/langchain/pull/36949) / [stream_chunk_timeout 参考](https://reference.langchain.com/python/langchain-openai/chat_models/base/BaseChatOpenAI/stream_chunk_timeout) — 已合并的内容 chunk 间隔看门狗
|
||||||
|
- [OpenAI Codex issue #33051](https://github.com/openai/codex/issues/33051) — 沉默检测→整路重试生产先例
|
||||||
|
- [Portkey issue #471](https://github.com/Portkey-AI/gateway/issues/471) — 总时长超时误杀流式的反面教材
|
||||||
|
- [bhope/hedge](https://github.com/bhope/hedge) / [InfoQ 自适应对冲](https://www.infoq.com/articles/adaptive-hedged-requests-p99-latency/) — DDSketch 动态阈值 + token bucket 压放大率
|
||||||
|
- [llms.blog 生产 LLM 对冲](https://www.llms.blog/posts/request-hedging-in-production-llm-serving-architecture-tail-latency-mitigation-and-cancellation-protocols) — TTFT 竞速架构(其"卡顿只在 prefill"断言须警惕)
|
||||||
|
- [vLLM disagg prefill](https://docs.vllm.ai/en/v0.12.0/features/disagg_prefill/) / [SGLang #32549](https://github.com/sgl-project/sglang/issues/32549) / [SGLang PR #29879](https://github.com/sgl-project/sglang/pull/29879) / [llm-d 观测文档](https://github.com/llm-d/llm-d/blob/3d74f4a6/docs/operations/observability/metrics.md) — 中段卡顿的 serving 层成因与指标
|
||||||
|
- [NVIDIA NIM metrics](https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html) / [NVIDIA 博客](https://developer.nvidia.com/blog/llm-benchmarking-fundamental-concepts/) — TTFT/ITL/E2EL 官方口径
|
||||||
|
- [Anyscale metrics](https://docs.anyscale.com/llm/serving/benchmarking/metrics.md) — TPOT/ITL 聚合权重
|
||||||
|
- [MLPerf inference_rules](https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc) — 基准延迟自调度时刻起算
|
||||||
|
- [vLLM per-request metrics](https://docs.vllm.ai/en/stable/features/per_request_metrics/) / [metrics 设计](https://docs.vllm.ai/en/stable/design/metrics/) — generation_time_ms 命名与排除条款范本
|
||||||
|
- [vLLM #27485](https://github.com/vllm-project/vllm/issues/27485) — chunk≠token 口径陷阱
|
||||||
|
- [Brendan Gregg Off-CPU Analysis](https://www.brendangregg.com/offcpuanalysis.html) / [Off-CPU Flame Graphs](https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html) — 墙钟分解与归因原则
|
||||||
|
- [pi-tps](https://pi.dev/packages/@logicouo/pi-tps?page=49) — 沉默扣除唯一直接先例(500ms 阈值、stallMs/stallCount、三分支防御)
|
||||||
|
- [OpenRouter 延迟指南](https://openrouter.ai/docs/guides/best-practices/latency-and-performance) — 用户侧 TTFT/TPS 二元口径
|
||||||
|
|
||||||
|
**弃用(及原因)**
|
||||||
|
|
||||||
|
- learnaivisually、hivenet、clickhouse、themainthread 等科普稿 — 定义与 NVIDIA/Anyscale 一手文档重复且更浅
|
||||||
|
- macnica(代理商博客)、AWS Neuron/营销页、acecloud、saraswatmks 博客 — 营销/转载性质
|
||||||
|
- Portkey/Helicone/Bifrost 营销文档 — 只证实"无中段沉默对冲实现",不承载技术语义
|
||||||
|
- Solana/Rust hedged RPC crates — 与 LLM 流式无关的一元 RPC 竞速
|
||||||
|
- eunomia eBPF 教程 — Gregg 一手文已覆盖
|
||||||
|
|
||||||
|
## 五、未能确认的事项
|
||||||
|
|
||||||
|
1. **"中段沉默触发并发副本"是否有任何已投产实现**——未找到;不排除闭源网关(如各云厂 AI 网关)内部存在。建议以"看门狗→重试"为基线交付,把中段对冲作为可选增强。
|
||||||
|
2. pi-tps 的 500ms 阈值是否经生产校准(单个开源扩展,样本未知);我们的阈值应基于自有遥测分布定,不照抄数值。
|
||||||
|
3. Tail at Scale 之后是否有**学术**流式对冲后续工作(本次检索未见;如需可再查 IEEE/ACM 数据库)。
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
type: finding
|
||||||
|
node_id: finding:2026-09-11-streaming-stall-validation
|
||||||
|
title: "1.3.8 流式中段沉默:净口径与中段对冲验证报告"
|
||||||
|
date: 2026-09-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.8 流式中段沉默:净口径与中段对冲验证报告
|
||||||
|
|
||||||
|
> 计划:`research-wiki/plans/2026-09-11-streaming-stall.md`;设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`(人类已批准 + 独立审查补遗 B1-B4/N1-N9 全处置)。
|
||||||
|
> 分支 `feature/1.3.8-streaming-stall`;基线 main `a28d944`(1.3.7)+ 设计/计划提交 `97de410`/`6f3a620`;代码提交 `901a193`(T1)/`53bcf43`(T2)/`e0b0be0`(T3)/`2ac05a2`(T4),文档提交见本文件 git 历史。
|
||||||
|
> 所有命令在 `PolyGateway` conda 环境执行,未接管道(退出码不失真)。
|
||||||
|
|
||||||
|
## 1. 红绿证据索引
|
||||||
|
|
||||||
|
证据目录 `tests/outputs/138/{t1,t2,t3,t4}`(不提交,本地留存)。与 137 惯例不同,138 日志**未逐文件落 `EXIT_CODE=` 行**;退出码由日志内 `conda ... failed` ERROR 行(红)与 pytest 汇总行(绿)佐证,如实登记。
|
||||||
|
|
||||||
|
| 任务 | 相位 | 证据文件 | 结果与失败形态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| T1 端口事件 + stall 原语 | 红(批次 A) | `t1/red-batch-a.log` | exit≠0:收集期 `ImportError: cannot import name '_stall_from_chunk_times'`(纯函数不存在,非断言值不符) |
|
||||||
|
| T1 | 绿(批次 A) | `t1/green-batch-a.log` | 260 passed(test_openai_compat + test_types) |
|
||||||
|
| T1 | 绿(unit 全套) | `t1/green-unit.log` | 1602 passed,既有断言一行未改 |
|
||||||
|
| T2 净口径记账 | 红(批次 B) | `t2/red-batch-b.log` | exit≠0:6 failed——`CallStats`/`_CallContext` 字段缺失、`generation_ms` 仍得墙钟值、两败轮归因未置 None |
|
||||||
|
| T2 | 绿(批次 B) | `t2/green-batch-b.log` | 40 passed |
|
||||||
|
| T2 | 绿(unit / unit+contracts) | `t2/green-unit.log` / `t2/green-unit-contracts.log` | 1608 passed / 1669 passed, 17 skipped |
|
||||||
|
| T3 遥测两列 | 红(批次 C) | `t3/red-batch-c.log` | exit≠0:14 failed, 224 passed——24 处 AssertionError(冻结清单先扩即红:列数 36≠38、列序末两位、DDL 首现顺序、占位符数)+ 装配闸/落行断言未实现形态 |
|
||||||
|
| T3 | 绿(批次 C) | `t3/green-batch-c.log` | 238 passed |
|
||||||
|
| T3 | 绿(unit+contracts) | `t3/green-unit.log` | 1673 passed, 17 skipped |
|
||||||
|
| T4 中段对冲 + 配置守卫 | 红(批次 D) | `t4/red-batch-d.log` | exit≠0:16 failed, 153 passed——9×`TypeError: RetryMW.__init__() got an unexpected keyword argument 'stall_after_s'`、2×`AttributeError: 'GatewaySettings' object has no attribute ...`、键未识时 `STREAM` 段被当源(`['qwen_1','stream_1'] == ['qwen_1']`)、守卫未抛/默认值未生效断言,均为未实现形态 |
|
||||||
|
| T4 | 绿(批次 D,首轮) | `t4/green-batch-d-first.log` | 169 passed(与红相位 16+153 总量一致) |
|
||||||
|
| T4 | 绿(unit / contracts) | `t4/green-unit.log` / `t4/green-contracts.log` | 1688 passed, 17 skipped / 61 passed, 17 skipped(批次 E 默认关闭回归门:既有语义断言一行未改,例外仅为计划登记的签名/清单同步) |
|
||||||
|
| T4 | lint | `t4/lint.log` | `make lint` 通过,ruff 全绿,import-linter Contracts: 1 kept, 0 broken |
|
||||||
|
| T1 期 ruff 先红 | 红 | `t1/ruff.log` | 1 error(`strict=` 缺显式值),T2 起 `ruff.log` 转 All checks passed |
|
||||||
|
|
||||||
|
## 2. 批次与提交映射
|
||||||
|
|
||||||
|
| 提交 | 计划任务 | 覆盖批次(计划 §5) | 关键断言 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `901a193` | T1 观测原语与端口契约 | A | 流式每增量 chunk 置位 content_event;reasoning chunk 计活性;非流式永不置位;None 不观测行为不变;漏传必填 kw 即 TypeError;stall 纯函数(超阈累计/恰等不减/<2 返回 None/尾部不计/空帧不计时);`SourceConfig.stall_threshold_ms` 默认 1000 且 0/负/bool 拒 |
|
||||||
|
| `53bcf43` | T2 净口径记账 | B | 两字段默认 None 旧构造不炸;`generation_ms == wall − stall` 且还原公式成立;clamp 负值归零;stall None 不退减;两败轮归因 None |
|
||||||
|
| `e0b0be0` | T3 遥测两列 | C | 冻结清单 38 列与列序/DDL/占位符同源;SQLite 落行两列恰为传入值;auto_migrate 幂等补列(二次初始化);manual 档点名两条新 ALTER;取消输家行 stall 恒 None;装配闸三负例照旧绿 |
|
||||||
|
| `2ac05a2` | T4 中段对冲 + 守卫 | D、E | 首 token 后沉默触发对冲且赢家净口径;同 pass 复检防误触发;四格独立矩阵;仅 stall+非流式退单路;看门狗掐死主路对冲路存活且记账照常;两败计一次预算;stall 等待中外部取消穿透无残留;对冲路 content_event 恒 None;阈值三层可调;STREAM 保留段;四守卫(有效最小值/deadline 冲突 ValueError、单源 warning、Embedding/OCR B4 warning) |
|
||||||
|
|
||||||
|
## 3. 实测核对(文档承诺 vs 运行实测,T5 交付时本会话复核)
|
||||||
|
|
||||||
|
| 承诺 | 核对方式 | 实测结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `CallStats` 两新字段默认值 | `CallStats(logical_call_id='x', attempts=1, total_latency_ms=5)` 仅旧参构造 | `generation_ms=0`、`hedges=0`、`hedge_won=False`、`stall_ms=None`、`stall_count=None`,构造不炸 |
|
||||||
|
| 协议字段数 | `inspect.signature(TelemetryRecorder.record_llm_call)` | 38 参(36 → 38,含 self 不计) |
|
||||||
|
| 遥测列 | `telemetry/schema.py::COLUMNS` | `len == 38`,末两位 `('stall_ms', 'stall_count')` |
|
||||||
|
| 两键缺省与三层 | `GatewaySettings.from_env(env=...)` 注入单源最小 env | 不设两键 → `stall_threshold_ms=1000`、`hedge_stall_after_s=None`,且已分发进 SourceConfig(1000);设 `LLM__STREAM__STALL_THRESHOLD_MS=250`/`LLM__HEDGE__STALL_AFTER_S=8` → 250/8.0 |
|
||||||
|
| 值域校验落点 | `dataclasses.replace(src, stall_threshold_ms=0)` | `ValueError: SourceConfig.stall_threshold_ms 必须是正整数`(replace 触发 `__post_init__` 重跑,校验只此一处) |
|
||||||
|
| 版本号不动 | `pyproject.toml` 与 `src/polygateway/__init__.py` | 两处均 1.3.7,本计划不 bump、不 tag、不发布 |
|
||||||
|
|
||||||
|
## 4. 验收命令与退出码(T1–T4 各卡)
|
||||||
|
|
||||||
|
| 任务 | 验收命令(均 `conda run -n PolyGateway`,禁接管道) | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| T1 | `pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q` | 260 passed → 1602 passed,exit 0 |
|
||||||
|
| T2 | `pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q` | 批次 40 passed → 1669 passed, 17 skipped,exit 0 |
|
||||||
|
| T3 | `pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q` | 238 passed → 1673 passed, 17 skipped,exit 0 |
|
||||||
|
| T4 | `pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint` | 169 passed → 1688+61 passed,exit 0;lint 通过 |
|
||||||
|
|
||||||
|
合并前验收门(计划 §6:全套件 + slow 交集子集 + 整分支审查 + verifier)**不在 T5 范围**,随合并/发布流程执行;结果届时补记本节或另文。
|
||||||
|
|
||||||
|
## 5. 豁免索引(未跑项与去向)
|
||||||
|
|
||||||
|
| 未跑项 | 理由 | 去向 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| PG 实库遥测迁移用例(`tests/integration/test_postgres_telemetry.py`) | 执行期无沙箱可跑(计划 §7 阻塞矩阵已登记此情形);批次 C 的 SQLite 幂等用例(二次初始化)保留未删,PG 侧 `_EXPECTED_COLUMNS` 冻结清单已同步扩展 | **发布门 slow 交集补跑**(CLAUDE.md §4.4.1 第 4 步;本 diff 触及 telemetry schema,PG 实库形态在交集内) |
|
||||||
|
| `tests/e2e/` 与 Redis 时间语义变体(slow) | T1–T4 未动 `streaming.py`/限流 Lua/`Permit` 端口/`backends/**`;但 diff 触及 retry(结算/取消/编排)与公开入口 `complete` 签名 | 发布门按 diff 交集选子集:**Redis 时间语义变体 + 真实网关冒烟届时在交集内**(计划 §6 门 3 已登记) |
|
||||||
|
| `test_thinking_live.py` 全模型能力矩阵 | 本 diff 零触碰 `thinking.py`/能力注册表/相关 e2e 设施 | 按 2026-09-10 生效的交集规则**豁免**,复用 1.3.7 已登记的有效矩阵证据,不外推到已变化路径 |
|
||||||
|
|
||||||
|
## 6. T4 worker 超时恢复实录(提交链完整性说明)
|
||||||
|
|
||||||
|
T4 由前台委派 worker 执行,worker 在收尾阶段(证据落盘后、提交前)会话超时失联。处置:父会话复核 `tests/outputs/138/t4/` 五份证据(red-batch-d 16 红形态均为未实现错误、green-batch-d-first 169 passed 与红相位总量一致、green-unit 1688 / green-contracts 61 全绿、lint 1 kept 0 broken)并核对工作区 diff 精确命中计划 §2.1 表定五文件(`client.py`/`config.py`/`retry.py`/`test_config.py`/`test_stall_hedge.py`,无越界改动)后,代为提交 `2ac05a2`。**教训沉淀**:委派任务的提交动作与证据落盘之间隔着一个会话生命周期,证据先行落盘(计划 §1 证据纪律)使本次恢复无需重跑任何测试。
|
||||||
|
|
||||||
|
## 7. 残余复述(设计/计划已批准口径)
|
||||||
|
|
||||||
|
| 项 | 口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 中段对冲无业界公开先例 | 调研 §A3 结论,CHANGELOG/README/ARCHITECTURE 三处诚实标注;2A 看门狗链仍是不启用对冲时的默认行为 |
|
||||||
|
| `generation_ms` 语义突变 | 人类「窗口期最小代价」裁决放行的例外(字段发布仅 2 天/消费者唯一/遥测无账本);披露义务已在 T5 三处文档落地 |
|
||||||
|
| 复检竞速窗口 | N3① 复检 `is_set()` 只能把误触发窗口收窄到同一事件循环 pass,不能归零——已是设计批准的最强形态 |
|
||||||
|
| 挂起率统计的不对称 | 看门狗掐死喂熔断 vs 对冲输家取消不喂(1.3.6/1.3.7 各自既有语义,本版不改任一);运维侧统计须知 |
|
||||||
|
|
||||||
|
## 8. 结论
|
||||||
|
|
||||||
|
T1–T4 全部行为变更具备先红后绿证据(§1),默认关闭回归门成立(批次 E:unit 1688 + contracts 61 全绿,既有语义断言一行未改),lint 与 import-linter 契约通过;文档承诺经运行实测核对(§3)。版本 bump、tag、发布与 slow 交集子集**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §5 豁免索引补跑交集项)。
|
||||||
|
|
||||||
|
## 5. 合并前独立验证补跑(2026-09-14,verifier P2-1/P2-2 缺口闭合)
|
||||||
|
|
||||||
|
| 项 | 证据 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 全套件(含 integration) | `tests/outputs/138/verify/full-suite2.log` | **1764 passed, 23 skipped, exit=0** |
|
||||||
|
| PG 遥测 30 用例 | `pg-integration-rerun2.log` | 30 passed exit=0(首轮 12 failed → 根因: `_minimal_fields` 测试 helper 未同步 1.3.8 两列,`postgres.py:450` 按物理列取值 KeyError;修复 helper + `test_pre_135` 期望字典,同类 B1 测试同步缺口,非生产缺陷——生产路径由装配闸强制全签名) |
|
||||||
|
| 关键用例重跑 | `key-reruns.log` | 10 passed exit=0(净口径精确值/复检防误触发/看门狗掐死汇合/遥测迁移幂等) |
|
||||||
|
| make lint | `verify/lint.log` | exit=0,Contracts: 1 kept, 0 broken |
|
||||||
|
|
||||||
|
## 6. 发布门(2026-09-14,main b0232b7)
|
||||||
|
|
||||||
|
| 门 | 证据 | 结果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 合并后 make lint | `tests/outputs/138/release-lint.log` | exit=0,Contracts: 1 kept |
|
||||||
|
| 合并后全套件 | `tests/outputs/138/release-full-gate.log` | 1764 passed, 23 skipped, exit=0 |
|
||||||
|
| slow 交集(Redis 时间语义变体+网关冒烟) | `tests/outputs/138/release-slow-scoped.log` | 22 passed,EXIT_CODE=0,20m17s |
|
||||||
|
| thinking 矩阵豁免 | 交集规则(CLAUDE.md §4.4.1 第4步) | 本 diff 未动 thinking/能力注册表/e2e 设施,复用 1.3.7 矩阵证据 |
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# #29 分发来源前置核查
|
||||||
|
|
||||||
|
日期:2026-10-01;基线 0e29dea;在 T1 前执行。本记录不是法律认证。
|
||||||
|
|
||||||
|
| 范围 | 实际核查与结论 |
|
||||||
|
|---|---|
|
||||||
|
| 跟踪源码 | `git ls-files src` 列出 polygateway Python 模块与 py.typed,无 vendor 子树、外部 LICENSE/NOTICE、打包外部代码目录 |
|
||||||
|
| 内部移植 | rg 检索移植/参考/Copyright/SPDX/copied/adapted:streaming、sources、Redis Lua、ports、openai_compat 的来源为三个内部项目;用户已明确均本人原创并授权 Apache-2.0 |
|
||||||
|
| 外部参考声明 | 阅读 types.py Effort、providers.py ThinkingWire/注册表、thinking.py 能力证据段;使用的是协议词汇/参数形态/实测结论,未发现来自外部仓的成段实现或第三方版权头;不把相同协议字符串认定为复制实现 |
|
||||||
|
| 打包边界 | pyproject 仅从 src 发现包,reference 不跟踪、不纳入分发;本次不复制外部参考实现 |
|
||||||
|
| 许可来源 | 系统 `/usr/share/common-licenses/Apache-2.0` 提供标准许可正文;构建时核对包内文本与元数据 |
|
||||||
|
|
||||||
|
未发现需额外通知的实际复制片段,因此不编造 NOTICE。外部参考仓含 AGPL 不等于本库受其许可约束;用户对原创代码的授权也不能覆盖第三方。实施/审查若发现新的实际复制证据,停止并提交主管处理,不用本文替代核查。
|
||||||
|
|
||||||
|
## 审查意见处置
|
||||||
|
|
||||||
|
P1 采纳:协议快照缺 role 不可直接进入下一轮,SSE/非流式/缓存统一要求 assistant;不补造 role,默认 text 不变。
|
||||||
|
P2 采纳:将本次来源核查前移到 T0,T6 仅复核及落实 LICENSE/打包/文档。两项均是已批准方向的正确性修订,无需新增用户授权。
|
||||||
@@ -225,6 +225,31 @@
|
|||||||
"id": "finding:2026-09-09-134-thinking-contracts-validation",
|
"id": "finding:2026-09-09-134-thinking-contracts-validation",
|
||||||
"label": "1.3.4 T0–T4 与 T7 确定性验证",
|
"label": "1.3.4 T0–T4 与 T7 确定性验证",
|
||||||
"type": "finding"
|
"type": "finding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"label": "OpenAI消息保真设计",
|
||||||
|
"type": "design"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:2026-10-01-issue29-openai-fidelity",
|
||||||
|
"label": "OpenAI消息保真实现计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review:2026-10-01-issue29-cache-review",
|
||||||
|
"label": "保真缓存审查修复与1.4.0准备",
|
||||||
|
"type": "review"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "plan:2026-09-10-24-hedged-requests",
|
||||||
|
"label": "issue #24 长尾对冲请求与裸生成时间实施计划",
|
||||||
|
"type": "plan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "design:2026-09-10-24-hedged-requests-design",
|
||||||
|
"label": "长尾对冲请求(issue #24)设计",
|
||||||
|
"type": "design"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"links": [
|
||||||
@@ -472,6 +497,34 @@
|
|||||||
"relation": "implements",
|
"relation": "implements",
|
||||||
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
|
"evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计",
|
||||||
"added": "2026-09-09T13:42:00.298915+00:00"
|
"added": "2026-09-09T13:42:00.298915+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-10-01-issue29-openai-fidelity",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "已批准方向与独立审查P1/P2修订",
|
||||||
|
"added": "2026-10-01T17:52:42.425248+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "schema:llm-calls",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "复用既有单出口与schema,无新增列;工具error为attempt失败",
|
||||||
|
"added": "2026-10-01T17:52:43.831331+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "review:2026-10-01-issue29-cache-review",
|
||||||
|
"target": "design:2026-10-01-issue29-openai-fidelity-design",
|
||||||
|
"relation": "informs",
|
||||||
|
"evidence": "统一缓存与transport的有效产物判据,保持内核依赖边界",
|
||||||
|
"added": "2026-10-01T18:27:16.434742+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "plan:2026-09-10-24-hedged-requests",
|
||||||
|
"target": "design:2026-09-10-24-hedged-requests-design",
|
||||||
|
"relation": "implements",
|
||||||
|
"evidence": "计划头部与设计 §9 批准状态(2026-09-10 H1-H8 全数获批)",
|
||||||
|
"added": "2026-09-10T16:48:37.849653+00:00"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+24
-5
@@ -1,8 +1,8 @@
|
|||||||
# Research Wiki 索引
|
# Research Wiki 索引
|
||||||
|
|
||||||
> 自动生成,更新时间:2026-09-09 16:49 UTC
|
> 自动生成,更新时间:2026-10-02 02:23 UTC
|
||||||
|
|
||||||
## design (43)
|
## design (47)
|
||||||
|
|
||||||
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
|
- [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design`
|
||||||
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
- [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design`
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
- [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design`
|
||||||
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
- [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design`
|
||||||
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
|
- [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design`
|
||||||
|
- [2026-09-09-136-call-budgets-design](designs/2026-09-09-136-call-budgets-design.md) `design:2026-09-09-136-call-budgets-design`
|
||||||
|
- [2026-09-10-24-hedged-requests-design](designs/2026-09-10-24-hedged-requests-design.md) `design:2026-09-10-24-hedged-requests-design`
|
||||||
|
- [2026-09-11-streaming-stall-design](designs/2026-09-11-streaming-stall-design.md) `design:2026-09-11-streaming-stall-design`
|
||||||
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
- [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling`
|
||||||
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
- [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2`
|
||||||
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
- [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards`
|
||||||
@@ -38,6 +41,7 @@
|
|||||||
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
- [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience`
|
||||||
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
- [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr`
|
||||||
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
- [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration`
|
||||||
|
- [OpenAI消息保真设计](designs/2026-10-01-issue29-openai-fidelity-design.md) `design:2026-10-01-issue29-openai-fidelity-design`
|
||||||
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
- [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget`
|
||||||
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
- [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields`
|
||||||
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
- [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe`
|
||||||
@@ -48,17 +52,23 @@
|
|||||||
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
- [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions`
|
||||||
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
- [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params`
|
||||||
|
|
||||||
## finding (16)
|
## finding (22)
|
||||||
|
|
||||||
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
|
- [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation`
|
||||||
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
|
- [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation`
|
||||||
|
- [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion`
|
||||||
|
- [1.3.6 可选调用期限与取消结算验证记录](findings/2026-09-10-136-call-deadline-validation.md) `finding:2026-09-10-136-call-deadline-validation`
|
||||||
|
- [1.3.8 流式中段沉默:净口径与中段对冲验证报告](findings/2026-09-11-streaming-stall-validation.md) `finding:2026-09-11-streaming-stall-validation`
|
||||||
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
- [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload`
|
||||||
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
- [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance`
|
||||||
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
- [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline`
|
||||||
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
- [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance`
|
||||||
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
- [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak`
|
||||||
|
- [2026-09-11-streaming-stall-hedging-research](findings/2026-09-11-streaming-stall-hedging-research.md) `finding:2026-09-11-streaming-stall-hedging-research`
|
||||||
|
- [2026-10-01-issue29-license-audit](findings/2026-10-01-issue29-license-audit.md) `finding:2026-10-01-issue29-license-audit`
|
||||||
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
- [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression`
|
||||||
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
- [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation`
|
||||||
|
- [issue #24 长尾对冲请求与裸生成时间验证报告](findings/2026-09-10-24-hedged-requests-validation.md) `finding:2026-09-10-24-hedged-requests-validation`
|
||||||
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
- [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes`
|
||||||
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
- [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload`
|
||||||
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
- [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance`
|
||||||
@@ -67,10 +77,12 @@
|
|||||||
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
- [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak`
|
||||||
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
- [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens`
|
||||||
|
|
||||||
## plan (38)
|
## plan (42)
|
||||||
|
|
||||||
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
|
- [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts`
|
||||||
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
|
- [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability`
|
||||||
|
- [1.3.6 可选调用期限与取消结算修复实施计划](plans/2026-09-10-136-call-deadline.md) `plan:2026-09-10-136-call-deadline`
|
||||||
|
- [1.3.8 流式中段沉默:计量口径与对冲实施计划](plans/2026-09-11-streaming-stall.md) `plan:2026-09-11-streaming-stall`
|
||||||
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
- [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan`
|
||||||
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
- [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan`
|
||||||
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
- [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan`
|
||||||
@@ -89,12 +101,14 @@
|
|||||||
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
- [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort`
|
||||||
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
- [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling`
|
||||||
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
- [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation`
|
||||||
|
- [issue #24 长尾对冲请求与裸生成时间实施计划](plans/2026-09-10-24-hedged-requests.md) `plan:2026-09-10-24-hedged-requests`
|
||||||
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
- [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan`
|
||||||
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
- [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan`
|
||||||
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
- [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed`
|
||||||
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
- [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience`
|
||||||
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
- [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr`
|
||||||
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
- [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration`
|
||||||
|
- [OpenAI消息保真实现计划](plans/2026-10-01-issue29-openai-fidelity.md) `plan:2026-10-01-issue29-openai-fidelity`
|
||||||
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
- [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy`
|
||||||
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
- [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields`
|
||||||
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
- [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan`
|
||||||
@@ -108,8 +122,13 @@
|
|||||||
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
- [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions`
|
||||||
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
- [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan`
|
||||||
|
|
||||||
## review (1)
|
## review (6)
|
||||||
|
|
||||||
|
- [2026-09-11-138-branch-review](reviews/2026-09-11-138-branch-review.md) `review:2026-09-11-138-branch-review`
|
||||||
|
- [2026-09-11-138-verifier](reviews/2026-09-11-138-verifier.md) `review:2026-09-11-138-verifier`
|
||||||
|
- [2026-09-11-streaming-stall-design-review](reviews/2026-09-11-streaming-stall-design-review.md) `review:2026-09-11-streaming-stall-design-review`
|
||||||
|
- [2026-09-11-streaming-stall-plan-review](reviews/2026-09-11-streaming-stall-plan-review.md) `review:2026-09-11-streaming-stall-plan-review`
|
||||||
|
- [保真缓存审查修复与1.4.0准备](reviews/2026-10-01-issue29-cache-review.md) `review:2026-10-01-issue29-cache-review`
|
||||||
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
- [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review`
|
||||||
|
|
||||||
## schema (1)
|
## schema (1)
|
||||||
|
|||||||
@@ -159,3 +159,15 @@
|
|||||||
- [2026-09-09 13:42 UTC] 新增边: plan:2026-09-09-135-call-observability --implements--> design:2026-09-09-135-call-observability-design
|
- [2026-09-09 13:42 UTC] 新增边: plan:2026-09-09-135-call-observability --implements--> design:2026-09-09-135-call-observability-design
|
||||||
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
|
- [2026-09-09 13:42 UTC] 重建索引: 100 篇页面
|
||||||
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
|
- [2026-09-09 16:49 UTC] 重建索引: 101 篇页面
|
||||||
|
- [2026-09-09 18:01 UTC] 重建索引: 102 篇页面
|
||||||
|
- [2026-09-10 16:48 UTC] 新增边: plan:2026-09-10-24-hedged-requests --implements--> design:2026-09-10-24-hedged-requests-design
|
||||||
|
- [2026-09-10 16:48 UTC] 重建索引: 107 篇页面
|
||||||
|
- [2026-10-01 17:52 UTC] 新增 design: OpenAI消息保真设计 (design:2026-10-01-issue29-openai-fidelity-design)
|
||||||
|
- [2026-10-01 17:52 UTC] 新增 plan: OpenAI消息保真实现计划 (plan:2026-10-01-issue29-openai-fidelity)
|
||||||
|
- [2026-10-01 17:52 UTC] 新增边: plan:2026-10-01-issue29-openai-fidelity --implements--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 17:52 UTC] 新增边: schema:llm-calls --implements--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 17:52 UTC] 重建索引: 119 篇页面
|
||||||
|
- [2026-10-01 18:24 UTC] 新增 review: 保真缓存审查修复与1.4.0准备 (review:2026-10-01-issue29-cache-review)
|
||||||
|
- [2026-10-01 18:27 UTC] 新增边: review:2026-10-01-issue29-cache-review --informs--> design:2026-10-01-issue29-openai-fidelity-design
|
||||||
|
- [2026-10-01 18:27 UTC] 重建索引: 120 篇页面
|
||||||
|
- [2026-10-02 02:23 UTC] 重建索引: 120 篇页面
|
||||||
|
|||||||
@@ -31,3 +31,9 @@ date: 2026-07-20
|
|||||||
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
|
| 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) |
|
||||||
|
|
||||||
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
|
本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。
|
||||||
|
|
||||||
|
## #29 验收增量(2026-10-01)
|
||||||
|
|
||||||
|
复用指标及 llm_calls,不另建表:error→正常 attempts=2、tool-only attempts=1、
|
||||||
|
cache_hit attempts=0;协议失败不写缓存,取消不得遗留 permit/task。
|
||||||
|
确定性证据位于 `tests/unit/test_openai_fidelity.py`;完整协议不新增遥测正文列。
|
||||||
|
|||||||
@@ -202,3 +202,7 @@ stack = ExtractionProviderStack(
|
|||||||
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
|
| **AIMD 自适应并发**(库常量: 每源初始 8、429 削减 ×0.5、成功 +1/limit、下限 1、ceiling=max(64, 源级 max_concurrency)) | 冷启动每源并发从"无上限"变为 8 起步爬升——高吞吐下游首分钟吞吐低于旧行为;无 env 开关(韧性常量,同 jitter 系数先例),需要禁用者构造函数注入自定义 pacer |
|
||||||
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
|
| 连败通道受健康证据抑制(窗口样本 ≥ min_calls 且失败率 < fail_rate 时 5 连败不开路) | CHS "连败必开"在高流量健康源上不再成立(防随机噪声误熔唯一好源);冷启动/低流量语义不变 |
|
||||||
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
|
| 结构化重问缺省 1→2 | 解析失败时最多多一跳成本;`PGW_STRUCTURED_MAX_RETRIES=1` 可显式还原 |
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
CHS 已把 `ResultInvalidError` 作终态(坏结果≠坏服务,熔断记成功),1.3.10 文本 length/过滤且无正文落该分类与既有语义同向,worker `_TERMINAL` 分支无需改;缺 `[DONE]` 而完整的流从此直接成功。
|
||||||
|
|||||||
@@ -170,3 +170,7 @@ loop = AgentLoop(client, max_steps=...,
|
|||||||
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
|
| R7 | 内存限流在多进程(arq)下的口径须在文档/配置中显式警示(B7 过渡期语义),避免误配全局限额 | M1 |
|
||||||
|
|
||||||
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
|
以上 G1/G2 若按本文方向解决,GovDoc 全量迁移中业务代码(agent/workflow/govdoc api)对治理层的消费**零改动**,改动收敛于: 装配净新增、types.py/protocols.py/test_imports.py 三处改写、`.env` 改键。
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
装配处 `retryable_exceptions=(TransientError, AllSourcesExhausted)`(§4)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,AgentLoop 步级重试**不再**重抽该步——这正是本次修复的意图(确定性结局重抽只花钱)。业务侧若想对 length 截断做"加大 max_tokens 再试",须显式 except `ResultInvalidError` 并按异常消息原因码 `length_without_content` 分支;其余缺 `[DONE]` 而完整的流(#31)从此直接成功。
|
||||||
|
|||||||
@@ -200,3 +200,7 @@ ocr = FrameOcrAdapter(OcrTextPort_from_env()) # 业务适配器包装库 OCR 端
|
|||||||
---
|
---
|
||||||
|
|
||||||
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
|
*不确定项声明*: 库侧 `from_env(role=...)`、OCR 装配工厂的具体 API 名称以 M1/M3 设计文档定稿为准,本文 §5.2/§6 中相应伪代码届时同步修订;`telemetry.db` 是否存在项目外的离线分析脚本消费方未能实测(项目内 grep 无读取方)。
|
||||||
|
|
||||||
|
## 1.3.10 影响核对(2026-10-14,#30/#31)
|
||||||
|
|
||||||
|
AgentLoop 注入的 `(TransientError, AllSourcesExhausted)`(§4 点 3)**不含** `ResultInvalidError`:文本 length/过滤且无正文改抛它后,步级重试不再重抽该步(意图如此)。需要"加大 max_tokens 再试"的业务须显式 except 并按原因码 `length_without_content` 分支。§保留项 3"缺 `[DONE]` 判瞬时、不写缓存"在 1.3.10 收窄为"无 finish 或 finish 后无实测 usage 才算截断";finish+实测 usage+干净 EOF 视完整。
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-09-10-136-call-deadline
|
||||||
|
title: "1.3.6 可选调用期限与取消结算修复实施计划"
|
||||||
|
date: 2026-09-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.6 可选调用期限与取消结算修复实施计划
|
||||||
|
|
||||||
|
> 设计:`research-wiki/designs/2026-09-09-136-call-budgets-design.md`,**人类于 2026-09-10 正式批准**(§9 七项批准项全数获批,H3 取 (a′):取消结算修复并入本版而非另立前置 issue)。
|
||||||
|
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
|
||||||
|
> 目标:① 修好取消路径的 TPM 结算(§6.3 矩阵 S1–S7);② 给一次逻辑调用一条**可选**墙钟硬边界(issue #22),缺省 `None` 时行为逐字等于 1.3.5;③ 修 `Retry-After` 非有限值防御缺口。
|
||||||
|
> 方案:设计 §3 方案 A——三个公开边界各一次 `asyncio.timeout`,配**局部变量身份比较**判据(探针实证:只看 `cm.expired()` 会把清理期自抛的 `TimeoutError` 误标成 deadline)。
|
||||||
|
> 技术:Python 3.12+、asyncio、frozen dataclass、pytest + FakeClock + 真实 `asyncio.Event`、真实实验室 Redis、ruff、import-linter。
|
||||||
|
> 基线 HEAD:`d2455e8`(分支 `feature/1.3.6-call-budgets`;工作区仅 `CLAUDE.md` 既有 markdown 差异、未跟踪 `.pi/` 与本轮两份文档,前两者一律不动、不暂存)。
|
||||||
|
|
||||||
|
**不实现 issue #24(长尾对冲)**:未获批准,任何提交、测试与文档均不得出现 hedge/对冲机制,也不得声称本版缓解 #24。
|
||||||
|
|
||||||
|
## 1. 边界、授权与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 固定边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4、新公共面变化或验证豁免须停下确认 |
|
||||||
|
| 公共面 | 只做设计 §9 已批准四项:新错误类 `CallDeadlineExceeded`、新配置键 `{SCOPE}__CALL_DEADLINE_S`、三个 client 构造参数 + 四个公开方法 keyword-only 参数、取消路径结算口径。**不新增其它键/端口方法/遥测列** |
|
||||||
|
| 记账边界 | 只改**取消路径**的 `settle()` 入参取值(设计 §6.3 S3/S5/S7);成功、`RequestRejected`、`ResultInvalid`、`SourceDead` 四条既有路径与**未分类异常逃逸路径(S8,仍 `0`)**的结算值逐字不变;实现只能用**函数内局部阶段变量**,不得新增公开参数;限流 Lua、`Permit` 端口签名、幂等语义一律不动 |
|
||||||
|
| 依赖铁律 | 新模块 `deadline.py` 只 import stdlib + `errors.py`;`middleware/` 仍只依赖端口与内核;import-linter 契约新增一层执法 |
|
||||||
|
| 取消 | `CancelledError` 永不吞没;不引入 `shield`、不开后台任务;清理仍在 `finally`,允许超出期限 |
|
||||||
|
| 降级方向 | 限流/熔断后端仍 fail-closed;遥测/缓存仍 warning 降级;非法期限值 → **当场 `ValueError`**(装配错误不属降级面) |
|
||||||
|
| 证据与秘密 | 不打印 `.env`、token、Authorization;不提交 `.pi/`、`tests/outputs/`;命令输出只记路径、状态与退出码 |
|
||||||
|
| 证据复用 | 复用既有 FakeClock / 假 transport / `settle_and_release` 出口 / 限流契约套件 / 真实 Redis 跨连接用例;**不重跑模型能力矩阵**,本版零付费调用 |
|
||||||
|
|
||||||
|
Skill 纪律:T0 已执行 `writing-plans`;T1–T3 行为变更执行 `test-driven-development`(先失败后通过的证据须落在本会话工具输出里);每次提交执行 `commit`(英文祈使标题、无 AI 签名、显式路径暂存);T4 前执行 `requesting-code-review` 与 `verification-before-completion`;异常先 `systematic-debugging` 定根因。
|
||||||
|
|
||||||
|
## 2. 文件职责与不变接缝
|
||||||
|
|
||||||
|
| 动作 | 精确路径 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新建 | `src/polygateway/deadline.py` | `ensure_call_deadline()` 值域校验 + `with_call_deadline()` 单一硬边界(§3.1) |
|
||||||
|
| 修改 | `src/polygateway/errors.py` | 追加 `CallDeadlineExceeded(PolyGatewayError)`(§3.2);`SCOPE_REASONS`/四分类**不动** |
|
||||||
|
| 修改 | `src/polygateway/__init__.py` | `from polygateway.errors import ... CallDeadlineExceeded`;`__all__` 插在 `"CallStats"` 之后、`"CircuitOpenError"` 之前(现读 `:61-62`;该列表并非全字母序,头部 `DEFAULT_PROFILES`/`EFFORT_ORDER`/`Effort` 是既有例外,**不得顺手重排**) |
|
||||||
|
| 修改 | `src/polygateway/config.py` | `GatewaySettings` 末尾追加 `call_deadline_s: float \| None = None`;`_load_call_deadline()`;`_validate_call_deadline()` 进 `__post_init__`(§3.3) |
|
||||||
|
| 修改 | `src/polygateway/client.py` | `__init__` 追加 `call_deadline_s`(入口即校);`chat()` 追加 per-call 参数;`:398` 包裹;`from_settings` 透传 |
|
||||||
|
| 修改 | `src/polygateway/embedding.py` | 同上三处(`:209` 包裹整次 `_embed_all`);`_attempt` 结算矩阵(§3.5) |
|
||||||
|
| 修改 | `src/polygateway/ocr.py` | `__init__`/两个公开方法/`_call` 两级透传;`:274` 包裹 `_run`;`:449 settle_and_release(permit, 0)` **保持 0** |
|
||||||
|
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt` 结算矩阵(§3.5:`actual` 初值仍 `0` + 局部 `settlement_known`);`__call__` 循环、`backoff_delay`、`StallClock` 一字不动 |
|
||||||
|
| 修改 | `src/polygateway/transports/openai_compat.py` | `_parse_retry_after`(`:111-119`)增 `math.isinf` 判据 + 一条 warning + 必填私有 kw `source_name`;同步唯一调用处 `_translate_429`(`:140`)(§3.6) |
|
||||||
|
| 修改 | `pyproject.toml` | import-linter layers 在 `"polygateway.thinking"` 与 `"polygateway.providers : polygateway.sources"` 之间插入 `"polygateway.deadline"` 一行 |
|
||||||
|
| 新建 | `tests/unit/test_deadline.py` | `deadline.py` 的值域与五种形态区分(§5 批次 A/B) |
|
||||||
|
| 修改 | `tests/unit/test_retry.py` | 取消结算红绿(S1/S3/S4/S5)+ `FakeTransport` 加 `entered` Event |
|
||||||
|
| 修改 | `tests/unit/test_embedding.py` | 取消结算(S7)、多批共享一份期限 |
|
||||||
|
| 修改 | `tests/unit/test_ocr_client.py` | 取消结算恒 0(S6)、两个入口的期限与 per-call 校验 |
|
||||||
|
| 修改 | `tests/unit/test_client.py` | chat 期限命中、终态遥测行、已计费成功被丢弃、注入钟无关 |
|
||||||
|
| 修改 | `tests/unit/test_config.py` | 键未设/非法值 × env / 直接构造 / `dataclasses.replace` / 三个 `__init__` 直传 |
|
||||||
|
| 修改 | `tests/unit/test_openai_compat.py` | F1 四类取值 |
|
||||||
|
| 修改 | `tests/integration/test_redis_cross_connection.py` | 真实 Redis 上的取消结算契约(复用既有 `clients`/`_limiter`/`_client`/`ScriptedTransport`,**不改 Lua、不改契约套件**) |
|
||||||
|
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example` | 新键、新异常、对外承诺三句话(§4 C4) |
|
||||||
|
| 新建 | `research-wiki/findings/2026-09-10-136-call-deadline-validation.md` | 红绿、命令、豁免索引,≤300 行 |
|
||||||
|
|
||||||
|
**不改**:`ports.py`(`Permit.settle` 签名与语义不动)、`middleware/admission.py`(`settle_and_release` 逐字不动)、`middleware/ratelimit.py`、`middleware/breaker.py`、`middleware/structured.py`、`middleware/cache.py`、`middleware/telemetry.py`、`telemetry/schema.py`(零新增列)、`backends/**`(含全部 Lua)、`sources.py`、`streaming.py`、`types.py`、`transports/monkey_ocr.py`(`:58` 不解析 `Retry-After`)、`tests/contracts/**`(复用现有 settle 契约,只跑不改)。若实施时发现必须突破本清单,先说明最小原因交父会话核定。
|
||||||
|
|
||||||
|
## 3. 跨任务接口(可执行定义,禁止占位)
|
||||||
|
|
||||||
|
### 3.1 `src/polygateway/deadline.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def ensure_call_deadline(value: object, origin: str) -> float | None:
|
||||||
|
"""全装配路径共用的值域校验: None 或有限正数, 否则 ValueError(消息含 origin)。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}") # bool 先判
|
||||||
|
v = float(value)
|
||||||
|
if not math.isfinite(v) or v <= 0:
|
||||||
|
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") # NaN/inf/0/负
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
|
||||||
|
if deadline_s is None:
|
||||||
|
return await aw # 未启用: 不进上下文, 逐字旧路径
|
||||||
|
inner_timeout: BaseException | None = None
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(deadline_s) as cm:
|
||||||
|
try:
|
||||||
|
return await aw
|
||||||
|
except TimeoutError as exc:
|
||||||
|
inner_timeout = exc # 体内(含清理路径)自抛, 非本层期限
|
||||||
|
raise
|
||||||
|
except TimeoutError as exc:
|
||||||
|
if cm.expired() and exc is not inner_timeout:
|
||||||
|
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
三条实现红线:① **校验先于构造 awaitable**(否则非法值抛错时遗留未 await 协程 → `RuntimeWarning` + 资源不释放);② 只传**相对时长**,绝不把注入 `now` 加偏移换算成绝对截止时刻;③ 身份比较**不可退化**为只看 `cm.expired()`——探针 `/tmp/pgw_deadline_probe.py` E1/E2 实测:清理路径自抛的 `TimeoutError` 会被只看 `expired()` 的写法改标成 `CallDeadlineExceeded`;`__cause__` 启发式同样失效(内层 `asyncio.timeout` 的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。本机制**不新增公共配置、不开后台任务、不改异常对象**。
|
||||||
|
|
||||||
|
### 3.2 `errors.py` 新类(唯一定义点)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CallDeadlineExceeded(PolyGatewayError):
|
||||||
|
"""调用方设定的整体期限到期; 不是网关不可用、也不是源故障。"""
|
||||||
|
|
||||||
|
def __init__(self, *, scope: str, deadline_s: float) -> None:
|
||||||
|
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
|
||||||
|
self.scope = scope
|
||||||
|
self.deadline_s = deadline_s
|
||||||
|
```
|
||||||
|
|
||||||
|
无 `retry_after_s`(期限到期不含"何时可再试",给 `0.0` 会按既定语义指示下游立刻重打饱和渠道);不进 `SCOPE_REASONS`;不属四分类。`__init__.py` 导出后,三个边界既有的 `except PolyGatewayError` 自动接住并写终态行——**遥测零改动**。
|
||||||
|
|
||||||
|
### 3.3 配置(`config.py`)
|
||||||
|
|
||||||
|
| 项 | 精确定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 键名 | `{SCOPE}__CALL_DEADLINE_S`(两段式,`"LLM__CALL_DEADLINE_S".split("__")` 长度 **2** ≠ 4,故 `_load_sources`(函数定义 `:380`,判据行 `:384`)天然跳过,**不必**加进 `_RESERVED_SEGMENTS`) |
|
||||||
|
| loader | `_load_call_deadline(scope, env)`:`found = _first(env, f"{scope}__CALL_DEADLINE_S")`;`None → None`;否则 `ensure_call_deadline(_cast(found[1], "float", found[0]), found[0])`。**用 `_first` 不用 `_require`**(`_require` 会把未设当成配置缺失报错 = 破坏性变更);**origin 传实际命中的 env 键名 `found[0]`**,不传 `"GatewaySettings.call_deadline_s"`——否则 env 里写 `LLM__CALL_DEADLINE_S=0` 的人会拿到一条指向字段名的错误,在多 scope 部署里无法定位是哪个键(`_cast` 只能接住“不是数字”,`0`/负/`inf` 会穿过它) |
|
||||||
|
| 字段 | `GatewaySettings` **末尾**追加 `call_deadline_s: float \| None = None`(有默认值,不扰动既有位置构造;`EmbeddingSettings.gateway` / `OcrSettings.gateway` 自动继承) |
|
||||||
|
| 守卫 | `_validate_call_deadline()` 加入 `__post_init__`(`:186-194`)末位,实现体是 `object.__setattr__(self, "call_deadline_s", ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"))`——盖住直接构造与 `dataclasses.replace` 两条路;env 路已在 loader 里拿真键名报过错,此处重跑对合法值是幂等空操作 |
|
||||||
|
| 装配透传 | `GatewaySettings.from_env`(`:349`)返回字典加 `call_deadline_s=_load_call_deadline(scope_u, env)`;`GatewayClient.from_settings`(`client.py:449`)传 `settings.call_deadline_s`;`EmbeddingClient.from_settings`(`embedding.py:584`)与 `OcrClient.from_settings`(`ocr.py:631`)传 `gw.call_deadline_s`;三个 `from_env` 签名不变 |
|
||||||
|
| 不耦合 | **不校验** `call_deadline_s` 与 `timeout_s`/`stall_window_s` 的大小关系:期限短于单次超时是调用方的合法选择 |
|
||||||
|
|
||||||
|
### 3.4 三个接入点与 per-call 透传(唯一三处)
|
||||||
|
|
||||||
|
| 文件:行 | 改后 |
|
||||||
|
| --- | --- |
|
||||||
|
| `client.py:398` | `response = await with_call_deadline(self._handler(request), deadline_s=deadline, scope=self._scope)` |
|
||||||
|
| `embedding.py:209` | 同款包住 `self._embed_all(...)`(**整次调用一份**,N 批共享) |
|
||||||
|
| `ocr.py:274` | 同款包住 `self._run(...)` |
|
||||||
|
|
||||||
|
- 三处均在既有 `try` 之内、`_CallContext` 创建之后 → 到期照走 `except PolyGatewayError → emit_terminal_once`。
|
||||||
|
- `StructuredMW._run_ladder`(`structured.py:67-98`)与 `_embed_batch`(`embedding.py:295`)**严禁**新建 scope:同级重问/分批共享同一份期限,否则期限被轮数放大 N 倍。
|
||||||
|
- 三个 `__init__` 追加 keyword-only `call_deadline_s: float | None = None`,函数体首行即 `self._call_deadline_s = ensure_call_deadline(call_deadline_s, "<Class>(call_deadline_s=...)")`;`client.py` 需自存 `self._scope = scope`(现只传给 RetryMW,未自存)。
|
||||||
|
- 四个公开方法(`chat`/`embed`/`recognize_text`/`parse_layout`)追加 keyword-only `call_deadline_s: float | None = None`;`None` = 继承装配值,正数 = 本次覆盖,**不提供"本次关闭"**。取值一行:`deadline = self._call_deadline_s if call_deadline_s is None else ensure_call_deadline(call_deadline_s, "<method>(call_deadline_s=...)")`,位置在既有输入校验之列、`_CallContext` 创建**之前**。
|
||||||
|
- OCR 两个入口经 `recognize_text`/`parse_layout` → `_call` 形参透传;`_call` 内的校验须与 `image` 校验同列(`ocr.py:266-269`),即仍在 `_CallContext`(`:272`)之前。
|
||||||
|
- `embedding.py:205-217` 的 `texts == []` 早返回在 `try` 之前,天然落在期限之外——保持原样,测试显式记一笔。
|
||||||
|
|
||||||
|
### 3.5 取消结算矩阵落到代码(设计 §6.3 的唯一实现形态)
|
||||||
|
|
||||||
|
`middleware/retry.py::_attempt`(对照现读行号)。人类只批准了“**取消路径**的结算口径”这一条,故 `actual` 初值**不得**改成 `est`——那会把语义泛化到一切未分类异常(`RuntimeError`、`KeyError` 逃逸),属未批准范围。改用**局部阶段变量**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
actual = 0 # 初值不动:未分类异常逃逸时仍逐字走 1.3.5 语义
|
||||||
|
settlement_known = False # 局部变量:该刻库是否已算出确定结算(不进任何签名)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 现状行 | 现状 | 改后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `:281` | `actual = 0` | **保持 `0`**,紧随一行新增 `settlement_known = False`(局部阶段变量,带注释:只服务于取消分支的兜底取值,不进任何签名) |
|
||||||
|
| `:288` | `await self._transport.complete(...)` | 不动(S3 的“端口已开始”窗口就是它未返回的那段) |
|
||||||
|
| `:301` | `actual = source.effective_est_tokens()`(usage 不可得) | 值不变,其后置 `settlement_known = True` |
|
||||||
|
| `:303` | `actual = result.prompt_tokens + result.completion_tokens` | 值不变,其后置 `settlement_known = True`(S4;**真实 usage 恰为 0 也算已知**,取消不得覆写) |
|
||||||
|
| `:311` `RequestRejectedError` | 隐式 0 | 分支**首句**:`actual = 0; settlement_known = True`(逐字保住 1.3.5,且取消落在本分支 await 中途仍得 0) |
|
||||||
|
| `:315` `ResultInvalidError` | 隐式 0 | 同上(已计费的坏结果仍退全款属另一族缺口,本版不动) |
|
||||||
|
| `:320` `CancelledError` | 不动 `actual` | **唯一**新增赋值点,且在本分支首句:`if not settlement_known: actual = source.effective_est_tokens()`;`is_probe` / `_emit` / `raise` 三行原样 |
|
||||||
|
| `:325` 失败分支入口 | `dead = isinstance(exc, SourceDeadError)` | 完成该分支原有同步分类/选源反馈后,紧贴第一个 `await record_failure` 之前插入 `actual = 0 if dead else source.effective_est_tokens(); settlement_known = True`;不得前移到同步分类之前改变其异常结算 |
|
||||||
|
| `:335-336` | `if not dead: actual = est` | **删除**(已上移);非取消路径的最终值与 1.3.5 逐字相同,只是算得更早 |
|
||||||
|
| `:339-341` | `finally: pacer.leave(); settle_and_release(permit, actual)` | 一字不动 |
|
||||||
|
|
||||||
|
**“确定结算”的界桩就是上表的 await 位置**:失败分支的结算决定前移到两个记账 await 之前,故**取消发生在已知 `SourceDead` 之后时保留那个既有的 `0`**(源已判死就不该继续占额度)。**为何必须靠位置而不能只靠标志位**:`except asyncio.CancelledError` 与 `except (SourceDeadError, TransientError)` 是**同级**分支,落在后者块内 await 上的取消**不会**被前者接住,直接穿到 `finally`——那一刻 `actual` 是什么就结什么,标志位没有机会被读到。故失败分支必须在其**第一个 await 之前**就把 `actual` 定死。本版**不把 S5 泛化成“一切失败按 `est` 结算”**;`est` 只是“取消且结算未定”这一格的兜底值。
|
||||||
|
|
||||||
|
`embedding.py::_attempt` 同构:`:345` 保持 `actual = 0` 并新增 `settlement_known = False`;`:358`/`:360` 值不变、其后置 `True`;`:377`(`RequestRejected`/`ResultInvalid` 合并分支)首句 `actual = 0; settlement_known = True`;`:408` 失败分支在 `dead` 之后、`record_failure`(`:414`)之前插入 `actual = 0 if dead else est; settlement_known = True` 并删掉 `:414-415` 的 `if not dead:` 赋值;`:392` 取消分支首句加同款条件赋值;`:429-430 finally` 不动。
|
||||||
|
|
||||||
|
`ocr.py:449 settle_and_release(permit, 0)` **保持 0**:OCR 无 token 是事实而非"未知"(`ocr.py:9` 既有声明),不得改成 est,也不引入 `settlement_known`。
|
||||||
|
|
||||||
|
**防越界回归(必带)**:假 transport 抛 `RuntimeError`(不属四分类、无 except 接住)→ `tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`。两条把“不得扩到未批准语义”钉成可回归的断言。
|
||||||
|
|
||||||
|
共享状态与探针一律不变:`pacer.leave()`、`permit.release()`、`breaker.release_probe()`(`retry.py:321-322`、`ocr.py:410-411`、`embedding.py:393-394`)、`mark_progress` 的调用点、次数与顺序全部逐字保留;**不新增任何公开参数**。
|
||||||
|
|
||||||
|
### 3.6 F1:`Retry-After` 非有限值(`transports/openai_compat.py:111-119`)
|
||||||
|
|
||||||
|
签名改为 `_parse_retry_after(raw: str | None, *, source_name: str) -> float | None`——`source_name` 是**必填 keyword-only 参数**(私有模块内函数,不属公共面,故不给默认值;漏传即 `TypeError`);**唯一调用处**是 `_translate_429`(`:140`),改传 `source_name=source.name`(该函数已持有 `source`,不需新参数)。
|
||||||
|
|
||||||
|
判据与告警(按已批设计 §6.1 原文精确定义,不得自行扩大):
|
||||||
|
|
||||||
|
| 输入形态 | 返回 | 日志 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `inf` / `-inf` / `1e999`(`float()` 成功且 `math.isinf(seconds)`) | `None` | **一条 `logger.warning`**,只写源名与判据词(如 `retry_after_not_finite`),**不拼接、不截断、不打印原始头字符串** |
|
||||||
|
| `nan` | `None` | **无告警**:沿用既有 `seconds > 0` 恒假的值语义,本版**不为它新增分支、不改判据顺序** |
|
||||||
|
| HTTP-date / 空串 / 负数 / 不可解析 | `None` | 无告警(429 风暴下逐次告警会淹掉真信号) |
|
||||||
|
| 有限正数 | 该值 | 无 |
|
||||||
|
|
||||||
|
实现上只在 `float()` 成功后、`seconds > 0` 之前插一段 `if math.isinf(seconds): warning; return None`;`_translate_429` 的分类、`backoff_delay` 的 `max(delay, retry_after)` 取大逻辑一字不动(设计 §6.2:不夹 `backoff_max_s`)。
|
||||||
|
|
||||||
|
## 4. 任务与提交点(4 个原子提交)
|
||||||
|
|
||||||
|
### T0:设计批准状态与本计划(本任务,无代码)
|
||||||
|
|
||||||
|
产出:设计文档状态改批准 + §6.3 结算矩阵 + §5.2 五形态;本计划。不提交代码、不动测试。
|
||||||
|
|
||||||
|
### T1 → 提交 1 `fix: settle cancelled attempts against the source estimate`
|
||||||
|
|
||||||
|
1. **先红**:按 §5 批次 C 写 S3/S7 用例(`test_retry.py`、`test_embedding.py`),确认失败信息是 `tpm_used == 0 != 400`(不是构造错误);同批写 S5-dead 与 S8 两条**防越界**用例(实现前应已绿,作回归锁)。
|
||||||
|
2. 改 `middleware/retry.py::_attempt` 与 `embedding.py::_attempt`(§3.5:初值保 0 + 局部 `settlement_known`,失败分支结算决定上移到两个 await 之前),`ocr.py` 只补注释不改值;**不新增任何公开参数、不改未分类异常路径**。
|
||||||
|
3. **后绿**:新用例通过;`pytest tests/unit -q` 全绿(S1/S4/S5-dead/S6/S8 回归断言在批次 C 内一并落地)。
|
||||||
|
4. 真实 Redis:`tests/integration/test_redis_cross_connection.py` 新增取消结算用例(§5 批次 F),跑 `pytest tests/integration/test_redis_cross_connection.py -q`。
|
||||||
|
5. 暂存路径:`src/polygateway/middleware/retry.py`、`src/polygateway/embedding.py`、`src/polygateway/ocr.py`、三个测试文件。
|
||||||
|
|
||||||
|
### T2 → 提交 2 `feat: add an optional per-call wall-clock deadline`
|
||||||
|
|
||||||
|
1. 新建 `deadline.py`(§3.1)、`errors.py` 新类(§3.2)、`__init__.py` 导出、`pyproject.toml` layers 一行。
|
||||||
|
2. `config.py` 四处(字段/loader/守卫/`from_env`)、三个 client 的构造参数 + 公开方法参数 + 包裹点 + `from_settings` 透传(§3.3/§3.4)。
|
||||||
|
3. 先红后绿顺序:批次 A(`test_deadline.py` 值域)→ 批次 B(五形态)→ 批次 D(三链路命中与覆盖面)→ 批次 E(配置四条路)。
|
||||||
|
4. 回归门:`pytest tests/unit tests/contracts -q` 全绿且**未改一行既有断言**;`make lint`(含 import-linter 新层)通过。
|
||||||
|
5. 暂存路径:`src/polygateway/deadline.py`、`errors.py`、`__init__.py`、`config.py`、`client.py`、`embedding.py`、`ocr.py`、`pyproject.toml`、`tests/unit/test_deadline.py` 及四个改动测试文件。
|
||||||
|
|
||||||
|
### T3 → 提交 3 `fix: ignore non-finite Retry-After hints`
|
||||||
|
|
||||||
|
1. 先红:`tests/unit/test_openai_compat.py` 加 `inf`/`-inf`/`1e999`/`nan`/空/负/HTTP-date 七例,并加一例漏传 `source_name` 的 `TypeError`(批次 G)。
|
||||||
|
2. 改 `_parse_retry_after`:加必填私有 kw `source_name`、加 `math.isinf` 判据与一条 warning,同步唯一调用处 `_translate_429`(`:140`);跑该文件与 `tests/unit -q`。
|
||||||
|
3. 暂存:`src/polygateway/transports/openai_compat.py`、`tests/unit/test_openai_compat.py`。
|
||||||
|
|
||||||
|
### T4 → 提交 4 `docs: document the optional call deadline and cancellation settlement`
|
||||||
|
|
||||||
|
1. `CHANGELOG.md` 未发布段:三句强制措辞——**期限治理的是等待、返回时刻 = 期限 + 清理耗时(实测 5–7 倍)**;**到期不等于未产出、未计费**;**不配置即保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。另记取消结算口径变化:**仅当取消发生在“端口已开始、结算尚未确定”时**按 `est` 保留预扣(方向为宁多扣不空退);已知结算(含真实 usage 恰为 0、已判 `SourceDead` 的 `0`)不被覆写,**未分类异常仍按 `0`**。另列"`except GatewayUnavailableError` 接不住新异常"。
|
||||||
|
2. `README.md` **四处同步(缺一不可,按行号定位)**:① 能力表(`:10-22` 区间)新增一行“调用期限”,措辞用 §5.3 三句;② “### 4. 业务侧异常处理”示例(`:185-195`)——该段 `except GatewayUnavailableError` **接不住** `CallDeadlineExceeded`,必须加一条 `except CallDeadlineExceeded` 分支并注明它无 `retry_after_s`;③ “哪些异常会到达调用方”表(`:466-476`)左列新增 `CallDeadlineExceeded` 行,并写明它**不属四分类、不属 `GatewayUnavailableError` 族**,只在显式配期限后才可能出现;④ 错误模型段补一句**有限大 `Retry-After` 残留**(能力表 `:15` 写的“尊重 Retry-After”仍成立:库不夹 `backoff_max_s`,服务端给 3600s 就睡 3600s,唯一制约手段是本版的调用期限;`inf`/`1e999` 自 1.3.6 起按无提示处理)。另:`.env.example` 在 `LLM__CIRCUIT_OPEN`(`:71`)之后加注释行 `# LLM__CALL_DEADLINE_S=`(缺省不启用,说明其治理对象是等待)。
|
||||||
|
3. `research-wiki/findings/2026-09-10-136-call-deadline-validation.md`:红绿证据、命令与退出码、豁免索引。
|
||||||
|
4. 独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本号与 wiki 同步留给发布清单(本计划不 bump、不发布)。
|
||||||
|
|
||||||
|
## 5. 测试矩阵 → 任务映射
|
||||||
|
|
||||||
|
**测试设施复用与"哪一份副本"的硬性核对**(历史坑,动手前必须核对):
|
||||||
|
|
||||||
|
| 事实 | 证据 | 纪律 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tests/unit/test_retry.py:35`、`tests/unit/test_embedding.py:176` 从 `tests.contracts.conftest` import `FakeClock` | 现读 | 改这一份即影响契约与两个单测文件 |
|
||||||
|
| `tests/unit/test_ocr_client.py:344` **自带一份同名 `FakeClock`** | 现读 | OCR 用例只吃这一份;给 OCR 加期限用例时不得误改 contracts 那份并以为生效 |
|
||||||
|
| `tests/unit/test_embedding.py:177` 从 `tests.unit.test_backpressure` import `BoundedSleep` | 现读 | 复用它做"轮询次数有界"断言,不新造 |
|
||||||
|
| 无 `tests/conftest.py` / `tests/unit/conftest.py` | `ls` 实测 | 新 fixture 只能进各文件本地,或复用 `tests/contracts/conftest.py`(已被 unit 直接 import) |
|
||||||
|
|
||||||
|
**取消白箱的确定性纪律**:既有取消用例用 `await asyncio.sleep(0.05)` 撞窗口(`test_retry.py:474`、`test_ocr_client.py:365`)——新用例**不得**沿用。做法:给 `test_retry.py::FakeTransport` 的 `"hang"` 分支加 `self.entered.set()`(构造期 `self.entered = asyncio.Event()`,3.10+ 不绑定 loop),用例 `await transport.entered.wait()` 后再 `task.cancel()`;embedding/OCR 的 `ScriptedEmbedTransport`/`ScriptedOcrTransport` 同款加一个 `entered`。既有用例不动。
|
||||||
|
|
||||||
|
| 批次 | 断言(→ 任务) | 落点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A 值域 | `None` 通过;`0`/负/`nan`/`inf`/`"1"`/`True`(bool 不得当 1 秒)/`object()` → `ValueError` 且消息含 origin(→T2) | `tests/unit/test_deadline.py` |
|
||||||
|
| B 形态区分 | ①到期 → `CallDeadlineExceeded`(`scope`/`deadline_s` 正确);②未到期内层自抛 `TimeoutError` → 原样上抛;③**到期后清理自抛 `TimeoutError`** → 原样上抛且**断言不是** `CallDeadlineExceeded`(钉住身份比较);④外部 `task.cancel()`(先于/晚于到期各一例)→ `CancelledError`;⑤擦边成功 → 正常返回且 `task.cancelling() == 0`;⑥到期窗口内体内先抛领域异常(同步自旋构造)→ 上抛该异常,**不断言必为 deadline**;⑦`deadline_s=None` → 逐字旧路径(→T2) | `tests/unit/test_deadline.py`(真实 loop 时钟,期限 0.05s、体 0.3s,4–10× 余量,不标 slow) |
|
||||||
|
| C 取消结算 | S3:`tpm=1000, est_tokens=400`、transport `hang`、`entered` 后取消 → `tpm_used == 400`(**红→绿核心**)且 `inflight == 0`;S4:假 gate 在 `record_success` 处 `set()` 后挂起 → 取消 → `tpm_used == 15`(真实 usage 未被覆盖);S1:熔断开路使 `pick` 走 `settle_and_release(permit, 0)` → `tpm_used == 0`;S5-dead:假 gate 在 `SourceDead` 的 `record_failure` 处挂起 → 取消 → `tpm_used == 0`(**不得**变 `est`);S5-transient:同位置但瞬时失败 → `tpm_used == est`;S6:OCR 源 `tpm=600`、transport `hang` → 取消 → `tpm_used == 0`;S7:embedding 同 S3;**S8 防越界**:假 transport 抛 `RuntimeError` → `tpm_used == 0` 且异常原样上抛;真实 usage 恰为 0 的成功 → `tpm_used == 0`;四条既有路径(成功/`SourceDead`/`RequestRejected`/`ResultInvalid`)结算值逐字不变(→T1) | `test_retry.py`、`test_embedding.py`、`test_ocr_client.py` |
|
||||||
|
| D 覆盖面 | 期限分别落在 ①退避 `sleep`(注入真 `asyncio.sleep`)②准入排队(配额满轮询)③结构化重问 ④embedding 多批 → 均抛 `CallDeadlineExceeded`;embedding 断言 **N 批共享一份**期限(总时长不随批数放大);`texts == []` 早返回不受期限影响(→T2) | `test_client.py`、`test_embedding.py`、`test_ocr_client.py` |
|
||||||
|
| D2 到期代价 | ①终态遥测:到期恰好一条 `event_kind='terminal_failure'`、`error_type='CallDeadlineExceeded'`,被取消的 attempt 行仍 `cancelled`,两行 `logical_call_id` 一致,列数不变;②清理不可越过 + 量化:假 permit/假 emitter 各注入已知 sleep → 返回时刻 ≈ 期限 + 已知清理时长(断言 > 期限的若干倍,不断言上界);③已计费成功被丢弃:假缓存后端 `set` 慢于期限 → 抛 deadline 且断言 transport **已成功调用一次**(→T2) | `test_client.py` |
|
||||||
|
| E 配置四条路 | 键未设 → `None`;非法值 × {env、`GatewaySettings(...)` 直接构造、`dataclasses.replace`、三个 client `__init__` 直传} 各一例 → `ValueError`;per-call 非法值抛错且**无 "coroutine was never awaited" 警告**(`pytest.warns` 反向断言 / `-W error::RuntimeWarning`);`call_deadline_s < timeout_s` 合法不报错;注入钟跳变 10^6 秒**不**触发期限,而期限触发时 `total_latency_ms` 仍取自注入钟(→T2) | `test_config.py`、`test_client.py` |
|
||||||
|
| F 真实 Redis | 复用 `tests/integration/test_redis_cross_connection.py` 的 `clients`/`_limiter`/`_client`/`ScriptedTransport(hang=True)`:源 `tpm=1000, est_tokens=400`,`inflight` 出现后取消 → `source_stats.tpm_used == 400` 且 `inflight == 0`。**不改 Lua、不改 `tests/contracts/`**;另跑既有 `pytest tests/contracts/test_limiter_contract.py -q`(memory+redis 双参数)证明后端算术未被触碰(→T1) | `tests/integration/test_redis_cross_connection.py` |
|
||||||
|
| G F1 | `inf`/`-inf`/`1e999` → `None` + 各一条 warning(断言日志**含源名、不含**原始字符串);`nan`/空/负/HTTP-date → `None` 且**无** warning(`nan` 仍走既有 `seconds > 0` 值语义,不新增分支);漏传 `source_name` → `TypeError`(钉住必填 kw);有限正数仍参与 `max(delay, retry_after)`;`insufficient_quota` 仍归 `SourceDead`(→T3) | `test_openai_compat.py` |
|
||||||
|
| H 未启用回归 | `call_deadline_s=None` 时 `tests/unit`、`tests/contracts` 全绿且**未改一行既有断言**(→T2 门) | 全套件 |
|
||||||
|
|
||||||
|
命令(全部 `conda run -n PolyGateway`,禁止接管道以免退出码失真):`pytest tests/unit -q`、`pytest tests/contracts -q`、`pytest tests/integration/test_redis_cross_connection.py -q`、`make lint`。真实网关 e2e 与 `-m slow` 变体本计划**不跑**,由发布清单第 4 步统一负责。
|
||||||
|
|
||||||
|
## 6. 阻塞矩阵与交接
|
||||||
|
|
||||||
|
| 触发条件 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 需要新增本计划外的公共键/端口方法/遥测列 | **停下上报**(设计 §9 边界之外即未批准) |
|
||||||
|
| 批次 B③(清理期自抛 `TimeoutError`)在实现里无法确定性构造 | 改用假端口在 `except CancelledError` 内直接 `raise TimeoutError`(探针 D3 已证可复现);仍不可得则记入 findings 的豁免索引,不得删断言 |
|
||||||
|
| 批次 D2② 的量化断言在 CI 机器上抖动 | 只断言下界(返回时刻 > 期限 × 2),不断言上界;不得改成 `sleep` 猜测 |
|
||||||
|
| 真实 Redis 不可用(`REDIS_URL` 未配置) | 用例自动 skip;findings 必须显式记"未取得真实 Redis 证据",不得以 memory 结果冒充 |
|
||||||
|
| 发现 `ResultInvalid`/`RequestRejected` 退全款想顺手修 | **不修**(设计 §6.3 末段:未批准的另一族记账语义),登记为新 issue 交父会话 |
|
||||||
|
| 失败分支结算决定上移后发现某条既有用例变红 | 先 `systematic-debugging` 定根因;如确为语义变化(非取消路径的最终值应与 1.3.5 逐字相同)则**停下上报**——说明本项只改算得更早、不改算出什么 |
|
||||||
|
| 想把保守口径扩到未分类异常(`RuntimeError` 等) | **不扩**(人类仅批准取消路径);S8 回归用例就是这道锁,需要就另立 issue |
|
||||||
|
| issue #24 相关想法 | 一律不实现、不写进代码与文档 |
|
||||||
|
|
||||||
|
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
|
||||||
|
|
||||||
|
## 7. 自审
|
||||||
|
|
||||||
|
| 检查 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 路径/行号/签名是否可执行无 TBD | 是——所有接入点均现读行号(`retry.py:281/288/301/303/311/315/320/325/335/339`、`embedding.py:345/349/358/360/377/392/408/414/429`、`ocr.py:449`、`client.py:398`、`embedding.py:209`、`ocr.py:274`、`config.py:186/349/380-384`、`openai_compat.py:111-119/140`、`__init__.py:61-62`) |
|
||||||
|
| 是否复用而非重造 | 是——`settle_and_release`、`emit_terminal_once`、`claim_terminal`、`asyncio.timeout` 范式、FakeClock/BoundedSleep/ScriptedTransport、限流契约套件与真实 Redis 用例全部复用;新增仅 1 文件 + 1 异常类 + 1 配置键 |
|
||||||
|
| 是否有先失败后通过的证据点 | 是——T1 的 S3/S7、T2 的 A/B/D、T3 的 G 均先红 |
|
||||||
|
| 取消与降级铁律 | 未新增 `except Exception`;`CancelledError` 无新捕获点;期限未启用时不进任何上下文 |
|
||||||
|
| 反 gold-plating | `ResultInvalid`/`RequestRejected` 退全款、#24、`shield`、遥测列、Lua 一律不碰 |
|
||||||
|
| 残余诚实标注 | S3 是保守选择而非"已计费"的证明;**未分类异常(S8)仍按 `0` 退全款,属已知残留、本版不动**;清理期自抛 `TimeoutError` 时无终态行;跨 Python 版本仅 3.12.13 有探针实证——四条均已写进设计 §12,findings 需复述 |
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-09-10-24-hedged-requests
|
||||||
|
title: "issue #24 长尾对冲请求与裸生成时间实施计划"
|
||||||
|
date: 2026-09-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# issue #24 长尾对冲请求与裸生成时间实施计划
|
||||||
|
|
||||||
|
> 设计:`research-wiki/designs/2026-09-10-24-hedged-requests-design.md`,**人类于 2026-09-10 正式批准**(§9 H1–H7 及增补 H8 全数获批;H1 取方案 A 并发对冲,H3 取零新列档,H5 取 v1 只允许单路)。
|
||||||
|
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
|
||||||
|
> 目标:① chat 链路可选对冲(挂起超阈值时并发向**异源**再发一次,先回者赢、输家取消);② `CallStats` 增 `hedges`/`generation_ms`/`hedge_won`,三条链路 `_attempt` 加 transport 级计时;③ 默认关闭,缺省行为逐字等于 1.3.6。
|
||||||
|
> 方案:设计 §3 方案 A——对冲轮 = 一次"超级尝试",复用完整准入(QuotaGate + BreakerGate + pacer + 冷却备忘),输家落 1.3.6 取消 S3 格结算,零新错误分类。
|
||||||
|
> 技术:Python 3.12+、asyncio 任务组编排(`asyncio.wait(FIRST_COMPLETED)` + 显式收口)、frozen dataclass、pytest + 事件驱动假 transport + 真实 loop 钟(4–10× 余量)、ruff、import-linter。
|
||||||
|
> 基线 HEAD:`166b286`(main,1.3.6 已发布);分支 `feature/24-hedged-requests`。
|
||||||
|
|
||||||
|
**范围纪律**: 不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口;不做 embedding/OCR 对冲、分位数触发、同源对冲、per-call 对冲参数;不引入 shield/后台任务;不加遥测新列(`hedge_cancelled` 字符串 + `CallStats` 三字段经既有通道带出)。
|
||||||
|
|
||||||
|
## 1. 边界、授权与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 固定边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.X 合并/发布授权沿用;跨到 1.4 或新公共面变化须停下确认 |
|
||||||
|
| 公共面 | 只做设计 §9 已批准项:`Transport.complete` 加 `first_token_event` 必填 kw(H2)、两配置键 `{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA`(H4)、`GatewayClient.__init__` 两 keyword-only 参数、`CallStats` 三字段(H3+H8)、输家 `hedge_cancelled` 标签。**不新增其它键/端口方法/遥测列/异常类** |
|
||||||
|
| 对冲边界 | 仅 chat(RetryMW);EmbeddingClient/OcrClient 不加对冲参数(非目标 A),但它们的 `_attempt` 照样加 generation 计时点(H8);v1 单路对冲(H5) |
|
||||||
|
| 记账边界 | 输家取消走既有取消路径:`settlement_known=False` → `settle(est)` 保留预扣(`retry.py:327-331`);输家**不** `record_failure`、不喂熔断/健康分(挂起 ≠ 源死亡);两任务都失败才进重试且 `fails += 1` 只计一次(H6) |
|
||||||
|
| 取消铁律 | 外部取消到达时两任务同消并穿透;不 shield、不留后台任务;收口 await 允许被再取消(同 136 清理纪律) |
|
||||||
|
| 降级方向 | 限流/熔断后端不可用 → 照常冒泡(fail-closed);准入失败 → **静默放弃对冲**,原请求继续等(不抛错、不硬等);遥测仍 warning 降级 |
|
||||||
|
| 时钟纪律 | 对冲触发只用**相对时长 + 事件循环钟**(`asyncio.wait` timeout),绝不读注入 `now`;`generation_ms` 计时用该链路既有注入钟(与 `total_latency_ms` 同钟,差值才有意义;生产即 `time.monotonic`) |
|
||||||
|
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/`、`tests/outputs/`;测试事件驱动,**禁 sleep 撞窗口**;对冲阈值用 0.05s 级真实 loop 钟,断言容差 4–10×(`tests/unit/test_streaming.py` 既有范式,不标 slow) |
|
||||||
|
|
||||||
|
Skill 纪律:T1–T3 行为变更执行 `test-driven-development`(先红后绿证据落在本会话工具输出);每次提交执行 `commit`;T4 前执行 `requesting-code-review` 与 `verification-before-completion`;异常先 `systematic-debugging`。
|
||||||
|
|
||||||
|
**保真校验**: 本计划不涉及 `reference/` 参考实现迁移(对冲编排为 D13 自研语义,蓝本即本库 1.3.6 的准入/结算/取消机制),保真校验不适用。
|
||||||
|
|
||||||
|
## 2. 文件职责与不变接缝
|
||||||
|
|
||||||
|
| 动作 | 精确路径 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:51-60)加 `first_token_event` 必填 kw;顶部加 `import asyncio`(stdlib,不违 P7) |
|
||||||
|
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:426-436)加参透传;`_complete_stream`(:552 起)首 token 处(:573-575)置位;`_complete_once`(:646 起)接收但**永不置位**(docstring 明写) |
|
||||||
|
| 修改 | `src/polygateway/middleware/retry.py` | `_attempt`(:270-353)加 `first_token_event`/`generation_sink` 私有 kw + transport 级计时;`__call__`(:224-267)单轮尝试 → 任务组编排;`_past_hedge_window`/`_attempt_hedged`/`_combine_failures` 新方法;CancelledError 分支(:327-334)`hedge_cancelled` 标签;`__init__` 加 `hedge_after_s` |
|
||||||
|
| 修改 | `src/polygateway/middleware/admission.py` | `pick`(:171-213)加 keyword-only `exclude: frozenset[str] \| None = None` |
|
||||||
|
| 修改 | `src/polygateway/types.py` | `CallStats`(:296-318)增三字段(全带默认值,追加在 `total_latency_ms` 后);`_CallContext`(:321-360)docstring 修订 + 三个计数 + `record_generation`/`register_hedge`;`snapshot`(:348-353)填三字段 |
|
||||||
|
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"HEDGE"`;`GatewaySettings` 字段(:190 后)加 `hedge_after_s`/`hedge_max_extra`;`__post_init__`(:201 后)加 `_validate_hedge()`;新增 `_load_hedge`(:695 `_load_call_deadline` 之后)与模块级 `check_hedge_assembly` 守卫;`from_env`(:396 同列)透传 |
|
||||||
|
| 修改 | `src/polygateway/client.py` | `__init__`(:239 后)加两 keyword-only 参数,入口即校(复用 `check_hedge_assembly`);RetryMW 构造(:254-273)传 `hedge_after_s`;`from_settings`(:511 同列)透传 |
|
||||||
|
| 修改 | `src/polygateway/embedding.py` | `_attempt`(:351 起,transport 调用 :373)两侧计时 + 成功分支 `record_generation(accumulate=True)` |
|
||||||
|
| 修改 | `src/polygateway/ocr.py` | `_attempt`(:376 起,`_invoke` 调用 :397)两侧计时 + 成功分支 `record_generation(accumulate=False)` |
|
||||||
|
| 新建 | `tests/unit/test_hedge.py` | 对冲编排全部用例(批次 G) |
|
||||||
|
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(端口加参);`_complete` helper(:82-90)同步签名 |
|
||||||
|
| 修改 | `tests/unit/test_retry.py` `test_types.py` `test_embedding.py` `test_ocr_client.py` `test_client.py` `test_config.py` | 批次 B–F、H;`test_retry.py:82` FakeTransport 签名同步 |
|
||||||
|
| 修改 | `tests/unit/test_backpressure.py:213` `tests/unit/test_ports.py:75` `tests/unit/test_client.py:1741` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:284-303` | fake/包装 transport 签名同步(e2e 包装**转发** `first_token_event`) |
|
||||||
|
| 修改 | `tests/unit/test_live_evidence.py:270-279`(`_complete` helper)+`:1258`(直调 `ObservedTransport(...).complete(...)`);`tests/unit/test_usage_source_domain.py:135`(直调真实 `OpenAICompatTransport.complete`) | **调用方**同步(独立审 B1): helper 加 `first_token_event=None` 转发、两直调传 `None`;不传则必填 kw 报 `TypeError`,unit 门必红 |
|
||||||
|
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example` | 新键、三字段、对外承诺措辞(§4 T4) |
|
||||||
|
| 新建 | `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md` | 红绿、命令、豁免索引,≤300 行 |
|
||||||
|
|
||||||
|
**不改**:`errors.py`(零新异常)/`deadline.py`/`telemetry/schema.py`(零新列)/`middleware/{ratelimit,breaker,structured,cache,telemetry}.py`/`backends/**`(含全部 Lua)/`tests/contracts/**`;`embedding.py`/`ocr.py` 除计时点外一字不动;`StallClock`/`backoff_delay`/`settle_and_release` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
|
||||||
|
|
||||||
|
## 3. 跨任务接口(可执行定义,禁止占位)
|
||||||
|
|
||||||
|
### 3.1 T1:`Transport.complete` 加首 token 事件(H2)
|
||||||
|
|
||||||
|
`ports.py:51-60` 签名改为(顺序追加在 `reasoning_effort` 后,**必填、不设默认值**,与端口既有约定同款):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def complete(
|
||||||
|
self, *, messages: list[dict[str, Any]], source: SourceConfig, stream: bool,
|
||||||
|
overlay: dict[str, Any], call_id: str, reasoning_effort: Effort | None,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
) -> TransportResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
docstring 补两句:`None` = 调用方不观测首 token(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间阈值)。`OpenAICompatTransport.complete`(:426-436)加同款必填 kw 并透传两条路径;`_complete_stream` 在 :573-575 `if ttft_ms is None:` 块内加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if first_token_event is not None:
|
||||||
|
first_token_event.set()
|
||||||
|
```
|
||||||
|
|
||||||
|
`_complete_once`(:646)接收该参数但永不置位,docstring 明写"非流式无中途信号"。`retry.py:291-300` 调用处 T1 先传字面 `first_token_event=None`(必填参数不传即全库 TypeError;T3 换成真事件)。
|
||||||
|
|
||||||
|
假 transport 同步纪律(`test_retry.py:71-73` 既有注释的同款):签名加 `first_token_event`,**不给默认值**;除 `test_hedge.py` 外所有 fake 忽略该参数即可。e2e 包装(`tests/e2e/conftest.py:284-303`)必须**转发**给被包 transport。
|
||||||
|
|
||||||
|
### 3.2 T2:`CallStats` 三字段与 `_CallContext` 计数(H3+H8)
|
||||||
|
|
||||||
|
`types.py` `CallStats`(:296-318)在 `total_latency_ms` 后追加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
hedges: int = 0
|
||||||
|
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
|
||||||
|
generation_ms: int = 0
|
||||||
|
"""裸生成时间: 赢家/成功那次 transport 调用的墙钟时长(口径见设计 §4.5 H8)。"""
|
||||||
|
hedge_won: bool = False
|
||||||
|
"""赢家是否为对冲路;无对冲恒 False。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`_CallContext`(:321-360):docstring "每调用一个实例的**单任务**对象" 修订为 "每逻辑调用一个实例,**可多任务并发登记**(对冲);全部方法无 await,事件循环内任务安全";`__slots__` 与 `__init__` 加 `_generation_ms: int`/`_hedges: int`/`_hedge_won: bool`;新增两个同步方法:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def record_generation(self, elapsed_ms: int, *, accumulate: bool) -> None:
|
||||||
|
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。"""
|
||||||
|
self._generation_ms = self._generation_ms + elapsed_ms if accumulate else elapsed_ms
|
||||||
|
|
||||||
|
def register_hedge(self, *, hedge_won: bool) -> None:
|
||||||
|
"""对冲路实际发出即计数;赢家裁定后一次性登记。"""
|
||||||
|
self._hedges += 1
|
||||||
|
self._hedge_won = hedge_won
|
||||||
|
```
|
||||||
|
|
||||||
|
`snapshot`(:348-353)按字段名填 `hedges=self._hedges, generation_ms=self._generation_ms, hedge_won=self._hedge_won`。
|
||||||
|
|
||||||
|
### 3.3 T2:三条链路 `_attempt` 的 transport 级计时点
|
||||||
|
|
||||||
|
统一形态:计时**只包 transport 调用本身**,用该链路既有注入钟 `self._now`(生产 = `time.monotonic`);起点紧贴调用前、终点在返回后首句,**中间无 await**(取消落进来时 transport 未返回,本就不计)。
|
||||||
|
|
||||||
|
| 链路 | 计时点 | 记录点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| chat(`retry.py:291-300`) | `gen_started = self._now()` 紧贴 `await self._transport.complete(...)` 前;返回后首句 `generation_sink.append(int((self._now() - gen_started) * 1000))` | **不在 `_attempt` 内记录**——对冲赢家归属由编排裁定。`_attempt` 加私有 kw `generation_sink: list[int]`;`__call__`(:248-256)每轮建 sink,`outcome` 为 `LLMResponse` 且 `call_context` 非 None 时 `record_generation(sink[0], accumulate=False)`(与 `register_attempt` 同款 None 守卫) |
|
||||||
|
| embedding(`embedding.py:373`) | 同款两侧包 `await self._transport.embed(...)` | 成功分支(`:386 settlement_known = True` 之后)`context.record_generation(gen_ms, accumulate=True)`——分批累加 |
|
||||||
|
| ocr(`ocr.py:397`) | 同款两侧包 `await self._invoke(...)` | 成功分支 `context.record_generation(gen_ms, accumulate=False)` |
|
||||||
|
|
||||||
|
缓存命中/空输入不产生 transport 调用 → `generation_ms` 恒 0(0 是实测,不违 `types.py` "None 表未知" 惯例——本字段语义是时长不是用量)。
|
||||||
|
|
||||||
|
### 3.4 T3:对冲编排(retry.py,设计 §4.6 的唯一实现形态)
|
||||||
|
|
||||||
|
`RetryMW.__init__` 加 keyword-only `hedge_after_s: float | None = None`(存 `self._hedge_after_s`;`hedge_max_extra` **不下传**——v1 编排固定单路,H5;配置面值域与 v1 生效口径由 §3.6 守卫负责)。模块级:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_HEDGE_LOSER_ATTR = "_polygateway_hedge_loser"
|
||||||
|
"""编排在 cancel() 之前给输家任务置位的标记;_attempt 读它选遥测标签。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`__call__`(:244-256)循环体内:`self._hedge_after_s is None` → **逐字旧路径**(`_attempt` 传 `first_token_event=None`,单任务,默认关闭回归门据此成立);否则 `outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)`,其后的 `_is_rate_limited`/refund/`fails` 计数机制一字不动。
|
||||||
|
|
||||||
|
`_attempt_hedged` 编排(Phase 注释组织;`_attempt` 相应加 `first_token_event` kw 取代 T1 的字面 None):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Phase 1 启动原路: 事件与 sink 每轮新建(局部状态,严禁实例属性)
|
||||||
|
source, permit, entry = picked
|
||||||
|
first_token: asyncio.Event = asyncio.Event()
|
||||||
|
sink_p: list[int] = []
|
||||||
|
primary = asyncio.create_task(
|
||||||
|
self._attempt(request, source, permit, entry, reasons, attempt_fails,
|
||||||
|
first_token_event=first_token, generation_sink=sink_p)
|
||||||
|
)
|
||||||
|
# Phase 2 触发窗: 只认"阈值到 + 首 token 未至 + 原路在途"(loop 相对时长,不读注入 now)
|
||||||
|
if not await self._past_hedge_window(primary, first_token):
|
||||||
|
return await primary # 原路已了结/首 token 已至: 等价于未配置对冲
|
||||||
|
# Phase 3 异源准入(完整 pick 路径, 无旁路): 拿不到候选 = 静默等原路
|
||||||
|
hedge_picked, _ = await self._admission.pick(
|
||||||
|
reasons, attempt_fails, exclude=frozenset({source.name})
|
||||||
|
)
|
||||||
|
if hedge_picked is None:
|
||||||
|
return await primary
|
||||||
|
```
|
||||||
|
|
||||||
|
Phase 4-5(赢家裁定与收口)规则:
|
||||||
|
|
||||||
|
```python
|
||||||
|
sink_h: list[int] = []
|
||||||
|
hedge = asyncio.create_task(
|
||||||
|
self._attempt(request, *hedge_picked, reasons, attempt_fails,
|
||||||
|
first_token_event=None, generation_sink=sink_h) # v1 单路: 对冲路不再触发梯次
|
||||||
|
)
|
||||||
|
done, pending = await asyncio.wait({primary, hedge}, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **裁定**:done 中有成功(LLMResponse)即赢家;两路同时成功(竞速)→ **原路优先**(`hedge_won=False`,保守不弃原路成果);done 全是失败且 pending 非空 → 等 pending 了结后再裁定。
|
||||||
|
- **收口**:赢家产生后,对 pending 中的输家先 `setattr(task, _HEDGE_LOSER_ATTR, True)` 再 `task.cancel()`,然后 `await asyncio.gather(*pending, return_exceptions=True)`——输家 finally 的结算/遥测跑完才返回(快照含输家,`attempts==2`;`client.py:442` 快照在返回后,天然在收口之后)。两路同时完成的竞速落选者**不置标记**(它没被取消,attempt 行是正常成功/失败行)。
|
||||||
|
- **登记**:对冲路实际发出(Phase 3 之后)即计一次;赢家裁定后 `context.record_generation(赢家 sink[0], accumulate=False)` + `context.register_hedge(hedge_won=winner is hedge)`;**两败轮次(无赢家)同样照登** `register_hedge(hedge_won=False)`——设计 §4.5 已批准口径是"实际并发发出即计,触发但准入失败静默不计";`call_context is None` 时跳过(同 `register_attempt` 守卫)。
|
||||||
|
- **两败汇合**(H6,只计一次预算;deterministic):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _combine_failures(primary_f: _Failed, hedge_f: _Failed) -> _Failed:
|
||||||
|
"""任一非 429 优先(计预算);两路皆 429 才按 429 免预算退还 stall 账;同类取原路。"""
|
||||||
|
if _failure_reason(primary_f.exc) == "rate_limited" != _failure_reason(hedge_f.exc):
|
||||||
|
return hedge_f
|
||||||
|
return primary_f
|
||||||
|
```
|
||||||
|
|
||||||
|
- **取消穿透**:Phase 2-5 全程包 `except BaseException`(含 `CancelledError` 与准入冒泡的 `GovernanceBackendError`)→ 两任务(存在者)`cancel()` + `gather(return_exceptions=True)` 尽力收口后 `raise` 原异常;收口 await 允许被再取消,不 shield。
|
||||||
|
|
||||||
|
`_past_hedge_window` 实现红线(waiter 任务必须收口,且**不得吞外部取消**):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _past_hedge_window(self, primary: asyncio.Task, first_token: asyncio.Event) -> bool:
|
||||||
|
waiter = asyncio.create_task(first_token.wait())
|
||||||
|
try:
|
||||||
|
await asyncio.wait({primary, waiter}, timeout=self._hedge_after_s,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
waiter.cancel()
|
||||||
|
try:
|
||||||
|
await waiter
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if asyncio.current_task().cancelling(): # 外部取消,穿透
|
||||||
|
raise
|
||||||
|
return not primary.done() and not first_token.is_set()
|
||||||
|
```
|
||||||
|
|
||||||
|
输家标签:`_attempt` 的 CancelledError 分支(:327-334)把 `error="cancelled"` 换成按标记选择——`label = "hedge_cancelled" if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False) else "cancelled"`。竞速误贴(外部取消与对冲取消同时到达)记账方向一致(est 保留),属设计 §4.5 已批准的可接受残留。
|
||||||
|
|
||||||
|
### 3.5 T3:`admission.pick` 私有排除参数(设计 §4.2)
|
||||||
|
|
||||||
|
`pick`(:171-173)签名加 keyword-only `exclude: frozenset[str] | None = None`;候选循环**首部**加:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if exclude and cand.name in exclude:
|
||||||
|
continue # 被排除不是源的拒绝: 不计 gate_rejections、不写 reasons
|
||||||
|
```
|
||||||
|
|
||||||
|
理由:写进 `reasons`/`gate_rejections` 会污染 `on_no_runnable`(:224)的分派判据与 `per_source_reasons` 对账。对冲调用方拿到 `None` 的处置是静默等原路(§3.4 Phase 3),**严禁**对它调 `on_no_runnable`(那会按 quota/circuit 策略抛错或睡觉,语义全错)。三条既有调用方(`retry.py:244`、`embedding.py:332`、`ocr.py:348` 所在循环)不传该参数,行为逐字不变。
|
||||||
|
|
||||||
|
### 3.6 T3:配置两键 + 两守卫 + client 透传(H4)
|
||||||
|
|
||||||
|
`config.py` 改动(单一定义点纪律,值域/交叉守卫只写一份):
|
||||||
|
|
||||||
|
| 项 | 精确定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 保留段 | :57 `_RESERVED_SEGMENTS` 加 `"HEDGE"`(防 provider 段撞名);两键均 3 段,`:405` 的 `len(parts) != 4` 判据天然跳过 `_load_sources` |
|
||||||
|
| loader | 新增 `_load_hedge(scope, env)`(:695 `_load_call_deadline` 之后):`AFTER_S` 用 `_first` + `_cast(..., "float", ...)` + `ensure_call_deadline`(origin 传实际命中键名,同 `_load_call_deadline` 纪律);`MAX_EXTRA` 用 `_first` + `_cast(..., "int", ...)`,未设 = 1;返回 `{"hedge_after_s": ..., "hedge_max_extra": ...}` |
|
||||||
|
| 字段 | `GatewaySettings` :190 后追加 `hedge_after_s: float \| None = None`、`hedge_max_extra: int = 1` |
|
||||||
|
| 守卫 | 新增模块级 `check_hedge_assembly(*, hedge_after_s, hedge_max_extra, sources, call_deadline_s, origin)`,返回归一化后的 `hedge_after_s`;`GatewaySettings.__post_init__`(:201 后)加 `_validate_hedge()` 调它并 `object.__setattr__` 写回归一化值(同 `_validate_call_deadline` 形态);`GatewayClient.__init__` 调同一份(client.py:22 已 import config,合法) |
|
||||||
|
| from_env | :396 同列加 `**_load_hedge(scope_u, env)` |
|
||||||
|
|
||||||
|
`check_hedge_assembly` 守卫全表(设计 §5;`hedge_after_s is None` 时值域归一化后直接返回,交叉守卫不查):
|
||||||
|
|
||||||
|
| 守卫 | 判定 |
|
||||||
|
| --- | --- |
|
||||||
|
| 值域 | `ensure_call_deadline(hedge_after_s, origin)`(None 或有限正数,复用 `deadline.py` 同款校验) |
|
||||||
|
| `hedge_after_s ≥ min(源 timeout_s)` | `ValueError`(对冲永不可能触发,配置即错误) |
|
||||||
|
| `hedge_after_s ≥ call_deadline_s`(两者皆设) | `ValueError`(期限先于对冲触发) |
|
||||||
|
| `hedge_after_s ≥ min(已设 ttft_timeout_s)` | 装配期 **warning**(流式档被 TTFT 看门狗先行切断;非流式仍有效,不升 ValueError) |
|
||||||
|
| 单源 scope 设了阈值 | 装配期 **warning**,允许(运行期拿不到候选自然静默) |
|
||||||
|
| `hedge_max_extra` | 非 int/bool 或不在 [1,3] → `ValueError`;**>1 → warning**"v1 仅单路对冲生效,梯次追加为 H5 预留"(值域按设计 §5 表放到 3,运行期 H5 只允许 1,warning 保 fail-loud 不静默) |
|
||||||
|
|
||||||
|
`client.py`:`__init__` :239 后加 keyword-only `hedge_after_s: float | None = None, hedge_max_extra: int = 1`,在 :245-247 期限校验同列调 `check_hedge_assembly(hedge_after_s=..., hedge_max_extra=..., sources=sources, call_deadline_s=self._call_deadline_s, origin="GatewayClient(...)")`;RetryMW 构造(:254-273)传 `hedge_after_s=` 归一化值(**max_extra 不下传**,§3.4);`from_settings` :511 同列传 `settings.hedge_after_s`/`settings.hedge_max_extra`。`EmbeddingClient`/`OcrClient` 不加对冲参数;它们的 settings 嵌 `GatewaySettings` 故守卫照常跑(文档明写对冲键只对 chat 生效)。
|
||||||
|
|
||||||
|
## 4. 任务与提交点(4 个原子提交)
|
||||||
|
|
||||||
|
### T0:设计增补并入与本计划(本任务,无代码)
|
||||||
|
|
||||||
|
产出:设计文档 §4.5/§7/§8/§9 增补(已完成)+ 本计划。不提交代码。
|
||||||
|
|
||||||
|
### T1 → 提交 1 `feat: add a required first-token event to the transport port`
|
||||||
|
|
||||||
|
1. **先红**:批次 A(`tests/unit/test_openai_compat.py` 四用例),确认失败为 `TypeError`(签名无此 kw)而非断言值不符。
|
||||||
|
2. 按 §3.1 改 `ports.py`、`openai_compat.py`、`retry.py:291-300` 传 None;同步六处 fake/包装(§2 表)+ `test_live_evidence.py`/`test_usage_source_domain.py` 三处调用点(§2 表末行);`_complete` helper(:82-90)加 `first_token_event=None` 默认转发(测试设施,与生产端口的"必填无默认"约定不冲突——生产端口不变)。
|
||||||
|
3. **后绿**:批次 A 通过;`pytest tests/unit -q` 全绿且不改一行既有断言。
|
||||||
|
4. 暂存:`src/polygateway/ports.py`、`src/polygateway/transports/openai_compat.py`、`src/polygateway/middleware/retry.py`、六个测试文件。
|
||||||
|
|
||||||
|
### T2 → 提交 2 `feat: expose bare generation time and hedge flags in CallStats`
|
||||||
|
|
||||||
|
1. **先红**:批次 B(`test_types.py` 三用例,字段不存在 → `TypeError`/`AttributeError`)+ C-F(各链路计时断言,字段恒 0 → 断言失败)。
|
||||||
|
2. 按 §3.2 改 `types.py`;按 §3.3 改三条 `_attempt` 与 `retry.py::__call__` sink 接线;**对冲计数本提交保持 0/False**(T3 才登记)。
|
||||||
|
3. **后绿**:批次 B–F 通过;`pytest tests/unit tests/contracts -q` 全绿不改既有断言。
|
||||||
|
4. 暂存:`src/polygateway/types.py`、`middleware/retry.py`、`embedding.py`、`ocr.py`、五个测试文件。
|
||||||
|
|
||||||
|
### T3 → 提交 3 `feat: add opt-in cross-source hedged requests for chat`
|
||||||
|
|
||||||
|
1. **先红**:批次 G(`test_hedge.py`,对冲未实现 → 挂起用例超时或 `hedges==0` 断言失败)+ H(`test_config.py`,键未识 → `ValueError`/`None` 断言失败)。
|
||||||
|
2. 按 §3.5 改 `admission.py` → §3.6 改 `config.py`/`client.py` → §3.4 改 `retry.py` 编排。
|
||||||
|
3. **后绿**:批次 G/H 通过;**默认关闭回归门**:`pytest tests/unit tests/contracts -q` 全绿且不改一行既有断言(批次 I);`make lint` 通过。
|
||||||
|
4. 暂存:`src/polygateway/middleware/{retry,admission}.py`、`src/polygateway/{config,client}.py`、`tests/unit/test_hedge.py`、`tests/unit/test_config.py`。
|
||||||
|
|
||||||
|
### T4 → 提交 4 `docs: document hedged requests and bare generation time`
|
||||||
|
|
||||||
|
1. `CHANGELOG.md` 未发布段:对冲三句强制措辞——**默认关闭,开启即用配额换延迟**(挂起窗口内 in-flight 翻倍);**输家可能已被上游计费**(est 保留只是闸内保守记账,非上游计量);**对冲只对 chat 生效,embedding/OCR 仅获得 `generation_ms` 计时**。另记 `CallStats` 三字段口径(`generation_ms` 与 `total_latency_ms` 差值 = 波动开销)与 `hedge_cancelled` 标签的遥测 join 用法。
|
||||||
|
2. `README.md`:能力表加"长尾对冲(可选)"一行(三句措辞同上);配置键清单加两键与值域/守卫;`CallStats` 说明处加三字段。
|
||||||
|
3. `.env.example`:`LLM__CALL_DEADLINE_S` 注释行后加 `# LLM__HEDGE__AFTER_S=` 与 `# LLM__HEDGE__MAX_EXTRA=1`(缺省关闭,说明触发语义与成本含义)。
|
||||||
|
4. `research-wiki/findings/2026-09-10-24-hedged-requests-validation.md`:红绿证据、命令与退出码、豁免索引(含 H5 的 v1 单路口径与竞速误贴残留)。
|
||||||
|
5. wiki 注册本计划与 findings(add_entity/add_edge/rebuild_index);独立验证(全新上下文 verifier)与整分支审查在本提交前完成;版本 bump/发布**不在本计划内**。
|
||||||
|
|
||||||
|
## 5. 测试矩阵 → 任务映射
|
||||||
|
|
||||||
|
**设施复用核对(动手前必读)**:`tests/unit/test_retry.py:120-127` 的 `FakeSleep` 只记录不推进时钟——退避推进须用例自带 `async def sleep(s): clock.advance(s)` 闭包;`tests/unit/test_embedding.py:217` 已有 `_ClockAdvancingEmbedTransport`(尝试内推进时钟),批次 D 直接复用;`tests/unit/test_config.py:31-33` 已有 loguru WARNING 捕获 fixture,warning 断言用它(caplog 抓不到 loguru);`tests/unit/test_client.py:134-139` 已有 `InMemoryCache` 命中回路,批次 F 复用;对冲编排用例(`test_hedge.py`)用**真实 loop 钟**(不注入 FakeClock),阈值 0.05s、断言容差 4–10×;取消窗口用 `entered` Event 范式(`test_retry.py:79-89` 既有),禁 sleep 撞窗口。
|
||||||
|
|
||||||
|
| 批次 | 断言(→ 任务) | 落点(精确测试名) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| A 端口加参 | 流式首 token 置位事件;非流式永不置位;`None` 不观测行为不变;漏传 → `TypeError`(钉住必填)(→T1) | `test_openai_compat.py::test_stream_sets_first_token_event`、`test_non_stream_never_sets_first_token_event`、`test_none_first_token_event_keeps_behavior`、`test_first_token_event_is_required_keyword` |
|
||||||
|
| B 三字段 | 三字段默认值(0/0/False);仅旧三参数构造 `CallStats(...)` 不炸;`record_generation` 覆盖/累加语义;`register_hedge` 计数;`snapshot` 带出三字段(→T2) | `test_types.py::test_callstats_hedge_fields_default`、`test_callcontext_record_generation_overwrite_and_accumulate`、`test_callcontext_register_hedge_counts`、`test_snapshot_includes_hedge_fields` |
|
||||||
|
| C chat 计时 | 脚本 [Transient, ok]:退避推进时钟 5s、成功次 transport 推进 0.2s → `generation_ms == 200` 且 `total_latency_ms ≥ 5200`(证明排除 backoff);无对冲时 `hedges == 0`/`hedge_won is False`(→T2) | `test_retry.py::test_generation_ms_excludes_backoff_and_admission`、`test_generation_ms_zero_hedge_flags_without_hedging`(配 `_GenClockTransport` 薄包装:委托 FakeTransport 并在返回前 `clock.advance(delta)`) |
|
||||||
|
| C2 重问覆盖 | 结构化首轮坏 JSON(transport 推进 1s)、重问轮好 JSON(推进 0.2s)→ `generation_ms == 200`(最后一轮覆盖,非累加)(→T2) | `test_client.py::test_generation_ms_structured_last_round_wins`(复用 `_client(structured_strategy=...)` 与 :129-132 范式;若 `_client` 未暴露 `now` 注入,按其既有模式补 keyword 参数——测试设施非公共面) |
|
||||||
|
| D embedding 计时 | 两批各推进 0.3s → `generation_ms == 600`(批次和)(→T2) | `test_embedding.py::test_generation_ms_sums_batch_transports`(复用 `_ClockAdvancingEmbedTransport`) |
|
||||||
|
| E ocr 计时 | 单次 transport 推进 0.4s → `generation_ms == 400`(→T2) | `test_ocr_client.py::test_generation_ms_single_transport_call`(同款薄包装,推进 `test_ocr_client.py:344` 那份本地 FakeClock——历史坑:不是 contracts 那份) |
|
||||||
|
| F 缓存命中 | 第二次同 key 调用 `generation_ms == 0` 且 `attempts == 0` 且 `cache_hit is True`(→T2) | `test_client.py::test_cache_hit_generation_ms_zero`(复用 :134-139 回路) |
|
||||||
|
| G 对冲编排 | 见下表(→T3) | `tests/unit/test_hedge.py`(新建) |
|
||||||
|
| H 配置守卫 | 两键 env 解析(3 段键不被当源字段、`HEDGE` 在保留段);非法值四路(env/直接构造/`dataclasses.replace`/client 直传)→ `ValueError`;`after_s ≥ min(timeout_s)` → ValueError;`after_s ≥ min(ttft)` → warning;单源 → warning;`after_s ≥ call_deadline_s` → ValueError;`max_extra` 0/"x" → ValueError、2/3 → warning 且生效 1(→T3) | `test_config.py::test_hedge_keys_from_env_skip_source_loader`、`test_hedge_after_s_domain_four_paths`、`test_hedge_guard_below_min_timeout_raises`、`test_hedge_guard_ttft_warns`、`test_hedge_guard_single_source_warns`、`test_hedge_guard_deadline_conflict_raises`、`test_hedge_max_extra_v1_cap`;client 直传入口校验 `test_client.py::test_client_hedge_params_entry_validation` |
|
||||||
|
| I 默认关闭回归 | 不配对冲键时 `tests/unit` + `tests/contracts` 全绿,**不改一行既有断言**(→T3 门) | 全套件 |
|
||||||
|
|
||||||
|
批次 G(`test_hedge.py`)用例全表——设施:两源 scope(`s1`/`s2`),event-driven 假 transport(每源一对 `entered`/`release` Event + 可脚本化"先置 first_token 再挂起"),真实 loop 钟,`hedge_after_s=0.05`:
|
||||||
|
|
||||||
|
| 测试名 | 断言(设计 §8 矩阵编号) |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_non_stream_triggers_hedge_and_fast_leg_wins` | s1 挂起、s2 即时成功:总时长 < 10× 阈值(①);`hedges==1`、`hedge_won is True`;`generation_ms < total_latency_ms` 且 ≥ s2 实际 transport 耗时下界(⑩:不含触发前等待) |
|
||||||
|
| `test_stream_triggers_only_when_first_token_absent` | 两例:首 token 未至 → 触发;假 transport 先 `first_token_event.set()` 再挂起 → **不触发**,`attempts==1`(①) |
|
||||||
|
| `test_hedge_goes_to_other_source` | 对冲请求落在 s2(transport.calls 断言);`logical_call_id` 两行一致(②⑤) |
|
||||||
|
| `test_hedge_silent_when_no_candidate` | s2 permit 预占满 → 不对冲:`hedges==0`、`attempts==1`、原请求放行后正常成功(③) |
|
||||||
|
| `test_hedge_silent_when_single_source` | 单源 scope:运行期自然静默,行为与不配阈值逐字相同(②) |
|
||||||
|
| `test_winner_settles_actual_loser_keeps_est` | memory limiter:赢家源 `tpm_used == 真实 usage`,输家源 `tpm_used == est`(S3 格);调用结束后两源 `inflight == 0`(④) |
|
||||||
|
| `test_loser_row_labelled_hedge_cancelled` | 假 emitter:输家 attempt 行 `error=="hedge_cancelled"`,赢家行无 error;两行 `logical_call_id` 相同;无 `terminal_failure` 行(④⑤) |
|
||||||
|
| `test_loser_does_not_feed_breaker` | memory gate:挂起源 `failure_count` 不变、健康喂数无 `ok=False`;赢家照常 `record_success`(④,§3 关键判断) |
|
||||||
|
| `test_attempts_two_and_no_task_leak` | `call_stats.attempts == 2`;返回后 `asyncio.all_tasks()` 无本调用残留任务(⑤) |
|
||||||
|
| `test_external_cancel_cancels_both_legs` | 两路均挂起,`entered` 双置位后 `task.cancel()`:`CancelledError` 上抛;两行 attempt 均 `"cancelled"`(标记只在赢家产生后置,外部取消无 `hedge_cancelled`);两 permit 释放(⑦) |
|
||||||
|
| `test_deadline_cuts_hedged_tree` | client 级 `call_deadline_s=0.2` + 两路挂起 → `CallDeadlineExceeded`;两 permit 释放(⑧) |
|
||||||
|
| `test_primary_late_success_wins_back` | s1 挂 0.3s 后成功、s2 对冲路挂起:对冲已触发但原路先完成 → `hedge_won is False`、`generation_ms` 为原路时长、s2 行 `hedge_cancelled`(⑩ 取快者) |
|
||||||
|
| `test_both_fail_counts_budget_once` | 两路 Transient:`max_attempts=2` 时恰进第二轮(两败只计一次);最终 `retry_exhausted` 在第二轮两败后(H6) |
|
||||||
|
| `test_both_429_refund_no_budget` | 两路 429:不耗预算(`max_attempts=1` 不抛 `retry_exhausted`),stall 账退还——小 `stall_window_s` 下终局 `reason=="stalled"` 而非 `"retry_exhausted"` |
|
||||||
|
| `test_mixed_429_and_failure_counts_budget` | 一路 429 一路 Transient → 计一次预算、不退还 stall 账(§3.4 `_combine_failures`) |
|
||||||
|
|
||||||
|
命令(全部 `conda run -n PolyGateway`,禁接管道):`pytest tests/unit/test_openai_compat.py -q`、`pytest tests/unit -q`、`pytest tests/unit/test_hedge.py -q`、`pytest tests/unit tests/contracts -q`、`make lint`。真实 Redis/网关 slow 用例本计划不新增、不跑,由发布清单第 4 步按 diff 交集选子集(本 diff 触及 retry/限流结算路径,Redis 时间语义变体届时在交集内)。
|
||||||
|
|
||||||
|
## 6. 阻塞矩阵与交接
|
||||||
|
|
||||||
|
| 触发条件 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 需要新增本计划外的公共键/端口方法/遥测列/异常类 | **停下上报**(设计 §9 边界之外即未批准) |
|
||||||
|
| `asyncio.current_task().cancelling()` 在目标 Python 版本语义不符 | 3.12 语义同 136 探针已验证的取消计数;若实测不符,改用"取消标志位置于 `_attempt_hedged` 局部"方案并记入 findings,不得吞取消 |
|
||||||
|
| 两路同时成功的竞速在测试中无法确定性构造 | 用双 Event 栅栏(两 transport 都等同一放行事件)构造;仍不可得则记入 findings 豁免索引,不得删"原路优先"断言 |
|
||||||
|
| 批次 G 计时断言在 CI 机器抖动 | 只断言下界与相对比较(`generation_ms < total_latency_ms`、总时长 < 10× 阈值),不断言精确值;精确值断言只在注入钟批次(C–F) |
|
||||||
|
| 想顺手让对冲路再触发梯次对冲 | **不做**(H5:v1 单路;`hedge` 任务恒传 `first_token_event=None`) |
|
||||||
|
| 想把输家记进熔断/健康分 | **不记**(§3 关键判断:挂起 ≠ 源死亡);运维面靠 `hedge_cancelled` 遥测行统计 |
|
||||||
|
| `hedge_max_extra > 1` 应 warning 还是 ValueError 存疑 | 本计划取 warning(§5 值域 [1,3] 与 H5 "v1 只允许 1" 的并存解);若人类审定应 ValueError,改 `check_hedge_assembly` 一处 + 批次 H 一条断言 |
|
||||||
|
| 发现 embedding/OCR 也想加对冲参数 | **不加**(非目标 A,H7);登记为后续 issue |
|
||||||
|
|
||||||
|
交接物:4 个提交、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
|
||||||
|
|
||||||
|
## 7. 自审
|
||||||
|
|
||||||
|
| 检查 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 路径/行号/签名是否可执行无 TBD | 是——接入点均现读:`ports.py:51-60`、`openai_compat.py:426/552/573-575/646`、`retry.py:244/248-256/270/291-300/327-334`、`admission.py:171-213/224`、`types.py:296-318/321-360`、`config.py:57/190/201/396/405/695`、`client.py:239/245-247/254-273/511`、`embedding.py:373/386`、`ocr.py:397`、六个 fake transport 精确行号 |
|
||||||
|
| 是否复用而非重造 | 是——取消结算 S3 格、准入全链路、`settle_and_release`、`asyncio.wait` 范式、`ensure_call_deadline` 值域校验、`_ClockAdvancingEmbedTransport`/loguru 捕获 fixture/InMemoryCache 回路全部复用;新增仅 1 测试文件 + 2 配置键 + 3 字段 |
|
||||||
|
| 先失败后通过证据点 | 是——T1 批次 A(TypeError)、T2 批次 B–F(字段缺失/恒 0)、T3 批次 G/H(未实现/键未识)均先红 |
|
||||||
|
| 取消与降级铁律 | 取消穿透路径显式收口不吞没;准入失败静默(设计 §4.3 批准);后端不可用照常冒泡;无 shield/后台任务 |
|
||||||
|
| 反 gold-plating | 四分类/熔断语义/429 分账/Lua/deadline.py/遥测列/embedding-OCR 对冲/分位数/同源/per-call 参数一律不碰;`hedge_max_extra` 不下传 RetryMW(v1 无消费者) |
|
||||||
|
| 跨任务签名一致 | `first_token_event`(T1 端口 → T3 接线)、`generation_sink`/`record_generation`(T2 定义,T3 编排消费)、`check_hedge_assembly`(config 定义,client 消费)三处接缝均在 §3 写出实际代码 |
|
||||||
|
| 残余诚实标注 | 输家 est 保留 ≠ 上游真实计费计量;非流式误对冲慢生物理不可分;竞速误贴标签可接受(设计 §4.5);`hedge_max_extra` v1 生效口径取 warning(§6 阻塞矩阵已列复核点) |
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-09-11-streaming-stall
|
||||||
|
title: "1.3.8 流式中段沉默:计量口径与对冲实施计划"
|
||||||
|
date: 2026-09-11
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.8 流式中段沉默:计量口径与对冲实施计划
|
||||||
|
|
||||||
|
> 设计:`research-wiki/designs/2026-09-11-streaming-stall-design.md`,**人类已批准 + 独立审查补遗 B1-B3/N1-N9 全处置并入,计划期补遗 B4(PM 2026-09-11 裁决)已并入 §6/§11**;本计划一切语义以该设计为准,不另行发明。
|
||||||
|
> 计划审核门:Claude 自审 + 独立模型审查;plan 无人类门,审毕直接执行。
|
||||||
|
> 目标:① P1(issue #27)`generation_ms` 口径重定义为**净生成时间**(墙钟 − Σ 超阈值中段沉默),`CallStats` 增 `stall_ms`/`stall_count` 归因字段,遥测表加两列;② P2(issue #28)`{SCOPE}__HEDGE__STALL_AFTER_S` 中段沉默对冲(独立生效,默认关闭);③ 缺省行为(两键均不设)逐字等于 1.3.7。
|
||||||
|
> 方案:P1 = 设计 §4.2 方案 1B'(净口径直接写进 `generation_ms`);P2 = 设计 §5.2 方案 2B(增量 chunk Event 等待超时触发,复用 1.3.7 对冲编排/记账/遥测全部语义)。
|
||||||
|
> 技术:Python 3.12+、asyncio 任务组编排(1.3.7 既有 `_attempt_hedged` 骨架扩两阶段触发窗)、frozen dataclass、pytest + 事件驱动假 transport + FakeClock/FakeSleep(净口径断言)与真实 loop 钟(对冲触发,4–10× 余量)、ruff、import-linter。
|
||||||
|
> 基线: main `a28d944`(1.3.7 已发布)+ 设计提交 `97de410`;分支 `feature/1.3.8-streaming-stall`。
|
||||||
|
|
||||||
|
**范围纪律**:不改四分类/熔断语义/429 分账/限流 Lua/`deadline.py`/`Permit` 端口/看门狗 `streaming.py` 语义;不做流式透传、内容拼接、自适应阈值、per-call 沉默阈值覆盖、embedding/OCR 对冲(设计 §8 非目标 A–F);不加墙钟/净双口径并存字段(`active_generation_ms` 否决);`generation_ms` **不进**遥测列(YAGNI,墙钟 = `generation_ms + stall_ms` 还原);除设计 §7 已批准的三次端口变更外不动任何端口签名。
|
||||||
|
|
||||||
|
## 1. 边界、授权与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 固定边界 |
|
||||||
|
| --- | --- |
|
||||||
|
| 唯一 writer | 一工作区一 writer;父会话负责前台委派与审核派发。1.3.8 合并/发布授权沿用 1.3.X 惯例;跨到 1.4 或设计 §9 之外的新公共面须停下确认 |
|
||||||
|
| 公共面 | 只做设计 §7/§9 已批准项:`Transport.complete` 加 `content_event` 必填 kw(第二次端口变更)、`TransportResult` 增 `stall_ms`/`stall_count`、`TelemetryRecorder.record_llm_call` 加两参(第三次端口变更)、`CallStats` 增 `stall_ms`/`stall_count`、两配置键 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`(默认 1000,可调)/`{SCOPE}__HEDGE__STALL_AFTER_S`(未设=关闭)、`GatewayClient.__init__` 对应 keyword-only 参数、遥测表 `stall_ms`/`stall_count` 两列(可空)。`generation_ms` 语义重定义为净口径(已批准例外,披露义务见 T5)。**不新增其它键/端口方法/遥测列/异常类** |
|
||||||
|
| 三次端口变更 | ①`Transport.complete` 加 `content_event`(必填、无默认,与 `first_token_event` 同款约定);②`TransportResult` 增两字段(带默认 None);③`record_llm_call` 加 `stall_ms`/`stall_count`(无默认,装配闸判据即本签名)。全部同步点见 §2.2/§2.3 同步清单(真实 grep 结果,非凭记忆) |
|
||||||
|
| 口径钉死(N8) | TransportResult `stall_ms: float \| None`;retry 层 `wall_ms=int((now−gen_started)*1000)` 照旧,`net = max(0, wall_ms − int(stall_ms or 0))`(clamp 防御);`CallStats.stall_ms = int(stall_ms)`(None 透传)——**取整换算全库只此一处**(retry 层),各实现不得另取整 |
|
||||||
|
| 钟同源纪律(N4) | 沉默序列(transport 内 `time.monotonic`)与墙钟(retry 注入 `now`,生产即 `time.monotonic`)生产同钟;**FakeClock 测试用自控 stall 值的假 transport,真实 transport 测试不注入假钟**;对冲触发只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`(1.3.7 §4.1 同款) |
|
||||||
|
| 取消铁律 | 外部取消到达时对冲两路同消并穿透;不 shield、不留后台任务;`_past_hedge_window` 两阶段重构**逐字保留** waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568 既有范式);stall 等待每轮迭代自建 waiter |
|
||||||
|
| Event 并发纪律(N3) | ①超时到期后触发对冲前必须复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 `content_event` 恒 None(1.3.7 首 token 约定延伸,v1 不梯次);④等待者唯一(编排),Event 无需多等待者语义 |
|
||||||
|
| 排除条款 | 非流式 / 增量 chunk <2 → `stall_ms`/`stall_count` 为 None 且 `generation_ms` 不退减(= 原墙钟);chunk = 任何增量产出(`content` 或 `reasoning_content` 均计),空 chunk/usage 帧/SSE keepalive 不计时;对冲输家的沉默不进赢家快照(已完成落选者的沉默在其 attempt 遥测行;被取消输家无 TransportResult、`hedge_cancelled` 行 stall 恒 None) |
|
||||||
|
| 降级方向 | 准入失败 → 静默放弃对冲(不抛错不硬等);限流/熔断后端不可用照常冒泡(fail-closed);遥测写失败 warning 降级;遥测两列对既有实例**幂等** ALTER(auto_migrate 先探测后补列,manual 档裁剪+点名告警) |
|
||||||
|
| 证据纪律 | 不打印 `.env`/token/Authorization;不提交 `.pi/`、`tests/outputs/`;测试事件驱动,禁 sleep 撞窗口;红绿证据落 `tests/outputs/138/t<N>/`(pytest 输出文件,不提交) |
|
||||||
|
|
||||||
|
Skill 纪律:T1–T4 行为变更执行 `test-driven-development`(先红后绿证据落本会话工具输出 + `tests/outputs/138/t<N>/` 文件);每次提交执行 `commit`;T5 前执行 `requesting-code-review` 与 `verification-before-completion`(全新上下文 verifier);异常先 `systematic-debugging`。
|
||||||
|
|
||||||
|
**保真校验**:本计划不涉及 `reference/` 迁移(沉默计量与中段对冲为库内自研口径,蓝本即本库 1.3.7 编排与 `streaming.py` 看门狗),保真校验不适用。
|
||||||
|
|
||||||
|
## 2. 文件职责、同步清单与不变接缝
|
||||||
|
|
||||||
|
### 2.1 文件职责
|
||||||
|
|
||||||
|
| 动作 | 精确路径 | 职责 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 修改 | `src/polygateway/ports.py` | `Transport.complete`(:56-66)加 `content_event` 必填 kw + docstring;`TelemetryRecorder.record_llm_call`(:306-344)加 `stall_ms`/`stall_count` 两参 + docstring("36 字段"→"38 字段");`TransportResult` 在 types.py(见下) |
|
||||||
|
| 修改 | `src/polygateway/types.py` | `TransportResult`(:560-586)增 `stall_ms: float \| None = None`/`stall_count: float \| None = None`(追加在 `applied_effort` 后);`CallStats`(:296-324)在 `hedge_won` 后增 `stall_ms: int \| None = None`/`stall_count: int \| None = None`;`_CallContext`(:328-388)`__slots__`/`__init__` 增两私有位、`record_generation` 增 keyword-only `stall_ms`/`stall_count`(默认 None)、`snapshot` 填两字段;`SourceConfig`(:590-700)在 `effort_fallback` 后追加 `stall_threshold_ms: int = 1000` + `__post_init__` 值域校验(正整数、拒 bool) |
|
||||||
|
| 修改 | `src/polygateway/transports/openai_compat.py` | `complete`(:427-473)加 `content_event` 透传两路径;`_complete_stream`(:553-616)chunk 循环(:580-590 邻域)记增量 chunk 时间戳 + 每 chunk `set` content_event(None 跳过),装配处(:597-616 邻域)由序列算 stall 两值;模块级纯函数 `_stall_from_chunk_times`;`_complete_once`(:660-705)接收 content_event 但**永不置位**(docstring 明写),stall 两值恒 None(默认) |
|
||||||
|
| 修改 | `src/polygateway/middleware/retry.py` | `__call__` 入口门(:273-283)改两阈值判定;`_attempt`(:304-400)加 `content_event` 私有 kw、sink 元素改三元组、成功分支取整换算 + `_emit` 传 stall;`_attempt_hedged`(:415-531)Phase 1/4 接线 content_event、Phase 5 汇合复用;`_past_hedge_window`(:533-562)两阶段重构;`_record_generation`/`_register_hedge` 邻域(净口径计算收敛一处);`__init__` 加 `stall_after_s` kw |
|
||||||
|
| 修改 | `src/polygateway/config.py` | `_RESERVED_SEGMENTS`(:57)加 `"STREAM"`;`GatewaySettings`(:192-196 后)加 `stall_threshold_ms: int = 1000`、`hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传新参;`from_env`(:398-427)`**_load_stream(...)`;新增 `_load_stream`(:745 `_load_hedge` 邻域);`check_hedge_assembly`(:772-849)加 `stall_after_s` 参与三守卫;`EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`)加 B4 对冲键 warning |
|
||||||
|
| 修改 | `src/polygateway/client.py` | `__init__`(:240 邻域)加 `hedge_stall_after_s`/`stall_threshold_ms` keyword-only 参数,守卫(:252-257)扩传;`stall_threshold_ms` 经 `dataclasses.replace` 分发进各 SourceConfig;RetryMW 构造(:264-283)传 `stall_after_s`;`from_settings`(:485-526)透传 |
|
||||||
|
| 修改 | `src/polygateway/middleware/telemetry.py` | `_AttemptUsage`(:199-236)增两字段;`emit_attempt`(:396-447)加 `stall_ms`/`stall_count` kw(默认 None)+ 转发;`emit_cache_hit`/`emit_terminal_failure`(:449-540)显式传 None;`_record`(:560-665)加两参并在 :618 调用点透传 |
|
||||||
|
| 修改 | `src/polygateway/telemetry/schema.py` | 五编辑点:`SQLITE_DDL`/`PG_DDL` 末尾(`total_latency_ms` 后)加两列;`SQLITE_BACKFILL`(:113-141)追加两项;`_PG_BACKFILL_DECLS`(:144-166)追加两项(保序对齐);`COLUMNS`(:177-215)追加两名;`telemetry_schema_sql`(:323-372)由常量派生**同源再生成**(本函数零编辑,列数断言随动);模块头 "36/37" 计数 docstring 同步 |
|
||||||
|
| 修改 | `src/polygateway/telemetry/sqlite.py` / `postgres.py` | `record_llm_call`(:145/:411)为 `**fields` 形态,**零签名改动**自动拾取新列;docstring "36 字段冻结签名"→"38 字段";补列/裁剪逻辑由 schema 常量驱动,零代码改动 |
|
||||||
|
| 修改 | `tests/unit/test_openai_compat.py` | 批次 A(content_event 四用例 + stall 纯函数用例);`_complete` helper(:82-93)同步签名 |
|
||||||
|
| 新建 | `tests/unit/test_stall_hedge.py` | 批次 D 中段对冲编排用例(与 1.3.7 `test_hedge.py` 同设施范式,不动该文件既有断言) |
|
||||||
|
| 修改 | `tests/unit/test_types.py` `test_retry.py` `test_config.py` `test_client.py` `test_telemetry.py` `test_ports.py` | 批次 B/C/E 与 fake 同步(§2.2/§2.3);`test_telemetry.py` 冻结列清单按 T3 扩展(:53-99、:265、:295-297、:374 等断言点) |
|
||||||
|
| 修改 | `tests/unit/test_backpressure.py:213` `test_ports.py:75` `test_client.py:224/1827` `test_retry.py:82/112` `test_hedge.py:55` `tests/integration/test_redis_cross_connection.py:78` `tests/e2e/conftest.py:285` `tests/unit/test_live_evidence.py:280/1270` `tests/unit/test_usage_source_domain.py:142` | T1 端口签名同步(e2e 包装**转发** `content_event`;详见 §2.2 全量清单) |
|
||||||
|
| 修改 | `tests/integration/test_postgres_telemetry.py` | `_EXPECTED_COLUMNS`(:33-77 邻域)追加两列;`_CALL_OBSERVABILITY_COLUMNS`(:84)为派生式自动纳入,manual 档告警比对用例随动核对 |
|
||||||
|
| 修改 | `CHANGELOG.md`、`README.md`、`.env.example`、`research-wiki/ARCHITECTURE.md`、`research-wiki/ROADMAP.md` | T5 文档与登记(§4 T5) |
|
||||||
|
| 新建 | `research-wiki/findings/2026-09-11-streaming-stall-validation.md` | 红绿、命令、豁免索引,≤300 行 |
|
||||||
|
|
||||||
|
**不改**:`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`backends/**`/`embedding.py`/`ocr.py` 链路本体(仅其 emit_attempt 调用点经默认 None 自然兼容,零改动)/`tests/contracts/**`;`StallClock`/`backoff_delay`/`settle_and_release`/`_combine_failures` 逐字不动。若必须突破本清单,先说明最小原因交父会话核定。
|
||||||
|
|
||||||
|
### 2.2 T1 同步清单:`Transport.complete` 加 `content_event`(真实 grep 结果)
|
||||||
|
|
||||||
|
执行于 2026-09-11,命令:`grep -rn "def complete\|first_token_event" tests/ src/ | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志后)。`content_event` 与 `first_token_event` 同签名形态(必填 kw、`asyncio.Event | None`),下列每一处都要同步。
|
||||||
|
|
||||||
|
**实现/协议(11 处 `def complete`)**:
|
||||||
|
|
||||||
|
| # | 位置 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `src/polygateway/ports.py:56`(Transport 协议) | 加 `content_event` 必填 kw + docstring(None = 不观测增量 chunk;非流式永不置位;对冲路恒 None) |
|
||||||
|
| 2 | `src/polygateway/transports/openai_compat.py:427`(真实实现) | 加参透传 `_complete_stream`(:469)/`_complete_once`(:473)两路径 |
|
||||||
|
| 3 | `src/polygateway/transports/openai_compat.py:560`(`_complete_stream`) | 加参;每增量 chunk 置位(:587-588 首 token 块邻域扩展为每 chunk) |
|
||||||
|
| 4 | `src/polygateway/transports/openai_compat.py:666`(`_complete_once`) | 接收但**永不置位**,docstring 明写 |
|
||||||
|
| 5 | `tests/unit/test_retry.py:82`(FakeTransport) | 签名加 `content_event`,不给默认值(test_retry.py:71-73 既有注释同款纪律) |
|
||||||
|
| 6 | `tests/unit/test_retry.py:112`(第二个 fake) | 同上;:123 调用处转发 |
|
||||||
|
| 7 | `tests/unit/test_hedge.py:55`(事件驱动假 transport) | 加参并**逐次记录身份**(:50/:59 同款,供"对冲路恒 None"断言) |
|
||||||
|
| 8 | `tests/unit/test_backpressure.py:213` | 加参忽略 |
|
||||||
|
| 9 | `tests/unit/test_ports.py:75` | 加参忽略 |
|
||||||
|
| 10 | `tests/unit/test_client.py:224` 与 `:1827` | 加参忽略 |
|
||||||
|
| 11 | `tests/integration/test_redis_cross_connection.py:78` / `tests/e2e/conftest.py:285` | e2e/conftest 包装必须**转发** `content_event`(:294/:306 同款);integration fake 加参忽略 |
|
||||||
|
|
||||||
|
**调用点(11 处)**:
|
||||||
|
|
||||||
|
| # | 位置 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `src/polygateway/middleware/retry.py:275`(单路路径) | T1 先传字面 `content_event=None`;T4 接线真事件 |
|
||||||
|
| 2 | `src/polygateway/middleware/retry.py:344`(`_attempt` 内) | 透传 `_attempt` 的 `content_event` kw |
|
||||||
|
| 3 | `src/polygateway/middleware/retry.py:436`(对冲原路) | T4 接线:流式且 `stall_after_s` 已设才给真事件,否则 None |
|
||||||
|
| 4 | `src/polygateway/middleware/retry.py:484`(对冲路) | 恒 `content_event=None`(N3③,v1 不梯次) |
|
||||||
|
| 5 | `src/polygateway/transports/openai_compat.py:469/:473` | 两路径透传(见实现 #2) |
|
||||||
|
| 6 | `tests/unit/test_openai_compat.py:84/:93`(`_complete` helper) | helper 加 `content_event=None` 默认转发(测试设施,与生产端口"必填无默认"约定不冲突) |
|
||||||
|
| 7 | `tests/unit/test_live_evidence.py:280` 与 `:1270` | 两直调传 `content_event=None`;不传则必填 kw 报 TypeError,unit 门必红 |
|
||||||
|
| 8 | `tests/unit/test_usage_source_domain.py:142` | 直调传 `content_event=None` |
|
||||||
|
|
||||||
|
### 2.3 T3 同步清单:`TelemetryRecorder.record_llm_call` 加两参(真实 grep 结果)
|
||||||
|
|
||||||
|
执行于 2026-09-11,命令:`grep -rn "record_llm_call" tests/ src/ --include="*.py" | grep -v __pycache__`(剔除 `tests/outputs/` 历史日志)。
|
||||||
|
|
||||||
|
| # | 位置 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `src/polygateway/ports.py:306`(协议) | 末尾追加 `stall_ms: int \| None`、`stall_count: int \| None`(**无默认值**,与既有 36 参同款约定);docstring "36 字段"→"38 字段"。**本签名是装配闸唯一事实源**,漏改即全部 `**fields` recorder 在装配期 `signature.bind` 炸——这是有意的(1.3.5 先例) |
|
||||||
|
| 2 | `src/polygateway/middleware/telemetry.py:618`(唯一调用点 `_record`) | 显式传 `stall_ms=`/`stall_count=`;`_record` 签名加两参 |
|
||||||
|
| 3 | `src/polygateway/telemetry/sqlite.py:145` | `**fields` 形态,零签名改动;行取值按探测列裁剪,新列由 schema 常量驱动自动纳入;docstring 计数同步 |
|
||||||
|
| 4 | `src/polygateway/telemetry/postgres.py:411` | 同上 |
|
||||||
|
| 5 | `tests/unit/test_ports.py:102`(`_DummyRecorder`,**显式全签名**) | +2 参(它镜像协议全签名,不加则与本协议脱节) |
|
||||||
|
| 6 | `tests/unit/test_telemetry.py:3327/:3349/:3354`(装配闸**负例** fake,故意残缺签名) | **不改**——协议加参后它们仍不满足签名,负例断言照常通过;T3 绿态核对这三条仍绿 |
|
||||||
|
| 7 | `**fields` fake(13 处):`test_ocr_client.py:149`、`test_pricing.py:131`、`test_openai_compat.py:106`、`test_embedding.py:246`、`test_cache.py:562`、`test_client.py:528/955/968/1516/1842`、`test_usage_source_domain.py:71`、`test_telemetry.py:1191/2059` | **零改动**自动兼容;批次 C 断言落行时用其中一份捕获 fields |
|
||||||
|
| 8a | 发射器 fake:`tests/unit/test_backpressure.py:233`(`*args, **kwargs` 形态) | **零改动**(`emit_attempt` 新参默认 None,见 §3.4 取舍) |
|
||||||
|
| 8b | 发射器 fake:`tests/unit/test_hedge.py:122`(RecordingEmitter,显式全签名 8 kw,无 `**kwargs`) | **列入 T3 同步清单**(审查 B1):加 `stall_ms=None, stall_count=None` 两 kw——T3 后 `_emit` 显式转发两 kw,不加则 TypeError;"新参默认 None"只护调用方、护不住被调的显式签名 fake |
|
||||||
|
|
||||||
|
## 3. 跨任务接口(可执行定义,禁止占位)
|
||||||
|
|
||||||
|
### 3.1 T1:观测原语与端口契约(P1 底层 + P2 信号源)
|
||||||
|
|
||||||
|
**端口签名**(ports.py:56-66,顺序追加在 `first_token_event` 后,**必填、不设默认值**,同款约定):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def complete(
|
||||||
|
self, *, messages, source, stream, overlay, call_id, reasoning_effort,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
) -> TransportResult: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
docstring 补:`None` = 调用方不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content` 或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk/usage 帧/SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 None(v1 不梯次,N3③)。
|
||||||
|
|
||||||
|
**`TransportResult`**(types.py:560-586,追加在 `applied_effort` 后):
|
||||||
|
|
||||||
|
```python
|
||||||
|
stall_ms: float | None = None
|
||||||
|
"""超阈值相邻增量 chunk 沉默总额(毫秒,未取整;换算唯一处在 retry 层,设计 §4.2 N8)。"""
|
||||||
|
stall_count: float | None = None
|
||||||
|
"""超阈值相邻间隔个数;非流式/增量 chunk <2 → None(排除条款照 vLLM,设计 §4.2)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**`SourceConfig.stall_threshold_ms`**(types.py `effort_fallback` 后追加,`int = 1000`):**这是钉死的传入路径裁决**。任务给的两条候选中:走 `complete` 新 kw = 设计 §7 未批准的第四次端口变更(且 §2.2 清单 22 处全部二次翻动,计量口径被耦进端口契约),**否决**;走 SourceConfig 字段与 `inter_token_timeout_s`(types.py:612)同先例——transport 在 `_complete_stream` 直接读 `source.stall_threshold_ms`,端口零新增,watchdog 成对校验的 `_validate_watchdog`(:676-681)同款落点加值域校验(正整数、拒 bool)。scope 级键 → per-source 字段的分发由装配层 `dataclasses.replace` 完成(T4,§3.5),运行时零额外接线。符合"决策逻辑与状态存储分离":字段是纯配置不可变数据,计算逻辑在 transport,编排判定在 retry。
|
||||||
|
|
||||||
|
**`_complete_stream` 原语**(:580-590 max_gap 循环邻域):循环内已有逐 chunk 迭代,追加两件零判定动作——`chunk_times.append(now)`(或等价的增量累计,见下)与 `content_event.set()`(None 跳过);**热路径不做阈值判定**(设计 §3 派生原则)。stall 计算收敛为模块级纯函数(P6):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _stall_from_chunk_times(times: list[float], threshold_ms: int) -> tuple[float | None, float | None]:
|
||||||
|
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
|
||||||
|
|
||||||
|
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
|
||||||
|
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
|
||||||
|
"""
|
||||||
|
if len(times) < 2:
|
||||||
|
return None, None
|
||||||
|
total = 0.0
|
||||||
|
count = 0
|
||||||
|
for prev, cur in zip(times, times[1:]):
|
||||||
|
gap_ms = (cur - prev) * 1000
|
||||||
|
if gap_ms > threshold_ms:
|
||||||
|
total += gap_ms
|
||||||
|
count += 1
|
||||||
|
return (total, float(count)) if count else (0.0, 0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:无超阈段但 chunk ≥2 时返回 `(0.0, 0.0)` 而非 None——"观测过且零沉默"与"无从观测"(None)是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。`_complete_stream` 装配处(:597-616 邻域)填 `stall_ms=`/`stall_count=`;`_complete_once` 走默认值 None。`max_inter_token_ms` 原语义一字不动。
|
||||||
|
|
||||||
|
### 3.2 T2:净口径记账(P1 落账)
|
||||||
|
|
||||||
|
`CallStats`(types.py:296-324,`hedge_won` 后追加):
|
||||||
|
|
||||||
|
```python
|
||||||
|
stall_ms: int | None = None
|
||||||
|
"""赢家路超阈值中段沉默总额(净口径归因;None = 非流式/chunk<2/无赢家)。墙钟 = generation_ms + stall_ms。"""
|
||||||
|
stall_count: int | None = None
|
||||||
|
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则,调研 §B2)。"""
|
||||||
|
```
|
||||||
|
|
||||||
|
`_CallContext`(:328-388):`__slots__`/`__init__` 加 `_stall_ms`/`_stall_count`(初值 None);`record_generation` 增 keyword-only `stall_ms: int | None = None, stall_count: int | None = None`(chat 赢家路传入;embedding/ocr 既有调用零改动,恒 None 即"无沉默观测");`snapshot` 填两字段。
|
||||||
|
|
||||||
|
**retry 层换算唯一处**(N8):`_attempt` 成功分支在现有 sink append 处(retry.py:344-347 邻域)把 sink 元素从 `int` 升级为三元组:
|
||||||
|
|
||||||
|
```python
|
||||||
|
wall_ms = int((self._now() - gen_started) * 1000)
|
||||||
|
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms) # 取整只此一处
|
||||||
|
stall_count_i = None if result.stall_count is None else int(result.stall_count)
|
||||||
|
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
|
||||||
|
```
|
||||||
|
|
||||||
|
净口径计算收敛进共享守卫 `_record_generation`(单路)与 `_register_hedge`(对冲赢家)共同调用的一个内部换算:`net = max(0, wall_ms - (stall_ms_i or 0))`(clamp 防御,N4),然后 `context.record_generation(net, accumulate=False, stall_ms=stall_ms_i, stall_count=stall_count_i)`。三条消费路径:
|
||||||
|
|
||||||
|
| 路径 | 位置 | 口径 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 单路成功 | `__call__` :283-288 邻域(`generation_sink` 非空分支) | net + stall 两值进快照 |
|
||||||
|
| 对冲赢家 | `_register_hedge`(winner_sink 非空) | 赢家路 net + 赢家路 stall 两值;**输家沉默不进赢家快照**(设计 §4.2 排除条款) |
|
||||||
|
| 两败(无赢家) | `_register_hedge(hedge_won=False, winner_sink=None)` | 不记 generation、**stall 保持 None**(无归属);两路各自的沉默在各自 attempt 遥测行 |
|
||||||
|
|
||||||
|
非流式/chunk<2:TransportResult stall 恒 None → `net = wall`(不退减),快照 stall 两值 None(设计 §4.2 排除条款原样落地,无需分支——None 透传天然达成)。
|
||||||
|
|
||||||
|
### 3.3 T2:FakeClock 纪律下的测试设施
|
||||||
|
|
||||||
|
净口径断言用 `test_retry.py:120-127` 既有 FakeSleep/FakeClock 范式 + **自控 stall 假 transport**:fake 委托现有 FakeTransport,返回前 `clock.advance(wall)` 并在 TransportResult 上填**固定** `stall_ms`/`stall_count`(假 transport 自己报数,不经过真实计时)——真实 transport(`openai_compat` 内部 `time.monotonic`)**不注入假钟**(N4)。精确值断言只允许在这类注入钟 + 自控 stall 的用例里;真实 loop 钟用例只断下界与相对比较(1.3.7 §6 同款纪律)。
|
||||||
|
|
||||||
|
### 3.4 T3:遥测两列(P1 归因进库)
|
||||||
|
|
||||||
|
**schema.py 五编辑点**(顺序与 1.3.5 十列同款纪律;新列全部可空、无默认值、追加在末尾——旧行 NULL 表达"补列之前根本没记过这件事",不回填):
|
||||||
|
|
||||||
|
| 编辑点 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| ① `SQLITE_DDL`/`PG_DDL` 末尾 | `total_latency_ms` 后加 `stall_ms INTEGER` / `stall_count INTEGER`(PG 同名 `INTEGER`;**不用 REAL**——落库值是 retry 层已取整的 int,口径与 N8 一致) |
|
||||||
|
| ② `SQLITE_BACKFILL`(:113-141) | 追加 `("stall_ms", "INTEGER")`、`("stall_count", "INTEGER")` |
|
||||||
|
| ③ `_PG_BACKFILL_DECLS`(:144-166) | 追加同名两项,与 ② 逐项保序对齐(两路径物理列序不许分叉) |
|
||||||
|
| ④ `COLUMNS`(:177-215) | 末尾追加 `"stall_ms"`, `"stall_count"` |
|
||||||
|
| ⑤ `telemetry_schema_sql`(:323-372) | **零编辑**——由 ②③ 常量派生,同源再生成天然达成;列数断言测试随动(38) |
|
||||||
|
|
||||||
|
**协议与发射器**:ports.py:306 协议 +2 参(§2.3 #1);`_record` +2 参并在 :618 透传;`_AttemptUsage` +2 字段(默认 None;`of()` **不从 response 取**——LLMResponse 不增顶层字段,设计 §11-B2 裁决 (b));`emit_attempt` 加 keyword-only `stall_ms: int | None = None, stall_count: int | None = None` 显式转发。**默认值取舍**:与 `reasoning_applies` 不同——stall 漏传落 None 是**合法事实**("非流式/无沉默观测"),不是"没表态",默认 None 语义安全且保住约 50 处既有 `emit_attempt` 关键字调用点零改动(反 gold-plating);chat 成功路径漏传会被批次 C 落行断言当场抓住。`emit_cache_hit`/`emit_terminal_failure` 显式传 None(缓存命中与终态行无 transport 观测)。
|
||||||
|
|
||||||
|
**retry 侧 `_emit`**(:665-700):加 `stall_ms`/`stall_count` kw(默认 None);`_attempt` 成功分支把 §3.2 已算好的 `stall_ms_i`/`stall_count_i` 传入;失败/取消分支不传(恒 None——被取消输家无 TransportResult,`hedge_cancelled` 行 stall 恒 None,其存在性由该行表达,设计 §11-B2)。embedding.py:532/ocr.py:611 调用点零改动(默认 None 即正确语义)。
|
||||||
|
|
||||||
|
### 3.5 T4:配置两键 + 守卫 + 装配分发
|
||||||
|
|
||||||
|
**`STREAM__STALL_THRESHOLD_MS`**(计量阈值,设计 §6 第一行):
|
||||||
|
|
||||||
|
| 项 | 精确定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| 保留段 | :57 `_RESERVED_SEGMENTS` 加 `"STREAM"`(防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,1.3.7 `HEDGE` 同款);3 段键天然跳过 `_load_sources`(:427-431 段数判据) |
|
||||||
|
| loader | 新增 `_load_stream(scope, env)`(:745 `_load_hedge` 邻域):`_first` + `_cast(..., "int", ...)`,origin 传实际命中键名;值域(正整数)归 `SourceConfig.__post_init__` 一处(字段在 §3.1 已钉死);返回 `{"stall_threshold_ms": ...}`,未设 = 1000 |
|
||||||
|
| 字段 | `GatewaySettings` :196 后加 `stall_threshold_ms: int = 1000` |
|
||||||
|
| 分发 | 装配层把 scope 值经 `dataclasses.replace(source, stall_threshold_ms=...)` 写进每个 SourceConfig:`from_settings`(client.py:485-526)恒分发(settings 值权威);`GatewayClient.__init__` 加 keyword-only `stall_threshold_ms: int | None = None`,**None = 保留各 SourceConfig 自带值**(不 stomp 直构造调用方的 per-source 设置),非 None 才分发;replace 触发`**post_init**` 重跑,值域校验复用同一处 |
|
||||||
|
|
||||||
|
**`HEDGE__STALL_AFTER_S`**(对冲触发,设计 §6 第二行 + §11-N5/B4):
|
||||||
|
|
||||||
|
| 项 | 精确定义 |
|
||||||
|
| --- | --- |
|
||||||
|
| loader | `_load_hedge`(:745-769)扩读 `STALL_AFTER_S`:`_first` + `_cast` + `ensure_call_deadline`(origin 传实际命中键名),返回字典加 `"hedge_stall_after_s"` 键;未设 = None(关闭) |
|
||||||
|
| 字段 | `GatewaySettings` 加 `hedge_stall_after_s: float \| None = None`;`_validate_hedge`(:382-392)扩传 |
|
||||||
|
| 守卫(全部落进 `check_hedge_assembly` :772-849,签名加 keyword-only `stall_after_s` 参;**返回契约钉死(审查 B2)**:返回 `tuple[float | None, float | None]`= (归一化 after_s, 归一化 stall_after_s),`config.py:384` 与 `client.py:252`两处调用点随之解构,`test_config.py:1174/:1184`列入 T4 同步清单(`is None` 断言按新返回形态改写);**两阈值皆 None 才早退**——"同 `after is None` 早退形态"字面执行会让仅 stall 配置绕过守卫②③④,禁止) | ①值域 = `ensure_call_deadline` 同款(有限正数);②`stall_after_s ≥ min over sources of (inter_token_timeout_s or timeout_s)`(有效最小值,与 openai_compat.py:573 回落同款)→ **ValueError**(对冲窗永不开,装配期直接拒绝;stall 场景无非流式残留收益,warning 论证不足,N5);③`stall_after_s ≥ call_deadline_s`(两者皆设)→ ValueError(与 `after_s` 同款,config.py:825-828 邻域);④单源 scope 设阈值 → warning 允许(1.3.7 §5 同款);⑤**B4**:Embedding/OCR scope 装配 warning 落 `EmbeddingSettings.__post_init__`(:865-869 邻域)与 `OcrSettings`(新增 `__post_init__`):内嵌 gateway 的 `hedge_after_s` **或** `hedge_stall_after_s` 非 None → warning"对冲键仅对 chat scope 生效,本 scope 非流式静默退化"(1.3.7 `AFTER_S` 同病顺带补齐,缺陷修复不扩公共面);chat 侧装配 warning 明确不做(per-call stream,装配期无信号,B4 裁决) |
|
||||||
|
| client | `GatewayClient.__init__` 加 `hedge_stall_after_s: float \| None = None` keyword-only,:252-257 守卫调用扩传;RetryMW 构造(:264-283)传 `stall_after_s=` 归一化值(取守卫返回二元组第二元,B2);`from_settings`(:525 邻域)透传 |
|
||||||
|
|
||||||
|
### 3.6 T4:retry 编排改动(P2 唯一实现形态)
|
||||||
|
|
||||||
|
`RetryMW.__init__` 加 keyword-only `stall_after_s: float | None = None`(存 `self._stall_after_s`;守卫在装配层,本类不重复校验——同 `hedge_after_s` 先例)。
|
||||||
|
|
||||||
|
**`__call__` 入口门**(:273-283 重构,设计 §11-B3):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self._hedge_after_s is None and self._stall_after_s is None:
|
||||||
|
# 两阈值均未设: 逐字 1.3.7 单路路径(默认关闭回归门据此成立)
|
||||||
|
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
|
||||||
|
elif self._hedge_after_s is None and not request.stream:
|
||||||
|
# 仅 stall 已设且请求非流式: 中段沉默无非流式概念,逐字走单路(等价未配置)
|
||||||
|
outcome = await self._attempt(..., first_token_event=None, content_event=None, ...)
|
||||||
|
else:
|
||||||
|
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_attempt_hedged` 接线**:Phase 1 原路事件的构造改为——`first_token` 恒建(AFTER_S 已设时要等它;仅 stall 时阶段 2 入口也要先等它,见下);`content_event = asyncio.Event() if (request.stream and self._stall_after_s is not None) else None`,随 `content_event=` 传入 `_attempt`。Phase 4 对冲路恒 `first_token_event=None, content_event=None`(N3③)。Phase 5 赢家裁定/收口/**"先了结的是失败则等另一路"**/`_combine_failures`/两败一次预算**逐字复用**——主路被看门狗掐死(Transient)时落入既有"先了结的是失败"分支等副路,attempt 级记账(breaker `record_failure`/`attempt_fails`/遥测 error 行)照常(设计 §11-N9;不对称声明:掐死喂熔断 vs 输家取消不喂,本版不改任一)。
|
||||||
|
|
||||||
|
**`_past_hedge_window` 两阶段重构**(:533-562;waiter 收口 + `cancelling()` 穿透纪律逐字保留,签名加 `content_event`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def _past_hedge_window(self, primary, first_token, content_event) -> bool:
|
||||||
|
# 阶段 1 首 token 前: AFTER_S 已设才等(设计 §5.2;仅 stall 时此阶段不触发,
|
||||||
|
# 但仍须等首 token 到达才能进阶段 2——否则 pre-TTFT 沉默会被 stall 窗误吞)
|
||||||
|
if self._hedge_after_s is not None:
|
||||||
|
<既有 waiter 形态: asyncio.wait({primary, waiter(first_token)},
|
||||||
|
timeout=self._hedge_after_s, FIRST_COMPLETED),finally 收口 + cancelling 穿透>
|
||||||
|
if not primary.done() and not first_token.is_set():
|
||||||
|
return True # 首 token 超时未至 → AFTER_S 触发
|
||||||
|
else:
|
||||||
|
<同形态 waiter 但无 timeout: 只等 first_token 或 primary 了结>
|
||||||
|
if primary.done():
|
||||||
|
return False
|
||||||
|
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
|
||||||
|
if self._stall_after_s is None or content_event is None:
|
||||||
|
return False
|
||||||
|
while not primary.done():
|
||||||
|
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
|
||||||
|
try:
|
||||||
|
await asyncio.wait({primary, waiter}, timeout=self._stall_after_s,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
finally:
|
||||||
|
<waiter.cancel() + await 收口 + cancelling() 穿透,逐字套用既有范式>
|
||||||
|
if primary.done():
|
||||||
|
return False
|
||||||
|
if content_event.is_set(): # N3① 复检: chunk 与超时同 pass 到达 → 防误触发
|
||||||
|
content_event.clear() # N3② check→clear 之间无 await
|
||||||
|
continue
|
||||||
|
return True # 复检仍未置位 = 真沉默 → 触发对冲
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
红线:阶段 2 的 `clear()` 只许出现在 `is_set()` 为真且两者间无 await 的紧邻位置;每轮 waiter 必须进 finally 收口;外部取消在任一阶段穿透两路(`_attempt_hedged` 既有 `except BaseException` 收口块不动)。
|
||||||
|
|
||||||
|
## 4. 任务与提交点(T1→T2→T3→T4→T5;T3 可并 T2 后)
|
||||||
|
|
||||||
|
> 依赖顺序:T1(端口+原语)→ T2(记账,消费 T1 的 TransportResult 字段)→ T3(遥测,消费 T1/T2 的口径;可在 T2 后并行,因只碰 telemetry/ports/retry._emit 邻域)→ T4(编排+配置,消费 T1 的 content_event 与 T2 的快照口径)→ T5(文档,需前四者行为冻结)。每任务卡:目标 / 改动文件:行号邻域 / 先失败测试清单 / 证据出口 / 验收命令 / 回滚点。所有命令 `conda run -n PolyGateway`,**禁接管道**(退出码失真教训);证据文件不提交。
|
||||||
|
|
||||||
|
### T0:设计 B4 补遗与本计划(本任务,无代码)
|
||||||
|
|
||||||
|
产出:设计 §6 配置表 STALL_AFTER_S 行修正 + §11 补 B4 行(**已完成**,2026-09-11 PM 裁决)+ 本计划。不提交代码。
|
||||||
|
|
||||||
|
### T1 → 提交 1 `feat: add a content event and stall accounting to the transport port`
|
||||||
|
|
||||||
|
- **目标**:观测原语(增量 chunk 时间戳 + content_event)+ 端口契约(`complete` 加 kw、`TransportResult`/`SourceConfig` 增字段)+ stall 纯函数;§2.2 同步清单全量落地。
|
||||||
|
- **改动**:`ports.py:56-66`;`types.py:560-586/590-700`;`openai_compat.py:427-473/553-616/660-705`;`retry.py:275/314/344/436/484`(T1 阶段恒传 None);§2.2 表 11 实现 + 11 调用点全量同步。
|
||||||
|
- **先失败测试清单**(批次 A;先红证据 = TypeError/ImportError/属性缺失,非断言值不符):
|
||||||
|
|
||||||
|
| 测试名 | 断言点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_openai_compat.py::TestContentEvent::test_stream_sets_content_event_per_chunk` | 流式 N 个增量 chunk → event 被置位(每 chunk 一次;计数/逐次 is_set 观测) |
|
||||||
|
| `…::test_reasoning_chunk_counts_as_liveness` | 只流 `reasoning_content` 的流也置位 content_event 且计入 chunk 序列(B1:思考增量同样是存活证据) |
|
||||||
|
| `…::test_non_stream_never_sets_content_event` | 非流式永不置位 |
|
||||||
|
| `…::test_none_content_event_keeps_behavior` | None = 不观测,行为逐字不变 |
|
||||||
|
| `…::test_content_event_is_required_keyword` | 漏传 → TypeError(钉住必填) |
|
||||||
|
| `…::TestStallFromChunkTimes::test_gap_above_threshold_accumulates` | 构造时间戳序列 [0, 0.3, 2.0, 2.1] 阈值 1000ms → stall_ms≈1700、stall_count==1(多段序列 → 总额/次数) |
|
||||||
|
| `…::test_gap_exactly_at_threshold_not_deducted` | 间隔恰好 == 阈值 → 不减(严格大于) |
|
||||||
|
| `…::test_fewer_than_two_chunks_returns_none` | 0/1 个 chunk → (None, None)(vLLM 排除条款) |
|
||||||
|
| `…::test_tail_not_counted` | 末 chunk 之后的流结束间隔不进 stall(尾部归活跃侧,N1) |
|
||||||
|
| `…::test_empty_or_usage_frames_not_timed` | 空 chunk/usage 帧/keepalive 不产生时间戳记录点 |
|
||||||
|
| `test_types.py::test_transport_result_stall_fields_default_none` | 两字段默认 None,旧位置构造不炸 |
|
||||||
|
| `test_types.py::test_source_config_stall_threshold_validation` | 默认 1000;0/负/bool → ValueError |
|
||||||
|
|
||||||
|
- **证据出口**:`tests/outputs/138/t1/red-batch-a.log`(先红)+ `green-unit.log`(后绿)。
|
||||||
|
- **验收命令**:`pytest tests/unit/test_openai_compat.py tests/unit/test_types.py -q` → `pytest tests/unit -q`(全绿且不改一行既有断言,§2.2 同步遗漏会在此暴露为 TypeError)。
|
||||||
|
- **回滚点**:`git revert <提交1>`(同步清单全量在同一提交,revert 即全回)。
|
||||||
|
|
||||||
|
### T2 → 提交 2 `feat: redefine generation_ms as net generation time with stall attribution`
|
||||||
|
|
||||||
|
- **目标**:`generation_ms` 净口径 + `CallStats`/`_CallContext` 两归因字段 + retry 三路径赢家记账(换算唯一处)。
|
||||||
|
- **改动**:`types.py:296-324/328-388`(§3.2);`retry.py:283-288/332-360` 邻域 + `_record_generation`/`_register_hedge` 共享换算(§3.2 表)。
|
||||||
|
- **先失败测试清单**(批次 B;先红 = 字段不存在 TypeError / 断言仍得墙钟值):
|
||||||
|
|
||||||
|
| 测试名 | 断言点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_types.py::test_callstats_stall_fields_default_none` | 两字段默认 None;仅旧参数构造 `CallStats(...)` 不炸(1.3.7 及以前兼容) |
|
||||||
|
| `test_types.py::test_callcontext_record_generation_with_stall_and_snapshot` | stall 两值经 record_generation 进 snapshot;不传恒 None |
|
||||||
|
| `test_retry.py::test_generation_ms_net_of_stall` | FakeClock + 自控 stall 假 transport:wall 推进 5.0s、stall_ms=3200 → `generation_ms == 1800` 且 `stall_ms == 3200`、`stall_count` 透传;`generation_ms + stall_ms == wall`(还原公式) |
|
||||||
|
| `test_retry.py::test_generation_ms_clamps_negative_to_zero` | stall > wall(假 transport 报 6.0s stall、wall 5.0s)→ `generation_ms == 0`(clamp,N4) |
|
||||||
|
| `test_retry.py::test_non_stream_stall_none_no_deduction` | stall None → `generation_ms == wall`(不退减),快照两值 None(排除条款) |
|
||||||
|
| `test_retry.py::test_stall_attribution_none_when_no_winner` | 两败轮次(复用 1.3.7 两败设施)→ snapshot stall 两值 None、generation_ms 不记 |
|
||||||
|
|
||||||
|
- **证据出口**:`tests/outputs/138/t2/red-batch-b.log` + `green-unit.log`。
|
||||||
|
- **验收命令**:`pytest tests/unit/test_types.py tests/unit/test_retry.py -q` → `pytest tests/unit tests/contracts -q`(全绿;对冲赢家口径断言在 T4 编排落地后补,见批次 D 末行)。
|
||||||
|
- **回滚点**:`git revert <提交2>`。
|
||||||
|
|
||||||
|
### T3 → 提交 3 `feat: add stall_ms and stall_count telemetry columns`
|
||||||
|
|
||||||
|
- **目标**:遥测两列全链路(schema 五编辑点 + 协议两参 + emitter 透传 + 两 recorder 自动拾取);§2.3 同步清单落地。
|
||||||
|
- **改动**:`telemetry/schema.py` 五编辑点(§3.4 表)+ 模块头计数 docstring;`ports.py:306-344`;`middleware/telemetry.py:199-236/396-447/449-540/560-665` + `:368` docstring 计数("组装 36 字段"→38,审查 N4);`retry.py:_emit`(§3.4);`sqlite.py:145`/`postgres.py:411` docstring 计数;`test_ports.py:102` +2 参;`test_telemetry.py` 冻结清单扩展;`tests/integration/test_postgres_telemetry.py:33-77` 冻结清单扩展。
|
||||||
|
- **先失败测试清单**(批次 C;先红 = 冻结清单已扩展而 schema 未改 → 列数/列序断言失败;协议未加参 → 装配闸 ValueError):
|
||||||
|
|
||||||
|
| 测试名 | 断言点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_telemetry.py::TestSchemaFreeze::test_columns_and_ddl_are_frozen`(扩) | `_EXPECTED_COLUMNS` 末尾追加两列后:`len(COLUMNS) == 38`;两端 DDL 首现顺序含新列;`PG_BACKFILL[-1]` 变 stall_count 项;占位符 `$38)`/`?`×38 |
|
||||||
|
| `test_ports.py:313` 与 `test_telemetry.py:748` 硬编码计数(扩,审查 N1) | `len(params)-1 == 36` → 38;`len(physical) == 37` → 39;连带 :650/:743/:756 注释文本同步——两处列入批次 C 清单,防被实施者"发现"而非计划登记 |
|
||||||
|
| `test_telemetry.py::TestBackendColumnParity::test_new_columns_are_appended_last`(扩) | `COLUMNS[-2:] == ("stall_ms", "stall_count")` |
|
||||||
|
| `test_telemetry.py::<SQLite 落行新用例>` | emit attempt(stall_ms=3200, stall_count=2)→ SQLite 行两列恰为该值;success 非流式行两列 NULL |
|
||||||
|
| `test_telemetry.py::<迁移幂等新用例>` | 既有 1.3.5 形态表(37 物理列)开 auto_migrate recorder → ALTER 补两列不炸;**重复初始化第二次**幂等;manual 档缺列告警逐字含两条新 ALTER |
|
||||||
|
| `test_hedge.py::<取消输家行新用例>` | 既有 AFTER_S 对冲触发,输家 `hedge_cancelled` 行 `stall_ms`/`stall_count` 恒 None(被取消输家无 TransportResult,B2) |
|
||||||
|
| `test_telemetry.py` 装配闸负例三条(:3327/:3349/:3354) | **不改**,协议加参后仍绿(负例仍旧不满足签名) |
|
||||||
|
|
||||||
|
- **证据出口**:`tests/outputs/138/t3/red-batch-c.log` + `green-unit.log`;PG 实库形态由 `tests/integration/test_postgres_telemetry.py` 覆盖(slow,发布门交集见 §6)。
|
||||||
|
- **验收命令**:`pytest tests/unit/test_telemetry.py tests/unit/test_ports.py tests/unit/test_hedge.py -q` → `pytest tests/unit tests/contracts -q`。
|
||||||
|
- **回滚点**:`git revert <提交3>`(DDL 为可空加列,旧代码读新表兼容;回滚后新列闲置 NULL)。
|
||||||
|
|
||||||
|
### T4 → 提交 4 `feat: add opt-in mid-stream stall hedging for chat`
|
||||||
|
|
||||||
|
- **目标**:`HEDGE__STALL_AFTER_S` 独立生效的中段对冲 + `STREAM__STALL_THRESHOLD_MS` 装配三层可调 + 全部守卫(B4 含 1.3.7 AFTER_S 同病补齐)。
|
||||||
|
- **改动**:`config.py:57/192-196/382-392/398-427/745-769/772-849/865-869/OcrSettings 新 __post_init__`(§3.5);`client.py:240/252-257/264-283/485-526`(§3.5);`retry.py:__init__/:273-283/:415-531/:533-562`(§3.6)。
|
||||||
|
- **先失败测试清单**(批次 D;先红 = 键未识时 3 段键被 `config.py:431` 静默跳过、实际红因 AttributeError/TypeError(审查 N3 措辞修正,红仍成立)、None 断言失败、对冲未触发 `hedges==0`、挂起用例超时):
|
||||||
|
|
||||||
|
| 测试名(新建 `tests/unit/test_stall_hedge.py`,设施复用 test_hedge.py 范式:两源、event-driven 假 transport、真实 loop 钟、阈值 0.05s、容差 4–10×) | 断言点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_stall_triggers_hedge_after_first_token` | 假 transport 首 chunk 后沉默:s1 挂起、s2 即时成功 → 对冲触发,`hedges==1`、`hedge_won is True`;`generation_ms` 为赢家路净口径(T2 批次 B 顺延的对冲赢家断言落这里) |
|
||||||
|
| `test_stall_recheck_prevents_false_trigger` | chunk 在超时到期的同一事件循环 pass 到达(0.049s 置位,阈值 0.05s)→ **不触发**,`attempts==1`(N3① 复检) |
|
||||||
|
| `test_stall_and_after_s_independent_matrix` | 参数化四格:仅 AFTER_S / 仅 STALL_AFTER_S / 两者 / 皆无 → 各格触发语义与设计 §5.2 逐字一致(仅 stall 时 pre-TTFT 沉默**不**触发——阶段 1 不开窗) |
|
||||||
|
| `test_stall_only_non_stream_degrades_to_single_leg` | 仅 stall 已设 + `stream=False` → 逐字单路,`attempts==1`、`hedges==0`(入口门) |
|
||||||
|
| `test_watchdog_kills_primary_hedge_leg_survives` | 主路首 chunk 后沉默超 `inter_token_timeout_s` 被看门狗掐死、副路已在跑 → 本轮不因主路之死而败,副路结果裁定;主路源 `failure_count + 1`(attempt 记账照常,N9)、遥测主路 error 行 + 副路正常行 |
|
||||||
|
| `test_both_fail_counts_budget_once` | 主路看门狗掐死 + 副路 Transient → `fails` 只计一次(`_combine_failures` 复用),两败轮 stall 快照 None |
|
||||||
|
| `test_external_cancel_during_stall_wait_penetrates` | stall 等待中(阶段 2 循环内)外部 `task.cancel()` → CancelledError 穿透、waiter 收口无残留任务、两路(若已发)同消;1.3.7 `test_external_cancel_cancels_both_legs` 既有断言**一字不改**仍绿 |
|
||||||
|
| `test_hedge_leg_content_event_always_none` | 对冲路收到的 content_event 恒 None(N3③,test_hedge.py:50/:59 同款逐次身份记录) |
|
||||||
|
| `test_config.py::test_stall_threshold_key_loads_three_layers` | env 键 / GatewaySettings 字段 / GatewayClient kwarg 三层均可调(§9-③ 人类要求);SourceConfig 分发经 replace 生效;未设恒 1000 |
|
||||||
|
| `test_config.py::test_stream_segment_reserved` | `STREAM` 在保留段,`LLM__STREAM__1__X` 不造源;3 段键跳过 `_load_sources` |
|
||||||
|
| `test_config.py::test_stall_after_s_guard_effective_minimum_raises` | `stall_after_s ≥ min(inter_token_timeout_s or timeout_s)` → ValueError(有效值回落同款;配 watchdog 的源用 inter_token,未配的用 timeout_s) |
|
||||||
|
| `test_config.py::test_stall_after_s_guard_deadline_conflict_raises` | `≥ call_deadline_s` → ValueError |
|
||||||
|
| `test_config.py::test_stall_after_s_single_source_warns` | 单源 warning 允许(loguru 捕获 fixture,test_config.py:31-33 既有) |
|
||||||
|
| `test_config.py::test_embedding_ocr_scope_hedge_keys_warn` | Embedding/OCR settings 内嵌 gateway 的 AFTER_S **或** STALL_AFTER_S 非 None → warning(B4,含 1.3.7 同病补齐);chat 侧无此 warning(B4 裁决) |
|
||||||
|
| 批次 E 默认关闭回归门 | 两键均不设 → `pytest tests/unit tests/contracts -q` 全绿且**不改一行既有语义断言**(例外登记——审查 B1/B2/N1:fake 签名同步、`test_config.py:1174/:1184` 返回形态断言、两处硬编码计数,均属签名/清单同步,非语义断言改动) |
|
||||||
|
|
||||||
|
- **证据出口**:`tests/outputs/138/t4/red-batch-d.log` + `green-unit.log` + `green-contracts.log` + `lint.log`。
|
||||||
|
- **验收命令**:`pytest tests/unit/test_stall_hedge.py tests/unit/test_config.py -q` → `pytest tests/unit tests/contracts -q` → `make lint`。
|
||||||
|
- **回滚点**:`git revert <提交4>`(两键默认关闭,revert 后配置键失效由 `_load_*` 缺省自然达成)。
|
||||||
|
|
||||||
|
### T5 → 提交 5 `docs: document net generation time and mid-stream stall hedging`
|
||||||
|
|
||||||
|
- **目标**:披露义务与登记(设计 §4.2/§7);无代码行为变更。
|
||||||
|
- **改动清单**:
|
||||||
|
1. `CHANGELOG.md` 未发布段:设**独立"行为变更"段**显著标注——`generation_ms` 1.3.7(墙钟)→ 1.3.8(**净生成时间** = 墙钟 − Σ 超阈值中段沉默,仍含 TTFT 与尾部),还原公式 **墙钟 = `generation_ms + stall_ms`**;正当性三行(字段发布仅 2 天/消费者唯一/遥测无账本,设计 §4.2);`max_inter_token_ms` 原语义不变;新两字段/两键/遥测两列;对冲强制措辞三句(默认关闭;开启即用配额换延迟——实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲;输家可能已被上游计费);**"中段沉默对冲无业界公开先例"诚实标注**(调研 §A3);看门狗掐死喂熔断 vs 对冲输家取消不喂的不对称声明(N9)。
|
||||||
|
2. `README.md`:口径表(`generation_ms` 净口径定义/边界含 TTFT+尾部/还原公式/`stall_ms is None` 分支条件/与 NVIDIA-vLLM 基准口径不扣除沉默的显式区隔)+ 配置表两键(值域/缺省/守卫/三层调整接口)+ 能力表"中段沉默对冲(可选,超业界现状能力)"行。
|
||||||
|
3. `.env.example`:对冲两行注释后加 `# LLM__STREAM__STALL_THRESHOLD_MS=1000` 与 `# LLM__HEDGE__STALL_AFTER_S=`(缺省关闭,语义与成本说明)。
|
||||||
|
4. `research-wiki/ARCHITECTURE.md`:口径段(`generation_ms` 净口径 + 归因两字段 + 换算唯一处纪律)与对冲触发段(两阈值独立生效/两阶段触发窗/与看门狗交互)登记。
|
||||||
|
5. `research-wiki/ROADMAP.md`:1.3.8 里程碑登记。
|
||||||
|
6. `research-wiki/findings/2026-09-11-streaming-stall-validation.md`:红绿证据、命令与退出码、豁免索引(≤300 行);wiki 注册本计划与 findings。
|
||||||
|
- **证据出口**:`tests/outputs/138/t5/lint.log` + 审查/验证纪要不进 outputs(research-wiki/reviews/ 按既有惯例)。
|
||||||
|
- **验收命令**:`make lint` + `make ci`(只读验证);版本 bump/tag/构建/上传 registry **不在本计划内**,按 CLAUDE.md §4.4.1 另行执行。
|
||||||
|
- **回滚点**:文档提交独立 revert 即可。
|
||||||
|
|
||||||
|
## 5. 测试矩阵 → 任务映射(汇总)
|
||||||
|
|
||||||
|
| 批次 | 内容 | 任务 | 落点 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A | 端口加参 + 原语 + stall 纯函数(12 用例) | T1 | test_openai_compat.py / test_types.py |
|
||||||
|
| B | 净口径记账(6 用例) | T2 | test_types.py / test_retry.py |
|
||||||
|
| C | 遥测两列(冻结扩 + 4 新用例 + 3 负例核对) | T3 | test_telemetry.py / test_hedge.py / test_ports.py |
|
||||||
|
| D | 中段对冲编排(8 用例)+ 配置守卫(6 用例) | T4 | test_stall_hedge.py(新)/ test_config.py |
|
||||||
|
| E | 默认关闭回归门(不改一行既有断言) | T4 门 | tests/unit + tests/contracts 全套件 |
|
||||||
|
|
||||||
|
**设施复用核对(动手前必读)**:`test_retry.py:120-127` FakeSleep 只记录不推进时钟(推进须用例自带闭包);`test_hedge.py:41-69` 事件驱动假 transport(content_event 身份逐次记录,同款扩展);`test_config.py:31-33` loguru WARNING 捕获 fixture(caplog 抓不到 loguru);取消窗口用 `entered` Event 范式(`test_retry.py:79-89`);真实 loop 钟阈值 0.05s、容差 4–10×(`test_streaming.py` 既有范式,不标 slow);**命名区隔**(审查 N5):`test_hedge.py:150` 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s`(对冲触发阈值)是不同概念,`test_stall_hedge.py` 复用设施时注释明写;`test_telemetry.py:53-99` 冻结清单的扩展属"先红"手段(先改清单 → schema 未改即红)。
|
||||||
|
|
||||||
|
## 6. 风险节与验收门
|
||||||
|
|
||||||
|
| 风险 | 纪律 |
|
||||||
|
| --- | --- |
|
||||||
|
| **钟混用**:测试注入 FakeClock 时真实 transport 用了假钟,或沉默序列与墙钟异钟 | N4 钉死:沉默序列(transport `time.monotonic`)与墙钟(retry 注入 `now`,生产 = `time.monotonic`)生产同钟;**FakeClock 用例一律配自控 stall 值的假 transport**,真实 transport 测试不注入假钟;对冲触发只用 loop 相对时长,绝不读注入 `now`(1.3.7 §4.1) |
|
||||||
|
| **取消穿透回归**:两阶段重构动 `_past_hedge_window`,1.3.6/1.3.7 既有取消语义被破坏 | 既有取消/对冲/看门狗测试**不得改语义只许扩展**;waiter 收口 + `cancelling()` 穿透逐字保留;批次 D 外部取消用例 + 批次 E 全绿为双门 |
|
||||||
|
| **遥测 DDL 对既有实例不幂等** | 新列可空无默认追加末尾;auto_migrate 先探测后 ALTER(SQLite duplicate column 竞态已有处理);批次 C 迁移幂等用例(二次初始化)为门;manual 档裁剪 + 点名告警同源(`missing_columns_warning` 由常量派生,零编辑) |
|
||||||
|
| **端口签名同步遗漏**(1.3.7 T1 教训) | §2.2/§2.3 两表为真实 grep 全量结果;T1/T3 全套件绿为门(遗漏 = TypeError/装配闸 ValueError 必红);**不许凭记忆补点**,执行时先重跑同一条 grep 复核 |
|
||||||
|
| **换算漂移**:多处各自取整/各自减沉默 | N8:取整与净额计算全库只 retry 层一处;`_record_generation`/`_register_hedge` 共用同一内部换算;遥测列取 INTEGER 与之一致 |
|
||||||
|
| **误触发**:chunk 与超时同一 pass 到达 | N3① 复检 `is_set()`;批次 D `test_stall_recheck_prevents_false_trigger` 为门 |
|
||||||
|
| **触发率成本**:实测沉默 >20s 占 19.0%,远高于首 token 对冲 | 默认必须关闭;CHANGELOG/README 强制措辞"对冲 = 用配额换延迟";守卫②把"对冲窗永不开"的配置在装配期直接拒掉 |
|
||||||
|
| **反馈环**:计量阈值与对冲阈值自适应耦合 | 不做自适应(设计 §8-B);两阈值皆绝对值,文档明写 |
|
||||||
|
| **emit_attempt 默认 None 掩盖 chat 路径漏传** | 批次 C 落行断言(chat 成功行两列恰为传入值)为门;取舍理由已录 §3.4 |
|
||||||
|
|
||||||
|
**验收门(合并前,全部 `conda run -n PolyGateway` 禁接管道)**:
|
||||||
|
|
||||||
|
1. `make lint`(ruff --fix + import-linter;ports/types/errors 不 import 实现的铁律由契约执法)。
|
||||||
|
2. 全套件:`pytest tests/unit tests/contracts tests/integration -q`(integration 中 PG/Redis 用例按其既有标记;默认不含 slow)。
|
||||||
|
3. **slow 交集子集**(CLAUDE.md §4.4.1 第 4 步,按 diff 交集):本 diff 触及 retry(结算/取消/编排)与 telemetry → **Redis 时间语义变体**(`tests/integration/test_redis_governance_time.py` 等与取消/结算交集的 slow 变体)+ **网关冒烟**(`tests/e2e/test_smoke_gateway.py`,公开入口 `complete` 签名变更属交集)。`test_thinking_live.py` 全模型矩阵**不跑**(本 diff 未动 thinking.py/能力注册表;复用 1.3.7 有效矩阵证据并如实登记)。
|
||||||
|
4. T5 前:`requesting-code-review`(整分支)+ `verification-before-completion`(全新上下文 verifier subagent,前台)。
|
||||||
|
|
||||||
|
## 7. 阻塞矩阵与交接
|
||||||
|
|
||||||
|
| 触发条件 | 处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| 需要新增设计 §9 之外的公共键/端口方法/遥测列/异常类 | **停下上报**(边界之外即未批准) |
|
||||||
|
| `asyncio.current_task().cancelling()` 语义在目标版本不符 | 3.12 语义同 1.3.6/1.3.7 已验证路径;若实测不符,改"取消标志位置于 `_attempt_hedged` 局部"方案并记 findings,**不得吞取消** |
|
||||||
|
| 复检防误触发用例在 CI 抖动 | 只断"不触发 + attempts==1"结果,不断精确时序;仍不稳则调宽阈值比(0.45×阈值置位)并记 findings,不得删复检断言 |
|
||||||
|
| 想顺手让对冲路携带 content_event(梯次) | **不做**(N3③:v1 单路;对冲路恒 None) |
|
||||||
|
| 想把看门狗掐死改成不喂熔断 / 把输家取消改成喂熔断 | **不改任一**(N9 不对称声明:前者 1.3.6 起既有语义,后者 1.3.7 已批准语义);运维"挂起率"统计须知此不对称,落 README |
|
||||||
|
| 想给 LLMResponse 加顶层 stall 字段 | **不加**(B2 裁决 (b):CallStats 已承载赢家口径,两处平铺必漂移) |
|
||||||
|
| 发现 embedding/OCR 也想用中段对冲 | **不加**(非目标 E);B4 warning 已覆盖误配场景;登记为后续 issue |
|
||||||
|
| PG 实库迁移用例无沙箱可跑 | 记 findings 豁免索引,发布门 slow 交集时补跑;不得删批次 C 的 SQLite 幂等用例顶替 |
|
||||||
|
|
||||||
|
交接物:5 个提交(T0 文档并入首个提交或独立,由 commit skill 裁定)、1 份 findings、CHANGELOG 未发布段。版本号 bump、tag、构建、上传 registry 与 wiki 同步**不在本计划内**,按 CLAUDE.md §4.4.1 另行执行(发布时按 §6 验收门 3 补 slow 交集)。
|
||||||
|
|
||||||
|
## 8. 自审
|
||||||
|
|
||||||
|
| 检查 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 路径/行号/签名是否可执行无 TBD | 是——全部接入点本会话现读:`ports.py:56-66/306-344`、`types.py:296-324/328-388/560-586/590-700`、`openai_compat.py:427-473/553-616/660-705`、`retry.py:239-300/304-400/371-562/665-700`、`config.py:57/192-196/382-392/398-427/745-769/772-849/854-910`、`client.py:217-310/485-526`、`middleware/telemetry.py:199-236/396-665`、`telemetry/schema.py:22-215/323-372`、§2.2/§2.3 全部测试落点 |
|
||||||
|
| 同步清单是否真实 grep | 是——§2.2(11 实现 + 11 调用点)与 §2.3(8 类落点)均为 2026-09-11 本会话实跑结果,非凭记忆;执行时重跑复核已写进 §6 |
|
||||||
|
| 先失败后通过证据点 | 是——T1 批次 A(TypeError/ImportError)、T2 批次 B(字段缺失/仍得墙钟)、T3 批次 C(冻结清单先扩即红/装配闸)、T4 批次 D(键未识/hedges==0/超时)均先红;证据出口 `tests/outputs/138/t<N>/` 逐卡列出 |
|
||||||
|
| 设计保真 | 是——净口径/排除条款/换算唯一处/两阶段触发窗/N3 并发纪律/N9 不对称/B2 裁决 (b)/B4 操作化均逐字落自设计;无另行发明语义 |
|
||||||
|
| 取消与降级铁律 | 取消穿透路径逐字保留既有收口;准入失败静默;后端不可用冒泡;遥测写失败降级;DDL 幂等;无 shield/后台任务 |
|
||||||
|
| 反 gold-plating | 四分类/熔断/429/Lua/streaming.py/embedding-OCR 链路/LLMResponse 顶层字段/梯次对冲/自适应阈值一律不碰;recorder `**fields` 零改动;约 50 处 emit_attempt 调用点零改动 |
|
||||||
|
| 跨任务签名一致 | `content_event`(T1 端口 → T4 接线)、stall 三元组 sink(T2 定义 → T4 赢家断言)、`stall_after_s`(config 定义 → client 透传 → RetryMW 消费)、`stall_threshold_ms`(SourceConfig 字段 → 装配 replace 分发 → transport 读)四处接缝均在 §3 写出实际代码 |
|
||||||
|
| 行数 | 约 400 行 ≤1000;表格/伪代码优先;无超 15 行代码块 |
|
||||||
|
| 残余诚实标注 | 中段对冲无业界公开先例(调研 §A3,CHANGELOG 必写);`generation_ms` 语义突变为人类"窗口期最小代价"裁决放行的例外(披露义务在 T5);复检竞速窗口只能收窄不能归零(N3① 已是设计批准的最强形态) |
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-10-01-issue29-openai-fidelity
|
||||||
|
title: OpenAI消息保真实现计划
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# #29 OpenAI 消息保真实现计划
|
||||||
|
|
||||||
|
> 状态:计划及首轮代码独审已完成,P1/P2 均采纳;缓存空产物和测试双向依赖已修复,发布目标按人类最终指定改为 1.3.9(取代未发布的 1.4.0),最终独审与 T8 外部发布仍由父会话执行。
|
||||||
|
> 设计:[批准方向的设计落实稿](../designs/2026-10-01-issue29-openai-fidelity-design.md)。
|
||||||
|
> 目标:同一 chat 治理链返回可供下游继续工具对话的完整单 choice 协议结果,拒绝截断和 error 帧伪成功。
|
||||||
|
> 方案:增加 response_mode 与嵌套 completion;协议解析与聚合内聚在 transport 内部模块,治理层仅传模式/搬结果;旧文本默认保持兼容。
|
||||||
|
> 技术:Python 3.12+、frozen dataclass、httpx SSE、现有 asyncio 看门狗/对冲、pytest、真实 Redis;不加运行时依赖。
|
||||||
|
|
||||||
|
## 0. 工作区、审批与执行纪律
|
||||||
|
|
||||||
|
| 项目 | 已核验值 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工作目录 | `/home/iomgaa/.config/superpowers/worktrees/PolyGateway/issue29-openai-fidelity` |
|
||||||
|
| 分支 | `feat/issue29-openai-fidelity` |
|
||||||
|
| 基线 | `0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8 |
|
||||||
|
| 原工作区 | `/home/iomgaa/Projects/PolyGateway`,main 上已有未提交文档与 .pi,不得覆盖或夹带提交 |
|
||||||
|
| 基线命令 | `conda run -n PolyGateway pytest tests/unit -q` |
|
||||||
|
| 基线结果 | 1627 passed in 12.25s;仅单元基线,不代表集成/slow/新增功能通过 |
|
||||||
|
|
||||||
|
所有 Python 命令用 PolyGateway conda 环境;始终从上述隔离目录执行,确认 pytest 配置 pythonpath=src,避免误测主工作区 editable 安装。不要复制主树 `.env` 到受跟踪路径;需要真实验证时显式读取已有配置,不输出秘密。reference/ 不存在于新 worktree 的跟踪树,不为规划复制/修改参考仓。
|
||||||
|
|
||||||
|
技术方向、内部原创权属和 Apache-2.0 已获用户明确批准;细化设计与计划由独立 subagent 审查,Codex 不再调用。只在新产品/权利边界未获授权时暂停。父会话掌握审查、后续编码、合并/发布权限;当前任务不执行这些步骤。
|
||||||
|
|
||||||
|
本计划不涉及参考实现迁移,保真校验不适用;涉及既有移植流式看门狗的消费面,必须对照本库 `streaming.py`、1.3.8 stall 设计逐段保持其超时/取消/换算语义,不能借工具支持改算法。
|
||||||
|
|
||||||
|
## 1. 文件结构与职责锁定
|
||||||
|
|
||||||
|
| 路径(相对工作目录) | 动作与职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/polygateway/types.py` | 新 ResponseMode/OpenAIChatCompletion;末尾追加 ChatRequest.response_mode、两个响应 completion |
|
||||||
|
| `src/polygateway/__init__.py` | 导出新公共类型;最终发布时同步版本 |
|
||||||
|
| `src/polygateway/ports.py` | complete 增必填模式参数;说明工具进展与单 choice 契约 |
|
||||||
|
| `src/polygateway/client.py` | chat 新模式校验、消息/overlay 快照、组合拒绝;不重写治理入口 |
|
||||||
|
| `src/polygateway/transports/_openai_completion.py`(新) | 协议结构校验、每尝试聚合、非流式快照;无 I/O/计时 |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | error 检查、解析器接线、投影/usage/空补全/DONE、统一进展事件 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | 模式传入、completion 搬运;无重试/对冲算法改写 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 模式 key、嵌套序列化/重建、缺协议 miss、序列化降级 |
|
||||||
|
| `tests/unit/test_openai_completion.py`(新) | 纯协议聚合与非流式校验的源命名测试 |
|
||||||
|
| `tests/unit/test_openai_compat.py` | 真实 transport 的 SSE/非流式/error/raw/DONE/活性测试 |
|
||||||
|
| `tests/unit/test_types.py`, `test_client.py`, `test_ports.py`, `test_cache.py` | 公共类型、校验、端口、缓存往返与兼容 |
|
||||||
|
| `tests/unit/test_retry.py`, `test_hedge.py`, `test_stall_hedge.py`, `test_deadline.py`, `test_backpressure.py` | 模式传递、错误重试、工具活性/对冲、取消与结算 |
|
||||||
|
| `tests/integration/test_governance_stack.py`, `test_redis_cache.py`, `test_redis_cross_connection.py` | 完整链路、真实缓存往返、必要 fake 签名适配 |
|
||||||
|
| `tests/e2e/conftest.py` | 现有取证 transport 包装显式转传模式,不改变 thinking 矩阵判据 |
|
||||||
|
| `tests/e2e/test_openai_tools_live.py`(新) | 受控少量真实工具双轮冒烟,独立 slow 用例 |
|
||||||
|
| `tests/unit/test_package.py` | LICENSE 文件/包元数据回归 |
|
||||||
|
| `LICENSE`, `pyproject.toml` | 标准 Apache-2.0 与打包元数据;不捆绑 reference/ |
|
||||||
|
| `README.md`, `CHANGELOG.md`, `.env.example`, `research-wiki/ARCHITECTURE.md` | 新模式、限制、raw 配置、缓存迁移、同步桥接说明 |
|
||||||
|
| `research-wiki/findings/2026-10-01-issue29-license-audit.md`(新) | 分发源码第三方来源核查证据与范围 |
|
||||||
|
| `research-wiki/graph/edges.json`, `index.md`, `log.md`, `schemas/llm-calls.md`, `metrics/call-telemetry-coverage.md` | 审查通过后的设计/计划/复用遥测关系注册,不新增表或指标实体 |
|
||||||
|
|
||||||
|
其余测试若显式调用 complete 必须补参数,仅机械适配;不重构不相关 fixture。`thinking.py`/能力表/streaming.py 算法不在预期改动内。不要为“兼容 SDK”创建不存在的 openai_sdk.py。
|
||||||
|
|
||||||
|
## 2. 跨任务接口
|
||||||
|
|
||||||
|
T1 定义公共字段,T2–T5 均消费同一类型名,禁止再起 tool_response/raw_response 别名。
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 协议结果,message/usage 是独立 JSON 快照。"""
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
```
|
||||||
|
|
||||||
|
公共与端口变化均是追加参数/字段:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# GatewayClient.chat 的末尾 keyword-only 参数;其余参数保持原样。
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
# ChatRequest 末尾字段。
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
# Transport.complete 末尾必填 keyword-only 参数。
|
||||||
|
response_mode: ResponseMode
|
||||||
|
# TransportResult / LLMResponse 末尾字段。
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
`build_cache_key` 在已有 keyword-only 参数之后增加 `response_mode: ResponseMode = "text"`,仅非 text 时参与摘要。缓存重建只依赖内核类型/本层校验,不 import transports;若复用协议类型重建的最低形态检查,把纯 JSON 快照检查放类型自身的具名构造方法,不把 transport 的 SSE 状态机下沉到内核。
|
||||||
|
|
||||||
|
T2 内部模块只由同目录 transport 消费,不成为公共导出。采用以下窄接口(内部类型在该模块定义):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatDelta:
|
||||||
|
"""一帧提取结果;progress 表示真实生成进展。"""
|
||||||
|
content: str
|
||||||
|
reasoning_content: str
|
||||||
|
progress: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChatCompletionAccumulator:
|
||||||
|
"""每次 transport 尝试独享的单 choice 聚合状态。"""
|
||||||
|
def __init__(self, *, response_mode: ResponseMode) -> None: ...
|
||||||
|
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta: ...
|
||||||
|
def finish(self) -> OpenAIChatCompletion: ...
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
上述 `...` 是签名约定而非待交付占位实现。聚合器抛含判据的 TransientError(operation=chat);openai_compat 负责补选中源上下文、复用有界摘要。聚合器不判断 DONE、不读时钟;transport 掌握线路完整性。text 模式可共用帧提取与进展识别,但不调用严格 openai finish,保持旧文本工具容忍边界。
|
||||||
|
|
||||||
|
## 3. 分任务与独立验收
|
||||||
|
|
||||||
|
### T0 — 审查与来源前置核查
|
||||||
|
|
||||||
|
- [x] T1 前已核查分发来源,见 findings/2026-10-01-issue29-license-audit.md;发现不兼容复制即停止。
|
||||||
|
- [ ] 父会话安排全新上下文原生 subagent 审查设计/计划,重点:单 choice、未知增量字段、身份重复/冲突、raw/managed、缓存错误降级及取消。
|
||||||
|
- [ ] 核对每条意见,修订文档并记录接受/不接受的理由;如改变用户已批边界,走主管决定,不静默改契约。
|
||||||
|
- [ ] 在隔离树注册文档关系,复用 llm-calls schema/call-telemetry-coverage 指标;不在主树运行生成器。
|
||||||
|
- [ ] 文档审查通过后才调用 commit skill 提交设计/计划;当前阶段禁止提交。
|
||||||
|
|
||||||
|
验证命令:`wc -l research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md research-wiki/plans/2026-10-01-issue29-openai-fidelity.md` → 分别 ≤400、≤1000;`git diff --check` → 无错误。Wiki 命令经 `conda run -n PolyGateway python .claude/tools/research_wiki.py --help` 核对实际参数后执行 add_entity/add_edge/rebuild_index;检查生成 diff 不改原有条目正文。
|
||||||
|
|
||||||
|
### T1 — 公共契约贯穿完整治理链
|
||||||
|
|
||||||
|
文件:types.py、__init__.py、ports.py、client.py、middleware/retry.py;单元 types/client/ports/retry;所有显式 fake complete 签名。
|
||||||
|
|
||||||
|
先补测试:默认 text 不变、新类型可导入、末尾带默认字段不破旧构造;非法 response_mode、openai+structured、n=2/0/True 在适用边界拒绝;保真消息与嵌套 overlay 脱离调用方容器,返回上一轮 assistant 和 role=tool 请求 JSON 逐字段相同。
|
||||||
|
|
||||||
|
实现:chat 校验放在 context/handler 前;选源后非法 source.extra_body.n 在 transport HTTP 前 RequestRejected;模式作为必填端口参数穿透重试/对冲,_build_response 原样搬 completion。不把模式塞进发给上游的 payload。
|
||||||
|
|
||||||
|
验收:调用级拒绝不发 HTTP;源级拒绝结算/释放正常;自定义 transport fake 看见准确模式;既有旧构造回归全绿。命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_ports.py tests/unit/test_retry.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
先红测试须为行为断言或缺失 API 的明确证据,记录基线 SHA 与测试名;绿后提交点:`feat: add explicit OpenAI response contract`。
|
||||||
|
|
||||||
|
### T2 — 内聚协议解析器与 error 帧修复
|
||||||
|
|
||||||
|
文件:新增 _openai_completion.py、openai_compat.py;新增 test_openai_completion.py,扩展 test_openai_compat.py。
|
||||||
|
|
||||||
|
补测 SSE/非流式/缓存缺 role、非 assistant role;合法 role-only 起始帧后工具成功。先红测试至少含下表各组;复用现有 SSE/MockTransport,不引入第二套 HTTP fake 框架。
|
||||||
|
|
||||||
|
| 组 | 输入与精确预期 |
|
||||||
|
| --- | --- |
|
||||||
|
| 并行工具 | 2 工具交错 index;同帧两工具;name/arguments 分片;空白/Unicode/引号逐字相等;最终按 index 排序 |
|
||||||
|
| 身份 | id/type 同值重复只保留一次,冲突失败;缺 id/type/name/arguments、重复 id、bool/负数 index、非 function 类型失败 |
|
||||||
|
| 文本 | 同帧正文+推理均保留;缺省/null/空串区别;原消息 think 标签不剥,旧投影仍剥 |
|
||||||
|
| envelope | 顶层数组/null、choices/delta/tool_calls 非容器、坏字段类型、多 choice/非零 choice index 均受控失败 |
|
||||||
|
| 非流式 | 完整消息、扩展字段、usage/finish/id 全保留;工具参数不做业务 JSON 修复;原响应对象改动不影响快照 |
|
||||||
|
| error | 首帧 error、正文后 error+usage+DONE、error=null、error={}、salvage 配置下 error 都立即失败;text/openai 两模式参数化 |
|
||||||
|
|
||||||
|
复用现有 summarize_body/compose_message,断言异常与遥测诊断有界,不打印工具参数正文。默认文本模式不因无用工具字段的严格 finish 校验而新增失败,但不吞顶层 error。无新 HTTP 状态映射。
|
||||||
|
|
||||||
|
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_http_error_body.py -q` → PASS;提交点:`fix: preserve chat deltas and reject upstream error frames`。
|
||||||
|
|
||||||
|
### T3 — 完成语义、活性与非流式接线
|
||||||
|
|
||||||
|
文件:openai_compat.py、ports.py docstring;test_openai_compat.py、test_streaming.py。
|
||||||
|
|
||||||
|
先红:tool-only + DONE 成功 attempts=1;空文本且空工具失败;正文+坏工具不成功;openai 缺 DONE 即使 missing_done=salvage 仍失败;text salvage 保持;finish_reason=length 原样交回,不解析 arguments。非流式工具同样成功,两事件永不置位、ttft/stall=None。
|
||||||
|
|
||||||
|
按设计接线:一帧一进展,同帧多个字段只记一个 chunk_time;真正非空工具增量/首次身份触发事件,重复身份和 role/usage/finish/空片段不触发。看门狗仍包进展迭代器,stall 派生/净额取整不动。
|
||||||
|
|
||||||
|
测试以受控 Event/FakeClock 驱动,避免用几十毫秒竞争制造脆弱测试:持续工具片段撑过 TTFT/inter-token;只有注释/usage 必超时;工具首进展设置 first_token_event;真实停顿计入 max_inter_token/stall,两字段与正文相同口径。
|
||||||
|
|
||||||
|
命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_streaming.py -q` → PASS;提交点:`feat: complete tool responses with unified stream liveness`。
|
||||||
|
|
||||||
|
### T4 — 完整协议缓存往返与隔离
|
||||||
|
|
||||||
|
文件:middleware/cache.py;test_cache.py、tests/integration/test_redis_cache.py。
|
||||||
|
|
||||||
|
先红测试:同消息 text/openai key 不同、旧默认 text key 字节不变;tools、tool_choice、raw effort、tool result、assistant arguments 改动均 miss;相同请求完整 completion round-trip,非流式扩展字段不丢,message 的 null/缺省可区别;usage 缺省/null 均为 None;openai 缺 completion/null/坏 message/坏工具条目 miss 后回源,不冒 dict 假装 dataclass。
|
||||||
|
|
||||||
|
检查命中历史 response_id 不变、本次 call_id 重建、attempts=0、cache_hit=true、cost=0;变更一次命中消息不污染下一次命中;旧 text 缓存继续可读。失败路径无 set;序列化/backend set 抛错 warning 后仍返回结果;CancelledError 原样穿透。
|
||||||
|
|
||||||
|
实现保真重建校验不能 import transport(import-linter 必守);内核值类型可提供纯值构造校验,协议拼接算法只保留在 transport;避免两份 SSE/tool 聚合逻辑。
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_cache.py -q
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q
|
||||||
|
conda run -n PolyGateway lint-imports
|
||||||
|
```
|
||||||
|
|
||||||
|
真实 Redis 专用测试 namespace,沿既有 fixture 清理自有 key,不 flush 共享 DB;未配置 skip 必须记录为未覆盖,发布前补实测。提交点:`fix: isolate and round-trip OpenAI completion caches`。
|
||||||
|
|
||||||
|
### T5 — 完整链路、对冲、取消与遥测不变量
|
||||||
|
|
||||||
|
文件:test_retry.py、test_hedge.py、test_stall_hedge.py、test_deadline.py、test_backpressure.py、tests/integration/test_governance_stack.py、test_redis_cross_connection.py、tests/e2e/conftest.py;生产 retry 仅修必要搬运漏项,不改算法。
|
||||||
|
|
||||||
|
先红→绿证据须覆盖真实 OpenAICompatTransport 接入 GatewayClient,而不是只用返回预制 completion 的 fake 证明解析成功:
|
||||||
|
|
||||||
|
| 场景 | 断言 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第一次部分正文/工具→error→DONE,第二次正常 | 只返回第二次 completion,attempts=2;第一次 error 的字段不会污染结果或缓存 |
|
||||||
|
| 工具持续进展 | 首 token 对冲及时止窗,中段 event 复位,未错误触发 |
|
||||||
|
| 真沉默触发对冲 | 副路完整工具结果胜出,返回自己的 id/usage,输家不泄露半成品 |
|
||||||
|
| primary 超时但副路尚在运行 | 不提前判总失败;两败仍只烧一次超级尝试预算 |
|
||||||
|
| 流中/准入中/退避中取消与 deadline | CancelledError/CallDeadlineExceeded 类型保留,permit/inflight/task 收口,TPM 保守结算 |
|
||||||
|
| 观测与降级 | 正常/失败/cache_hit 遥测沿单 helper;attempt 行/terminal 行口径不变;后端写失败不影响结果 |
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_hedge.py tests/unit/test_stall_hedge.py tests/unit/test_deadline.py tests/unit/test_backpressure.py -q
|
||||||
|
conda run -n PolyGateway pytest tests/integration/test_governance_stack.py tests/integration/test_redis_cross_connection.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
不删除“不需要失败计费”对应的现有失败遥测/attempts/结算;本任务不新增失败 token 取证。提交点:`test: cover governed tool retries hedges and cancellation`。
|
||||||
|
|
||||||
|
### T6 — 来源审计、Apache-2.0 与用户文档
|
||||||
|
|
||||||
|
文件:LICENSE、pyproject.toml、test_package.py、新 license-audit finding、README、CHANGELOG、.env.example、ARCHITECTURE;设计/计划 Wiki 注册关系。
|
||||||
|
|
||||||
|
| 工作 | 验收 |
|
||||||
|
| --- | --- |
|
||||||
|
| 来源审计复核(初审前置 T0) | 查 tracked 分发文件的移植/参考/版权注释及具体片段;列内部原创授权记录,外部只读 reference 的路径/许可/实际使用范围;未发现不等于法律认证 |
|
||||||
|
| 第三方义务 | 对实际复制片段核实许可、保留通知;发现 AGPL 不兼容复制须上报,不借用户授权覆盖第三方 |
|
||||||
|
| LICENSE | 使用标准 Apache-2.0 全文,不自行重写;版权归属沿项目元数据,不猜用户真实姓名;NOTICE 仅在实际需要时新增 |
|
||||||
|
| 包元数据 | 当前 setuptools>=68,采用 `license = {file = "LICENSE"}` 兼容现有后端;验证 wheel/sdist 包含 license,不为 PEP 639 单独提高构建依赖 |
|
||||||
|
| 文档 | 新 chat 示例 ≤15 行;response_mode 与 raw 所有权分开;单 choice、未知 SSE 方言失败、非流式、length、无 DONE 不打捞、缓存旧值迁移、取消/线程桥接限制写清 |
|
||||||
|
|
||||||
|
`.env.example` 只解释 raw-only 必须去掉源级受管意图,不加模式 env 键。README 应说明“完整协议结果”仅四字段及已定义 SSE 字段,不能暗示返回所有顶层 logprobs/created 等;上游模型使用 LLMResponse.model_reported,不把 model 配置别名当真实版本。
|
||||||
|
|
||||||
|
ARCHITECTURE 更新 §5 类型/签名、§6.2 空补全与 error、§7.1 DONE 与工具协议、§7.2/7.6 工具活性、§7.5 缓存维度,并明确这些只是批准方向的增量,不引入 Agent Loop。站点下线,无具体 Wiki 页面可同步。
|
||||||
|
|
||||||
|
验证:`conda run -n PolyGateway pytest tests/unit/test_package.py -q` 先红后绿;`git diff --check`;构建验收见 T8。提交点:`docs: document faithful chat and Apache-2.0 distribution`。
|
||||||
|
|
||||||
|
### T7 — 少量真实工具冒烟与独立验证
|
||||||
|
|
||||||
|
新增 tests/e2e/test_openai_tools_live.py,复用 tests/e2e/conftest.py 的请求取证/输出机制,不为本次重跑整个 thinking 矩阵。fixture 的 complete 显式转传 response_mode 是机械接线,不改取证判分算法。
|
||||||
|
|
||||||
|
测试使用通用无业务领域的函数,如 echo_value;强制工具选择但工具在测试内执行纯函数,库本体不执行。流式、非流式各一次工具请求,随后带原 assistant+tool 消息做第二轮;断言 ID、finish、arguments 可用和请求消息相等。并行两工具完整逐字拼接的硬门由确定性 fixture 保证,不把模型偶尔不发两个工具解释为库聚合正确。
|
||||||
|
|
||||||
|
真实测试标 slow/requires_llm,预算与超时不紧于现有 `.env` 生产配置。raw=max 使用已配支持模型的 raw-only 源;不发明新模型/API。Markdown 安全报告记录模式、源/model_reported、attempts、finish、工具数、结果/失败原因;不输出 key、敏感消息或完整参数。费用/网络异常如实呈现,不宽泛 skip。
|
||||||
|
|
||||||
|
命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda run -n PolyGateway pytest -m slow tests/e2e/test_openai_tools_live.py tests/e2e/test_smoke_gateway.py -q
|
||||||
|
make ci
|
||||||
|
```
|
||||||
|
|
||||||
|
全套件由当前前台执行;若预计长跑需 tmux,禁用缓冲、保存原退出码,不在 pytest 末尾接管道。生成唯一日志路径 `tests/outputs/issue29/<任务>-<red|green>-<时间>.log`,不提交产物。无新增功能代码的当前阶段仅跑过单元基线,不预填完成框。
|
||||||
|
|
||||||
|
父会话安排全新上下文 verifier 独立查实际 diff 与测试输出。若修改了准入/取消/退避生产逻辑,补 `tests/integration/test_redis_governance_time.py` 和 `test_redis_cross_connection.py` 对应 slow 子集;仅机械传参/协议解析时记录无 Lua/退避算法交集的理由。慢测试 skip/失败不得当通过。
|
||||||
|
|
||||||
|
- [ ] 审查员检查依赖铁律、未知字段策略、旧文本回归与大对象保留成本。
|
||||||
|
- [ ] 每项行为能链接红绿日志、命令、SHA、退出码;基线失败必须归因,不能“重跑绿了”掩盖。
|
||||||
|
- [ ] 合并前测试/独立审查通过;提交均调用 commit skill,不夹带主树既有文档差异。
|
||||||
|
|
||||||
|
### T8 — 发布与 issue 外部验收(父会话持有,不由本设计任务执行)
|
||||||
|
|
||||||
|
发布版本已由人类最终指定为 1.3.9(取代未发布的 1.4.0),不能在规划中冒称已发布。遵守 CLAUDE.md §4.4.1 全步骤:README/CHANGELOG/两处版本先完成 → 合并 main/push → 合并后 lint/全套件/相交 slow → tag push → 构建/twine check → registry 上传 → 实际下载核验 → Release/挂库/包页核验。
|
||||||
|
|
||||||
|
| 发布门 | 可接受证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 构建 | `conda run -n PolyGateway python -m build` 和 twine check 成功;解包核 LICENSE、许可元数据、completion 类型/参数/解析器均在产物中 |
|
||||||
|
| 安装 | 在独立临时环境从私有索引下载/安装已发布版本;必要时 --no-build-isolation 避免索引无 setuptools;核来源/hash,不误用本地 editable |
|
||||||
|
| 外部页面 | registry 包正文与仓库链接、Gitea Release/tag 实际可见;不以 POST 返回码代替最终状态 |
|
||||||
|
| 下游 | 以发布包运行不含业务逻辑的 tool 双轮接入验收;给 dissect3 说明同 loop 线程桥接和 raw-only 配置,不在本计划越权修改其业务仓 |
|
||||||
|
| 关闭 issue | 再查询 open issue,#29 评论附版本/验收/限制与缓存迁移说明后关闭;若出现新 issue 单独定范围,不假称本计划涵盖未来问题 |
|
||||||
|
|
||||||
|
## 4. 自审清单与残余边界
|
||||||
|
|
||||||
|
| 需求 | 覆盖任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| 工具双并行、消息往返、raw=max、非流式 | T1–T3、T7 |
|
||||||
|
| error+DONE 所有模式失败、attempts=2 | T2、T5 |
|
||||||
|
| 缺 DONE 不 salvage 工具、length 不修复 | T3 |
|
||||||
|
| 活性、对冲、取消、准入/结算 | T3、T5 |
|
||||||
|
| 完整缓存、模式隔离、旧默认 key 与污染迁移 | T4、T6 |
|
||||||
|
| Apache 授权与第三方来源、分发验证 | T6、T8 |
|
||||||
|
| 测试先红后绿、独立审查、外部发布完成 | 各任务、T7、T8 |
|
||||||
|
|
||||||
|
当前残余:首轮代码独审已完成,修复后待父会话最终复核;严格 SSE 方言会拒绝未定义增量字段,不承诺任意 OpenAI 兼容网关;旧文本毒化缓存只能由使用方迁移身份;公共 dataclass 嵌套 JSON 可变,靠快照隔离而非深冻结;模型真实冒烟依赖外部可用性。以上均需在文档和验收中如实保留,不当成全部自动解决。
|
||||||
|
|
||||||
|
## 5. 本轮实施记录(2026-10-01,未发布)
|
||||||
|
|
||||||
|
P1 最终 assistant role、P2 来源核查前置均已采纳,证据见来源核查 finding。
|
||||||
|
T1–T6 代码/文档/许可已落实;T7 工具真实冒烟、Redis、质量门已执行,独立代码审查由父会话接续;T8 禁止本 worker 执行。
|
||||||
|
|
||||||
|
| 验证 | 实际结果/证据 |
|
||||||
|
| --- | --- |
|
||||||
|
| 基线红 | tests/outputs/issue29/baseline-red.log:error+DONE 未抛、双文本丢 reasoning、模式 API 缺失,3 failed |
|
||||||
|
| 扩展红绿 | expanded-red.log 19 failed(git archive 0e29dea 的 src,不改主树);expanded-green.log 同19 passed |
|
||||||
|
| 防御红绿 | defense-red.log 3 failed:usage 裸错误/空 tool_calls 丢失;defense-green.log 同3 passed |
|
||||||
|
| 全量 | ci-final.log + ci-final.exit=0:1812 passed、23 skipped、110 deselected,覆盖率95%;之后仅增测试/文档,无生产变更 |
|
||||||
|
| 最后定向 | targeted-final.log:264 passed,包含后增的非流式事件/取消/managed冲突/名称分片回归 |
|
||||||
|
| 真实缓存 | test_redis_cache.py 6 passed,专用随机 key,无共享数据清理 |
|
||||||
|
| 真实工具 | live-final.log + live-final.exit=0:流式/非流式双轮2 passed,101.34s;安全Markdown在 tests/outputs/issue29/live |
|
||||||
|
| 构建 | /tmp/pgw-issue29-dist-final 的 wheel/sdist、twine check、LICENSE/源码字节核验通过;仍1.3.8本地测试产物,绝非发布 |
|
||||||
|
|
||||||
|
23 skips 为17个已转专用时间语义的 Redis 契约变体、6个缺私有图像语料的 OCR 测试;未把这些当本次通过。110 deselected 为默认 slow;未重跑全模型 thinking 矩阵,因为只机械转传端口参数、未改其判据或能力表。
|
||||||
|
|
||||||
|
未提交代码,供父会话独立验收完整工作区。conda 启动有基础环境 RequestsDependencyWarning(pytest本体无该告警),pi-lens 使用错误环境时的缺包/枚举提示已用 conda 实证排除,不据此改变依赖。
|
||||||
|
Wiki add_entity 会覆写无frontmatter同名文件,现场检查发现后已从本会话原始 tool-write 精确恢复两文档,并加 frontmatter 后保留 P1/P2 修订;没有丢失设计内容。
|
||||||
|
|
||||||
|
补充最终证据:unit-final.log 1678 passed(23.84s),smoke.log + smoke.exit=0 为既有真实 chat 冒烟4 passed(97.18s);未跑昂贵全模型矩阵。源代码最后变更早于 ci-final,后续只增测试/文档;最后单元覆盖全部新增测试。
|
||||||
|
|
||||||
|
## 6. 审查修复与1.4.0准备(2026-10-01,未发布)
|
||||||
|
|
||||||
|
已读取首轮独立报告并核验采纳 P2 空产物缓存、Minor 测试双向依赖,详见 [审查处置](../reviews/2026-10-01-issue29-cache-review.md)。本轮只在隔离 feature 树写入,主树既有脏文档未改;不合并、不 push/tag/上传、不关issue。
|
||||||
|
|
||||||
|
| 本轮命令/证据(日志均在 tests/outputs/issue29,不提交) | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 新缓存回归修复前:review-cache-red.log | 10 failed、1 passed:五种无效产物×两种缓存投影误命中;合法tool-only通过 |
|
||||||
|
| 四模块定向:review-cache-green.log | 268 passed,17.71s;包含11项新增缓存回归 |
|
||||||
|
| 真实Redis+包测试:review-redis-package.log | 13 passed,2.66s(6 Redis、7包) |
|
||||||
|
| 首次 lint / make ci | 测试import顺序失败;已修正,不作为通过证据 |
|
||||||
|
| tmux make ci:review-ci-final.log / .exit=0 | 1827 passed、23 skipped、110 deselected,317.28s;95%覆盖;格式102文件、ruff通过、依赖契约1 kept/0 broken |
|
||||||
|
| 远端 tags 与 registry simple | 最大均1.3.8,没有1.4.0;版本两处改1.4.0,README约束>=1.4.0,<2,CHANGELOG定版 |
|
||||||
|
| build / twine / 解包:review-build-final.log、review-twine.log、review-artifacts.log | 1.4.0 wheel/sdist 均通过;所有Python源码、README、标准LICENSE逐字相同,Version/License-File正确且无reference |
|
||||||
|
| README数字断言 | inspect.signature 实测 TelemetryRecorder.record_llm_call 为38字段,未凭记忆更新 |
|
||||||
|
|
||||||
|
wheel SHA256 `3186d01d7710c3b5ef727b59f95e8c2db0821277a79491ffb913dd2ce34a888d`;sdist SHA256 `b254dedf65d25673cf57986a613a1f4e9895da8b5be1e6ee241f00065a95ad90`。这些是隔离树本地构建,不是registry产物。解包探针首次使用email默认ASCII解码比较中文README产生误报,改为METADATA正文原始字节比较后通过,未改包来迎合错误探针。
|
||||||
|
|
||||||
|
跳过项仍为17个独立slow Redis时间变体及6个缺私有图像语料OCR;110 slow未选中。本轮不重复付费网关:复用前轮流式/非流式工具双轮2通过和chat冒烟4通过,当前生产改动只统一空产物判据,工具/文本正常行为已有定向与全套件证明;合并后slow发布门由父执行。构建有setuptools许可表格弃用提醒(兼容setuptools>=68设计),并有基础conda RequestsDependencyWarning;均不等于当前构建或测试失败。
|
||||||
|
|
||||||
|
按commit skill提交feature全部实现、修复、许可与发布准备,原main脏文件及运行产物不纳入。提交后交父最终独立复核;T8外部发布尚未完成,不能提前关闭#29。
|
||||||
|
|
||||||
|
## 7. 版本定案与历史文档归档(2026-10-01 EDT / 2026-10-02 UTC)
|
||||||
|
|
||||||
|
用户最终指定本次发布 __1.3.9__,取代尚未打 tag/上传的 1.4.0。上节 1.4.0 构建散列、测试结果与历史提交标题均是当时实证,保留原样,不冒充 1.3.9 产物。README 安装约束、能力标题、CHANGELOG 与两处版本同步;自定义 `Transport.complete(response_mode=...)` 适配要求及旧缓存更换 namespace/salt 提醒保持不变。CHANGELOG 使用执行环境本地日期 2026-10-01(EDT),知识库时间戳明确使用 UTC。
|
||||||
|
|
||||||
|
历史六文档独立归档提交 `ce10881`;采纳独审发现,仅补 #24 implements 边缺少的 plan/design 两节点。验证旧新节点/边/日志并集完整、120 个索引链接与计数一致、三文件仅空白/空引用格式、无冲突标记。剩余 15 个历史未注册端点以及 wiki lint 的 15 条孤立/稀疏提示均未扩大处理。原始备份 `/tmp/pgw140-release` 及 stash 保留。
|
||||||
|
|
||||||
|
本轮只准备与本地构建,不合并、不 push/tag/上传、不关闭 issue;后续独审及发布外部验收仍是硬门。相关功能代码未变,可复用 `/tmp/pgw140-release/main-ci.log` 的 1828 项通过、95% 覆盖及 `main-slow.log` 的 6 项真实网关通过(205.81s);本轮另执行包/协议定向、质量检查和 1.3.9 构建解包验证。
|
||||||
|
|
||||||
|
| 本轮证据(`/tmp/pgw139-prepare/`) | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| source.log / tests.log | 显式 `PYTHONPATH=/home/iomgaa/Projects/PolyGateway/src` 确认源码位置及1.3.9;包+缓存+协议四模块276 passed,12.43s,无新增行为测试 |
|
||||||
|
| check.log | `make check` 格式、ruff、依赖契约通过,1 kept/0 broken |
|
||||||
|
| docs-validation.log / wiki-lint.log | #24两端点补齐;原新记录保全、120链接计数通过;存量15个端点缺口及15条lint提示未清理 |
|
||||||
|
| remote-tags.txt / registry-simple.html | 2026-10-02 UTC读取远端tag与匿名simple均无1.3.9/1.4.0,无版本冲突;发布前仍须复查 |
|
||||||
|
| build.log / twine.log / artifacts.log / unpacked-import.log | 1.3.9 wheel/sdist构建、twine及全Python源码/README/LICENSE逐字核验通过;解包路径独立导入成功,38遥测字段实测不变 |
|
||||||
|
|
||||||
|
本地 wheel SHA256 `45306557152fd44f8135928041e47924834c3b4039c586f72d521f51b4590dd3`;sdist SHA256 `8f455973e7fef71811e2b86f5a14cb03469d893ac9720b902f522d2b1a8dcd40`。产物位于 `/tmp/pgw139-prepare/dist`,不是已发布包。构建仅有已知许可证表格弃用提示、conda基础环境依赖警告;不为清警告增加依赖。
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
---
|
||||||
|
type: plan
|
||||||
|
node_id: plan:2026-10-14-issue30-31-completion-verdict
|
||||||
|
title: 统一完成判定实现计划(#30/#31)
|
||||||
|
date: 2026-10-14
|
||||||
|
---
|
||||||
|
|
||||||
|
# #30/#31 统一完成判定实现计划
|
||||||
|
|
||||||
|
**目标**: 按[设计](../designs/2026-10-14-issue30-31-completion-verdict-design.md)(下称"设计")实现统一完成判定,修复 #30/#31 并发布 1.3.10。
|
||||||
|
|
||||||
|
**方案概述**: 在 `types.py` 内核放一组纯判定函数(流完整性 → 结局分类 → 可缓存),transport 两条路径、CacheMW 读写共用;聚合器改为 null 优先 + 未知字段无损合并;`LLMResponse`/`TransportResult` 追加 `finish_reason`/`stream_end`,遥测加 `stream_end` 列。
|
||||||
|
|
||||||
|
**技术**: Python 3.12、httpx、pydantic、pytest(asyncio)、SQLite/PG 遥测、Redis 缓存。环境 `conda run -n PolyGateway`。
|
||||||
|
|
||||||
|
本计划不涉及参考实现迁移,保真校验不适用。
|
||||||
|
|
||||||
|
## 0. 执行纪律
|
||||||
|
|
||||||
|
- 分支 `fix/issue30-31-completion-verdict`。每个任务一次提交,经 `commit` skill;pre-commit 钩子会跑全套件 + ruff + "改动触及函数复杂度 < C",故**每个提交点必须全绿**,改到的既有高复杂度函数(`add_chunk` F42、`_complete_stream`/`_complete_once` C、`_rehydrate` C、`OpenAIChatCompletion.__post_init__` D)须在同一提交内拆到 B 及以下;钩子(2026-10-14 经人类同意改为只拦"本次改动触及"的块,存量 ≥C 降为告警,提交 5d2ad40)按 radon 块判定,**类块**(`ChatCompletionAccumulator` C16、`OpenAIChatCompletion` D24)同样须降到 B 及以下。不改 `OpenAICompatTransport.complete()`(C14),新增逻辑一律放在拆出的 helper 中。
|
||||||
|
- 每个任务的统一验证(与钩子一致): `conda run -n PolyGateway pytest tests/ -q`(含 integration,本地有 Redis/PG 时真跑)+ `conda run -n PolyGateway ruff check src` + `conda run -n PolyGateway radon cc -n C <本任务改动文件>`(输出必须为空,或仅含本任务未触及的存量块)。各任务列出的子集命令只作开发期快速回路。
|
||||||
|
- 测试结果门: 每个任务的新增/改写测试先在未改实现的代码上运行并记录**失败**输出(粘贴进提交说明正文的"Red:"一行,写明失败用例名与断言),再实现至通过。
|
||||||
|
- 后台长跑命令末尾禁止接管道(CLAUDE.md §3)。
|
||||||
|
|
||||||
|
## 1. 文件结构
|
||||||
|
|
||||||
|
| 文件 | 职责变化 |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/polygateway/types.py` | 新增 `StreamEnd`、`FinishClass`、`_OutputSummary`、`_Verdict` 与七个纯函数(§2 列出;另 `_effective_finish`);`TransportResult`/`LLMResponse` 末尾追加两字段;`OpenAIChatCompletion` 接受 `tool_calls: None` 并复用 `_tool_snapshot_ok` |
|
||||||
|
| `src/polygateway/__init__.py` | 导出 `StreamEnd`;`__version__` → 1.3.10(T6) |
|
||||||
|
| `src/polygateway/transports/_openai_completion.py` | 聚合器: null 优先、未知字段合并、标签键、冲突路径、finish/usage 位置记录、reasoning 别名;`parse_chat_completion` 容忍 null |
|
||||||
|
| `src/polygateway/transports/openai_compat.py` | 两条路径改走判定;有界告警节流;`finish_reason`/`stream_end` 填充;投影别名 |
|
||||||
|
| `src/polygateway/middleware/retry.py` | `_build_response` 搬运两字段 |
|
||||||
|
| `src/polygateway/middleware/cache.py` | 写/读按 `_is_cacheable`;`stream_end` 校验;finish 回退 |
|
||||||
|
| `src/polygateway/ports.py` | `record_llm_call` 追加 `stream_end` |
|
||||||
|
| `src/polygateway/middleware/telemetry.py` | `_AttemptUsage.stream_end`、`_record` 搬运、两 emit 显式 None |
|
||||||
|
| `src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py` | 新列 + 补列 |
|
||||||
|
| tests | 新增 `tests/unit/test_completion_verdict.py`;改写/扩充 `test_openai_completion.py`、`test_openai_fidelity.py`、`test_openai_compat.py`、`test_cache.py`、`test_telemetry.py`、`test_types.py`、`test_ports.py`、`tests/integration/test_postgres_telemetry.py` 及各 `record_llm_call` 替身 |
|
||||||
|
| 文档 | README、CHANGELOG、ARCHITECTURE §6.2/§7/§14、1.3.9 设计修订指针、`schemas/llm-calls.md`、`migrations/` 三份、pyproject 版本 |
|
||||||
|
|
||||||
|
## 2. 跨任务接口(T1 定义,后续任务只消费)
|
||||||
|
|
||||||
|
```python
|
||||||
|
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
|
||||||
|
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
|
||||||
|
|
||||||
|
def _classify_finish(value: str | None) -> FinishClass: ...
|
||||||
|
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool: ...
|
||||||
|
def _stream_is_complete(
|
||||||
|
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
|
||||||
|
) -> StreamEnd | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _OutputSummary:
|
||||||
|
any_output: bool # 设计 §4.3 "任何产物"
|
||||||
|
body_output: bool # 设计 §4.3 "正文产物"
|
||||||
|
tools_present: bool # 至少一个非 null 工具条目(仅保真有意义)
|
||||||
|
tools_ok: bool # tools_present 时 _tool_snapshot_ok 结果;无工具恒 True
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Verdict:
|
||||||
|
deliver: bool
|
||||||
|
error_kind: Literal["transient", "result_invalid"] | None # deliver 时为 None
|
||||||
|
code: str | None # 设计 §4.3 原因码
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _summarize_text(content: str, thinking: str) -> _OutputSummary: ...
|
||||||
|
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary: ...
|
||||||
|
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict: ...
|
||||||
|
def _is_cacheable(
|
||||||
|
*, mode: ResponseMode, content: str, finish_reason: str | None,
|
||||||
|
stream_end: str | None, completion: OpenAIChatCompletion | None,
|
||||||
|
) -> bool: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
缓存读写共用(T1 定义,T4 消费),顶层优先、回退 `completion.finish_reason`,避免读写口径分叉:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`types.py` 无 `from __future__ import annotations`: `StreamEnd`/`FinishClass` 必须定义在 `TransportResult`/`LLMResponse` 之前。
|
||||||
|
|
||||||
|
聚合器对外只读属性(T2 定义,T3 消费): `finish_reason: str | None`、`finish_seen: bool`、`usage_after_finish: bool`、`conflicts: frozenset[str]`(归一路径)、`raw_message() -> dict[str, Any]`(未校验的合并消息深拷贝,含按 index 排序组装的 `tool_calls`,与 `finish()` 同一组装函数,供 `_tool_snapshot_ok` 判定)、`finish() -> OpenAIChatCompletion`(保真快照,校验失败抛 Transient,与 1.3.9 同)。`ChatDelta` 追加字段 `reasoning: str`(别名片段,默认 `""`)。
|
||||||
|
|
||||||
|
## 3. 任务
|
||||||
|
|
||||||
|
### T1 内核判定函数与公共字段
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/types.py`、`src/polygateway/__init__.py`;测试 `tests/unit/test_completion_verdict.py`(新)、`tests/unit/test_types.py`、`tests/unit/test_package.py`(导出)。
|
||||||
|
- 行为:
|
||||||
|
- `_classify_finish` 按设计 §4.1 表,大小写敏感;None → `absent`。
|
||||||
|
- `_tool_snapshot_ok`: `tool_calls` 缺省或 None → True;否则必须是 list,每个非 None 元素满足 1.3.9 规则(非空唯一 id、`type == "function"`、非空 `function.name`、字符串 `arguments`);list 中 None 元素跳过。`OpenAIChatCompletion.__post_init__` 改为调用它(并拆分以降复杂度),`tool_calls: None` 合法且原样保留键。
|
||||||
|
- `_stream_is_complete`: `done` → `"done"`;否则 `finish_reason is not None and usage_after_finish and usage_measured` → `"eof_after_finish"`;否则 None。
|
||||||
|
- `_summarize_text`: any = `content != "" or thinking != ""`;body = `content.strip() != ""`;tools_present False、tools_ok True。
|
||||||
|
- `_summarize_message`: any = 递归遍历 message 中除 `role`、`tool_calls` 外所有值存在非空字符串,或 tools_present;tools_present = `tool_calls` 为含非 None 元素的 list;tools_ok = `_tool_snapshot_ok(message)`;body = `projected_content.strip() != ""` 或 (tools_present 且 tools_ok)。
|
||||||
|
- `_judge_outcome`: 严格按设计 §4.3 九行首匹配;原因码字面量: `upstream_finish_error`、`tool_truncated`、`tool_incomplete`、`length_without_content`、`filtered_without_content`、`empty_completion`。行 2 仅 `mode == "openai"` 时生效。
|
||||||
|
- `_is_cacheable`: 设计 §6 四条,任一成立 False;`stream_end` 不在 `StreamEnd` 域且非 None → False;openai 模式且 `completion is None` → False。
|
||||||
|
- `TransportResult`、`LLMResponse` 末尾追加 `finish_reason: str | None = None`、`stream_end: StreamEnd | None = None`(放在现有最后一个字段之后,不扰动位置构造);docstring 写清 None 语义与 `usage_source` 分工。
|
||||||
|
- 包根导出 `StreamEnd`;`_has_completion_output` 本任务**保留不动**(T3/T4 迁移后删除)。
|
||||||
|
- 测试(新文件): `_classify_finish` 全值表参数化;`_stream_is_complete` 真值表(含 usage 早于 finish、usage 非 measured);`_judge_outcome` 九行 × 两模式参数化(每行至少一例,含"正文非空+坏工具+stop → transient tool_incomplete");`_summarize_message` 对 refusal-only、reasoning-only、whitespace content、未知嵌套字符串、`tool_calls: [None]`;`_is_cacheable` 四类否例 + 正例 + 非法 stream_end。`test_types.py`: `OpenAIChatCompletion` 接受 `tool_calls=None`、拒非 list、`[None]` 合法;两新字段默认 None。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit/test_completion_verdict.py tests/unit/test_types.py tests/unit/test_package.py -q` → 全过;`make lint` 无新增问题。
|
||||||
|
- [ ] 提交 `feat: add kernel completion verdict functions and response fields`
|
||||||
|
|
||||||
|
### T2 聚合器: null 优先、未知字段合并、finish/usage 记录
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/transports/_openai_completion.py`;测试 `tests/unit/test_openai_completion.py`。
|
||||||
|
- 保留 `message`、`tools` 实例属性名与语义(`test_openai_completion.py:70,93` 直接依赖)。
|
||||||
|
- 行为(设计 §5、§4.2):
|
||||||
|
- 每帧: 校验 chunk 对象;`usage` 非 null 时记录为 `last_usage`,若 `finish_seen` 或同帧 choice 带非 null finish → `usage_after_finish = True`。
|
||||||
|
- choices: 保真维持单 choice/index 0 校验;文本模式取 `index` 缺省或为 0 的第一个 choice,其余忽略。
|
||||||
|
- finish_reason: 保真走 `_identity`(非字符串/冲突 → Transient,同 1.3.9);文本模式首个字符串值生效,非字符串忽略。任一模式见到非 null 即 `finish_seen = True`。
|
||||||
|
- null 五层(设计 §5.1): chunk 层 `choices: null`、choice 层 `delta: null` 视为缺省;例外: 顶层 `error` 键(含 `error: null`)仍由 `_check_chat_error` 按 error 帧拒绝(不动,保 `test_error_before_done_is_failure`)。
|
||||||
|
- delta 与工具条目、function 三层: 值为 None 的键 → 若目标无此键则 `setdefault(key, None)`(保真),不做类型校验;`tool_calls` list 中 None 元素跳过。
|
||||||
|
- 已知字段非 None 时逐字沿用 1.3.9 校验与合并(content/reasoning_content 拼接、role 必须 assistant、工具 id/type `_identity`、name/arguments 拼接)。
|
||||||
|
- 未知字段(delta 层除 role/content/reasoning_content/tool_calls;工具层除 index/id/type/function;function 层除 name/arguments): 交给 `_merge_unknown(target, key, value, path)`,规则见设计 §5.2;标签键集合 `{"index", "type", "id", "format"}`;冲突 → 从 target 删除该键、把归一路径(数组下标替换为 `[]`,如 `delta.reasoning_details[].format`)加入 `conflicts` 与本尝试"封禁路径"集合,后续同路径值丢弃。`_merge_unknown` 返回"是否并入非空字符串片段"。
|
||||||
|
- `reasoning`: 在保真模式作为普通未知字符串合并进 message;两模式都把其片段放入 `ChatDelta.reasoning`。
|
||||||
|
- progress: 1.3.9 已知字段口径 **或** 任一 `_merge_unknown` 返回 True(文本模式也调用合并函数计算活性,但合并结果不对外)。
|
||||||
|
- 新增只读属性见 §2;`finish()` 改为: 组装 message(含排序后工具)后构造 `OpenAIChatCompletion`(校验失败仍抛 Transient)。
|
||||||
|
- `parse_chat_completion` 拆为两步: `check_completion_body(body) -> tuple[dict, str | None]`(只校验 body 为对象、恰好一个 choice、index 缺省或为 0、message 为对象、保真下 finish 为字符串或 null,失败抛 Transient;返回原始 message 与 finish)与 `build_completion(message, body) -> OpenAIChatCompletion`(构造快照,失败抛 Transient)。快照构造前剔除 `tool_calls` 中的 None 元素(与流式跳过一致,避免回传 `[null]` 被上游 400);`tool_calls: null` 原样保留。
|
||||||
|
- `_merge_unknown` 按值类型拆分私有函数(标签键、字符串、标量、对象、index 数组、其他数组),每个 ≤ B。
|
||||||
|
- 拆分 `add_chunk` 为 `_take_meta`、`_take_choice`、`_take_delta`、`_take_tools`、`_take_tool` 等私有方法,每个 ≤ B。
|
||||||
|
- 测试: 改写 `test_invalid_delta_is_domain_error` 参数: 删除 `{"audio": {}}`、`{"index":0,"extra":1}` 与 null 已知字段参数(改为正例),保留 role 非 assistant、content 非字符串、tool index 非法等仍应拒绝的参数。新增: `refusal:null`+`tool_calls:null` 帧成功且快照含 `refusal: None`;只有 `reasoning` 的帧 progress=True 且 `ChatDelta.reasoning` 有值;二次构造的 OpenRouter `reasoning_details` 分片(每帧重复 `format`/`id`,`text` 分片)合并后 `format` 不重复、`text` 拼接完整;工具层嵌套对象 `extra_content={"google":{"thought_signature":"s"}}` 原样保留;数值冲突 → 键被删、`conflicts` 含归一路径、调用成功;同路径冲突后再到达的值被丢弃;形态改变(字符串→对象、index 数组→普通数组)即冲突;布尔值冲突;其他数组追加;同帧多个字段/多个未知字符串只产生一次 progress;chunk `choices:null`、choice `delta:null` 视缺省;`role:null`、`arguments:null` 视缺省;全程只有 `role:null` → `finish()` 失败;非流式 `tool_calls:[None, 合法工具]` 快照只含合法工具;`usage_after_finish` 四种顺序(finish 前 usage、finish 同帧、finish 后 usage-only 帧、无 usage);文本模式 index=1 的 choice 被忽略;重复 tool id 不拼接(既有用例保持)。
|
||||||
|
- 验证: §0 统一验证;快速回路 `conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_fidelity.py tests/unit/test_openai_compat.py -q`。T2 期间 transport 仍调用 `parse_chat_completion` 的组合(保留为两步的薄包装),行为不变。
|
||||||
|
- [ ] 提交 `fix: merge unknown and null stream fields losslessly in completion accumulator`
|
||||||
|
|
||||||
|
### T3 transport 两条路径接入判定
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/transports/openai_compat.py`;测试 `tests/unit/test_openai_fidelity.py`、`tests/unit/test_openai_compat.py`。
|
||||||
|
- 行为:
|
||||||
|
- `_complete_stream`: 流结束后先算 `usage_measured = _resolve_usage(sink.get("usage") or {})[2] == "measured"`,再 `stream_end = _stream_is_complete(done=..., finish_reason=acc.finish_reason, usage_after_finish=acc.usage_after_finish, usage_measured=...)`。
|
||||||
|
- None 且 openai → Transient `"{源} missing_done: …"`;None 且 text → 沿 `_check_done`(零产物按 `_summarize_text` 的 any 判定 early_eof;`retry` 抛;`salvage` → `stream_end="salvaged"`)。
|
||||||
|
- `salvaged` 时 usage 沿 `_resolve_stream_usage(..., salvaged=True)`;`done`/`eof_after_finish` 时 salvaged=False。
|
||||||
|
- 投影: `thinking` = 非空 reasoning_content 片段拼接,否则 `ChatDelta.reasoning` 片段拼接;再走既有 `_finalize_text`(think 标签)。
|
||||||
|
- 判定: openai → `summary = _summarize_message(acc.raw_message(), content)`;text → `_summarize_text(content, thinking)`;`verdict = _judge_outcome(mode, summary, _classify_finish(acc.finish_reason))`;非 deliver → 按 `error_kind` 抛 `TransientError` 或 `ResultInvalidError`,message 固定格式 `f"{source.name} {code}: finish_reason={fr!r}, thinking_len={len(thinking)}"`,带 `source_name`、`operation="chat"`;`ResultInvalidError` 显式 `raw_text=""`(不回显推理内容)。deliver 且 openai → `acc.finish()` 构造快照。
|
||||||
|
- 删除 `_reject_empty_completion` 与 1.3.9 的"保真缺 DONE"分支;`_check_done` 只保留文本截断语义。
|
||||||
|
- `_complete_once`: 跳过第一步。保真: 先 `check_completion_body` 取原始 message 与 finish,用原始 message(剔除 None 工具后)算 `_summarize_message` 与 `_judge_outcome`,**仅 deliver 时**调 `build_completion` 构造快照——否则坏工具 + length 会在构造时抛 Transient,到不了 `tool_truncated`。文本: finish 取 `choices[0].get("finish_reason")`,非字符串记 None;同一判定;`stream_end=None`;投影别名同上(`message.get("reasoning_content") or message.get("reasoning")`,仅取字符串)。
|
||||||
|
- 两路径 `TransportResult` 填 `finish_reason`、`stream_end`。
|
||||||
|
- 告警节流: 实例属性 `_warned_fields: set[tuple[str, str]]`,上限 1024,饱和时记一次 `"告警集合已饱和,后续同类告警省略"`;对 `acc.conflicts` 中每个路径、以及 `_classify_finish == "unknown"` 的 finish 值(截断 64 字符,文案写明"该 finish_reason 不缓存")各告警一次;日志只含源名与路径/值,不回显字段内容。
|
||||||
|
- 拆分 `_complete_stream`/`_complete_once` 至 ≤ B(建议抽 `_consume_stream`、`_verdict_or_raise`、`_project_text`)。
|
||||||
|
- 测试(假 SSE,复用文件内既有 helper):
|
||||||
|
- #31 验收 1-6(设计 §10),断言 `stream_end`、`usage_source`、attempts=1(经 GatewayClient 或 RetryMW 的既有测试设施)。
|
||||||
|
- usage 早于 finish + EOF → 失败;finish 后 usage `{}` → 失败;`network_error`+usage+EOF → Transient。
|
||||||
|
- 文本模式: stop+usage+EOF 无 DONE(`missing_done="retry"`)→ 成功 `eof_after_finish`;无 finish + EOF → retry 抛、salvage 记 `salvaged`。
|
||||||
|
- #30 验收: 保真 reasoning-only+length → 交付,`completion.message["content"]` 缺省或空、`reasoning_content` 完整、`finish_reason=="length"`;文本同输入 → `ResultInvalidError` 且 message 含 `length_without_content`;保真 content 空+length(无任何产物)→ 交付;文本同 → ResultInvalid;保真/文本 零增量+DONE(带 stop、不带 finish)→ Transient(`test_stream_zero_content_with_done_is_transient` 保持不改);非流式 message 带 `tool_calls:null`+`refusal:null` → 成功;工具缺 name + length → ResultInvalid `tool_truncated`;content 非空 + 坏工具 + stop → Transient。
|
||||||
|
- 只有 `reasoning` 字段的流不触发 TTFT 超时(用既有看门狗测试设施设置小 ttft)且文本 `thinking` 等于拼接值;同时有非空 reasoning_content 时以其为准。
|
||||||
|
- 冲突/未知 finish 告警各只一次(loguru sink: `logger.add(list.append, level="WARNING")`,先例 `test_openai_compat.py:877`;caplog 抓不到 loguru);饱和用例预先往 `_warned_fields` 灌 1024 条再触发一次,断言饱和告警一次。
|
||||||
|
- 结局表传输层覆盖: 设计 §4.3 九行 × 两模式,每行至少流式一例 + 非流式一例(行 2 只保真);非流式"工具缺 name + length → ResultInvalid `tool_truncated`";文本 length 用例经 GatewayClient 断言 attempts=1 且熔断窗口失败计数为 0;#30 reasoning-only+length 经 GatewayClient 断言 attempts=1。
|
||||||
|
- 改写 `test_openai_never_salvages`: 断言"无 finish 的 EOF 在 `missing_done=salvage` 下仍失败",并新增"finish+usage+EOF 成功"作为对照。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过。
|
||||||
|
- [ ] 提交 `fix: judge stream completion by finish reason and trailing usage`
|
||||||
|
|
||||||
|
### T4 retry 搬运与缓存读写判据
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/middleware/retry.py`、`src/polygateway/middleware/cache.py`、`src/polygateway/types.py`(删除 `_has_completion_output`);测试 `tests/unit/test_cache.py`、`tests/unit/test_retry.py`、`tests/integration/test_redis_cache.py`。
|
||||||
|
- 行为:
|
||||||
|
- `_build_response` 搬运 `finish_reason`、`stream_end`。
|
||||||
|
- 写路径: 序列化前 `_is_cacheable(mode=request.response_mode, content=response.content, finish_reason=response.finish_reason, stream_end=response.stream_end, completion=response.completion)` 为 False → `logger.debug` 跳写。
|
||||||
|
- 读路径(`_rehydrate`): 重建 completion 后、`fields.update` 之前: `stored_end = fields.get("stream_end")` 非 None 且不在域 → miss+warning;`finish = fields.get("finish_reason")`,非 None 且非 str → miss;为 None 且 completion 存在 → 回退 `completion.finish_reason`;`_is_cacheable(...)` False → miss+warning;命中覆盖加 `stream_end=None`、`finish_reason=finish`。拆分 `_rehydrate` 至 ≤ B。
|
||||||
|
- 删除 `_has_completion_output` 及其 import(全仓 grep 为零)。
|
||||||
|
- 读写判定的 finish 统一取 `_effective_finish(response.finish_reason, response.completion)`(读路径对存储值同样调用)。
|
||||||
|
- 测试: 不可缓存六类(salvaged、reasoning-only length 保真、文本 length 带正文、unknown finish、保真 reasoning-only + stop(交付但无正文产物)、`content_filter` 带正文)均不写入(fake cache backend 断言 set 未调用)且第二次调用 attempts=1;手工注入上述条目 → 读视 miss;非法 `stream_end` 注入 → miss;1.3.9 旧文本条目(无新键)→ 命中;1.3.9 保真条目(无顶层 finish、completion.finish_reason="length")→ miss;命中返回 `stream_end is None`、`finish_reason` 为存储/回退值;retry 测试断言 LLMResponse 两字段来自 TransportResult。Redis integration 一例往返两新字段。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q`(需本地 Redis)→ 全过。
|
||||||
|
- [ ] 提交 `fix: gate cache writes and reads on a shared cacheability verdict`
|
||||||
|
|
||||||
|
### T5 遥测 `stream_end` 列与 recorder 端口
|
||||||
|
|
||||||
|
- 文件: `src/polygateway/ports.py`、`src/polygateway/middleware/telemetry.py`、`src/polygateway/telemetry/schema.py`、`sqlite.py`、`postgres.py`;测试 `tests/unit/test_telemetry.py`、`tests/unit/test_ports.py`、`tests/integration/test_postgres_telemetry.py`,以及全仓 `def record_llm_call` 测试替身(`grep -rn "def record_llm_call" tests`)。
|
||||||
|
- 行为:
|
||||||
|
- `TelemetryRecorder.record_llm_call` 在 `stall_count` 之后追加 keyword-only `stream_end: str | None`(无默认),docstring 注明 1.3.10。
|
||||||
|
- `_AttemptUsage` 加 `stream_end: str | None = None`,`of()` 读 `response.stream_end`;`emit_attempt` 把它传给 `_record`;`_record` 加同名参数并传给 recorder;`emit_cache_hit`、`emit_terminal_failure` 显式 `stream_end=None`。
|
||||||
|
- `schema.py`: SQLite 与 PG CREATE 末尾 `stream_end TEXT`;`SQLITE_BACKFILL`、`_PG_BACKFILL_DECLS` 追加 `("stream_end", "TEXT")`;`COLUMNS` 追加 `"stream_end"`。sqlite/postgres 若有硬编码列数或参数位次随之更新(以 `COLUMNS` 为准)。
|
||||||
|
- 替身与计数(按审查实测清单): ① 固定字段 fixture `tests/unit/test_telemetry.py:142` `_record_minimal`、`tests/integration/test_postgres_telemetry.py:121` `_minimal_fields` 补 `stream_end: None`(sqlite 按 `fields[col]` 取值,缺键 KeyError);② 硬编码计数 38→39 / 列清单追加: `test_ports.py:325`、`test_telemetry.py:53`(`_EXPECTED_COLUMNS`)`:271`、`:301-303`、`:779`、`:829`,`test_postgres_telemetry.py:33`、`:778`、`:1151`、`:1352`(执行时以 grep 复核行号);③ `test_ports.py:112` `_DummyRecorder` 补参数,`stream_end` 加入 `:332` 无默认值参数化列表;④ `**fields` 替身(14 处)无需改动。源码 docstring "38 字段"同步: `ports.py:293`、`middleware/telemetry.py:373`、`sqlite.py:146`、`schema.py:8`。形状闸 `middleware/telemetry.py:323` `_assert_recorder_shape` 从协议签名派生,不改闸本身。
|
||||||
|
- 测试: 成功流式行写 `done`/`eof_after_finish`;非流式、缓存命中、失败、取消、终态行为 NULL;旧库(无列)auto 档补列后写入;manual 档缺列 → 裁剪写入且告警点名 `stream_end`;形状闸拒绝缺 `stream_end` 参数的 recorder;PG integration 同上(需本地 PG,沿既有 fixture)。所有替身补参数。
|
||||||
|
- 验证: `conda run -n PolyGateway pytest tests/unit -q` → 全过;`conda run -n PolyGateway pytest tests/integration/test_postgres_telemetry.py -q` → 全过;`conda run -n PolyGateway python -c "import inspect, polygateway.ports as p; print(len(inspect.signature(p.TelemetryRecorder.record_llm_call).parameters) - 1)"` 记录字段数供 T6 使用。
|
||||||
|
- [ ] 提交 `feat: record stream end mode in telemetry`
|
||||||
|
|
||||||
|
### T6 文档、版本与另开 issue
|
||||||
|
|
||||||
|
- 文件: `README.md`、`CHANGELOG.md`、`research-wiki/ARCHITECTURE.md`、`research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md`(顶部加"§4.1/§4.2/§5/§6 部分规则已由 2026-10-14 设计修订"指针)、`research-wiki/schemas/llm-calls.md`、`research-wiki/migrations/*.md`、`pyproject.toml`、`src/polygateway/__init__.py`。
|
||||||
|
- 内容:
|
||||||
|
- CHANGELOG `## 1.3.10(未发布)`: 修复段(#31 两模式、#30 两部分、非流式 `tool_calls:null`、reasoning 别名、salvage 不再缓存);**行为变更**段(文本 length/过滤无正文 → ResultInvalid 不再经 `AllSourcesExhausted`;未知 finish 不缓存;活性口径增补);**破坏性**段(`record_llm_call` 新增必填 `stream_end`);提醒(工具快照成立+length 时 arguments 可能半截;冲突删键以告警为准;旧缓存污染迁移 namespace/salt)。
|
||||||
|
- README: 能力表补完成判定/`stream_end`;安装约束 `1.3.*` 核对;遥测字段数用 T5 实测值;活性口径一句。
|
||||||
|
- ARCHITECTURE: §6.2 空补全/missing_done 行、§7 `usage_source` 与 `stream_end` 映射、§14 协议/失败/缓存行按设计改写。
|
||||||
|
- `schemas/llm-calls.md`: 加 `stream_end` 列与取值表,修正陈旧计数。
|
||||||
|
- `migrations/` 三份: 核对下游对 `ResultInvalidError` 的 catch 点,写一行结论(有/无受影响路径)。
|
||||||
|
- 版本 1.3.10 两处一致。
|
||||||
|
- `docs-convention.md` §2 清单逐项过: Gitea Wiki 站点下线,由 README/CHANGELOG/ARCHITECTURE/schemas 承接;`.env.example` 无配置键变更(写明);`research-wiki/ROADMAP.md` 状态同步。
|
||||||
|
- 另开 Gitea issue(tea CLI): "文本模式 n>1 时多 choice 增量混流";"文本模式有产物无正文 + stop 的证据收集(决定是否改 ResultInvalid)"。
|
||||||
|
- 验证: `grep -n "1.3.10" pyproject.toml src/polygateway/__init__.py`;`conda run -n PolyGateway pytest tests/unit/test_package.py -q` → 过。
|
||||||
|
- [ ] 提交 `docs: document completion verdict and bump version to 1.3.10`
|
||||||
|
|
||||||
|
### T7 合并前验证、审查与发布
|
||||||
|
|
||||||
|
- [ ] `make lint`、`make test` 全绿(覆盖率不降);`tests/integration` 中 Redis/PG 相关全绿。
|
||||||
|
- [ ] 派全新上下文 verifier subagent(`verification-before-completion`),逐条对照设计 §4-§7、§10 与本计划验收,输出证据表。
|
||||||
|
- [ ] `requesting-code-review` 整分支审查;按 `receiving-code-review` 核验并修复。
|
||||||
|
- [ ] slow 子集(按 diff 交集): 动了公开入口与 transport → `pytest -m slow tests/e2e/test_smoke_gateway.py tests/e2e/test_openai_tools_live.py`;不跑 `test_thinking_live.py`(未动 thinking 能力表)。另做一次 magpie/glm 真实探针: 记录是否出现 `eof_after_finish`,作为 #31 外部证据。
|
||||||
|
- [ ] 按 CLAUDE.md §4.4.1 九步发布 1.3.10(CHANGELOG 定版日期、合并 main `--no-ff`、main 上重跑、tag、构建、上传、下载验证、Release + 包关联 + 页面核对)。
|
||||||
|
- [ ] 在 #30/#31 评论验收证据与版本,关闭。
|
||||||
|
|
||||||
|
## 4. 设计覆盖自查
|
||||||
|
|
||||||
|
| 设计条目 | 任务 |
|
||||||
|
| --- | --- |
|
||||||
|
| §3 公共字段、`StreamEnd` 导出、`tool_calls:null` | T1 |
|
||||||
|
| §3 recorder 端口、列 | T5 |
|
||||||
|
| §4.1 纯函数与 finish 分类 | T1 |
|
||||||
|
| §4.2 流完整性、usage 位置与 measured | T2(记录)+T3(判定) |
|
||||||
|
| §4.3 结局表、原因码、错误格式 | T1(判定)+T3(翻译) |
|
||||||
|
| §4.4 reasoning 别名 | T2+T3 |
|
||||||
|
| §5.1-5.3 null、合并、标签键、活性 | T2 |
|
||||||
|
| §5.4 告警节流 | T3 |
|
||||||
|
| §6 缓存读写 | T4 |
|
||||||
|
| §7 遥测落点 | T5 |
|
||||||
|
| §8 旧行为审计中的保留项(零增量+DONE、error 帧、多 choice) | T3 回归测试 |
|
||||||
|
| §10 文档、另开 issue、发布 | T6+T7 |
|
||||||
|
|
||||||
|
## 5. 审查修订记录
|
||||||
|
|
||||||
|
| 轮次 | 意见 | 处置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| P1 | B1 非流式先构造快照导致 `tool_truncated` 不可达 | 采纳: T2 拆 `check_completion_body`/`build_completion`,T3 先判定后构造 |
|
||||||
|
| P1 | S1 T5 替身/计数清单 | 采纳: T5 逐项列出 |
|
||||||
|
| P1 | S2 类块复杂度、S3 验证与钩子一致 | 采纳: §0 |
|
||||||
|
| P1 | S4 保留内部属性、S5 五层 null、S6 缓存漏两类、S7 结局表与熔断断言、S8 合并规则测试 | 采纳: T2/T3/T4 |
|
||||||
|
| P1 | 建议 1-8 | 全部采纳;建议 4 选"剔除 None 工具元素" |
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
九个审查点已全部核对完毕。以下为完整审查报告。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1.3.8 流式中段沉默分支:整分支合并前审查
|
||||||
|
|
||||||
|
- **结论先行:可合并(OK with notes)**。无阻断项;2 条建议级(P2)发现,均不阻塞合并。
|
||||||
|
- 范围:`main a28d944...HEAD ca8743b`(代码提交 901a193/53bcf43/e0b0be0/2ac05a2 + 文档提交)。审查方式:只读,未运行 git/pytest(审查纪律),一切裁决以当前工作区源码 + 测试 + 文档实读为证据;绿态证据引自 `findings/2026-09-11-streaming-stall-validation.md` 登记的命令输出。
|
||||||
|
|
||||||
|
## 逐点裁决表
|
||||||
|
|
||||||
|
| # | 审查点 | 裁决 | 关键证据 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 取消穿透 | **通过** | `retry.py:596-645` `_past_hedge_window` 两阶段均逐字套用"waiter.cancel() → await waiter → CancelledError 时 `cancelling()` 判外部取消穿透"既有范式;阶段 1(`:608-622`)、阶段 2 循环内(`:630-642`)、对冲在途(`_attempt_hedged` Phase 5 `:540-556` + `except BaseException` 收口 `:557-567` 逐字保留)三时点全部穿透。测试:`test_stall_hedge.py::test_external_cancel_during_stall_wait_penetrates`(阶段 2 内取消,无残留任务、permit 释放、`asyncio.all_tasks()` 前后相等)+ `test_hedge.py:501` 既有取消用例未动 |
|
||||||
|
| 2 | Event 并发纪律 | **通过** | 与计划 §3.6 伪代码逐点比对:①复检 `is_set()` 在 `primary.done()` 之后、触发之前(`retry.py:638`);②`is_set()`→`clear()` 紧邻无 await(`:638-639`);③对冲路恒 `content_event=None`(`:513` Phase 4);④每轮自建 waiter(`:630`);⑤等待者唯一(编排单点等待,waiter 进 finally 收口后才进下一轮)。唯一偏差:阶段 1 的 waiter 创建被提升到 `if` 分支外(`:608`),功能等价且更简洁,非问题 |
|
||||||
|
| 3 | 净口径换算唯一处 | **通过** | 全库 `int(.*stall` 仅 `retry.py:377-378` 两处相邻行(同一次换算);`net = max(0, wall_ms − (stall_ms or 0))` clamp 收敛在 `_record_winner_sample`(`:575-590`),单路(`_record_generation`)与对冲赢家(`_register_hedge`)共用。None 透传五路径逐一核实:非流式(`_complete_once` 永不置位、TransportResult 默认 None)、chunk<2(`_stall_from_chunk_times` 返回 `(None,None)`,`openai_compat.py:98-99`)、两败(`_register_hedge(winner_sink=None)` 不记 generation、stall 保持 None)、embedding/ocr(`record_generation` 不带 stall kw,embedding.py/ocr.py 零改动已 grep 证实) |
|
||||||
|
| 4 | 看门狗汇合 | **通过** | Phase 5"先了结的是失败则等另一路"(`retry.py:540-543`)逐字复用;看门狗掐死 → `StreamLivenessTimeout`→`TransientError`→`_attempt` 既有失败分支(`breaker.record_failure`/`attempt_fails`/遥测 error 行照常,`:415-437`)。测试 `test_stall_hedge.py::test_watchdog_kills_primary_hedge_leg_survives` 断言熔断失败样本 +1、主路 error 行携 TransientError 对象、副路正常行——N9 不对称(掐死喂熔断 vs 输家取消不喂)未改任一 |
|
||||||
|
| 5 | 配置守卫 | **通过** | `check_hedge_assembly` 返回 `tuple[float|None, float|None]`(`config.py:819/912`),两调用点(`config.py:391`、`client.py:255`)均解构;两阈值皆 None 才早退(`:855`,先于空源检查,B2 钉死落实);三守卫齐备:有效最小值 ValueError(`:885-894`,`inter_token_timeout_s or timeout_s` 回落与 `openai_compat.py` 看门狗装配同款)、deadline 冲突 ValueError(`:895-899`)、单源 warning(`:900-906`);B4 落 `_warn_hedge_keys_on_non_stream_scope` + Embedding/Ocr 两 `__post_init__`(`:950/:996`,含 1.3.7 AFTER_S 同病补齐);`STREAM` 保留段(`:57`);三层分发正确:`from_settings` 恒分发(`client.py:543`)、直构造 `None` 不 stomp、非 None 经 `dataclasses.replace` 触发 `__post_init__` 重跑校验(`client.py:267-270`)。测试 `test_config.py:1304-1436` 全覆盖 |
|
||||||
|
| 6 | 遥测链路 | **通过** | 协议 +2 参无默认值(`ports.py:343-344`,共 38 参,`test_ports.py:324` 钉住);`_AttemptUsage.of()` 不从 response 取(B2 裁决 b,`telemetry.py:219-223`);`emit_attempt` 显式转发、`emit_cache_hit`/`emit_terminal_failure` 显式 None(`:465-466/:515-516/:570-571`);`_record` +2 参透传(`:616-617/:686-687`);schema 五编辑点齐备且保序对齐(`schema.py:66-67/110-111/146-147/175-176/227-228`)物理列 39;DDL 幂等有测试(`test_telemetry.py:460-480` 二次初始化零 ALTER、`:485-503` manual 档点名两条新 ALTER);`test_hedge.py:145-148` RecordingEmitter 显式全签名 +2 kw(B1)已同步。附带:`sqlite.py:146` 计数已同步 38;`postgres.py:411` docstring 本就无字段计数文本,计划"计数同步"对它无对象,无残留"36"字样(全 src grep 证实) |
|
||||||
|
| 7 | 默认关闭回归 | **通过** | 入口门第一分支(两阈值皆 None)逐字走 1.3.7 单路且 `first_token_event=None, content_event=None`(`retry.py:285-295`);第二分支(仅 stall + 非流式)同形(`:297-307`);T1 阶段单路调用点恒传 None 已并入最终形态;`SourceConfig.stall_threshold_ms` 默认 1000 只影响计量(这是已批准的 P1 行为变更本体,CHANGELOG 行为变更段已披露,非回归);findings 登记批次 E 门:`1688 passed, 17 skipped` 且既有语义断言一行未改 |
|
||||||
|
| 8 | 反 gold-plating | **通过** | 全 src grep `1.3.8` 标记仅落在计划 §2.1 清单内 8 个文件(ports/types/config/client/retry/telemetry/schema/openai_compat);`errors.py`/`deadline.py`/`streaming.py`/`middleware/{ratelimit,breaker,structured,cache,admission}.py`/`embedding.py`/`ocr.py`/`tests/contracts/**` 零新增引用(仅既存 issue #8 stall-window 语境);无 `except Exception: pass`、无 `print()`、日志全 loguru、公共函数类型注解齐全;缓存指纹 `build_model_fingerprint` 只取 model/extra_body/thinking 三件套(`client.py:125-164`),`stall_threshold_ms` 不进指纹——改阈值不触发缓存冷启动,无意外副作用 |
|
||||||
|
| 9 | 测试质量 | **通过(带 1 条 P2)** | 批次 D 复检用例确实走到"waiter 正常完成 → is_set→clear→continue"路径(不是平静场景:chunk 压在 0.049s、阈值 0.05s,复检-继续被真实执行);对冲赢家净口径、看门狗记账、两败一次预算、取消穿透、对冲路恒 None 的断言强度与 `test_hedge.py` 同档(同一设施范式 + 逐次身份记录 + 任务泄漏断言)。`test_stall_hedge.py` 还主动补了计划外的 `test_single_leg_attempt_row_carries_stall`(T3 缺口钉:chat 成功行经 `_emit` 端到端落 stall 两值),属范围纪律内的测试加固,非 gold-plating |
|
||||||
|
|
||||||
|
## 发现列表
|
||||||
|
|
||||||
|
**阻断(B):无。**
|
||||||
|
|
||||||
|
**建议(N/P2):**
|
||||||
|
|
||||||
|
- **N1(P2)`retry.py:369-371` 注释阶段性残留**:`_attempt` 内 `content_event=content_event` 处注释仍写"本阶段(T1)所有调用点恒传 None = 不观测;T4 才在启用 stall 对冲的流式原路上接真事件"。T4 已落地,该缝现在转发的是调用方传入的真事件,"本阶段(T1)"措辞会让后来者误以为代码停在 T1 中间态。最小修:把"本阶段(T1)…T4 才…"改为终态表述("未启用 stall 对冲时调用方传 None;启用时流式原路携真事件,对冲路恒 None")。不影响行为,可合并后随手改。
|
||||||
|
- **N2(P2)复检用例时序余量仅 1ms**:`test_stall_hedge.py::test_stall_recheck_prevents_false_trigger` 用 `chunk_after=0.049s` 对阈值 `0.05s`,与本文件自定"容差 4–10×"纪律相逆(此处是反向边界,宽余量反而测不到复检路径,故取值本身可辩护);且真实 loop 钟下"chunk 与超时同一 pass 到达"不可确定性复现,该用例实际测到的是"超时前一刻到达 → 复检-继续",严格同 pass 竞态仍只剩代码走查兜底(已走查,正确)。风险是 CI 抖动下 sleep 晚醒 >1ms 会假触发使 `transport.calls` 多出 s2 而红。计划 §7 已预留缓解(调宽至 0.45× 阈值并记 findings),合并不阻塞,建议观察首轮 CI 表现。
|
||||||
|
|
||||||
|
## 残余风险与说明
|
||||||
|
|
||||||
|
- 本审查为只读静态审查 + 已登记绿态证据复核,未重跑测试;合并前按 CLAUDE.md §4.4.1 第 4 步仍需跑 slow 交集子集(Redis 时间语义变体 + `test_smoke_gateway.py`),`test_thinking_live.py` 矩阵按 2026-09-10 决议不跑(复用 1.3.7 证据)。
|
||||||
|
- `_past_hedge_window` 复检窗口只能收窄不能归零(计划 §8 已诚实标注),属已批准残留。
|
||||||
|
- 对冲取消与外部取消同时到达时可能误贴 `hedge_cancelled` 标签,`retry.py:418-423` 注释已声明记账方向一致、属已批准可接受残留。
|
||||||
|
|
||||||
|
**Merge verdict: OK with notes**(N1/N2 不阻塞;建议合并后小修 N1 注释)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**结论:可合并(OK with notes)。发现计数:阻断 0 / 建议 2(N1 `retry.py:369-371` 注释阶段性残留;N2 `test_stall_hedge.py` 复检用例 1ms 时序余量偏紧)。**
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 1.3.8 streaming-stall 合并前独立验证报告(verifier,全新上下文)
|
||||||
|
|
||||||
|
- 仓库: `/home/iomgaa/Projects/PolyGateway`;分支 `feature/1.3.8-streaming-stall`
|
||||||
|
- HEAD 核实: `.git/HEAD` → `ref: refs/heads/feature/1.3.8-streaming-stall`;`.git/refs/heads/feature/1.3.8-streaming-stall` = `8489b8ea50308ca75e0876d293fef7feddf9b086`(即 8489b8e)✓
|
||||||
|
- **能力声明(重要)**: 本 verifier 会话只有 read/grep/find/ls 四类只读工具,**无 shell 执行能力**(角色纪律: 禁止 shell、禁止写文件)。因此动作 b(套件重跑)、c 的重跑部分、d(行为抽测)、e(`make lint` 重跑)**无法由我本人执行**;我已亲自读取实现方落盘的全部日志与全部相关源码做静态+证据核实,运行时必须由 supervisor 按文末清单补跑后方可合并。pi-lens/LSP 诊断未作为任何判据(指令 g)。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
**有条件通过(OK with notes)**: T1–T5 全部声明在源码与落盘证据两个维度均核实为真;红绿证据链真实(红日志含真实失败因、绿日志计数单调递增且与提交序一致)。**两个证据缺口**(见发现 P2-1/P2-2)必须补跑: integration 套件无任何落盘证据、且本会话无法亲跑任何运行时命令。
|
||||||
|
|
||||||
|
## 逐声明验证证据
|
||||||
|
|
||||||
|
### a) 提交序列(静态替代 `git log`)
|
||||||
|
|
||||||
|
`.git/logs/HEAD` reflog 末端逐行亲读,分支自 main `a28d944`(1.3.7)拉出后序列:
|
||||||
|
|
||||||
|
| 提交 | 主题 | 对应 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 03cbf4c / 97de410 / 929d60e / 6f3a620 | docs: 设计 + 评审勘误 + 计划 | 文档前置 |
|
||||||
|
| 901a193 | feat: content event + stall accounting to transport port | T1 |
|
||||||
|
| 53bcf43 | feat: generation_ms 净口径重定义 | T2 |
|
||||||
|
| e0b0be0 | feat: stall_ms/stall_count 遥测列 | T3 |
|
||||||
|
| 2ac05a2 | feat: opt-in 中段沉默对冲 | T4 |
|
||||||
|
| ca8743b | docs: 净口径与中段对冲文档 | 文档 |
|
||||||
|
| 8489b8e(HEAD) | docs: resolve branch review note(content_event 接线终态注释) | 文档 |
|
||||||
|
|
||||||
|
与声明序列(901a193/53bcf43/e0b0be0/2ac05a2/ca8743b/8489b8e + 文档提交)**逐项一致** ✓
|
||||||
|
|
||||||
|
### T1 端口契约 —— 成立
|
||||||
|
|
||||||
|
- `src/polygateway/ports.py` `Transport.complete`: kw-only 参数列含 `content_event: asyncio.Event | None`,**无默认值**(与 `first_token_event` 同一约定,docstring 明示 1.3.8 #27/#28)✓
|
||||||
|
- `src/polygateway/types.py:619-628` `TransportResult.stall_ms: float|None=None` / `stall_count`,docstring 钉死 None(无从观测)vs 0.0(观测过零沉默)区分、尾部不计 ✓
|
||||||
|
- `types.py:683-689` `SourceConfig.stall_threshold_ms: int = 1000`,docstring 明示"严格大于才计";`types.py:734-743` `_validate_stall_threshold` 拒 bool/非 int/≤0 ✓
|
||||||
|
- `transports/openai_compat.py:88-109` 纯函数 `_stall_from_chunk_times`: `len<2 → (None,None)`(L98-99);`gap_ms > threshold_ms` 严格大于(L106);无超阈 → `(0.0,0.0)`(L109);只遍历相邻对(zip times/times[1:])→ 尾部天然不计 ✓
|
||||||
|
- `openai_compat.py:608-618` 流式循环: 每增量 chunk `chunk_times.append(now)` + `content_event.set()`;L634 由 `_stall_from_chunk_times(chunk_times, source.stall_threshold_ms)` 派生两值进 TransportResult(L643-644)✓
|
||||||
|
- 非流式 `_complete_once`(L698-769): 收两事件永不置位,TransportResult 不传 stall 字段(默认 None)✓
|
||||||
|
|
||||||
|
### T2 净口径 —— 成立
|
||||||
|
|
||||||
|
- `types.py:331-336` `CallStats.stall_ms/stall_count: int|None = None`;`types.py:322-328` `generation_ms` docstring 重定义为净口径(含 clamp、还原公式、排除条款)✓
|
||||||
|
- 唯一换算点 `middleware/retry.py:671-681` `_record_winner_sample`: `net_ms = max(0, wall_ms - (stall_ms or 0))`(clamp 到 0 ✓);None 透传不退减 ✓
|
||||||
|
- 取整唯一处 `retry.py:376-378`(墙钟/stall 同点 int 化,墙钟=generation_ms+stall_ms 可还原成立)✓
|
||||||
|
- embedding/ocr 路径:`record_generation` 的 stall 参数默认 None(`types.py:386-387`),既有调用点不传 → 恒 None ✓
|
||||||
|
|
||||||
|
### T3 遥测 —— 成立
|
||||||
|
|
||||||
|
- `telemetry/schema.py` `COLUMNS` 亲数 **38 项**(末两项 stall_ms/stall_count,L227-228);`SQLITE_DDL`(L66-67)与 `PG_DDL`(L110-111)均含两列;`SQLITE_BACKFILL`(L146-147)与 `_PG_BACKFILL_DECLS`(L175-176)列序对齐 ✓
|
||||||
|
- `ports.py` `TelemetryRecorder.record_llm_call` 38 个 kw 参数(末两个 stall_ms/stall_count,无默认值)✓
|
||||||
|
- 迁移幂等: SQLite `sqlite.py:125-143` 先 `PRAGMA table_info` 探测、`column in existing: continue` 跳过、duplicate column 竞态视为成功;PG `postgres.py:352-369` 同款先探测后 ALTER ✓
|
||||||
|
- 取消输家行 stall 恒 None: `retry.py` CancelledError 分支 `self._emit(..., error=label)` 不传 stall → `_emit` 默认 None(L774-775);emitter `emit_cache_hit`/`emit_terminal_failure` 显式 None(`middleware/telemetry.py:515-516,570-571`)✓
|
||||||
|
|
||||||
|
### T4 中段对冲 —— 成立
|
||||||
|
|
||||||
|
- `config.py:201` `hedge_stall_after_s: float|None = None`(默认关闭);`_load_hedge`(L801-807)读 `{SCOPE}__HEDGE__STALL_AFTER_S`;`_load_stream`(L756-774)读 `{SCOPE}__STREAM__STALL_THRESHOLD_MS`;`STREAM` 已进 `_RESERVED_SEGMENTS`(L57)✓
|
||||||
|
- 三守卫唯一事实源 `check_hedge_assembly`(L810-912): stall ≥ 有效最小值(`inter_token_timeout_s or timeout_s` 回落)→ ValueError(L888-894);stall ≥ call_deadline_s → ValueError(L895-899);单源 warning(L900-906);两阈值皆 None 才早退(审查 B2)✓;`GatewaySettings.__post_init__` 与 `GatewayClient.__init__` 共用同一份(config.py:391-401 / client.py:255-263)✓
|
||||||
|
- Embedding/OCR scope 对冲键 warning: `_warn_hedge_keys_on_non_stream_scope`(config.py:915-927),被 EmbeddingSettings(L950)与 OcrSettings(L996)`__post_init__` 调用 ✓
|
||||||
|
- 编排 `retry.py`: 仅 stall+非流式 → 逐字单路(L290-301);两阶段触发窗 `_past_hedge_window`(L591-652):阶段 1 首 token 前走 AFTER_S、仅 stall 时须等首 token(防 pre-TTFT 误吞);阶段 2 循环 `asyncio.wait(timeout=stall_after_s, FIRST_COMPLETED)` + N3① 超时后复检 `content_event.is_set()` 防误触发 + N3② check→clear 无 await;触发后 FIRST_COMPLETED 竞速(L538-539);对冲路恒 `content_event=None`(L533);输家 `cancel()` 前置 `_HEDGE_LOSER_ATTR` 标记(L568-571),_attempt 取消分支据此标 `hedge_cancelled` 且**不 record_failure**(不喂熔断,L414-429)✓
|
||||||
|
- 三层可调: env 键 → `GatewaySettings.stall_threshold_ms`(config.py:206)→ `from_settings` 恒分发(client.py:543);直传 `GatewayClient(stall_threshold_ms=...)` 经 `dataclasses.replace` 分发且重跑 `__post_init__` 校验(client.py:264-270)✓
|
||||||
|
|
||||||
|
### T5 默认关闭回归 —— 静态成立 + 落盘证据
|
||||||
|
|
||||||
|
- 代码门: `retry.py:279` `if self._hedge_after_s is None and self._stall_after_s is None:` → 单路 `first_token_event=None, content_event=None`(注释明示"逐字 1.3.7 单路路径,默认关闭回归门据此成立")✓
|
||||||
|
- 落盘: `tests/outputs/138/t4/green-unit.log` 末行 `1688 passed, 17 skipped in 54.90s`(与声明 5 的 1688 一致)✓
|
||||||
|
|
||||||
|
### c) 红绿证据真实性抽查 —— 真实
|
||||||
|
|
||||||
|
- t1 red: ImportError `_stall_from_chunk_times`(功能不存在); green: 260 passed ✓
|
||||||
|
- t2 red: 6 failed(CallStats 无 stall_ms 属性、record_generation 拒 stall kw、净口径 5000≠1800、clamp 5000≠0 等精确断言); green: batch 40 passed / unit 1608 / unit+contracts 1669 ✓
|
||||||
|
- t3 red: 14 failed(COLUMNS 缺两列、`$` 占位符 36≠38、真实 SQLite `no such column: stall_ms`、幂等补列断言红); green: batch 238 / unit 1673 ✓
|
||||||
|
- t4 red: 16 failed(RetryMW 拒 `stall_after_s` kw、settings 无 `hedge_stall_after_s`、STREAM 段造出假源 `stream_1`、三守卫 DID NOT RAISE);green: batch D 169 passed、contracts 61 passed ✓
|
||||||
|
- t4/t5 lint: ruff `All checks passed!` + import-linter `Contracts: 1 kept, 0 broken` ✓
|
||||||
|
- 计数链单调: 1602(T1)→1608(T2)→1673(T3)→1688(T4),与逐批新增用例一致;红日志失败因均为"功能尚不存在"类,非环境噪声 ✓
|
||||||
|
|
||||||
|
### f) 文档一致性 —— 一致
|
||||||
|
|
||||||
|
- CHANGELOG 未发布段: 行为变更(口径重定义+还原公式+None 条款+取消输家恒 NULL)、遥测两列(36→38,旧行不回填)、中段对冲(默认关闭/独立生效/三守卫/19.0% 触发率诚实标注)✓
|
||||||
|
- README §"1.3.8 生成时间口径与中段沉默对冲"(L115-140): `generation_ms` 定义句"净生成时间 = 赢家墙钟 − Σ(> STALL_THRESHOLD_MS 的相邻增量 chunk 沉默)"与代码 `max(0, wall-stall)` 一致;能力表"遥测必录 38 字段"(L26)、逻辑调用统计段(L27)、配置表两键(L137-138)与代码一致 ✓
|
||||||
|
- `.env.example` L83-89: `STREAM__STALL_THRESHOLD_MS=1000` 与 `HEDGE__STALL_AFTER_S` 两键注释与守卫文案一致 ✓
|
||||||
|
- ARCHITECTURE.md §7.2(L504/506)净口径+两阶段触发窗登记段、§7.5(L569/571)38 字段与 stall 两列登记段,与代码行为一致 ✓
|
||||||
|
|
||||||
|
## 发现列表
|
||||||
|
|
||||||
|
- **P2-1(证据缺口,合并前必须补)**: `tests/outputs/138/` 下**无任何 tests/integration 运行证据**;动作 b 要求 `pytest tests/unit tests/contracts tests/integration`。本 diff 直接触及 PG 遥测列(`tests/integration/test_postgres_telemetry.py` 的 `_EXPECTED_COLUMNS` 已含 stall 两列、占位符/列序断言只在真实 PG 上有效)与治理栈,integration 未跑 = PG 侧补列/写入路径只有静态与 SQLite 证据。
|
||||||
|
- **P2-2(流程限制)**: 本 verifier 无 shell,动作 b/c 重跑/d/e 全部无法亲跑;现结论中"套件绿"的依据是我亲读的实现方落盘日志(文件真实、失败因真实、计数链自洽),非我本人执行。合并前须由 supervisor 补跑下方命令清单,输出落 `tests/outputs/138/verify/`。
|
||||||
|
|
||||||
|
## supervisor 必须执行的命令(全部 `conda run -n PolyGateway`,禁管道,落 `tests/outputs/138/verify/`)
|
||||||
|
|
||||||
|
1. `git log --oneline main..HEAD`(核对序列;我已于 reflog 静态核实,仍建议权威跑一次)
|
||||||
|
2. `pytest tests/unit tests/contracts tests/integration -q`(记 exit code 与计数;unit 期望 1688,contracts 期望 61)
|
||||||
|
3. 关键用例重跑(现绿确认): `pytest tests/unit/test_retry.py::TestGenerationMs tests/unit/test_stall_hedge.py::TestStallHedgeTrigger::test_stall_recheck_prevents_false_trigger tests/unit/test_stall_hedge.py::TestStallHedgeWatchdogAndFailure::test_watchdog_kills_primary_hedge_leg_survives tests/unit/test_telemetry.py::TestStallColumns -q`
|
||||||
|
4. 行为抽测 d①-③(流式假传输端到端 stall/净 generation_ms;仅 STALL_AFTER_S 下沉默触发对冲且 hedge_won;两键不设时 hedges=0/hedge_won=False)
|
||||||
|
5. `make lint`
|
||||||
|
|
||||||
|
**合并判定: OK with notes** —— 静态与落盘证据全绿;P2-1/P2-2 补跑通过后即可合并,若补跑出现任何红则转 BLOCK。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
核实完毕。所有关键引用已对照源码验证。以下为审查报告全文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 流式中段沉默设计(1.3.8)独立审查报告
|
||||||
|
|
||||||
|
- 审查对象: `designs/2026-09-11-streaming-stall-design.md`(已批准,1B' + 2B)
|
||||||
|
- 对照: `findings/2026-09-11-streaming-stall-hedging-research.md`、`designs/2026-09-10-24-hedged-requests-design.md`、main `a28d944` 源码(retry.py / openai_compat.py / streaming.py / types.py / ports.py / telemetry/schema.py / middleware/telemetry.py / config.py / client.py 全部实读)
|
||||||
|
- 立场: 不推翻人类 §9 五项拍板;只找设计与实现层面的漏洞
|
||||||
|
|
||||||
|
## 结论: **有条件实施**
|
||||||
|
|
||||||
|
总体架构判断正确: 计量与触发共用同一观测原语、复用 1.3.7 准入/记账/取消语义、遥测迁移机制现成,均无方向性问题。但有 **3 处阻断级设计缺口**(chunk 定义未钉死、遥测透传链路改动面遗漏、独立生效的编排入口遗漏)必须在实施前以设计补遗钉死;另有 9 条建议级修正。均为"补钉"而非"返工"。
|
||||||
|
|
||||||
|
## 九条审查角度逐条裁决
|
||||||
|
|
||||||
|
| # | 角度 | 裁决 | 依据(源码实证) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 沉默计时边界 | **存疑**(N1) | 尾部(最后内容 chunk→[DONE]/usage 帧/连接收尾)不是"相邻 chunk 间隔",不会被减,但仍留在墙钟基底里 → 净值**含尾部**;调研 §B3 的先例(AIPerf 剔除 [DONE]、pi-tps 止于最后 message_update)是"尾部不计",与设计的墙钟基底口径不同,设计未察觉也未声明。TTFT 同理留在净值内(人类原话"减去中段沉默"字面如此,可接受,但须明写)。应然口径: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部",README 口径表同步 |
|
||||||
|
| 2 | 非流式与输家口径 | **成立**(建议 N2) | 排除条款(非流式/chunk<2 → None 且不退减)已明写,双口径是设计内选择;建议补一句"非流式 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 分支"。输家排除由 `_register_hedge` 只记赢家 sink 的机制保证(retry.py `_register_hedge`)✓;但"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立(无 TransportResult,行是 error 行)→ 见 B2 |
|
||||||
|
| 3 | P2 与看门狗竞态 | **成立**(措辞修正 N9) | 看门狗掐死抛 `StreamLivenessTimeout`(streaming.py:24-50),在 transport 边界翻译为 `TransientError ... from exc`(openai_compat.py:476-477),**不是** CancelledError。编排层**无需区分**两种主路之死: `_attempt_hedged` Phase 5 的"先了结的是失败则等另一路"分支对任何 `_Failed` 一律等副路,两败走 `_combine_failures`(retry.py:171-176)计一次预算——现有机制完整表达设计语义 ✓。但设计"主路之死不算失败"措辞不精确: attempt 级记账照常(breaker `record_failure("timeout")`、`attempt_fails`、`reasons`、遥测 error 行),"不算失败"的只是本轮超级尝试 |
|
||||||
|
| 4 | 触发信号并发安全 | **存疑**(N3) | `asyncio.Event` 在单循环内 check→clear 之间无 await 即无交错,但**超时到期后触发对冲前必须复检 `is_set()`**——否则"chunk 与超时同一事件循环 pass 到达"会误触发。1.3.7 的 `_past_hedge_window` 是一次性等待(retry.py:542-568),stall 需要**循环**等-清-重等,设计只写"等该事件超时",未钉循环模式。等待者只有一个(编排),对冲路恒传 None 是 1.3.7 约定(retry.py `_attempt_hedged` Phase 4),但设计未声明该约定**延伸到新的 chunk Event 参数** |
|
||||||
|
| 5 | 取消穿透 | **成立**(并入 N3) | 外部取消到达 → `_attempt_hedged` 的 `except BaseException` 两任务同消、尽力收口、原异常上抛,与 1.3.7 逐字一致 ✓;stall 等待循环每轮迭代自建 waiter,须逐字套用 `_past_hedge_window` 的 waiter 收口 + `cancelling()` 穿透纪律(retry.py:556-568),设计补一句即可 |
|
||||||
|
| 6 | 计量正确性 | **存疑**(N4) | 分层可行: 沉默在 transport 由 chunk 时间戳序列算出(openai_compat.py:580-590 循环邻域),净值在 retry 由"墙钟−stall"得。但**两处的钟不同源**: retry 用注入钟 `self._now`(retry.py:332/346,client.py:242 默认 `time.monotonic`),transport 硬编码 `time.monotonic()`(openai_compat.py:562/582)且 transport 不接收注入钟(client.py:508 构造只传 registry/capabilities)。生产同为 monotonic 无事;测试注入 FakeClock 时净值可为负/失真。赢家 sink 与 1.3.7 `generation_sink` 机制协调成立(改记净值即可)✓ |
|
||||||
|
| 7 | 遥测 DDL 迁移 | **成立**(编辑点清单见 N7) | schema.py 有成熟迁移机制: `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序追加、recorder "先探测后 ALTER"(auto 档)、`telemetry_schema_sql()` 给 manual 档同源 SQL。设计引用的迁移纪律(#12/#13/#15)与现状吻合 ✓。"遥测 DB 从未存过 generation_ms"经核实属实(COLUMNS 36 项无此字段)✓ |
|
||||||
|
| 8 | 配置守卫完备性 | **存疑**(N5) | 三段键解析**无冲突**: `_load_sources` 以 `len(parts) != 4` 跳过(config.py:431),`STREAM` 进 `_RESERVED_SEGMENTS`(config.py:57)防 4 段 `{SCOPE}__STREAM__{N}__*` 撞 provider 段,与 HEDGE 先例同型 ✓。但守卫有缺口: ①设计写 `stall_after_s ≥ inter_token_timeout_s`,未写**有效值回落**——运行时是 `inter_token_timeout_s or timeout_s`(openai_compat.py:573),`inter_token` 未设时守卫对象应是 `timeout_s`;②严重度与 1.3.7 不一致: 同类"永不触发"条件 `hedge_after_s ≥ min(timeout_s)` 是 **ValueError**(config.py:820-824),而 stall 场景非流式无残留收益(1.3.7 ttft warning 留有非流式收益,stall 没有),只给 warning 论证不足;③`stall_after_s ≥ call_deadline_s` 的 ValueError"沿用 1.3.7"须点名落进 `check_hedge_assembly`(config.py:772)同函数 |
|
||||||
|
| 9 | 内部一致性 | **存疑**(N7) | 无硬矛盾,但有三处错位: ①§4.2 改动面写 `config.py:46 邻域`——:46 是 `_SOURCE_FIELDS` 表(per-source 4 段键注册表),而 §6 明写 `{SCOPE}__STREAM__STALL_THRESHOLD_MS` 是 **3 段 scope 级键**,两处矛盾,正确先例是 `_load_hedge`(config.py:745-769)同款 loader + `GatewaySettings` 字段;②§4.2 写 `types.py:417-418 邻域(CallStats 增两字段)`——CallStats 在 types.py:296-324,417-418 邻域是 LLMResponse;③§5.2 写端口加参"须进批准项",但 §9 五项无对应条目(整体批准可视为覆盖,但设计自拟的程序未闭环) |
|
||||||
|
|
||||||
|
## 发现列表
|
||||||
|
|
||||||
|
### 阻断级(实施前必须以设计补遗钉死)
|
||||||
|
|
||||||
|
**B1 | 设计 §3/§4.2/§5.2 | "内容 chunk"定义未钉死,reasoning 增量归属不明**
|
||||||
|
- 问题: 源码现状是 `_iter_sse_deltas` 对 `content` 与 `reasoning_content` **都**产出(openai_compat.py:65-85),看门狗与 `first_token_event` 都把思考增量当存活证据(openai_compat.py:585-588 注释"思考增量同样是存活证据,与看门狗活性口径一致")。若按"内容 chunk"字面只计 `is_content=True` 的增量,则 reasoning 模型长 thinking 阶段(只流 reasoning_content)会被误计为沉默: P1 的 `stall_ms` 虚高、净口径失真;P2 在正常推理中误触发对冲。本库 reasoning 是一等特性,这不是边缘情形。
|
||||||
|
- 修法: §3 明写"chunk = 任何增量产出(content 或 reasoning_content),与看门狗/首 token 活性口径一致;仅空 chunk/usage 帧/SSE keepalive 不计时"。排除条款的"内容 chunk <2"同步按此口径。
|
||||||
|
|
||||||
|
**B2 | 设计 §4.2 改动面/§7 契约表 | 遥测两列的透传链路改动面遗漏,且"输家行可 join"表述不成立**
|
||||||
|
- 问题: attempt 行的用量字段由 emitter `_AttemptUsage.of(response)` 从 **LLMResponse** 读取(middleware/telemetry.py:216-236)。stall 两列要进 attempt 行,必须二选一: (a) LLMResponse 增 `stall_ms/stall_count`(公共类型,只增带默认值合法,但**改动面与 §7 契约表均未列**)+ `_build_response` 搬运;或 (b) `_emit` 显式传 TransportResult 两值。同时 `ports.py` 的 `TelemetryRecorder.record_llm_call` 签名必须加两参——这是**第三个端口签名变更**,§7 只列了 `Transport.complete` 与 `TransportResult`;emitter 的装配闸 `signature.bind` 以该协议签名为判据(telemetry.py:338),漏改协议直接装配期炸。另外 §4.2"输家数据留在其 attempt 遥测行可 join 还原"对**被取消**的输家不成立: 取消的输家无 TransportResult,其行是 `hedge_cancelled` error 行,stall 恒 None;只有"竞速落选但已完成"的输家才有数据。
|
||||||
|
- 修法: §4.2/§7 补全链路(选定 (a) 或 (b);ports.py recorder 签名 + emitter + 两 recorder 实现 + schema.py 四处编辑点);修正输家表述为"已完成输家的沉默在其 attempt 行;被取消输家无沉默数据(传输未返回),其存在性由 `hedge_cancelled` 行表达"。
|
||||||
|
|
||||||
|
**B3 | 设计 §5.2 改动面 | "独立于 AFTER_S 生效"所需的编排入口改动遗漏**
|
||||||
|
- 问题: 现状 `RetryMW.__call__` 仅在 `self._hedge_after_s is not None` 时进对冲路径(retry.py `__call__` 分支)。§9-④ 拍板独立生效意味着 `hedge_after_s=None + stall_after_s=25` 必须也能进对冲路径——`__call__` 入口门须改为"两阈值任一已设且(对 stall)请求为流式"。同时: ①非流式 + 仅 stall_after_s → 无中段沉默概念,应逐字走单路(等价未配置),设计未声明;②`_past_hedge_window` 须从一次性等待重构为两阶段(首 token 前 AFTER_S 仅当已设 / 首 token 后 stall 循环),改动面只写"扩为"偏轻;③纯非流式部署设了 stall_after_s 是否给装配 warning,设计未涉及。
|
||||||
|
- 修法: §5.2 改动面补 `__call__` 入口门与非流式退化声明;§6 配置表补"stall_after_s 仅对流式生效,纯非流式 scope 设置时 warning(或静默,须拍板一句)"。
|
||||||
|
|
||||||
|
### 建议级
|
||||||
|
|
||||||
|
**N1 | §3/§4.2 | 沉默计时边界未明写(TTFT 与尾部归属)**
|
||||||
|
- 尾部(末 chunk→[DONE]/usage 帧)不是相邻间隔、不会被减,但留在墙钟基底 → 净值含尾部;TTFT 同理。调研 §B3 先例是"流终止信号后不计",与墙钟基底口径不同。修法: 显式声明"净值 = 墙钟 − 中段沉默,仍含 TTFT 与尾部;尾部归属活跃侧,与 pi-tps'止于最后更新'的差异是有意的(墙钟基底),尾部通常 ≈0";README 口径表同步。
|
||||||
|
|
||||||
|
**N2 | §4.2 排除条款 | 非流式双口径补一句显式声明**
|
||||||
|
- "非流式场景 net≈wall(无中段沉默概念),下游预算公式以 `stall_ms is None` 为分支条件"。
|
||||||
|
|
||||||
|
**N3 | §5.2 触发机制 | stall Event 循环的并发与取消纪律未钉**
|
||||||
|
- ①超时到期后触发对冲前复检 `is_set()`;②check→clear 之间不得有 await;③对冲路 chunk Event 恒 None(1.3.7 首 token 约定延伸);④每轮 waiter 收口 + `cancelling()` 穿透逐字套 `_past_hedge_window`(retry.py:556-568);⑤等待者唯一(编排),Event 无需多等待者语义。或改用"最后 chunk 时间戳 + 计算剩余时长"模式免 clear 竞态——二选一,设计钉一种。
|
||||||
|
|
||||||
|
**N4 | §4.2 改动面 | 钟同源纪律与负值防御**
|
||||||
|
- retry 墙钟(注入 `now`)与 transport 沉默钟(硬编码 `time.monotonic()`)生产同源、测试分叉。修法: 设计声明"沉默序列与墙钟必须同为 monotonic 语义;`net = max(0, wall − stall)` clamp;FakeClock 测试用假 transport 自控 stall 值,真实 transport 测试不得注入假钟"。
|
||||||
|
|
||||||
|
**N5 | §5.2/§6 | 守卫完备性三缺口**
|
||||||
|
- ①`stall_after_s ≥ inter_token_timeout_s` 须用有效值 `inter_token_timeout_s or timeout_s`(openai_compat.py:573 回落同款);②"永不触发"严重度对照 1.3.7 的 ValueError(config.py:820-824)需论证为何只 warning(stall 无非流式残留收益,升 ValueError 的论据其实更强);③`stall_after_s ≥ call_deadline_s` ValueError 点名落进 `check_hedge_assembly`。
|
||||||
|
|
||||||
|
**N6 | §4.2 `stall_count` 定义 | "连续停顿合并一次"在 gap 口径下无所指**
|
||||||
|
- 间隔口径里任意两个超阈值 gap 之间至少隔一个 chunk,不存在可合并的"连续停顿";该子句移植自 pi-tps 的事件流语义,在本口径下无操作定义。修法: 直接定义"`stall_count` = 超阈值相邻间隔个数",删合并子句。
|
||||||
|
|
||||||
|
**N7 | §4.2/§5.2/§6/§7 | 引用勘误与编辑点清单**
|
||||||
|
- `types.py:417-418` 实为 LLMResponse 邻域(max_inter_token_ms 在 :406),CallStats 在 :296-324;`config.py:401-407` 实为 :427-431;`config.py:46` 是 `_SOURCE_FIELDS`(与 §6 三段键矛盾,见裁决 9);`openai_compat.py:605` 是 max_inter_token_ms 装配处,max_gap 循环在 :580-590;`schema.py:43/85` 指向 ttft_ms 行,实际编辑点 = DDL 末尾(created_at 之后,schema.py 头注释纪律)+ `SQLITE_BACKFILL`/`_PG_BACKFILL_DECLS` 保序对齐 + `COLUMNS` + 列数断言测试 + `telemetry_schema_sql` 同源再生成;§5.2"须进批准项"在 §9 无对应条目,补一条批准记录或删该句。
|
||||||
|
|
||||||
|
**N8 | §4.2 vs §7 | 类型换算点未钉**
|
||||||
|
- TransportResult `stall_ms: float | None` vs CallStats `int | None`: 钉一句换算顺序(建议 retry 层 `int(wall_ms) − int(stall_ms)` 或先减后取整,二选一),避免各实现 ±1ms 漂移。
|
||||||
|
|
||||||
|
**N9 | §5.2 与看门狗的交互 | 措辞修正 + 一句哲学论证**
|
||||||
|
- "主路之死不算失败"改为"本轮超级尝试不因主路之死而败(等副路);attempt 级记账(breaker failure/`attempt_fails`/遥测 error 行)照常"。并补一句论证: 看门狗掐死喂熔断失败 vs 对冲输家取消不喂熔断,同为源沉默事件却不同处置——前者是既有看门狗语义(1.3.6 起),后者是 1.3.7 已批准语义,本设计不改任一,但运维侧"挂起率"统计须知道这一不对称。
|
||||||
|
|
||||||
|
## 核实记录(引用抽查,全部实读源码)
|
||||||
|
|
||||||
|
| 设计引用 | 实况 | 结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| retry.py:542-562 `_past_hedge_window` | :542 起,返回式逐字一致 | ✓ |
|
||||||
|
| retry.py:~335/~346 计时点 | :332/:346 | ✓(邻域) |
|
||||||
|
| openai_compat.py:573 回落 | :573 逐字一致 | ✓ |
|
||||||
|
| openai_compat.py:605 max_gap | :580-590 循环,:605 装配 | 偏位(N7) |
|
||||||
|
| types.py:417-418 | CallStats 在 :296-324 | 错位(N7) |
|
||||||
|
| config.py:46/57/401-407 | :46 ✓ :57 ✓ :401-407 实为 :427-431 | 部分偏位 |
|
||||||
|
| 遥测无 generation_ms 列 | COLUMNS 36 项核实 | ✓ |
|
||||||
|
| 看门狗抛 TransientError | streaming.py + openai_compat.py:476 | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**结论行**: 有条件实施——3 条阻断级(B1 chunk 定义、B2 遥测透传链路、B3 编排入口)+ 9 条建议级(N1-N9),均为设计补遗级修正,无需推翻 §9 拍板。
|
||||||
|
|
||||||
|
**发现计数**: B1-B3(阻断级 3 条),N1-N9(建议级 9 条)。
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
核查完毕,所有证据齐备。撰写审查报告:
|
||||||
|
|
||||||
|
## Review
|
||||||
|
|
||||||
|
**结论:有条件执行** — 计划整体忠实于已批准设计(含 B1-B4/N1-N9),同步清单两条 grep 重跑基本属实,TDD 红绿逻辑与编排伪代码经源码逐行验证成立;但有 **2 处计划文本级硬伤**(按字面执行必在 T3/T4 验收门炸出计划声称"零改动/不改断言"的返工)需先修计划,另 5 处建议项。无设计级返工、无人类门事项。
|
||||||
|
|
||||||
|
### 8 条核查逐条裁决
|
||||||
|
|
||||||
|
**1. 同步清单真实性 — 基本属实,一处虚列。** 重跑两条 grep:§2.2 的 11 处 `def complete` + 11 处调用点全部命中且行号准确(含 test_retry.py:112 第二 fake 与 :123 转发、e2e/conftest.py:294/:306 转发、test_live_evidence.py:280/:1270、test_usage_source_domain.py:142);§2.3 的 13 处 `**fields` fake 计数精确、3 处负例(:3327/:3349/:3354 均为故意残缺显式签名)、2 处 `**fields` recorder(sqlite.py:145/postgres.py:411)、_DummyRecorder(test_ports.py:102)全部属实。**虚列**:§2.3 #8 声称 test_hedge.py:122"零改动"不成立(显式全签名 fake,见 B1);test_backpressure.py:233 是 `*args, **kwargs` 才真零改动。另漏列两处硬编码计数断言(见 N1)。
|
||||||
|
|
||||||
|
**2. 签名与行号 — 抽查 14 处,绝大多数精确。** 精确命中:ports.py:56/:306(36 参实测)、types.py:296/:328/:560/:581/:590/:633/:674、openai_compat.py:427/:553/:560/:659/:666(chunk 循环 :581-592、装配 :598-616)、retry.py:275/:314/:344/:346/:436/:484/:533-562/:574-578/:580-594、config.py:57/:195/:745-769/:772-849/:865/:904、client.py:240/:252/:280/:485、middleware/telemetry.py:198/:396/:449/:501/:556/:618、schema.py 五编辑点。**漂移**:`_attempt_hedged` 计划 :371-530、实际 **:415-531**;另有 5 处 ±1-6 小漂移(入口门 :268-279、from_env :394-424、_emit :668-700、_complete_once :659-705、_validate_hedge :375-392)。具名接缝均无歧义,不阻断(见 N2)。
|
||||||
|
|
||||||
|
**3. TDD 红绿逻辑 — 四批次全部成立。** 批次 A(端口未加参→TypeError、纯函数不存在→ImportError、字段不存在→TypeError)、批次 B(字段缺失→AttributeError、仍得墙钟值→断言失败)、批次 C(冻结清单先扩→列数/列序断言失败,已验证 `_EXPECTED_COLUMNS` 派生机制;协议未加参而 _DummyRecorder 先加→装配闸 ValueError)、批次 D(kw 不存在→TypeError、`hedges==0`)均为真红,无伪 TDD。措辞瑕疵:批次 D "键未识 ValueError" 实为 AttributeError/TypeError(3 段键被 config.py:431 静默跳过),红仍成立(见 N3)。
|
||||||
|
|
||||||
|
**4. 编排语义 — 并发正确性验证通过,与设计 §5.2 一致。** Event 复检防同 pass 误触发 ✓;check→clear 之间同步无 await(单线程循环下无交错)✓;每轮自建 waiter + finally 收口 + `cancelling()` 穿透逐字套用 :544-561 既有范式 ✓;Event 状态持久无丢失唤醒 ✓;残留竞速窗口已诚实标注(设计 N3① 批准形态)。仅 stall 时"阶段 1 无 timeout 等首 token→再进阶段 2"与设计 §5.2/B3 逐字一致——**pre-TTFT 沉默不被 stall 窗误吞正是设计意图**,TTFT 长等待由 ttft 看门狗治理 ✓。入口门三分支自洽(仅 stall+非流式→单路;AFTER_S+非流式→对冲路,事件退化为纯时间阈值,同 1.3.7)✓。**主路被看门狗掐死**:TransientError 在 `_attempt` except 分支转为 `_Failed` 返回(retry.py:393-406),primary task 正常完成 → Phase 5 "先了结的是失败则等另一路"(retry.py:505-509)原样承接,attempt 级记账(breaker/attempt_fails/遥测 error 行)在返回前已完成 ✓——设计 §11-N9 完全可由既有路径表达。
|
||||||
|
|
||||||
|
**5. 换算与钟纪律 — 成立。** N8 唯一换算处落点核实:retry.py:346 是现状唯一 int 换算点;sink 三元组升级的消费方仅 `__call__`:289、`_record_generation`:578、`_register_hedge`:593 三处,全在 retry.py 且计划 §3.2 已枚举,无测试直调这两个私有函数(grep 实证)。N4 钟同源核实:client.py:243 `now=time.monotonic` 默认与 transport 内 `time.monotonic` 同钟 ✓。`record_generation` 加 kw-only 默认 None 对 embedding.py:391/ocr.py:407/test_types.py:648-669 零冲击 ✓。
|
||||||
|
|
||||||
|
**6. 遥测链路 — 取舍安全,一处 fake 例外。** emit_attempt 加默认 None kw 对全部 ~45 处真实 emitter 直调(均关键字形态,grep 全量清点)与 embedding.py:532/ocr.py:611 安全 ✓;三处装配闸负例在协议 +2 参后仍不满足 `signature.bind`,负例仍绿 ✓(逻辑推理成立:残缺签名缺的不止两参)。**例外即 B1**:RetryMW._emit 是 emit_attempt 的调用方且将显式转发两 kw,"新参默认 None"只保护调用方、不保护被调的显式签名 fake。
|
||||||
|
|
||||||
|
**7. 配置守卫 — 落点属实,返回契约有缺口。** check_hedge_assembly 调用点四处(client.py:252、config.py:384、test_config.py:1174/:1184)全关键字形态,加 kw-only 参可行;有效最小值守卫与 openai_compat.py:573 回落同款 ✓;B4 落点核实:EmbeddingSettings.__post_init__ :865 存在且持 `gateway` 字段 ✓,OcrSettings :904 确无 `__post_init__`(计划"新增"表述准确)✓。**缺口**:归一化 stall_after_s 的返回路径未定义(现状返回 `float|None`),二元组返回会炸 test_config.py:1184 的 `is None` 断言,与批次 E 冲突(见 B2);早退须改写为"两阈值皆 None 才早退"(见 B2 附带)。
|
||||||
|
|
||||||
|
**8. 默认关闭回归 — 论证成立。** T1 恒传 None(retry.py:275 等)✓、入口门第一分支逐字保留 ✓;stall 序列无条件记录(content_event=None 只跳过 set,不跳过时间戳)→ 流式 ≥2 chunk 恒填 0.0/0.0 或实际值、非流式/<2 恒 None——**计量独立于对冲开关正是 P1 意图,设计与计划一致** ✓。TransportResult 新字段不破坏回归门:test_openai_compat.py 无相等断言、无 asdict/fields 计数(grep 实证)✓;CallStats 新字段默认 None 且无 `== CallStats` 断言 ✓。**字面例外**:B1/N1 所列硬编码计数断言与 test_config.py:1184 是批次 E"不改一行既有断言"的真实例外,计划应显式登记而非被实施者发现。
|
||||||
|
|
||||||
|
### 发现列表
|
||||||
|
|
||||||
|
| # | 级别 | 位置 | 问题 | 修法 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| B1 | 阻断(事实错误,修法小) | plan §2.3 #8;tests/unit/test_hedge.py:121-133 | RecordingEmitter.emit_attempt 是显式全签名 fake(收 8 个 kw,无 `**kwargs`);T3 后 `_emit` 显式转发 `stall_ms=`/`stall_count=` → TypeError,test_hedge.py 全部成功路径用例红,且违反批次 D/E "1.3.7 既有断言一字不改"承诺。"新参默认 None"的论证只护调用方,护不住被调 fake | §2.3 #8 拆两行:test_backpressure.py:233(`*args, **kwargs`)真零改动;test_hedge.py:122 列入 T3 同步清单(加 `stall_ms=None, stall_count=None` 两 kw,与 T1 fake 签名同步同款处置);批次 E 措辞澄清"fake 签名同步不属于断言改动" |
|
||||||
|
| B2 | 阻断(契约占位,违反计划自设标准) | plan §3.5 守卫行;config.py:772-849;test_config.py:1171-1190 | 计划要求 "RetryMW 传 stall_after_s=归一化值",但归一化值如何出 check_hedge_assembly 未定义(§3 自称"可执行定义,禁止占位");最自然的二元组返回炸 test_config.py:1184 的 `is None` 断言,与批次 E 直接冲突。附带:§3.5"同 after is None 既有早退形态"字面执行会让仅 stall 配置绕过守卫②③④(批次 D 能抓住,但属误导) | §3.5 钉死返回契约(建议 `tuple[float\|None, float\|None]` 或等值命名结果,config.py:384 与 client.py:252 两处随之解构);test_config.py:1174/:1184 列入 T4 同步清单;早退条件改写为"两阈值皆 None 才早退";批次 E 例外登记本处 + B1 + N1 |
|
||||||
|
| N1 | 建议 | test_ports.py:313、test_telemetry.py:748(连带 :650/:743/:756 注释文本) | 两处硬编码计数断言(`len(params)-1 == 36`、`len(physical) == 37`)未列入批次 C 清单;T3 验收门会自愈,但破坏"清单即全量"承诺 | 补入批次 C 扩展点(36→38、37→39),注释文本顺手同步 |
|
||||||
|
| N2 | 建议 | plan §2.1/§8 行号表 | `_attempt_hedged` :371-530 实为 :415-531;另有 5 处 ±1-6 漂移 | 执行前以 grep 复核为准(§6 已有此纪律),修正 §2.1 表防误锚 |
|
||||||
|
| N3 | 建议 | plan T4 先红清单 | "键未识 ValueError" 不准确:3 段未识键被 config.py:431 静默跳过,实际红因是 AttributeError/TypeError | 措辞修正即可,红成立 |
|
||||||
|
| N4 | 建议 | middleware/telemetry.py:368 | docstring "组装 36 字段" 未列入计数同步范围(ports.py:280/sqlite.py:146/schema.py:8 已覆盖) | T3 顺手同步,防计数漂移 |
|
||||||
|
| N5 | 建议 | tests/unit/test_hedge.py:150 `_harness(stall_window_s=300.0)` | 既有 `stall_window_s`(StallClock 预算)与新 `stall_after_s` 命名相邻,新文件复制设施时易混 | test_stall_hedge.py 设施复用时加注释区隔两概念 |
|
||||||
|
|
||||||
|
**Merge verdict: OK with notes** — B1/B2 修计划文本(均为小时级,不改任何设计语义)后即可按书执行;TDD 门对全部 N 项有自愈能力。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
type: review
|
||||||
|
node_id: review:2026-10-01-issue29-cache-review
|
||||||
|
title: 保真缓存审查修复与1.4.0准备
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# 保真缓存审查修复与1.4.0准备
|
||||||
|
|
||||||
|
## 审查采纳
|
||||||
|
|
||||||
|
| 级别/问题 | 核验与处置 |
|
||||||
|
| --- | --- |
|
||||||
|
| Important/P2:空协议缓存绕过成功判据 | 已实证:新测试在修复前 10 failed、1 passed;role-only、null/空/空白正文+空工具、reasoning-only,分别带空投影/伪造旧投影均误命中。采纳并修复。 |
|
||||||
|
| Minor:协议/治理测试互相导入 | 已核验双向依赖;将帧构造放基础协议测试中,上层 fidelity 单向复用,没有新增 helpers 抽象。 |
|
||||||
|
|
||||||
|
内核 `types._has_completion_output` 供 transport 和缓存共用:先经协议类型结构校验,有完整工具即有效;无工具须原协议正文和兼容投影均非空。快照类型仍允许描述空消息,不将成功判据强塞进构造器。缓存使用已有 warning/miss,text 旧缓存行为不变。没有 middleware→transport 依赖。
|
||||||
|
|
||||||
|
## 验证证据
|
||||||
|
|
||||||
|
定向 `test_cache/test_openai_completion/test_openai_fidelity/test_openai_compat` 为 268 passed;日志 `tests/outputs/issue29/review-cache-{red,green}.log`(运行产物不提交)。完整质量门1827 passed/23 skipped/110 deselected、95%覆盖、退出0;真实Redis+包13通过;1.4.0 wheel/sdist源码/README/LICENSE逐字校验及twine通过,详见计划最新记录;首轮 lint 发现一个测试 import 顺序,修正后重新运行全门,未掩盖首次失败。
|
||||||
|
|
||||||
|
## 发布准备边界
|
||||||
|
|
||||||
|
1.4.0 对应新增公共协议支持及 transport 端口参数;2026-10-01 远端 tags 和匿名 registry simple 最大版本均1.3.8,无1.4.0冲突。README安装约束、能力表、CHANGELOG定版、两处版本同步。新增 `.env.example` 仅四行说明,无凭据。源码/许可来源核查复用已独审 finding;新产物仍须父会话最终复核后发布。本轮不合并、不 push/tag/上传、不关issue;原main脏文件未改。
|
||||||
|
|
||||||
|
## 后续版本定案(2026-10-01 EDT / 2026-10-02 UTC)
|
||||||
|
|
||||||
|
用户最终指定发布 **1.3.9**,取代本文当时准备但从未发布的 1.4.0。本文标题、历史构建及审查证据保留原值;当前发布目标与新构建核验见[计划 §7](../plans/2026-10-01-issue29-openai-fidelity.md)。版本号变更不撤销自定义 Transport 的 `response_mode` 适配要求。
|
||||||
@@ -177,3 +177,26 @@ ORDER BY model, calls DESC;
|
|||||||
## 1.3.4 测试侧证据(不新增 schema)
|
## 1.3.4 测试侧证据(不新增 schema)
|
||||||
|
|
||||||
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
|
`tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。
|
||||||
|
|
||||||
|
## #29 复用声明(2026-10-01)
|
||||||
|
|
||||||
|
OpenAI 保真没有新增遥测表/列;仍经 RetryMW._emit → TelemetryEmitter。
|
||||||
|
error 帧是失败 attempt,正文与思考仍为兼容投影,不新增工具全文列。
|
||||||
|
本页原标题/表格停留 36 字段;实际 `telemetry/schema.py` 已含 stall_ms/stall_count
|
||||||
|
共 38 个 INSERT 字段(§7.8 已登记)。本次不改 DDL,真实定义以源码为准。
|
||||||
|
|
||||||
|
## 1.3.10 `stream_end` 列(#31,2026-10-14)
|
||||||
|
|
||||||
|
`llm_calls` 末尾追加可空 `stream_end TEXT`:INSERT 字段 38 → 39,物理列(含 `created_at`)39 → 40;
|
||||||
|
上文"36 字段"标题为历史口径,以 `telemetry/schema.py` 的 `COLUMNS` 为准。
|
||||||
|
|
||||||
|
| 值 | 含义 | 行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `done` | 收到 `[DONE]` | 流式成功 attempt |
|
||||||
|
| `eof_after_finish` | 无 DONE,finish + 其后实测 usage + 干净 EOF | 流式成功 attempt |
|
||||||
|
| `salvaged` | 文本 `missing_done=salvage` 打捞 | 流式成功 attempt |
|
||||||
|
| NULL | 非流式 / 缓存命中 / 失败 / 取消 / 终态行,或补列前旧行 | — |
|
||||||
|
|
||||||
|
来源: emitter 经 `_AttemptUsage.of(response)` 读 `LLMResponse.stream_end`(不走 `emit_attempt` kw);
|
||||||
|
命中与终态入口显式 None。评估复用 `metric:call-telemetry-coverage`;新增观察口径
|
||||||
|
`SELECT stream_end, count(*) FROM llm_calls WHERE event_kind='attempt' GROUP BY 1` 统计中转不发 DONE 的比例。
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from polygateway.config import EmbeddingSettings, GatewaySettings, OcrSettings
|
|||||||
from polygateway.embedding import EmbeddingClient
|
from polygateway.embedding import EmbeddingClient
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
|
CallDeadlineExceeded,
|
||||||
CircuitOpenError,
|
CircuitOpenError,
|
||||||
GatewayUnavailableError,
|
GatewayUnavailableError,
|
||||||
GovernanceBackendError,
|
GovernanceBackendError,
|
||||||
@@ -46,12 +47,15 @@ from polygateway.types import (
|
|||||||
OcrLayoutElement,
|
OcrLayoutElement,
|
||||||
OcrLayoutResult,
|
OcrLayoutResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
StreamEnd,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
ThinkingObservation,
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "1.3.5"
|
__version__ = "1.3.10"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROFILES",
|
"DEFAULT_PROFILES",
|
||||||
@@ -59,6 +63,7 @@ __all__ = [
|
|||||||
"Effort",
|
"Effort",
|
||||||
"AllSourcesExhausted",
|
"AllSourcesExhausted",
|
||||||
"CallStats",
|
"CallStats",
|
||||||
|
"CallDeadlineExceeded",
|
||||||
"CircuitOpenError",
|
"CircuitOpenError",
|
||||||
"EmbeddingClient",
|
"EmbeddingClient",
|
||||||
"EmbeddingResponse",
|
"EmbeddingResponse",
|
||||||
@@ -68,6 +73,9 @@ __all__ = [
|
|||||||
"GatewayUnavailableError",
|
"GatewayUnavailableError",
|
||||||
"GovernanceBackendError",
|
"GovernanceBackendError",
|
||||||
"LLMResponse",
|
"LLMResponse",
|
||||||
|
"ResponseMode",
|
||||||
|
"StreamEnd",
|
||||||
|
"OpenAIChatCompletion",
|
||||||
"ModelPrice",
|
"ModelPrice",
|
||||||
"OcrClient",
|
"OcrClient",
|
||||||
"OcrLayoutElement",
|
"OcrLayoutElement",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -19,7 +20,8 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
from polygateway.backends.memory.breaker import InMemoryGate
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
from polygateway.backends.memory.cache import InMemoryCache
|
from polygateway.backends.memory.cache import InMemoryCache
|
||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
from polygateway.config import GatewaySettings
|
from polygateway.config import GatewaySettings, check_hedge_assembly
|
||||||
|
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||||
from polygateway.errors import PolyGatewayError
|
from polygateway.errors import PolyGatewayError
|
||||||
from polygateway.middleware.base import compose
|
from polygateway.middleware.base import compose
|
||||||
from polygateway.middleware.cache import CacheMW
|
from polygateway.middleware.cache import CacheMW
|
||||||
@@ -47,6 +49,7 @@ from polygateway.types import (
|
|||||||
ChatRequest,
|
ChatRequest,
|
||||||
Effort,
|
Effort,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
ResponseMode,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
_CallContext,
|
_CallContext,
|
||||||
coerce_effort,
|
coerce_effort,
|
||||||
@@ -235,10 +238,38 @@ class GatewayClient:
|
|||||||
structured_strategy: StructuredOutputStrategy | None = None,
|
structured_strategy: StructuredOutputStrategy | None = None,
|
||||||
structured_escalation: StructuredOutputStrategy | None = None,
|
structured_escalation: StructuredOutputStrategy | None = None,
|
||||||
structured_max_retries: int = 1,
|
structured_max_retries: int = 1,
|
||||||
|
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,
|
now: Any = time.monotonic,
|
||||||
sleep: Any = asyncio.sleep,
|
sleep: Any = asyncio.sleep,
|
||||||
rng: Any = random.random,
|
rng: Any = random.random,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
||||||
|
self._call_deadline_s = ensure_call_deadline(
|
||||||
|
call_deadline_s, "GatewayClient(call_deadline_s=...)"
|
||||||
|
)
|
||||||
|
# 对冲守卫与 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 = (
|
emitter = (
|
||||||
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
|
TelemetryEmitter(telemetry, scope=scope, pricing=pricing, text_cap=text_cap)
|
||||||
if telemetry is not None
|
if telemetry is not None
|
||||||
@@ -261,6 +292,10 @@ class GatewayClient:
|
|||||||
ceiling=float(max([64, *(s.max_concurrency for s in sources if s.max_concurrency)]))
|
ceiling=float(max([64, *(s.max_concurrency for s in sources if s.max_concurrency)]))
|
||||||
),
|
),
|
||||||
emitter=emitter,
|
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,
|
now=now,
|
||||||
sleep=sleep,
|
sleep=sleep,
|
||||||
rng=rng,
|
rng=rng,
|
||||||
@@ -292,6 +327,8 @@ class GatewayClient:
|
|||||||
self._structured_available = structured_strategy is not None
|
self._structured_available = structured_strategy is not None
|
||||||
self._terminal = terminal # 内部引用: 装配自省/测试用
|
self._terminal = terminal # 内部引用: 装配自省/测试用
|
||||||
self._handler = compose(middlewares, terminal)
|
self._handler = compose(middlewares, terminal)
|
||||||
|
# 期限到期需要报出 scope(现之前只传给 RetryMW,未自存)
|
||||||
|
self._scope = scope
|
||||||
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
|
# 逻辑调用统计需要同一只注入钟(1.3.5);现之前只传给中间件未自存
|
||||||
self._now = now
|
self._now = now
|
||||||
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
|
# 终态行由公开边界统一写出(T3),故边界也需持有 emitter
|
||||||
@@ -336,9 +373,16 @@ class GatewayClient:
|
|||||||
reasoning_effort: Effort | str | None = None,
|
reasoning_effort: Effort | str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
|
response_mode: ResponseMode = "text",
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
"""一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。
|
||||||
|
|
||||||
|
`response_mode="openai"` 返回 `completion` 完整单 choice 消息快照;
|
||||||
|
不支持与 structured 并用,不执行工具。空正文但完整工具是合法结果;
|
||||||
|
缺 [DONE] 不打捞。默认 text 保持既有文本投影。返回模式不绕过受管推理
|
||||||
|
校验;raw-only 要求源级与请求级推理意图均为 None。
|
||||||
|
|
||||||
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
`overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级
|
||||||
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only
|
||||||
参数不影响既有调用点(issue #4)。
|
参数不影响既有调用点(issue #4)。
|
||||||
@@ -351,7 +395,29 @@ class GatewayClient:
|
|||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测、**不进缓存 key**
|
||||||
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
(租户隔离由 `cache_namespace` 负责,ARCH §7.5);前者享有真实列待遇
|
||||||
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
(可挂 RLS、可进复合索引),后者是任意 KV 容器(issue #11)。
|
||||||
|
|
||||||
|
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值,
|
||||||
|
正数 = 本次覆盖,**不提供"本次关闭"**。它治理的是**等待**: 到期抛
|
||||||
|
`CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、
|
||||||
|
已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。
|
||||||
"""
|
"""
|
||||||
|
if response_mode not in ("text", "openai"):
|
||||||
|
raise ValueError("response_mode 必须为 text 或 openai")
|
||||||
|
if response_mode == "openai":
|
||||||
|
if structured is not None:
|
||||||
|
raise ValueError("openai 模式不能与 structured 并用")
|
||||||
|
if (
|
||||||
|
overlay is not None
|
||||||
|
and "n" in overlay
|
||||||
|
and (type(overlay["n"]) is not int or overlay["n"] != 1)
|
||||||
|
):
|
||||||
|
raise ValueError("openai 模式仅支持 n=1")
|
||||||
|
try:
|
||||||
|
json.dumps(messages, allow_nan=False)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("messages 必须可 JSON 序列化") from exc
|
||||||
|
messages = copy.deepcopy(messages)
|
||||||
|
overlay = copy.deepcopy(dict(overlay)) if overlay is not None else None
|
||||||
if structured is not None and not self._structured_available:
|
if structured is not None and not self._structured_available:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
"结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配"
|
||||||
@@ -377,10 +443,18 @@ class GatewayClient:
|
|||||||
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
else coerce_effort(reasoning_effort, origin="chat(reasoning_effort=...)")
|
||||||
)
|
)
|
||||||
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
|
validate_thinking_raw(sampling, effort=effort, wire=None, origin="chat overlay")
|
||||||
|
# 期限取值与校验必须在创建 awaitable **之前**: 否则非法值抛错时会遗留
|
||||||
|
# 未 await 的协程(RuntimeWarning + 资源不释放)
|
||||||
|
deadline = (
|
||||||
|
self._call_deadline_s
|
||||||
|
if call_deadline_s is None
|
||||||
|
else ensure_call_deadline(call_deadline_s, "chat(call_deadline_s=...)")
|
||||||
|
)
|
||||||
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
|
# 三项校验均已通过 → 进入统计边界(设计 §3: 输入校验异常在边界之外,保持原行为)
|
||||||
context = _CallContext(now=self._now)
|
context = _CallContext(now=self._now)
|
||||||
request = ChatRequest(
|
request = ChatRequest(
|
||||||
messages=messages,
|
messages=messages,
|
||||||
|
response_mode=response_mode,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
parent_call_id=parent_call_id,
|
parent_call_id=parent_call_id,
|
||||||
cache_salt=cache_salt,
|
cache_salt=cache_salt,
|
||||||
@@ -395,7 +469,9 @@ class GatewayClient:
|
|||||||
call_context=context,
|
call_context=context,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await self._handler(request)
|
response = await with_call_deadline(
|
||||||
|
self._handler(request), deadline_s=deadline, scope=self._scope
|
||||||
|
)
|
||||||
except PolyGatewayError as exc:
|
except PolyGatewayError as exc:
|
||||||
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
|
# 统计边界内的一切领域失败均尝试写一条终态行(1.3.5 设计 §6 I3),
|
||||||
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
|
# 包括已有 attempt 错误行的 RequestRejected / ResultInvalid——两类行描述
|
||||||
@@ -485,6 +561,12 @@ class GatewayClient:
|
|||||||
structured_strategy=strategy,
|
structured_strategy=strategy,
|
||||||
structured_escalation=escalation,
|
structured_escalation=escalation,
|
||||||
structured_max_retries=settings.structured_max_retries,
|
structured_max_retries=settings.structured_max_retries,
|
||||||
|
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)
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
|
client._owns_cache = cache is None # 缓存后端可以是 None(backend=none),helper 会跳过
|
||||||
@@ -610,6 +692,7 @@ async def gather_bounded[T](aws: Iterable[Awaitable[T]], *, concurrency: int) ->
|
|||||||
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
"""有界并发 gather(D5 便利函数,替代 VT 手搓 semaphore+gather 样板)。
|
||||||
|
|
||||||
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
语义与 `asyncio.gather` 默认一致: 结果保序、首个异常上抛;仅增加并发上限。
|
||||||
|
期限计时从每次调用真正开始执行起算,信号量排队时长不在 `call_deadline_s` 之内。
|
||||||
"""
|
"""
|
||||||
if concurrency < 1:
|
if concurrency < 1:
|
||||||
raise ValueError("concurrency 必须 ≥ 1")
|
raise ValueError("concurrency 必须 ≥ 1")
|
||||||
|
|||||||
+263
-2
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING
|
|||||||
from dotenv import dotenv_values
|
from dotenv import dotenv_values
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.deadline import ensure_call_deadline
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
BackpressurePolicy,
|
BackpressurePolicy,
|
||||||
BreakerConfig,
|
BreakerConfig,
|
||||||
@@ -29,7 +30,7 @@ from polygateway.types import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
|
|
||||||
# FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增
|
# FIELD → (SourceConfig 属性, 类型);CHS config.py:95-104 全集 + M1 新增
|
||||||
_SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
_SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
||||||
@@ -53,7 +54,7 @@ _SOURCE_FIELDS: dict[str, tuple[str, str]] = {
|
|||||||
"TRUST_ENV": ("trust_env", "bool"),
|
"TRUST_ENV": ("trust_env", "bool"),
|
||||||
"EXTRA_BODY": ("extra_body", "json"),
|
"EXTRA_BODY": ("extra_body", "json"),
|
||||||
}
|
}
|
||||||
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE"})
|
_RESERVED_SEGMENTS = frozenset({"GLOBAL", "RETRY", "BREAKER", "BACKPRESSURE", "HEDGE", "STREAM"})
|
||||||
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
_SELECTORS = frozenset({"round_robin", "least_inflight", "health_aware"})
|
||||||
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
_QUOTA_FULL = frozenset({"wait", "fail_fast"})
|
||||||
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
|
# 熔断全拒时的处置(issue #14);值域与 _QUOTA_FULL 相同但语义不同——配额满是
|
||||||
@@ -182,6 +183,27 @@ class GatewaySettings:
|
|||||||
pricing_path: str | None
|
pricing_path: str | None
|
||||||
structured_max_retries: int
|
structured_max_retries: int
|
||||||
lease_ttl_s: float
|
lease_ttl_s: float
|
||||||
|
# 一次逻辑调用的**可选**墙钟硬边界(issue #22)。缺省 None = 不启用,行为逐字
|
||||||
|
# 等于 1.3.5;有默认值故追加在末尾,不扰动既有位置构造。`EmbeddingSettings.gateway`
|
||||||
|
# 与 `OcrSettings.gateway` 自动继承。值域由 `_validate_call_deadline` 把关,
|
||||||
|
# 直接构造、`dataclasses.replace` 与 env 三条路一致
|
||||||
|
call_deadline_s: float | None = None
|
||||||
|
# 长尾对冲(issue #24 H4): 挂起超阈值时并发向异源再发一次,先回者赢、输家取消。
|
||||||
|
# 缺省 None = 关闭,行为逐字等于 1.3.6。`hedge_max_extra` 值域 [1,3] 为 H5 梯次
|
||||||
|
# 预留,v1 仅单路生效(>1 装配期 warning);**不下传 RetryMW**。值域与交叉守卫
|
||||||
|
# 由 `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:
|
def __post_init__(self) -> None:
|
||||||
self._normalize()
|
self._normalize()
|
||||||
@@ -192,6 +214,8 @@ class GatewaySettings:
|
|||||||
self._validate_lease()
|
self._validate_lease()
|
||||||
self._validate_stall()
|
self._validate_stall()
|
||||||
self._validate_probe()
|
self._validate_probe()
|
||||||
|
self._validate_call_deadline()
|
||||||
|
self._validate_hedge()
|
||||||
|
|
||||||
def _normalize(self) -> None:
|
def _normalize(self) -> None:
|
||||||
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
"""把 `from_env` 一直在做的规范化补到构造路上,两条路必须产出同一个值。
|
||||||
@@ -345,6 +369,37 @@ class GatewaySettings:
|
|||||||
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
|
f"timeout_s + {_PROBE_GRACE_S}({floor});调大 probe_ttl_s 或调小源的 timeout_s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _validate_call_deadline(self) -> None:
|
||||||
|
"""期限值域守卫: 盖住直接构造与 `dataclasses.replace` 两条路(issue #22)。
|
||||||
|
|
||||||
|
env 路已在 `_load_call_deadline` 里带真实键名报过错, 此处对合法值是幂等空操作。
|
||||||
|
**不校验**它与 `timeout_s`/`stall_window_s` 的大小关系: 期限短于单次超时
|
||||||
|
是调用方的合法选择(要的就是“不让这次调用拖过 N 秒”)。
|
||||||
|
"""
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"call_deadline_s",
|
||||||
|
ensure_call_deadline(self.call_deadline_s, "GatewaySettings.call_deadline_s"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate_hedge(self) -> None:
|
||||||
|
"""对冲装配守卫(issue #24 H4): 与期限同款,盖住直接构造与 replace 两条路。
|
||||||
|
|
||||||
|
env 路的值域错误已在 `_load_hedge` 里带真实键名报过;此处对合法值是幂等
|
||||||
|
空操作,交叉守卫(阈值 vs timeout/deadline/ttft、单源)只在这里有一处。
|
||||||
|
"""
|
||||||
|
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
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
@@ -373,6 +428,9 @@ class GatewaySettings:
|
|||||||
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
|
selector=_load_choice(env, f"{scope_u}__SELECTOR", _SELECTORS, "health_aware"),
|
||||||
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
quota_full=_load_choice(env, f"{scope_u}__QUOTA_FULL", _QUOTA_FULL, "wait"),
|
||||||
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
|
circuit_open=_load_choice(env, f"{scope_u}__CIRCUIT_OPEN", _CIRCUIT_OPEN, "fail_fast"),
|
||||||
|
call_deadline_s=_load_call_deadline(scope_u, env),
|
||||||
|
**_load_hedge(scope_u, env),
|
||||||
|
**_load_stream(scope_u, env),
|
||||||
**_load_pgw(env),
|
**_load_pgw(env),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -671,6 +729,204 @@ def _load_lease_ttl(env: Mapping[str, str]) -> float:
|
|||||||
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
|
return float(_cast(found[1], "float", found[0])) if found else _DEFAULT_LEASE_TTL_S
|
||||||
|
|
||||||
|
|
||||||
|
def _load_call_deadline(scope: str, env: Mapping[str, str]) -> float | None:
|
||||||
|
"""读 `{SCOPE}__CALL_DEADLINE_S`(issue #22);键未设即 None = 不启用。
|
||||||
|
|
||||||
|
用 `_first` 而非 `_require`: 后者会把"未设"当成配置缺失报错,对存量下游
|
||||||
|
就是破坏性变更。键名两段式(`split("__")` 长度 2 ≠ 4),故 `_load_sources`
|
||||||
|
天然跳过它,不必进 `_RESERVED_SEGMENTS`。
|
||||||
|
|
||||||
|
origin 传**实际命中的 env 键名**而非字段名: `_cast` 只接得住"不是数字",
|
||||||
|
`0`/负数/`inf` 会穿过它落到 `ensure_call_deadline`——那时报一条指向字段名的错误,
|
||||||
|
在多 scope 部署里无法定位是哪个键写错了。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: 已大写的 scope 名。
|
||||||
|
env: 已合并的环境映射。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
一次逻辑调用的墙钟期限(秒);键未设或为空串时返回 None(不启用)。
|
||||||
|
"""
|
||||||
|
found = _first(env, f"{scope}__CALL_DEADLINE_S")
|
||||||
|
if found is None:
|
||||||
|
return 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`/`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}__*`
|
||||||
|
也不会被当成 provider 段造出源。`AFTER_S` 未设 = 关闭(缺省逐字等于 1.3.6);
|
||||||
|
`MAX_EXTRA` 未设 = 1。origin 传实际命中键名(同 `_load_call_deadline` 纪律);
|
||||||
|
值域的交叉守卫(ttft/单源/max_extra 生效口径)归 `check_hedge_assembly` 一处。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scope: 已大写的 scope 名。
|
||||||
|
env: 已合并的环境映射。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`{"hedge_after_s": float | None, "hedge_max_extra": int}`,直传构造器。
|
||||||
|
"""
|
||||||
|
found_after = _first(env, f"{scope}__HEDGE__AFTER_S")
|
||||||
|
after = (
|
||||||
|
ensure_call_deadline(_cast(found_after[1], "float", found_after[0]), found_after[0])
|
||||||
|
if found_after
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
found_extra = _first(env, f"{scope}__HEDGE__MAX_EXTRA")
|
||||||
|
extra = int(_cast(found_extra[1], "int", found_extra[0])) if found_extra else 1
|
||||||
|
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(
|
||||||
|
*,
|
||||||
|
hedge_after_s: float | None,
|
||||||
|
hedge_max_extra: int,
|
||||||
|
sources: Sequence[SourceConfig],
|
||||||
|
call_deadline_s: float | None,
|
||||||
|
origin: str,
|
||||||
|
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__` 调同一份,两条装配
|
||||||
|
路的值域/交叉守卫不漂移。返回契约(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: 首 token/整体耗时对冲触发阈值(秒);None = 关闭。
|
||||||
|
hedge_max_extra: 每次逻辑调用最多并发对冲路数;v1 仅单路生效(H5)。
|
||||||
|
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];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(
|
||||||
|
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)必须是 int: {hedge_max_extra!r}"
|
||||||
|
)
|
||||||
|
if not 1 <= hedge_max_extra <= 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"hedge_max_extra({{SCOPE}}__HEDGE__MAX_EXTRA)须在 [1,3]: {hedge_max_extra};"
|
||||||
|
"每次逻辑调用最多并发对冲路数,v1 仅单路生效"
|
||||||
|
)
|
||||||
|
after = ensure_call_deadline(hedge_after_s, origin)
|
||||||
|
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=[], ...))没有 settings 的
|
||||||
|
# 非空守卫先行拦截;报错必须定位到 hedge 键族,而不是裸 min() 空序列异常。
|
||||||
|
# 消息保留 "hedge_after_s" 字样: 既有用例据此定位键族(1.3.8 扩含 stall)
|
||||||
|
raise ValueError(
|
||||||
|
"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 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={}/hedge_stall_after_s={};"
|
||||||
|
"运行期拿不到异源候选,对冲自然静默",
|
||||||
|
after,
|
||||||
|
stall,
|
||||||
|
)
|
||||||
|
if hedge_max_extra > 1:
|
||||||
|
logger.warning(
|
||||||
|
"hedge_max_extra={} 已接受,但 v1 仅单路对冲生效(梯次追加为 H5 预留)",
|
||||||
|
hedge_max_extra,
|
||||||
|
)
|
||||||
|
return after, stall
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_hedge_keys_on_non_stream_scope(gateway: GatewaySettings, scope_kind: str) -> None:
|
||||||
|
"""B4(1.3.8 设计 §11): 装配期可证明纯非流式的 scope 配了对冲键 → warning。
|
||||||
|
|
||||||
|
chat scope 不做(`stream` 是 per-call 参数,装配期无信号,B4 裁决);对冲键在
|
||||||
|
Embedding/OCR 链路没有任何消费者,静默退化必须说出来。`hedge_after_s` 一并
|
||||||
|
检测(1.3.7 同病顺带补齐,缺陷修复不扩公共面)。
|
||||||
|
"""
|
||||||
|
if gateway.hedge_after_s is not None or gateway.hedge_stall_after_s is not None:
|
||||||
|
logger.warning(
|
||||||
|
"对冲键(HEDGE__AFTER_S/HEDGE__STALL_AFTER_S)仅对 chat scope 生效: "
|
||||||
|
"{} scope 非流式,对冲配置静默退化(1.3.8 B4)",
|
||||||
|
scope_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EmbeddingSettings:
|
class EmbeddingSettings:
|
||||||
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。
|
"""Embedding scope 装配配置(M2 §7): 复用 GatewaySettings + embedding 专用键。
|
||||||
@@ -691,6 +947,7 @@ class EmbeddingSettings:
|
|||||||
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
|
raise ValueError(f"EmbeddingSettings.batch_size 必须 ≥ 1: {self.batch_size}")
|
||||||
if self.expected_dim is not None and self.expected_dim < 1:
|
if self.expected_dim is not None and self.expected_dim < 1:
|
||||||
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
|
raise ValueError(f"EmbeddingSettings.expected_dim 必须 ≥ 1: {self.expected_dim}")
|
||||||
|
_warn_hedge_keys_on_non_stream_scope(self.gateway, "embedding")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
@@ -734,6 +991,10 @@ class OcrSettings:
|
|||||||
|
|
||||||
gateway: GatewaySettings
|
gateway: GatewaySettings
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""内嵌 gateway 由 `GatewaySettings.__post_init__` 自己把关;此处只补 B4 对冲键 warning。"""
|
||||||
|
_warn_hedge_keys_on_non_stream_scope(self.gateway, "ocr")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(
|
def from_env(
|
||||||
cls,
|
cls,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""一次逻辑调用的**可选**墙钟硬边界(issue #22;1.3.6 设计 §3 方案 A)。
|
||||||
|
|
||||||
|
只依赖标准库与 `errors.py`(依赖铁律最内层),供三个公开边界各包一次:
|
||||||
|
期限治理的是**等待**,不是"到期即无副作用"——在途请求可能已发出、已被上游
|
||||||
|
计费,清理照旧在 `finally` 完成,故返回时刻 = 期限 + 清理耗时。
|
||||||
|
|
||||||
|
缺省 `None` 时**完全不进上下文管理器**,行为逐字等于 1.3.5。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from polygateway.errors import CallDeadlineExceeded
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_call_deadline(value: object, origin: str) -> float | None:
|
||||||
|
"""全装配路径共用的期限值域校验: `None` 或**有限正数秒**,否则当场 `ValueError`。
|
||||||
|
|
||||||
|
装配错误不属降级面(缺失/非法配置直接报错,不静默取默认值)。`bool` 必须先判:
|
||||||
|
`isinstance(True, int)` 为真,放行会让 `call_deadline_s=True` 变成"1 秒期限"
|
||||||
|
这种没人写得出来的意图。巨大 int(如 `10**400`)超出 float 值域,`float()` 会抛
|
||||||
|
`OverflowError`——它不是 `ValueError` 的子类,泄漏出去会绕过调用方的
|
||||||
|
`except ValueError`,故在此统一成同一种装配错误。
|
||||||
|
|
||||||
|
`origin` 写进消息,用于在多 scope 部署里定位到底是哪个键/哪个参数非法。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise ValueError(f"{origin} 必须是 None 或有限正数秒: {value!r}")
|
||||||
|
try:
|
||||||
|
seconds = float(value)
|
||||||
|
except OverflowError:
|
||||||
|
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}") from None
|
||||||
|
if not math.isfinite(seconds) or seconds <= 0:
|
||||||
|
raise ValueError(f"{origin} 必须是有限正数秒: {value!r}")
|
||||||
|
return seconds
|
||||||
|
|
||||||
|
|
||||||
|
async def with_call_deadline[T](aw: Awaitable[T], *, deadline_s: float | None, scope: str) -> T:
|
||||||
|
"""给一个 awaitable 加一层可选期限;到期抛 `CallDeadlineExceeded`。
|
||||||
|
|
||||||
|
三条实现红线:
|
||||||
|
|
||||||
|
1. **校验先于构造 awaitable**——调用方必须先 `ensure_call_deadline`,否则非法值
|
||||||
|
抛错时会遗留未 await 的协程(`RuntimeWarning` + 资源不释放)。
|
||||||
|
2. 只用**相对时长**,绝不把注入的 `now` 换算成绝对截止时刻: 注入钟跳变
|
||||||
|
10^6 秒不该凭空触发期限。
|
||||||
|
3. 判据必须是**局部变量身份比较**,不可退化成只看 `cm.expired()`:
|
||||||
|
到期后清理路径自抛的 `TimeoutError` 也发生在 `expired()` 为真时,只看它
|
||||||
|
会把别人的超时改标成本层期限;`__cause__` 启发式同样失效(内层
|
||||||
|
`asyncio.timeout` 抛出的 `TimeoutError` 其 `__cause__` 也是 `CancelledError`)。
|
||||||
|
|
||||||
|
不新增后台任务、不 `shield`、不改异常对象:外部取消照常以 `CancelledError` 穿透。
|
||||||
|
"""
|
||||||
|
if deadline_s is None:
|
||||||
|
# 未启用: 不进上下文管理器,逐字走 1.3.5 旧路径
|
||||||
|
return await aw
|
||||||
|
# 体内(含清理路径)自抛的 TimeoutError 的**身份**,唯一可靠的区分依据
|
||||||
|
inner_timeout: BaseException | None = None
|
||||||
|
# 先建对象再进上下文: `as cm` 只在 `__aenter__` 返回后才绑定, 而 except 块无条件
|
||||||
|
# 读 `cm`——进入阶段一旦抛 TimeoutError 就会变成 NameError 掩盖真实错误
|
||||||
|
cm = asyncio.timeout(deadline_s)
|
||||||
|
try:
|
||||||
|
async with cm:
|
||||||
|
try:
|
||||||
|
return await aw
|
||||||
|
except TimeoutError as exc:
|
||||||
|
inner_timeout = exc
|
||||||
|
raise
|
||||||
|
except TimeoutError as exc:
|
||||||
|
if cm.expired() and exc is not inner_timeout:
|
||||||
|
raise CallDeadlineExceeded(scope=scope, deadline_s=deadline_s) from None
|
||||||
|
raise
|
||||||
@@ -28,6 +28,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from polygateway.client import _aclose_component, _telemetry_status_of
|
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||||
from polygateway.config import EmbeddingSettings
|
from polygateway.config import EmbeddingSettings
|
||||||
|
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
GovernanceBackendError,
|
GovernanceBackendError,
|
||||||
@@ -112,6 +113,7 @@ class EmbeddingClient:
|
|||||||
batch_size: int,
|
batch_size: int,
|
||||||
normalize: bool = False,
|
normalize: bool = False,
|
||||||
expected_dim: int | None = None,
|
expected_dim: int | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
now: Callable[[], float] = time.monotonic,
|
now: Callable[[], float] = time.monotonic,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||||
rng: Callable[[], float] = random.random,
|
rng: Callable[[], float] = random.random,
|
||||||
@@ -120,6 +122,10 @@ class EmbeddingClient:
|
|||||||
raise ValueError("batch_size 必须 ≥ 1")
|
raise ValueError("batch_size 必须 ≥ 1")
|
||||||
if expected_dim is not None and expected_dim < 1:
|
if expected_dim is not None and expected_dim < 1:
|
||||||
raise ValueError("expected_dim 必须 ≥ 1")
|
raise ValueError("expected_dim 必须 ≥ 1")
|
||||||
|
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
||||||
|
self._call_deadline_s = ensure_call_deadline(
|
||||||
|
call_deadline_s, "EmbeddingClient(call_deadline_s=...)"
|
||||||
|
)
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
# embed payload 硬编码 {model, input},带 extra_body 的源必须先剥离,
|
||||||
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
# 否则遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||||
@@ -174,11 +180,16 @@ class EmbeddingClient:
|
|||||||
parent_call_id: str | None = None,
|
parent_call_id: str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
) -> EmbeddingResponse:
|
) -> EmbeddingResponse:
|
||||||
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
"""一次治理 embedding 调用: 按 batch_size 切批,批间串行,全批合并返回。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11);它们属于
|
||||||
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
本次调用而非某一批,故每批的遥测行都带同一份维度。
|
||||||
|
|
||||||
|
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
|
||||||
|
**整次调用共享一份**——N 批串行跑在同一条期限内,不按批数放大 N 倍。
|
||||||
|
`texts == []` 的早返回在期限之外(零尝试,无等待可治)。
|
||||||
"""
|
"""
|
||||||
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
if not isinstance(texts, list) or any(not isinstance(t, str) for t in texts):
|
||||||
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
raise TypeError("texts 必须是 list[str](显式优于隐式,不收单条 str)")
|
||||||
@@ -188,6 +199,12 @@ class EmbeddingClient:
|
|||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
tenant_id, meta, origin="embed(tenant_id=..., meta=...)"
|
||||||
)
|
)
|
||||||
|
# 期限取值与校验必须在创建 awaitable **之前**(否则遗留未 await 的协程)
|
||||||
|
deadline = (
|
||||||
|
self._call_deadline_s
|
||||||
|
if call_deadline_s is None
|
||||||
|
else ensure_call_deadline(call_deadline_s, "embed(call_deadline_s=...)")
|
||||||
|
)
|
||||||
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
|
# 校验均已通过 → 进入统计边界(设计 §3.5: `texts` 类型与调用方维度校验之后)
|
||||||
context = _CallContext(now=self._now)
|
context = _CallContext(now=self._now)
|
||||||
if not texts:
|
if not texts:
|
||||||
@@ -206,8 +223,12 @@ class EmbeddingClient:
|
|||||||
call_stats=context.snapshot(),
|
call_stats=context.snapshot(),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
return await self._embed_all(
|
return await with_call_deadline(
|
||||||
|
self._embed_all(
|
||||||
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
|
texts, session_id, parent_call_id, dimension_tenant_id, dimensions, context
|
||||||
|
),
|
||||||
|
deadline_s=deadline,
|
||||||
|
scope=self._scope,
|
||||||
)
|
)
|
||||||
except PolyGatewayError as exc:
|
except PolyGatewayError as exc:
|
||||||
await self._emit_terminal(
|
await self._emit_terminal(
|
||||||
@@ -343,10 +364,17 @@ class EmbeddingClient:
|
|||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
started = self._now()
|
started = self._now()
|
||||||
actual = 0
|
actual = 0
|
||||||
|
# 局部阶段变量(与 RetryMW 同口径): 该刻库是否已算出确定结算。只服务于取消
|
||||||
|
# 分支的兜底取值,不进任何签名; 未分类异常逃逸时仍逐字走旧的全额退还。
|
||||||
|
settlement_known = False
|
||||||
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
|
# 登记在 transport 调用**之前**(同 RetryMW): 失败与取消的尝试也真的发出去了
|
||||||
context.register_attempt()
|
context.register_attempt()
|
||||||
try:
|
try:
|
||||||
|
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径),
|
||||||
|
# 与本链路 total_latency_ms 同一只注入钟;分批累加在成功分支登记
|
||||||
|
gen_started = self._now()
|
||||||
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
|
result = await self._transport.embed(texts=batch, source=source, call_id=call_id)
|
||||||
|
gen_ms = int((self._now() - gen_started) * 1000)
|
||||||
if self._expected_dim is not None and result.dim != self._expected_dim:
|
if self._expected_dim is not None and result.dim != self._expected_dim:
|
||||||
raise ResultInvalidError(
|
raise ResultInvalidError(
|
||||||
f"{source.name} 维度 {result.dim} 不符期望 {self._expected_dim}",
|
f"{source.name} 维度 {result.dim} 不符期望 {self._expected_dim}",
|
||||||
@@ -358,6 +386,9 @@ class EmbeddingClient:
|
|||||||
actual = source.effective_est_tokens()
|
actual = source.effective_est_tokens()
|
||||||
else:
|
else:
|
||||||
actual = result.prompt_tokens
|
actual = result.prompt_tokens
|
||||||
|
# 真实 usage 恰为 0 也是已知事实, 后续取消不得改写成 est
|
||||||
|
settlement_known = True
|
||||||
|
context.record_generation(gen_ms, accumulate=True)
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
latency_ms = int((self._now() - started) * 1000)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
@@ -375,6 +406,7 @@ class EmbeddingClient:
|
|||||||
)
|
)
|
||||||
return _BatchOutcome(result, source, call_id, latency_ms)
|
return _BatchOutcome(result, source, call_id, latency_ms)
|
||||||
except (RequestRejectedError, ResultInvalidError) as exc:
|
except (RequestRejectedError, ResultInvalidError) as exc:
|
||||||
|
actual, settlement_known = 0, True # 逐字保住 1.3.5 口径(本版不改这一族记账)
|
||||||
await self._gate_on_terminal(exc, entry)
|
await self._gate_on_terminal(exc, entry)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
batch,
|
batch,
|
||||||
@@ -390,6 +422,9 @@ class EmbeddingClient:
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
if not settlement_known:
|
||||||
|
# 端口已开始、结算未定: 保守保留预扣(设计 §6.3 S7)
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
if entry.is_probe:
|
if entry.is_probe:
|
||||||
await self._record_quietly(self._breaker.release_probe(entry))
|
await self._record_quietly(self._breaker.release_probe(entry))
|
||||||
await self._emit(
|
await self._emit(
|
||||||
@@ -409,10 +444,11 @@ class EmbeddingClient:
|
|||||||
dead = isinstance(exc, SourceDeadError)
|
dead = isinstance(exc, SourceDeadError)
|
||||||
reason = _failure_reason(exc)
|
reason = _failure_reason(exc)
|
||||||
reasons[source.name] = reason
|
reasons[source.name] = reason
|
||||||
|
# 结算决定定死在本分支第一个 await 之前: 同级 except CancelledError 接不住
|
||||||
|
# 落在本块 await 上的取消,它直穿 finally。值与 1.3.5 逐字相同,只是算得更早。
|
||||||
|
actual = 0 if dead else source.effective_est_tokens()
|
||||||
|
settlement_known = True
|
||||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||||
if not dead:
|
|
||||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
|
||||||
actual = source.effective_est_tokens()
|
|
||||||
await self._emit(
|
await self._emit(
|
||||||
batch,
|
batch,
|
||||||
source,
|
source,
|
||||||
@@ -624,6 +660,7 @@ class EmbeddingClient:
|
|||||||
batch_size=settings.batch_size,
|
batch_size=settings.batch_size,
|
||||||
normalize=settings.normalize,
|
normalize=settings.normalize,
|
||||||
expected_dim=settings.expected_dim,
|
expected_dim=settings.expected_dim,
|
||||||
|
call_deadline_s=gw.call_deadline_s,
|
||||||
)
|
)
|
||||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
return client
|
return client
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ SOURCE_REASONS = frozenset(
|
|||||||
class PolyGatewayError(Exception):
|
class PolyGatewayError(Exception):
|
||||||
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
"""库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。
|
||||||
|
|
||||||
`body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
`body_text` 是**HTTP 失败或 200 chat error 帧的摘要**——网关拒绝这次调用时说的话(issue #10)。
|
||||||
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
它与 `ResultInvalidError.raw_text` 是两回事,严禁混用:
|
||||||
|
|
||||||
============== ==================================================
|
============== ==================================================
|
||||||
@@ -217,3 +217,23 @@ class GovernanceBackendError(GatewayUnavailableError):
|
|||||||
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
# 父类会把 message 覆写为 "{scope} 网关暂时不可用: {reason}",而各构造点
|
||||||
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
|
# 携带的诊断串(如"限流后端 try_acquire 失败: ...")是排障主线索,必须保住
|
||||||
self.args = (message,)
|
self.args = (message,)
|
||||||
|
|
||||||
|
|
||||||
|
class CallDeadlineExceeded(PolyGatewayError): # noqa: N818 — 设计 §9 人类批准的公共名
|
||||||
|
"""调用方设定的整体调用期限到期; 不是网关不可用、也不是源故障。
|
||||||
|
|
||||||
|
刻意**不属**四分类、**不进** `SCOPE_REASONS`、**不继承** `GatewayUnavailableError`:
|
||||||
|
它描述的是调用方自己的耐心边界, 与"对方怎么了"正交——按四分类之一上报会让
|
||||||
|
下游的重试/换源/熔断逻辑对着一次本地超时做治理决策(库铁律「错误分类驱动」)。
|
||||||
|
|
||||||
|
也刻意**没有** `retry_after_s`: 期限到期不含"何时可再试"的信息, 给 `0.0`
|
||||||
|
会按既定语义指示下游立刻重打一条可能已经饱和的通道。
|
||||||
|
|
||||||
|
**到期不等于未产出、未计费**: 期限治理的是等待, 在途请求可能已经发出、
|
||||||
|
已被上游计费, 清理仍在 `finally` 里完成, 故返回时刻 = 期限 + 清理耗时。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, scope: str, deadline_s: float) -> None:
|
||||||
|
super().__init__(f"{scope} 调用期限 {deadline_s}s 到期")
|
||||||
|
self.scope = scope
|
||||||
|
self.deadline_s = deadline_s
|
||||||
|
|||||||
@@ -169,15 +169,28 @@ class SourceAdmission:
|
|||||||
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
# —— 选源与准入(CHS _pick_runnable 120-167)——
|
||||||
|
|
||||||
async def pick(
|
async def pick(
|
||||||
self, reasons: dict[str, str], attempt_fails: dict[str, int]
|
self,
|
||||||
|
reasons: dict[str, str],
|
||||||
|
attempt_fails: dict[str, int],
|
||||||
|
*,
|
||||||
|
exclude: frozenset[str] | None = None,
|
||||||
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
) -> tuple[tuple[SourceConfig, Permit, GateDecision] | None, int]:
|
||||||
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。"""
|
"""挑出第一个过闸的候选;返回 (选中三元组 | None, 熔断类拒绝计数)。
|
||||||
|
|
||||||
|
`exclude` 是对冲编排的私有排除参数(issue #24): 以在途源名为排除集,
|
||||||
|
保证对冲路落在**异源**。被排除不是源的拒绝——不计 gate_rejections、
|
||||||
|
不写 reasons,否则会污染 `on_no_runnable` 的分派判据与 per_source_reasons
|
||||||
|
对账。拿不到候选时返回 None,是否放弃由调用方决定(对冲方静默等原路,
|
||||||
|
**严禁**对这个 None 调 `on_no_runnable`)。
|
||||||
|
"""
|
||||||
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
stats = {s.name: await self._quota.stats(s) for s in self._sources}
|
||||||
gate_rejections = 0
|
gate_rejections = 0
|
||||||
ordered = _demote_call_failures(
|
ordered = _demote_call_failures(
|
||||||
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
self._selector.order(self._sources, stats), attempt_fails, self._health_view
|
||||||
)
|
)
|
||||||
for cand in ordered:
|
for cand in ordered:
|
||||||
|
if exclude and cand.name in exclude:
|
||||||
|
continue # 对冲排除在途源: 不是拒绝, 不计数不写原因(见 docstring)
|
||||||
if self._memo.active(cand.name):
|
if self._memo.active(cand.name):
|
||||||
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
# 冷却备忘跳过也计入拒绝数,保住 circuit_open 判据(CHS 同款)
|
||||||
gate_rejections += 1
|
gate_rejections += 1
|
||||||
|
|||||||
@@ -17,7 +17,16 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation
|
from polygateway.types import (
|
||||||
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
ResponseMode,
|
||||||
|
ThinkingObservation,
|
||||||
|
_effective_finish,
|
||||||
|
_is_cacheable,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -107,6 +116,7 @@ def build_cache_key(
|
|||||||
*,
|
*,
|
||||||
sampling: Mapping[str, Any] | None = None,
|
sampling: Mapping[str, Any] | None = None,
|
||||||
reasoning_effort: Effort | None = None,
|
reasoning_effort: Effort | None = None,
|
||||||
|
response_mode: ResponseMode = "text",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
"""缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。
|
||||||
|
|
||||||
@@ -127,6 +137,8 @@ def build_cache_key(
|
|||||||
"messages": digest_messages(messages),
|
"messages": digest_messages(messages),
|
||||||
"namespace": namespace,
|
"namespace": namespace,
|
||||||
}
|
}
|
||||||
|
if response_mode != "text":
|
||||||
|
key_obj["response_mode"] = response_mode
|
||||||
if salt is not None:
|
if salt is not None:
|
||||||
key_obj["salt"] = salt
|
key_obj["salt"] = salt
|
||||||
if sampling:
|
if sampling:
|
||||||
@@ -137,6 +149,54 @@ def build_cache_key(
|
|||||||
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
return _KEY_PREFIX + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _response_cacheable(mode: ResponseMode, response: LLMResponse) -> bool:
|
||||||
|
"""写路径判据;与读路径共用 `_is_cacheable` 与 `_effective_finish`,口径不分叉。"""
|
||||||
|
return _is_cacheable(
|
||||||
|
mode=mode,
|
||||||
|
content=response.content,
|
||||||
|
finish_reason=_effective_finish(response.finish_reason, response.completion),
|
||||||
|
stream_end=response.stream_end,
|
||||||
|
completion=response.completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_fields(raw: str, mode: ResponseMode) -> dict[str, Any]:
|
||||||
|
"""缓存原文 → LLMResponse 字段;不可缓存或形态坏抛 ValueError(调用方按 miss)。
|
||||||
|
|
||||||
|
可缓存判定对**存储原值**进行、先于命中覆盖: 覆盖后 `stream_end` 恒 None,
|
||||||
|
在那之后判会把 salvaged 条目放行(设计 §6)。
|
||||||
|
"""
|
||||||
|
data = json.loads(raw)
|
||||||
|
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
||||||
|
completion = fields.get("completion")
|
||||||
|
if completion is not None:
|
||||||
|
if not isinstance(completion, dict):
|
||||||
|
raise ValueError("缓存 completion 必须为对象")
|
||||||
|
fields["completion"] = OpenAIChatCompletion(**completion)
|
||||||
|
finish = _effective_finish(fields.get("finish_reason"), fields.get("completion"))
|
||||||
|
if finish is not None and not isinstance(finish, str):
|
||||||
|
raise ValueError("缓存 finish_reason 必须为字符串或 null")
|
||||||
|
if not _is_cacheable(
|
||||||
|
mode=mode,
|
||||||
|
content=fields.get("content", ""),
|
||||||
|
finish_reason=finish,
|
||||||
|
stream_end=fields.get("stream_end"),
|
||||||
|
completion=fields.get("completion"),
|
||||||
|
):
|
||||||
|
# 非法 stream_end 也落这里: 它关乎内容完整性,不像 thinking_observation
|
||||||
|
# 那样可以宽容降级
|
||||||
|
raise ValueError("缓存条目不可缓存(截断/空正文/非自然结束/非法 stream_end)")
|
||||||
|
fields["finish_reason"] = finish
|
||||||
|
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
||||||
|
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
||||||
|
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
||||||
|
if "thinking_observation" in fields:
|
||||||
|
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
||||||
|
if "applied_effort" in fields:
|
||||||
|
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
class CacheMW:
|
class CacheMW:
|
||||||
"""洋葱第二层(遥测内、结构化外)。
|
"""洋葱第二层(遥测内、结构化外)。
|
||||||
|
|
||||||
@@ -176,6 +236,7 @@ class CacheMW:
|
|||||||
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
# 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹,
|
||||||
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
# 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20)
|
||||||
reasoning_effort=request.reasoning_effort,
|
reasoning_effort=request.reasoning_effort,
|
||||||
|
response_mode=request.response_mode,
|
||||||
)
|
)
|
||||||
cached = await self._safe_get(key)
|
cached = await self._safe_get(key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
@@ -183,7 +244,17 @@ class CacheMW:
|
|||||||
if hit is not None:
|
if hit is not None:
|
||||||
return hit
|
return hit
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
await self._safe_set(key, self._serialize(response))
|
if not _response_cacheable(request.response_mode, response):
|
||||||
|
# 交付照常,只是不进 TTL: 截断/空正文/非自然结束固化后会把一次偶然
|
||||||
|
# 变成必然(设计 §6)。debug 级: 这是常规分流,不是故障
|
||||||
|
logger.debug("结果不可缓存(截断/空正文/非自然结束),跳过写入")
|
||||||
|
return response
|
||||||
|
try:
|
||||||
|
serialized = self._serialize(response)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("缓存序列化失败,跳过缓存: {}", type(exc).__name__)
|
||||||
|
else:
|
||||||
|
await self._safe_set(key, serialized)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# —— 命中路径 ——
|
# —— 命中路径 ——
|
||||||
@@ -191,21 +262,15 @@ class CacheMW:
|
|||||||
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
|
def _rehydrate(self, raw: str, request: ChatRequest) -> LLMResponse | None:
|
||||||
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
|
"""反序列化 + 按本次调用的 structured 档零网络重建;失败按未命中。"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw)
|
fields = _restore_fields(raw, request.response_mode)
|
||||||
fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS}
|
|
||||||
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
structured_data = self._rebuild_structured(fields.get("content", ""), request)
|
||||||
# JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉
|
|
||||||
# (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False);
|
|
||||||
# 键缺失即升级前写入的旧条目,交给 dataclass 默认值
|
|
||||||
if "thinking_observation" in fields:
|
|
||||||
fields["thinking_observation"] = _coerce_observation(fields["thinking_observation"])
|
|
||||||
if "applied_effort" in fields:
|
|
||||||
fields["applied_effort"] = _coerce_applied_effort(fields["applied_effort"])
|
|
||||||
fields.update(
|
fields.update(
|
||||||
cache_hit=True,
|
cache_hit=True,
|
||||||
latency_ms=0,
|
latency_ms=0,
|
||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
|
# 结束方式描述的是"那次流",命中没有流可言(与 ttft_ms 同口径)
|
||||||
|
stream_end=None,
|
||||||
call_id=str(uuid.uuid4()),
|
call_id=str(uuid.uuid4()),
|
||||||
structured_data=structured_data,
|
structured_data=structured_data,
|
||||||
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
|
# 显式覆盖: 历史条目里的 `call_stats` 是个 dict,而 `_RESPONSE_FIELDS`
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ if TYPE_CHECKING:
|
|||||||
RetryPolicy,
|
RetryPolicy,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
|
_CallContext,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -164,6 +165,22 @@ def _is_rate_limited(outcome: LLMResponse | _Failed) -> bool:
|
|||||||
return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited"
|
return isinstance(outcome, _Failed) and _failure_reason(outcome.exc) == "rate_limited"
|
||||||
|
|
||||||
|
|
||||||
|
_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 读它选遥测标签。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_failures(primary_f: _Failed, hedge_f: _Failed) -> _Failed:
|
||||||
|
"""任一非 429 优先(计预算);两路皆 429 才按 429 免预算退还 stall 账;同类取原路。"""
|
||||||
|
if _failure_reason(primary_f.exc) == "rate_limited" != _failure_reason(hedge_f.exc):
|
||||||
|
return hedge_f
|
||||||
|
return primary_f
|
||||||
|
|
||||||
|
|
||||||
class RetryMW:
|
class RetryMW:
|
||||||
"""尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。"""
|
"""尝试编排器;时钟/睡眠/随机全部注入,纯确定性可测(P6)。"""
|
||||||
|
|
||||||
@@ -183,6 +200,8 @@ class RetryMW:
|
|||||||
cooldown_memo: SourceCooldownMemo | None = None,
|
cooldown_memo: SourceCooldownMemo | None = None,
|
||||||
pacer: AdaptivePacer | None = None,
|
pacer: AdaptivePacer | None = None,
|
||||||
emitter: TelemetryEmitter | None = None,
|
emitter: TelemetryEmitter | None = None,
|
||||||
|
hedge_after_s: float | None = None,
|
||||||
|
stall_after_s: float | None = None,
|
||||||
now: Callable[[], float] = time.monotonic,
|
now: Callable[[], float] = time.monotonic,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||||
rng: Callable[[], float] = random.random,
|
rng: Callable[[], float] = random.random,
|
||||||
@@ -200,6 +219,13 @@ class RetryMW:
|
|||||||
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
|
# M2.5 §3.35: AIMD 自适应并发——429 收紧、成功回涨,超限调用排队不烧预算
|
||||||
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
|
self._pacer = pacer or AdaptivePacer(ceiling=64.0)
|
||||||
self._emitter = emitter
|
self._emitter = emitter
|
||||||
|
# 对冲触发阈值(issue #24): None = 关闭(__call__ 逐字走 1.3.6 单路路径)。
|
||||||
|
# 值域/交叉守卫在装配层(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._now = now
|
||||||
self._sleep = sleep
|
self._sleep = sleep
|
||||||
self._rng = rng
|
self._rng = rng
|
||||||
@@ -246,12 +272,44 @@ class RetryMW:
|
|||||||
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
await self._admission.on_no_runnable(gate_rejections, reasons, clock)
|
||||||
continue
|
continue
|
||||||
async with clock.attempting() as attempt:
|
async with clock.attempting() as attempt:
|
||||||
outcome = await self._attempt(request, *picked, reasons, attempt_fails)
|
# 每轮一个 sink: 生成样本(墙钟+沉默归因)由编排裁定归属
|
||||||
|
# (单路 = 成功那轮;对冲 = 赢家那一路),`_attempt` 只负责把本次
|
||||||
|
# transport 耗时与沉默观测投进来
|
||||||
|
generation_sink: list[_GenerationSample] = []
|
||||||
|
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,
|
||||||
|
reasons,
|
||||||
|
attempt_fails,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
generation_sink=generation_sink,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 对冲轮 = 一次"超级尝试": 编排内部自建 sink 并按赢家归属登记
|
||||||
|
outcome = await self._attempt_hedged(request, picked, reasons, attempt_fails)
|
||||||
rate_limited = _is_rate_limited(outcome)
|
rate_limited = _is_rate_limited(outcome)
|
||||||
if rate_limited:
|
if rate_limited:
|
||||||
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
|
# 免了重试预算就得进 stall 账,否则这段耗时无人治理(见 StallClock)
|
||||||
attempt.refund()
|
attempt.refund()
|
||||||
if isinstance(outcome, LLMResponse):
|
if isinstance(outcome, LLMResponse):
|
||||||
|
# 对冲轮由 _attempt_hedged 按赢家归属登记,此处外层 sink 恒为空
|
||||||
|
# (守卫在 _record_generation 内: 上下文缺失/空 sink 均跳过)
|
||||||
|
self._record_generation(request, generation_sink)
|
||||||
return outcome
|
return outcome
|
||||||
if not rate_limited:
|
if not rate_limited:
|
||||||
fails += 1
|
fails += 1
|
||||||
@@ -275,18 +333,31 @@ class RetryMW:
|
|||||||
entry: GateDecision,
|
entry: GateDecision,
|
||||||
reasons: dict[str, str],
|
reasons: dict[str, str],
|
||||||
attempt_fails: dict[str, int],
|
attempt_fails: dict[str, int],
|
||||||
|
*,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
generation_sink: list[_GenerationSample],
|
||||||
) -> LLMResponse | _Failed:
|
) -> LLMResponse | _Failed:
|
||||||
call_id = str(uuid.uuid4())
|
call_id = str(uuid.uuid4())
|
||||||
started = self._now()
|
started = self._now()
|
||||||
actual = 0
|
actual = 0
|
||||||
|
# 局部阶段变量: 该刻库是否已算出**确定**结算。只服务于取消分支的兜底取值,
|
||||||
|
# 不进任何签名/配置/遥测; 未分类异常逃逸时它无人读取, 故仍逐字走旧的全额退还。
|
||||||
|
settlement_known = False
|
||||||
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
|
# 登记在 transport 调用**之前**(1.3.5 设计 §4): 失败与取消的尝试同样
|
||||||
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
|
# "真的打出去了",挪到成功之后会让诊断最需要看见的那几次从计数里消失。
|
||||||
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
|
# 上下文为 None = 库内现场构造的请求,跳过而不是报错
|
||||||
if request.call_context is not None:
|
if request.call_context is not None:
|
||||||
request.call_context.register_attempt()
|
request.call_context.register_attempt()
|
||||||
try:
|
try:
|
||||||
|
# 净生成时间(1.3.8): 此处只量 transport 墙钟(起点紧贴调用前、
|
||||||
|
# 终点为返回后首句,中间无 await),沉默扣减在消费侧统一换算;
|
||||||
|
# 取消落进来时 transport 未返回,不计。
|
||||||
|
# 与该链路 total_latency_ms 同一只注入钟,差值(波动开销)才有意义
|
||||||
|
gen_started = self._now()
|
||||||
result = await self._transport.complete(
|
result = await self._transport.complete(
|
||||||
messages=request.messages,
|
messages=request.messages,
|
||||||
|
response_mode=request.response_mode,
|
||||||
source=source,
|
source=source,
|
||||||
stream=request.stream,
|
stream=request.stream,
|
||||||
overlay=request.overlay,
|
overlay=request.overlay,
|
||||||
@@ -294,33 +365,68 @@ class RetryMW:
|
|||||||
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
|
# 逐次尝试原样重传: 换源不改变调用方要的档位(源级默认由 transport
|
||||||
# 自己按选中的源解析,两者在 effective_effort 里汇合)
|
# 自己按选中的源解析,两者在 effective_effort 里汇合)
|
||||||
reasoning_effort=request.reasoning_effort,
|
reasoning_effort=request.reasoning_effort,
|
||||||
|
# 对冲编排(计划 §3.4): 原路携带首 token 事件,对冲路恒 None(v1 单路,
|
||||||
|
# 不再梯次);未启用对冲时 __call__ 传 None = 调用方不观测首 token
|
||||||
|
first_token_event=first_token_event,
|
||||||
|
# 中段沉默处置位(1.3.8): 未启用 stall 对冲时调用方传 None = 不观测;
|
||||||
|
# 启用时流式原路携真事件,对冲路恒 None(设计 §5.2 N3③)
|
||||||
|
content_event=content_event,
|
||||||
)
|
)
|
||||||
|
# 取整全库唯一处(设计 §4.2 N8): transport 的 float 沉默值在此
|
||||||
|
# 一次性转 int;None(非流式/增量 chunk<2)透传,消费侧不退减
|
||||||
|
wall_ms = int((self._now() - gen_started) * 1000)
|
||||||
|
stall_ms_i = None if result.stall_ms is None else int(result.stall_ms)
|
||||||
|
stall_count_i = None if result.stall_count is None else int(result.stall_count)
|
||||||
|
generation_sink.append((wall_ms, stall_ms_i, stall_count_i))
|
||||||
if result.usage_source == "unavailable":
|
if result.usage_source == "unavailable":
|
||||||
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
# 用量不可得时按入场预扣量结算(delta==0),否则押金会被整笔退回,
|
||||||
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
|
# 对"从不返回 usage 帧"的源等于 TPM 闸失效(设计 §3.2 #9)
|
||||||
actual = source.effective_est_tokens()
|
actual = source.effective_est_tokens()
|
||||||
else:
|
else:
|
||||||
actual = result.prompt_tokens + result.completion_tokens
|
actual = result.prompt_tokens + result.completion_tokens
|
||||||
|
# 真实 usage 恰为 0 也是**已知事实**, 后续取消不得把它改写成 est
|
||||||
|
settlement_known = True
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
self._feed_outcome(source.name, ok=True)
|
self._feed_outcome(source.name, ok=True)
|
||||||
self._pacer.on_success(source.name)
|
self._pacer.on_success(source.name)
|
||||||
response = self._build_response(source, result, call_id, started)
|
response = self._build_response(source, result, call_id, started)
|
||||||
await self._emit(request, source, call_id, started, response=response)
|
await self._emit(
|
||||||
|
request,
|
||||||
|
source,
|
||||||
|
call_id,
|
||||||
|
started,
|
||||||
|
response=response,
|
||||||
|
stall_ms=stall_ms_i,
|
||||||
|
stall_count=stall_count_i,
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
except RequestRejectedError as exc:
|
except RequestRejectedError as exc:
|
||||||
|
actual, settlement_known = 0, True # 逐字保住 1.3.5: 坏请求全额退还
|
||||||
await self._on_rejected(exc, source, entry)
|
await self._on_rejected(exc, source, entry)
|
||||||
await self._emit(request, source, call_id, started, error=exc)
|
await self._emit(request, source, call_id, started, error=exc)
|
||||||
raise
|
raise
|
||||||
except ResultInvalidError as exc:
|
except ResultInvalidError as exc:
|
||||||
|
actual, settlement_known = 0, True # 同上, 本版不改这一族记账口径
|
||||||
# 坏结果 ≠ 坏服务: 熔断记成功但不计窗口样本,亦不喂健康分(M2.5 §3.1)
|
# 坏结果 ≠ 坏服务: 熔断记成功但不计窗口样本,亦不喂健康分(M2.5 §3.1)
|
||||||
await self._record_quietly(self._breaker.record_success(entry, count_attempt=False))
|
await self._record_quietly(self._breaker.record_success(entry, count_attempt=False))
|
||||||
await self._emit(request, source, call_id, started, error=exc)
|
await self._emit(request, source, call_id, started, error=exc)
|
||||||
raise
|
raise
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
if not settlement_known:
|
||||||
|
# 端口已开始、结算未定: 保守保留预扣(宁多扣不凭空退款, 见设计 §6.3 S3)
|
||||||
|
actual = source.effective_est_tokens()
|
||||||
if entry.is_probe:
|
if entry.is_probe:
|
||||||
await self._record_quietly(self._breaker.release_probe(entry))
|
await self._record_quietly(self._breaker.release_probe(entry))
|
||||||
await self._emit(request, source, call_id, started, error="cancelled")
|
# 对冲输家在 cancel() 前被编排置位标记(计划 §3.4): 据此区分"被对冲
|
||||||
|
# 淘汰"与"外部取消",零新遥测列(设计 §4.5);外部取消与对冲取消同时
|
||||||
|
# 到达的竞速可能误贴——记账方向一致(est 保留),属已批准的可接受残留
|
||||||
|
label = (
|
||||||
|
"hedge_cancelled"
|
||||||
|
if getattr(asyncio.current_task(), _HEDGE_LOSER_ATTR, False)
|
||||||
|
else "cancelled"
|
||||||
|
)
|
||||||
|
await self._emit(request, source, call_id, started, error=label)
|
||||||
raise
|
raise
|
||||||
except (SourceDeadError, TransientError) as exc:
|
except (SourceDeadError, TransientError) as exc:
|
||||||
dead = isinstance(exc, SourceDeadError)
|
dead = isinstance(exc, SourceDeadError)
|
||||||
@@ -330,16 +436,267 @@ class RetryMW:
|
|||||||
self._feed_outcome(source.name, ok=False)
|
self._feed_outcome(source.name, ok=False)
|
||||||
if reason == "rate_limited":
|
if reason == "rate_limited":
|
||||||
self._pacer.on_backpressure(source.name)
|
self._pacer.on_backpressure(source.name)
|
||||||
|
# 结算决定必须在本分支**第一个 await 之前**定死: 同级的 except CancelledError
|
||||||
|
# 接不住落在本块 await 上的取消,它会直穿 finally——那一刻 actual 是什么就结什么。
|
||||||
|
# 值与 1.3.5 逐字相同(dead 全额退、瞬时保留预扣),只是算得更早。
|
||||||
|
actual = 0 if dead else source.effective_est_tokens()
|
||||||
|
settlement_known = True
|
||||||
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
await self._record_quietly(self._breaker.record_failure(entry, reason, dead))
|
||||||
if not dead:
|
|
||||||
# 保守: 失败请求可能已被网关计费(CHS 同款);与入场预扣同源取值
|
|
||||||
actual = source.effective_est_tokens()
|
|
||||||
await self._emit(request, source, call_id, started, error=exc)
|
await self._emit(request, source, call_id, started, error=exc)
|
||||||
return _Failed(exc, immediate=dead)
|
return _Failed(exc, immediate=dead)
|
||||||
finally:
|
finally:
|
||||||
self._pacer.leave(source.name)
|
self._pacer.leave(source.name)
|
||||||
await settle_and_release(permit, actual)
|
await settle_and_release(permit, actual)
|
||||||
|
|
||||||
|
# —— 对冲编排(issue #24 设计 §4.6;默认关闭,`hedge_after_s is None` 不进这里)——
|
||||||
|
|
||||||
|
async def _attempt_hedged(
|
||||||
|
self,
|
||||||
|
request: ChatRequest,
|
||||||
|
picked: tuple[SourceConfig, Permit, GateDecision],
|
||||||
|
reasons: dict[str, str],
|
||||||
|
attempt_fails: dict[str, int],
|
||||||
|
) -> LLMResponse | _Failed:
|
||||||
|
"""一次"超级尝试": 原路 + (触发后)异源对冲路,FIRST_COMPLETED 竞速。
|
||||||
|
|
||||||
|
计时只用事件循环相对时长(`asyncio.wait` timeout),绝不读注入 `now`
|
||||||
|
(设计 §4.1 时钟纪律);两路各持各的 permit,结算/遥测/熔断写回全部沿用
|
||||||
|
`_attempt` 既有路径,本方法只做编排与赢家裁定。
|
||||||
|
"""
|
||||||
|
# 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(
|
||||||
|
request,
|
||||||
|
source,
|
||||||
|
permit,
|
||||||
|
entry,
|
||||||
|
reasons,
|
||||||
|
attempt_fails,
|
||||||
|
first_token_event=first_token,
|
||||||
|
content_event=content_event,
|
||||||
|
generation_sink=sink_p,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hedge: asyncio.Task[LLMResponse | _Failed] | None = None
|
||||||
|
try:
|
||||||
|
# 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):
|
||||||
|
self._record_generation(request, sink_p)
|
||||||
|
return outcome
|
||||||
|
# Phase 3 异源准入(完整 pick 路径,无旁路): 拿不到候选 = 静默等原路
|
||||||
|
# (对冲是优化不是权利;严禁对这个 None 调 on_no_runnable,见 §3.5)
|
||||||
|
hedge_picked, _ = await self._admission.pick(
|
||||||
|
reasons, attempt_fails, exclude=frozenset({source.name})
|
||||||
|
)
|
||||||
|
if hedge_picked is None:
|
||||||
|
outcome = await primary
|
||||||
|
if isinstance(outcome, LLMResponse):
|
||||||
|
self._record_generation(request, sink_p)
|
||||||
|
return outcome
|
||||||
|
# Phase 3.5 准入后复查: 原路可能在对冲准入的 await 期间已了结——此时
|
||||||
|
# 一个对冲请求都不发(那是白付一次真实计费请求 + 一份 est 滞留 + 一条
|
||||||
|
# hedge_cancelled 行)。按既有语义释放刚拿到的对冲准入(probe 须
|
||||||
|
# release_probe,顺序同 _attempt 取消分支),直接裁定原路结果
|
||||||
|
if primary.done():
|
||||||
|
hedge_source, hedge_permit, hedge_entry = hedge_picked
|
||||||
|
try:
|
||||||
|
if hedge_entry.is_probe:
|
||||||
|
await self._record_quietly(self._breaker.release_probe(hedge_entry))
|
||||||
|
finally:
|
||||||
|
self._pacer.leave(hedge_source.name)
|
||||||
|
await settle_and_release(hedge_permit, 0)
|
||||||
|
outcome = await primary
|
||||||
|
if isinstance(outcome, LLMResponse):
|
||||||
|
self._record_generation(request, sink_p)
|
||||||
|
return outcome
|
||||||
|
# Phase 4 启动对冲路(v1 单路,H5): 对冲路恒传 first_token_event=None,
|
||||||
|
# 不再触发梯次对冲;content_event 同恒 None(N3③,v1 不梯次)
|
||||||
|
sink_h: list[_GenerationSample] = []
|
||||||
|
hedge = asyncio.create_task(
|
||||||
|
self._attempt(
|
||||||
|
request,
|
||||||
|
*hedge_picked,
|
||||||
|
reasons,
|
||||||
|
attempt_fails,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
generation_sink=sink_h,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Phase 5 赢家裁定与收口
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
{primary, hedge}, return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
if pending and not any(self._succeeded(t, done) for t in done):
|
||||||
|
# 先了结的是失败: 等另一路的结论再裁定(它可能后发先至;
|
||||||
|
# 原路先败、对冲在途时不重试,设计 §4.6)
|
||||||
|
more, pending = await asyncio.wait(pending)
|
||||||
|
done |= more
|
||||||
|
winner = (
|
||||||
|
primary
|
||||||
|
if self._succeeded(primary, done)
|
||||||
|
else hedge
|
||||||
|
if self._succeeded(hedge, done)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if winner is None:
|
||||||
|
primary_exc = primary.exception()
|
||||||
|
hedge_exc = hedge.exception() # 两路都取回,不留 never-retrieved 告警
|
||||||
|
# 非可重试族(RequestRejected/ResultInvalid)穿透,与单路同口径
|
||||||
|
if primary_exc is not None:
|
||||||
|
raise primary_exc
|
||||||
|
if hedge_exc is not None:
|
||||||
|
raise hedge_exc
|
||||||
|
# 两败(H6): 汇合为一次失败,重试预算只计一次;路数照登(无赢家)
|
||||||
|
outcome = _combine_failures(primary.result(), hedge.result())
|
||||||
|
self._register_hedge(request, hedge_won=False, winner_sink=None)
|
||||||
|
return outcome
|
||||||
|
# 两路同时成功的竞速 → 原路优先(保守不弃原路成果),由上面 primary
|
||||||
|
# 先判实现;竞速落选者已完成,不置标记——它没被取消,行是正常行
|
||||||
|
loser = hedge if winner is primary else primary
|
||||||
|
if not loser.done():
|
||||||
|
# 先置标记再 cancel: _attempt 的取消分支据标记选 hedge_cancelled
|
||||||
|
setattr(loser, _HEDGE_LOSER_ATTR, True)
|
||||||
|
loser.cancel()
|
||||||
|
# 收口: gather 等输家 finally 的结算/遥测跑完才返回(快照含输家,
|
||||||
|
# attempts==2);对已完成的输家顺带取回结果,不留 never-retrieved 告警
|
||||||
|
await asyncio.gather(loser, return_exceptions=True)
|
||||||
|
self._register_hedge(
|
||||||
|
request,
|
||||||
|
hedge_won=winner is hedge,
|
||||||
|
winner_sink=sink_h if winner is hedge else sink_p,
|
||||||
|
)
|
||||||
|
return winner.result()
|
||||||
|
except BaseException:
|
||||||
|
# 取消穿透与准入冒泡(GovernanceBackendError)同路: 两任务(存在且未完者)
|
||||||
|
# 同消,尽力收口后原异常上抛;不 shield,收口 await 允许被再取消(136 清理纪律)
|
||||||
|
tasks = [t for t in (primary, hedge) if t is not None and not t.done()]
|
||||||
|
for t in tasks:
|
||||||
|
t.cancel()
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
await waiter
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
if asyncio.current_task().cancelling(): # 外部取消,穿透
|
||||||
|
raise
|
||||||
|
if self._hedge_after_s is not None and not primary.done() and not first_token.is_set():
|
||||||
|
return True # AFTER_S 触发: 阈值到 + 首 token 未至 + 原路在途(1.3.7 原语义)
|
||||||
|
if primary.done():
|
||||||
|
return False
|
||||||
|
# 阶段 2 首 token 后: STALL_AFTER_S 循环等待;非流式/未设 stall → 不开窗
|
||||||
|
if self._stall_after_s is None or content_event is None:
|
||||||
|
return False
|
||||||
|
while not primary.done():
|
||||||
|
waiter = asyncio.create_task(content_event.wait()) # 每轮自建 waiter(N3④)
|
||||||
|
try:
|
||||||
|
await asyncio.wait(
|
||||||
|
{primary, waiter},
|
||||||
|
timeout=self._stall_after_s,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
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:
|
||||||
|
"""赢家裁定: 已完成、未被取消、无异常且结果为 LLMResponse。"""
|
||||||
|
return (
|
||||||
|
task in done
|
||||||
|
and not task.cancelled()
|
||||||
|
and task.exception() is None
|
||||||
|
and isinstance(task.result(), LLMResponse)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _record_generation(request: ChatRequest, sink: list[_GenerationSample]) -> None:
|
||||||
|
"""record_generation 的共享守卫: 上下文缺失(库内现场构造)或空 sink 均跳过。"""
|
||||||
|
if request.call_context is not None and sink:
|
||||||
|
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[_GenerationSample] | None
|
||||||
|
) -> None:
|
||||||
|
"""对冲登记(赢家裁定后一次性;同 register_attempt 的 None 守卫)。
|
||||||
|
|
||||||
|
hedges 计"实际并发发出的对冲路数"——触发但准入失败的静默不计(没走到
|
||||||
|
这里);两败轮次无赢家: 路数照登(hedge_won=False),裸生成时间无归属不记。
|
||||||
|
"""
|
||||||
|
context = request.call_context
|
||||||
|
if context is None:
|
||||||
|
return
|
||||||
|
if winner_sink:
|
||||||
|
RetryMW._record_winner_sample(context, winner_sink[0])
|
||||||
|
context.register_hedge(hedge_won=hedge_won)
|
||||||
|
|
||||||
async def _on_rejected(
|
async def _on_rejected(
|
||||||
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
|
self, exc: RequestRejectedError, source: SourceConfig, entry: GateDecision
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -383,6 +740,9 @@ class RetryMW:
|
|||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=result.content,
|
content=result.content,
|
||||||
|
completion=result.completion,
|
||||||
|
finish_reason=result.finish_reason,
|
||||||
|
stream_end=result.stream_end,
|
||||||
thinking=result.thinking,
|
thinking=result.thinking,
|
||||||
model=source.model,
|
model=source.model,
|
||||||
provider=source.provider,
|
provider=source.provider,
|
||||||
@@ -415,11 +775,15 @@ class RetryMW:
|
|||||||
*,
|
*,
|
||||||
response: LLMResponse | None = None,
|
response: LLMResponse | None = None,
|
||||||
error: PolyGatewayError | str | None = None,
|
error: PolyGatewayError | str | None = None,
|
||||||
|
stall_ms: int | None = None,
|
||||||
|
stall_count: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
|
"""逐次遥测(经注入的单一 Emitter);遥测失败不得影响调用(铁律)。
|
||||||
|
|
||||||
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常
|
异常**对象原样下传**而非先 `str()` 压平(1.3.5 设计 §5): 状态码、底层异常
|
||||||
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。
|
类型与网关响应体已经在异常上了,在这里压平就是把它们丢掉。
|
||||||
|
stall 两值(1.3.8)只在成功分支由 `_attempt` 显式传入(计划 §3.4);
|
||||||
|
失败/取消分支恒 None——被取消输家无 TransportResult(设计 §11-B2)。
|
||||||
"""
|
"""
|
||||||
if self._emitter is None:
|
if self._emitter is None:
|
||||||
return
|
return
|
||||||
@@ -435,6 +799,8 @@ class RetryMW:
|
|||||||
reasoning_applies=True,
|
reasoning_applies=True,
|
||||||
# 公开方法四值之一;本中间件只服务 chat 洋葱
|
# 公开方法四值之一;本中间件只服务 chat 洋葱
|
||||||
operation="chat",
|
operation="chat",
|
||||||
|
stall_ms=stall_ms,
|
||||||
|
stall_count=stall_count,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ class _AttemptUsage:
|
|||||||
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
# 内部字段用枚举类型;裸 str 归一化只发生在 `_record` 下沉 recorder 那一步。
|
||||||
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
# 失败尝试无响应可言,默认 UNKNOWN 本身就是事实("观测不到"),不撒谎
|
||||||
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
thinking_observation: ThinkingObservation = ThinkingObservation.UNKNOWN
|
||||||
|
# 1.3.8(issue #27): stall 归因**不从 response 取**——LLMResponse 不增顶层
|
||||||
|
# 字段(设计 §11-B2 裁决 b),stall 由 retry 层经 emit_attempt 显式 kw 传入;
|
||||||
|
# 两字段留在此处只为让"尝试视图"形状完整,默认 None 即"无沉默观测"
|
||||||
|
stall_ms: int | None = None
|
||||||
|
stall_count: int | None = None
|
||||||
|
# 1.3.10(#31): 与 stall 相反,结束方式**从 response 取**——LLMResponse 本次有
|
||||||
|
# 该字段,免去约 50 处 emit_attempt 调用点加参;失败尝试 None = 无流可言
|
||||||
|
stream_end: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
def of(cls, response: LLMResponse | None) -> _AttemptUsage:
|
||||||
@@ -234,6 +242,7 @@ class _AttemptUsage:
|
|||||||
model_reported=response.model_reported,
|
model_reported=response.model_reported,
|
||||||
reasoning_tokens=response.reasoning_tokens,
|
reasoning_tokens=response.reasoning_tokens,
|
||||||
thinking_observation=response.thinking_observation,
|
thinking_observation=response.thinking_observation,
|
||||||
|
stream_end=response.stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -365,7 +374,7 @@ def _assert_recorder_shape(recorder: TelemetryRecorder) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class TelemetryEmitter:
|
class TelemetryEmitter:
|
||||||
"""从请求与结果组装 36 字段并写入 recorder;一切写失败降级 warning。"""
|
"""从请求与结果组装 39 字段并写入 recorder;一切写失败降级 warning。"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -405,6 +414,12 @@ class TelemetryEmitter:
|
|||||||
reasoning_applies: bool,
|
reasoning_applies: bool,
|
||||||
operation: CallOperation,
|
operation: CallOperation,
|
||||||
class_prefixed_error: bool = False,
|
class_prefixed_error: bool = False,
|
||||||
|
# 1.3.8(issue #27): 沉默归因由调用点显式传入(成功分支从 TransportResult
|
||||||
|
# 换算;失败/取消分支不传)。**默认 None 语义安全**: 漏传落 NULL 是合法事实
|
||||||
|
# ("非流式/无沉默观测"),不是"没表态"——与 reasoning_applies 不同类;
|
||||||
|
# 默认保住约 50 处既有调用点零改动(计划 §3.4 取舍)
|
||||||
|
stall_ms: int | None = None,
|
||||||
|
stall_count: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
|
"""逐次尝试记录(三个 Client 的重试层调用);失败尝试无用量可言,记 0 并标 unavailable。
|
||||||
|
|
||||||
@@ -451,6 +466,9 @@ class TelemetryEmitter:
|
|||||||
event_kind="attempt",
|
event_kind="attempt",
|
||||||
attempts=None,
|
attempts=None,
|
||||||
total_latency_ms=None,
|
total_latency_ms=None,
|
||||||
|
stall_ms=stall_ms,
|
||||||
|
stall_count=stall_count,
|
||||||
|
stream_end=usage.stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_cache_hit(
|
async def emit_cache_hit(
|
||||||
@@ -498,6 +516,11 @@ class TelemetryEmitter:
|
|||||||
event_kind="cache_hit",
|
event_kind="cache_hit",
|
||||||
attempts=None,
|
attempts=None,
|
||||||
total_latency_ms=None,
|
total_latency_ms=None,
|
||||||
|
# 缓存命中行未产生 transport 观测: 无沉默可言,显式 None
|
||||||
|
stall_ms=None,
|
||||||
|
stall_count=None,
|
||||||
|
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
|
||||||
|
stream_end=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def emit_terminal_failure(
|
async def emit_terminal_failure(
|
||||||
@@ -550,6 +573,11 @@ class TelemetryEmitter:
|
|||||||
event_kind="terminal_failure",
|
event_kind="terminal_failure",
|
||||||
attempts=stats.attempts,
|
attempts=stats.attempts,
|
||||||
total_latency_ms=stats.total_latency_ms,
|
total_latency_ms=stats.total_latency_ms,
|
||||||
|
# 终态行无具体源与 transport 观测: 显式 None
|
||||||
|
stall_ms=None,
|
||||||
|
stall_count=None,
|
||||||
|
# 命中/终态行无流可言(缓存命中已在 CacheMW 置 None,此处显式不信任)
|
||||||
|
stream_end=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _record(
|
async def _record(
|
||||||
@@ -592,6 +620,12 @@ class TelemetryEmitter:
|
|||||||
event_kind: EventKind,
|
event_kind: EventKind,
|
||||||
attempts: int | None,
|
attempts: int | None,
|
||||||
total_latency_ms: int | None,
|
total_latency_ms: int | None,
|
||||||
|
# —— 1.3.8: 沉默归因两列(issue #27);各入口已按自己的事实定型,
|
||||||
|
# 本方法只搬运(同 reasoning_effort 先例)——
|
||||||
|
stall_ms: int | None,
|
||||||
|
stall_count: int | None,
|
||||||
|
# —— 1.3.10: 结束方式(#31);入口已定型,本方法只搬运 ——
|
||||||
|
stream_end: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
# 成本换算(M2 §6): 成功行按单价换算;缓存命中 0.0(未产生新调用);
|
||||||
@@ -660,6 +694,9 @@ class TelemetryEmitter:
|
|||||||
error_body=errors.error_body,
|
error_body=errors.error_body,
|
||||||
attempts=attempts,
|
attempts=attempts,
|
||||||
total_latency_ms=total_latency_ms,
|
total_latency_ms=total_latency_ms,
|
||||||
|
stall_ms=stall_ms,
|
||||||
|
stall_count=stall_count,
|
||||||
|
stream_end=stream_end,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+35
-1
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.client import _aclose_component, _telemetry_status_of
|
from polygateway.client import _aclose_component, _telemetry_status_of
|
||||||
|
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
GovernanceBackendError,
|
GovernanceBackendError,
|
||||||
@@ -115,10 +116,15 @@ class OcrClient:
|
|||||||
circuit_open: str = "fail_fast",
|
circuit_open: str = "fail_fast",
|
||||||
telemetry: TelemetryRecorder | None = None,
|
telemetry: TelemetryRecorder | None = None,
|
||||||
text_cap: int | None = None,
|
text_cap: int | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
now: Callable[[], float] = time.monotonic,
|
now: Callable[[], float] = time.monotonic,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||||
rng: Callable[[], float] = random.random,
|
rng: Callable[[], float] = random.random,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# 入口即校: 装配错误当场报,不等到第一次调用才炸
|
||||||
|
self._call_deadline_s = ensure_call_deadline(
|
||||||
|
call_deadline_s, "OcrClient(call_deadline_s=...)"
|
||||||
|
)
|
||||||
self._scope = scope
|
self._scope = scope
|
||||||
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
# MonkeyOCR 只发 multipart 表单,带 extra_body 的源必须先剥离,否则
|
||||||
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
# 遥测会记录一个从未发出的采样参数(issue #4 决策 G)
|
||||||
@@ -171,10 +177,14 @@ class OcrClient:
|
|||||||
parent_call_id: str | None = None,
|
parent_call_id: str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
) -> OcrTextResult:
|
) -> OcrTextResult:
|
||||||
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
"""一次治理文本转录(/ocr/text);text 空串 = 合法"无文字"。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||||
|
|
||||||
|
`call_deadline_s` 是本次调用的墙钟硬边界(issue #22): `None` = 继承装配值。
|
||||||
|
它治理的是**等待**——到期不等于未产出,返回时刻 = 期限 + 清理耗时。
|
||||||
"""
|
"""
|
||||||
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
# 必须在进链路之前校验: 链路内的一切失败都被遥测层降级成 warning
|
||||||
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
# (库铁律「遥测写失败降级不冒泡」),校验放下游等于没有校验
|
||||||
@@ -189,6 +199,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
dimension_tenant_id,
|
dimension_tenant_id,
|
||||||
dimensions,
|
dimensions,
|
||||||
|
call_deadline_s,
|
||||||
)
|
)
|
||||||
result = outcome.result
|
result = outcome.result
|
||||||
return OcrTextResult(
|
return OcrTextResult(
|
||||||
@@ -209,10 +220,13 @@ class OcrClient:
|
|||||||
parent_call_id: str | None = None,
|
parent_call_id: str | None = None,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
meta: Mapping[str, Any] | None = None,
|
meta: Mapping[str, Any] | None = None,
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
) -> OcrLayoutResult:
|
) -> OcrLayoutResult:
|
||||||
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
"""一次治理版面解析(/parse → ZIP);elements 空 = 合法"无元素"。
|
||||||
|
|
||||||
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
`tenant_id` 与 `meta` 是调用方自定义维度,只进遥测(issue #11)。
|
||||||
|
|
||||||
|
`call_deadline_s` 同 `recognize_text`(issue #22): `None` = 继承装配值。
|
||||||
"""
|
"""
|
||||||
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
# 校验早于链路,理由同 recognize_text;origin 标明方法名以便定位入口
|
||||||
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
dimension_tenant_id, dimensions = validate_caller_dimensions(
|
||||||
@@ -226,6 +240,7 @@ class OcrClient:
|
|||||||
parent_call_id,
|
parent_call_id,
|
||||||
dimension_tenant_id,
|
dimension_tenant_id,
|
||||||
dimensions,
|
dimensions,
|
||||||
|
call_deadline_s,
|
||||||
)
|
)
|
||||||
result = outcome.result
|
result = outcome.result
|
||||||
return OcrLayoutResult(
|
return OcrLayoutResult(
|
||||||
@@ -262,17 +277,28 @@ class OcrClient:
|
|||||||
parent_call_id: str | None,
|
parent_call_id: str | None,
|
||||||
tenant_id: str | None,
|
tenant_id: str | None,
|
||||||
meta: dict[str, Any],
|
meta: dict[str, Any],
|
||||||
|
call_deadline_s: float | None = None,
|
||||||
) -> tuple[_AttemptOutcome, CallStats]:
|
) -> tuple[_AttemptOutcome, CallStats]:
|
||||||
if not isinstance(image, bytes):
|
if not isinstance(image, bytes):
|
||||||
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
raise TypeError("image 必须是 bytes(路径读取/批量拼帧留业务侧,D9)")
|
||||||
if not image:
|
if not image:
|
||||||
raise ValueError("image 不能为空")
|
raise ValueError("image 不能为空")
|
||||||
|
# 期限校验与 `image` 校验同列(仍在 `_CallContext` 之前、创建 awaitable 之前)
|
||||||
|
deadline = (
|
||||||
|
self._call_deadline_s
|
||||||
|
if call_deadline_s is None
|
||||||
|
else ensure_call_deadline(call_deadline_s, f"{operation}(call_deadline_s=...)")
|
||||||
|
)
|
||||||
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
# M1 例外: `image` 校验在 `_call` 内而非公开方法,故上下文在该校验
|
||||||
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
# **通过之后**创建——这样设计 §3 的"校验在统计边界外"对 OCR 才成立
|
||||||
context = _CallContext(now=self._now)
|
context = _CallContext(now=self._now)
|
||||||
try:
|
try:
|
||||||
return await self._run(
|
return await with_call_deadline(
|
||||||
|
self._run(
|
||||||
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
kind, operation, image, session_id, parent_call_id, tenant_id, meta, context
|
||||||
|
),
|
||||||
|
deadline_s=deadline,
|
||||||
|
scope=self._scope,
|
||||||
)
|
)
|
||||||
except PolyGatewayError as exc:
|
except PolyGatewayError as exc:
|
||||||
await self._emit_terminal(
|
await self._emit_terminal(
|
||||||
@@ -369,11 +395,16 @@ class OcrClient:
|
|||||||
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
|
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,故这里只登记 **1** 次
|
||||||
context.register_attempt()
|
context.register_attempt()
|
||||||
try:
|
try:
|
||||||
|
# 裸生成时间(1.3.7 H8): 计时只包 transport 调用本身(同 RetryMW 口径);
|
||||||
|
# layout 的 POST + ZIP GET 在同一次 `_invoke` 内,属同一段生成耗时
|
||||||
|
gen_started = self._now()
|
||||||
result = await self._invoke(kind, image, source, call_id)
|
result = await self._invoke(kind, image, source, call_id)
|
||||||
|
gen_ms = int((self._now() - gen_started) * 1000)
|
||||||
await self._record_quietly(self._breaker.record_success(entry))
|
await self._record_quietly(self._breaker.record_success(entry))
|
||||||
await self._record_quietly(self._quota.mark_progress())
|
await self._record_quietly(self._quota.mark_progress())
|
||||||
self._feed_outcome(source.name, ok=True)
|
self._feed_outcome(source.name, ok=True)
|
||||||
latency_ms = int((self._now() - started) * 1000)
|
latency_ms = int((self._now() - started) * 1000)
|
||||||
|
context.record_generation(gen_ms, accumulate=False)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
kind,
|
kind,
|
||||||
operation,
|
operation,
|
||||||
@@ -446,6 +477,8 @@ class OcrClient:
|
|||||||
)
|
)
|
||||||
return _FailedAttempt(exc, immediate=dead)
|
return _FailedAttempt(exc, immediate=dead)
|
||||||
finally:
|
finally:
|
||||||
|
# OCR 的 0 token 是**事实**而非"未知"(设计 §6.3 S6): 故取消也恰恰结 0,
|
||||||
|
# 不引入 chat/embedding 那套 settlement_known 兜底。
|
||||||
await settle_and_release(permit, 0)
|
await settle_and_release(permit, 0)
|
||||||
|
|
||||||
async def _invoke(
|
async def _invoke(
|
||||||
@@ -668,6 +701,7 @@ class OcrClient:
|
|||||||
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
# OCR 行与 chat 行写同一张 llm_calls;漏传这一条,同表内就一半受控
|
||||||
# 一半不受控(issue #12)
|
# 一半不受控(issue #12)
|
||||||
text_cap=gw.telemetry_text_cap,
|
text_cap=gw.telemetry_text_cap,
|
||||||
|
call_deadline_s=gw.call_deadline_s,
|
||||||
)
|
)
|
||||||
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
_mark_owned_components(client, limiter=limiter, breaker=breaker, telemetry=telemetry)
|
||||||
return client
|
return client
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
时间量纲一律**秒**(CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。
|
时间量纲一律**秒**(CHS Redis 实现内部的毫秒换算是后端私事,不进契约)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -19,6 +20,7 @@ from .types import (
|
|||||||
OcrLayoutTransportResult,
|
OcrLayoutTransportResult,
|
||||||
OcrTextResult,
|
OcrTextResult,
|
||||||
OcrTextTransportResult,
|
OcrTextTransportResult,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
SourceStats,
|
SourceStats,
|
||||||
TelemetryStatus,
|
TelemetryStatus,
|
||||||
@@ -39,6 +41,10 @@ class Middleware(Protocol):
|
|||||||
class Transport(Protocol):
|
class Transport(Protocol):
|
||||||
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
"""一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。
|
||||||
|
|
||||||
|
`response_mode` 为必填返回契约:openai 成功必须提供 completion,
|
||||||
|
不支持者应 RequestRejected,不能静默退回文本。工具的实际增量与首次身份
|
||||||
|
同正文/推理共用活性事件;同帧只置位一次,重复身份不算进展。
|
||||||
|
|
||||||
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
`reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。
|
||||||
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的
|
||||||
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。
|
||||||
@@ -46,6 +52,16 @@ class Transport(Protocol):
|
|||||||
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
|
该参数**不设默认值**,与 `TelemetryRecorder.record_llm_call` 同一既有约定:
|
||||||
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
|
库外无第三方实现者,写全签名的成本为零,而默认值会把"某一层漏传"变成静默的
|
||||||
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
|
"调用方没表态"——一次本该报错的漏配就此变成一次悄悄涨价的调用。
|
||||||
|
|
||||||
|
`first_token_event` 同一约定(1.3.7 对冲 H2): `None` = 调用方不观测首 token
|
||||||
|
(未启用对冲);非流式实现**永不置位**(物理上无中途信号,事件自然退化为纯时间
|
||||||
|
阈值),流式实现在首个增量(内容或思考)到达时置位。
|
||||||
|
|
||||||
|
`content_event` 同一约定(1.3.8 中段沉默对冲,issue #27/#28): `None` = 调用方
|
||||||
|
不观测增量 chunk(未启用中段对冲);**流式**实现每到达一个增量 chunk(`content`
|
||||||
|
或 `reasoning_content`,与看门狗/首 token 活性口径一致)置位一次;空 chunk、
|
||||||
|
usage 帧、SSE keepalive 不算;非流式实现**永不置位**;对冲路恒收 `None`
|
||||||
|
(v1 不梯次,设计 §5.2 N3③)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def complete(
|
async def complete(
|
||||||
@@ -57,6 +73,9 @@ class Transport(Protocol):
|
|||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
call_id: str,
|
call_id: str,
|
||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult: ...
|
) -> TransportResult: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +290,7 @@ class TelemetryStatusProvider(Protocol):
|
|||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class TelemetryRecorder(Protocol):
|
class TelemetryRecorder(Protocol):
|
||||||
"""遥测后端;36 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5),唯一调用点是 TelemetryEmitter。
|
"""遥测后端;39 字段冻结(M1 设计 §4.4 + issue #3/#4/#11/#16/#20 + 1.3.5/1.3.8/1.3.10),唯一调用点是 TelemetryEmitter。
|
||||||
|
|
||||||
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
新增参数不设默认值: 库外无第三方实现者(三项目迁移时删除了各自的同名
|
||||||
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
Protocol),完整签名的成本为零,而少写一列会被 emitter 的降级吞成 warning。
|
||||||
@@ -336,4 +355,7 @@ class TelemetryRecorder(Protocol):
|
|||||||
error_body: str | None,
|
error_body: str | None,
|
||||||
attempts: int | None,
|
attempts: int | None,
|
||||||
total_latency_ms: int | None,
|
total_latency_ms: int | None,
|
||||||
|
stall_ms: int | None,
|
||||||
|
stall_count: int | None,
|
||||||
|
stream_end: str | None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
多处各存一份必然漂移,而漂移的表现是"下游照打印的 SQL 建完表,库仍报缺列"。
|
||||||
|
|
||||||
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
**`COLUMNS` 是 INSERT 字段序,不是物理列序**: 数据库自填的 `created_at` 不在其中(它带
|
||||||
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 36 个 INSERT 字段 +
|
`DEFAULT now()` / `datetime('now')`,库从不显式写它)。物理表列 = 39 个 INSERT 字段 +
|
||||||
`created_at` = 37;列数断言一律按物理列数写,两套口径混用是最易错处。
|
`created_at` = 40;列数断言一律按物理列数写,两套口径混用是最易错处。
|
||||||
|
|
||||||
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
本模块只依赖标准库: `telemetry/` 与 `backends/`、`transports/`、`structured/` 同层且
|
||||||
互不依赖(import-linter 契约执法)。
|
互不依赖(import-linter 契约执法)。
|
||||||
@@ -62,7 +62,10 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cause_type TEXT,
|
cause_type TEXT,
|
||||||
error_body TEXT,
|
error_body TEXT,
|
||||||
attempts INTEGER,
|
attempts INTEGER,
|
||||||
total_latency_ms INTEGER
|
total_latency_ms INTEGER,
|
||||||
|
stall_ms INTEGER,
|
||||||
|
stall_count INTEGER,
|
||||||
|
stream_end TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -104,7 +107,10 @@ CREATE TABLE IF NOT EXISTS llm_calls (
|
|||||||
cause_type TEXT,
|
cause_type TEXT,
|
||||||
error_body TEXT,
|
error_body TEXT,
|
||||||
attempts INTEGER,
|
attempts INTEGER,
|
||||||
total_latency_ms INTEGER
|
total_latency_ms INTEGER,
|
||||||
|
stall_ms INTEGER,
|
||||||
|
stall_count INTEGER,
|
||||||
|
stream_end TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -137,6 +143,12 @@ SQLITE_BACKFILL = (
|
|||||||
("error_body", "TEXT"),
|
("error_body", "TEXT"),
|
||||||
("attempts", "INTEGER"),
|
("attempts", "INTEGER"),
|
||||||
("total_latency_ms", "INTEGER"),
|
("total_latency_ms", "INTEGER"),
|
||||||
|
# 1.3.8 沉默归因两列(issue #27): 同样可空无默认——旧行 NULL = "补列之前
|
||||||
|
# 没记过这件事",与"观测过且零沉默"(0)是两回事
|
||||||
|
("stall_ms", "INTEGER"),
|
||||||
|
("stall_count", "INTEGER"),
|
||||||
|
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
|
||||||
|
("stream_end", "TEXT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
# PG 补列的列定义。语句由此派生成两份文本(见下),使"库内执行的那份"与"打印给
|
||||||
@@ -163,6 +175,11 @@ _PG_BACKFILL_DECLS = (
|
|||||||
("error_body", "TEXT"),
|
("error_body", "TEXT"),
|
||||||
("attempts", "INTEGER"),
|
("attempts", "INTEGER"),
|
||||||
("total_latency_ms", "INTEGER"),
|
("total_latency_ms", "INTEGER"),
|
||||||
|
# 1.3.8 两列,列序与 SQLITE_BACKFILL 逐项对齐(理由同上)
|
||||||
|
("stall_ms", "INTEGER"),
|
||||||
|
("stall_count", "INTEGER"),
|
||||||
|
# 1.3.10 结束方式(#31): 可空无默认,旧行 NULL = 补列之前没记过;值域见 StreamEnd
|
||||||
|
("stream_end", "TEXT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
# 新列排在 created_at 之后: 与旧表 ALTER 追加的位置一致(见 SQLITE_BACKFILL 同款注释)。
|
||||||
@@ -212,6 +229,11 @@ COLUMNS = (
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
# —— 1.3.8 沉默归因(issue #27)——
|
||||||
|
"stall_ms",
|
||||||
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
)
|
)
|
||||||
|
|
||||||
_COLUMN_SET = frozenset(COLUMNS)
|
_COLUMN_SET = frozenset(COLUMNS)
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class SQLiteRecorder:
|
|||||||
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
logger.warning("SQLite 遥测补列失败(写入将逐行降级): {}", exc)
|
||||||
|
|
||||||
async def record_llm_call(self, **fields: object) -> None:
|
async def record_llm_call(self, **fields: object) -> None:
|
||||||
"""写一行遥测;字段集合即 36 字段冻结签名(ports.TelemetryRecorder)。
|
"""写一行遥测;字段集合即 39 字段冻结签名(ports.TelemetryRecorder)。
|
||||||
|
|
||||||
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
取值按 `self._columns`(manual 档可能已被裁剪),与 `self._insert` 的
|
||||||
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
占位符同序——两者必须一起改,分开改就是把值写进错位的列。
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。
|
||||||
|
|
||||||
|
1.3.10(#30): null 一律视为缺省;已知字段沿用 1.3.9 语义;未知字段按 OpenAI 流式
|
||||||
|
累加惯例无损合并,冲突删键并登记路径——方言是确定性的,拒绝只会让重试重演同一失败。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from polygateway.errors import TransientError
|
||||||
|
from polygateway.types import OpenAIChatCompletion, ResponseMode
|
||||||
|
|
||||||
|
_DELTA_KNOWN = frozenset({"role", "content", "reasoning_content", "tool_calls"})
|
||||||
|
_TOOL_KNOWN = frozenset({"index", "id", "type", "function"})
|
||||||
|
_FUNCTION_KNOWN = frozenset({"name", "arguments"})
|
||||||
|
# 标签键: 每帧可能重复下发的身份/格式标记,同值幂等,绝不拼接(拼成 "x-v1x-v1"
|
||||||
|
# 回传上游会被拒);也不算生成进展
|
||||||
|
_LABEL_KEYS = frozenset({"index", "type", "id", "format"})
|
||||||
|
|
||||||
|
|
||||||
|
def _invalid(reason: str) -> TransientError:
|
||||||
|
"""协议形态失败,不回显可能敏感的工具参数。"""
|
||||||
|
return TransientError(f"OpenAI 协议失败: {reason}", operation="chat")
|
||||||
|
|
||||||
|
|
||||||
|
def _object(value: Any, name: str) -> dict[str, Any]:
|
||||||
|
"""外部对象先校验再读取。"""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise _invalid(f"{name} 必须为对象")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(target: dict[str, Any], key: str, value: Any) -> bool:
|
||||||
|
"""身份字段只接受同值重复,不把重复完整 ID 当分片拼接。"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise _invalid(f"{key} 必须为字符串")
|
||||||
|
if key in target and target[key] != value:
|
||||||
|
raise _invalid(f"{key} 冲突")
|
||||||
|
changed = key not in target and bool(value)
|
||||||
|
target[key] = value
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _append(target: dict[str, Any], key: str, value: Any) -> str:
|
||||||
|
"""字符串片段逐字拼接,保留缺省/null/空串区别。"""
|
||||||
|
if value is None:
|
||||||
|
target.setdefault(key, None)
|
||||||
|
return ""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise _invalid(f"{key} 必须为字符串或 null")
|
||||||
|
target[key] = (target.get(key) or "") + value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _assemble(message: dict[str, Any], tools: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
||||||
|
"""按 index 排序组装工具;`finish()` 与 `raw_message()` 共用,判定与快照同源。"""
|
||||||
|
assembled = copy.deepcopy(message)
|
||||||
|
if tools:
|
||||||
|
assembled["tool_calls"] = [copy.deepcopy(tools[index]) for index in sorted(tools)]
|
||||||
|
return assembled
|
||||||
|
|
||||||
|
|
||||||
|
def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion:
|
||||||
|
"""复用内核最终快照校验并翻译领域错误。"""
|
||||||
|
try:
|
||||||
|
return OpenAIChatCompletion(
|
||||||
|
message, metadata.get("finish_reason"), metadata.get("id"), metadata.get("usage")
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise _invalid(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# —— 未知字段合并(设计 §5.2) ——
|
||||||
|
|
||||||
|
|
||||||
|
class _MergeConflictError(Exception):
|
||||||
|
"""合并冲突(内部控制流): 由 `_ExtensionMerger.merge_key` 就地收口为删键。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_indexed(items: list[Any]) -> bool:
|
||||||
|
"""数组元素(忽略 null)均为带整数 index 的对象。"""
|
||||||
|
present = [item for item in items if item is not None]
|
||||||
|
return bool(present) and all(
|
||||||
|
isinstance(item, dict) and type(item.get("index")) is int for item in present
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fragment_progress(key: str, value: Any) -> bool:
|
||||||
|
"""不落盘的活性判据(文本模式): 非标签键上的非空字符串片段。"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return key not in _LABEL_KEYS and value != ""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_fragment_progress(k, v) for k, v in value.items())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_fragment_progress(key, item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _ExtensionMerger:
|
||||||
|
"""每尝试私有的未知字段合并器;冲突路径即封禁,后续同路径值丢弃。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.conflicts: set[str] = set()
|
||||||
|
self._array_modes: dict[str, bool] = {}
|
||||||
|
|
||||||
|
def merge_key(self, target: dict[str, Any], key: str, value: Any, path: str) -> bool:
|
||||||
|
"""合并一个键;返回是否并入了非空字符串片段(活性)。"""
|
||||||
|
if path in self.conflicts:
|
||||||
|
return False
|
||||||
|
if value is None:
|
||||||
|
target.setdefault(key, None)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
merged, grew = self._merge(target.get(key), value, key, path)
|
||||||
|
except _MergeConflictError:
|
||||||
|
target.pop(key, None)
|
||||||
|
self.conflicts.add(path)
|
||||||
|
return False
|
||||||
|
target[key] = merged
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _merge(self, old: Any, new: Any, key: str, path: str) -> tuple[Any, bool]:
|
||||||
|
if isinstance(new, dict):
|
||||||
|
return self._merge_dict(old, new, path)
|
||||||
|
if isinstance(new, list):
|
||||||
|
return self._merge_list(old, new, key, path)
|
||||||
|
if key in _LABEL_KEYS or not isinstance(new, str):
|
||||||
|
return self._merge_scalar(old, new)
|
||||||
|
if old is not None and not isinstance(old, str):
|
||||||
|
raise _MergeConflictError
|
||||||
|
return (old or "") + new, new != ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _merge_scalar(old: Any, new: Any) -> tuple[Any, bool]:
|
||||||
|
"""标签键与数字/布尔: 同值幂等,不同值冲突(不学 SDK 相加,计数相加会造假)。"""
|
||||||
|
if old is None:
|
||||||
|
return new, False
|
||||||
|
if type(old) is type(new) and old == new:
|
||||||
|
return old, False
|
||||||
|
raise _MergeConflictError
|
||||||
|
|
||||||
|
def _merge_dict(self, old: Any, new: dict[str, Any], path: str) -> tuple[Any, bool]:
|
||||||
|
if old is not None and not isinstance(old, dict):
|
||||||
|
raise _MergeConflictError
|
||||||
|
target = old if old is not None else {}
|
||||||
|
grew = False
|
||||||
|
for sub_key, sub_value in new.items():
|
||||||
|
grew |= self.merge_key(target, sub_key, sub_value, f"{path}.{sub_key}")
|
||||||
|
return target, grew
|
||||||
|
|
||||||
|
def _merge_list(self, old: Any, new: list[Any], key: str, path: str) -> tuple[Any, bool]:
|
||||||
|
if old is not None and not isinstance(old, list):
|
||||||
|
raise _MergeConflictError
|
||||||
|
target = old if old is not None else []
|
||||||
|
items = [item for item in new if item is not None]
|
||||||
|
if not items:
|
||||||
|
return target, False
|
||||||
|
indexed = _is_indexed(items)
|
||||||
|
if self._array_modes.setdefault(path, indexed) != indexed:
|
||||||
|
raise _MergeConflictError # 形态改变: 首次出现决定合并策略
|
||||||
|
if indexed:
|
||||||
|
return target, self._merge_indexed(target, items, f"{path}[]")
|
||||||
|
return target, self._merge_appended(target, items, key, f"{path}[]")
|
||||||
|
|
||||||
|
def _merge_appended(self, target: list[Any], items: list[Any], key: str, path: str) -> bool:
|
||||||
|
grew = False
|
||||||
|
for item in items:
|
||||||
|
merged, item_grew = self._merge(None, item, key, path)
|
||||||
|
target.append(merged)
|
||||||
|
grew |= item_grew
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _merge_indexed(self, target: list[Any], items: list[Any], path: str) -> bool:
|
||||||
|
grew = False
|
||||||
|
for item in items:
|
||||||
|
slot = next((e for e in target if e.get("index") == item["index"]), None)
|
||||||
|
if slot is None:
|
||||||
|
slot = {}
|
||||||
|
target.append(slot)
|
||||||
|
grew |= self._merge_dict(slot, item, path)[1]
|
||||||
|
return grew
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChatDelta:
|
||||||
|
"""一帧提取结果;progress 表示真实生成进展。
|
||||||
|
|
||||||
|
`reasoning` 是 `reasoning` 字段(vLLM/OpenRouter 新名)的片段,投影 thinking 时
|
||||||
|
作为 `reasoning_content` 缺席时的别名(设计 §4.4)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
reasoning_content: str
|
||||||
|
progress: bool
|
||||||
|
reasoning: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ChatCompletionAccumulator:
|
||||||
|
"""每次 transport 尝试独享的聚合器,文本模式不严格校验工具产物。"""
|
||||||
|
|
||||||
|
def __init__(self, *, response_mode: ResponseMode) -> None:
|
||||||
|
"""初始化尝试私有状态。"""
|
||||||
|
self.mode = response_mode
|
||||||
|
self.message: dict[str, Any] = {}
|
||||||
|
self.metadata: dict[str, Any] = {}
|
||||||
|
self.tools: dict[int, dict[str, Any]] = {}
|
||||||
|
self._merger = _ExtensionMerger()
|
||||||
|
self._finish_seen = False
|
||||||
|
self._usage_after_finish = False
|
||||||
|
|
||||||
|
# —— 只读视图(transport 判定消费) ——
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finish_reason(self) -> str | None:
|
||||||
|
"""choice 0 首个非 null finish_reason;未见为 None。"""
|
||||||
|
return self.metadata.get("finish_reason")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finish_seen(self) -> bool:
|
||||||
|
"""是否已收到 finish_reason。"""
|
||||||
|
return self._finish_seen
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage_after_finish(self) -> bool:
|
||||||
|
"""finish 同帧或之后是否收到非 null usage(设计 §4.2)。"""
|
||||||
|
return self._usage_after_finish
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conflicts(self) -> frozenset[str]:
|
||||||
|
"""被删除的冲突键路径(数组下标已归一为 `[]`)。"""
|
||||||
|
return frozenset(self._merger.conflicts)
|
||||||
|
|
||||||
|
def raw_message(self) -> dict[str, Any]:
|
||||||
|
"""未校验的合并消息深拷贝(含排序工具),供构造快照前的结局判定。"""
|
||||||
|
return _assemble(self.message, self.tools)
|
||||||
|
|
||||||
|
# —— 聚合 ——
|
||||||
|
|
||||||
|
def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta:
|
||||||
|
"""聚合一个已解码帧,一帧至多一个活性事件。"""
|
||||||
|
chunk = _object(chunk, "chunk")
|
||||||
|
if self.mode == "openai":
|
||||||
|
_identity(self.metadata, "id", chunk.get("id"))
|
||||||
|
choice = self._take_choice(chunk.get("choices"))
|
||||||
|
delta = self._take_delta(choice) if choice is not None else ChatDelta("", "", False)
|
||||||
|
self._take_usage(chunk.get("usage"))
|
||||||
|
return delta
|
||||||
|
|
||||||
|
def _take_usage(self, usage: Any) -> None:
|
||||||
|
"""usage 在 finish 处理之后登记,使同帧 finish+usage 算作"之后"。"""
|
||||||
|
if usage is None:
|
||||||
|
return
|
||||||
|
self.metadata["usage"] = _object(usage, "usage")
|
||||||
|
if self._finish_seen:
|
||||||
|
self._usage_after_finish = True
|
||||||
|
|
||||||
|
def _take_choice(self, choices: Any) -> dict[str, Any] | None:
|
||||||
|
"""选出本帧 choice 并登记 finish;usage-only 帧返回 None。"""
|
||||||
|
if choices is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(choices, list):
|
||||||
|
raise _invalid("choices 必须为数组")
|
||||||
|
if not choices:
|
||||||
|
return None
|
||||||
|
if self.mode == "openai":
|
||||||
|
if len(choices) != 1:
|
||||||
|
raise _invalid("仅支持单 choice")
|
||||||
|
choice = _object(choices[0], "choice")
|
||||||
|
if type(choice.get("index")) is not int or choice["index"] != 0:
|
||||||
|
raise _invalid("choice index 必须为 0")
|
||||||
|
_identity(self.metadata, "finish_reason", choice.get("finish_reason"))
|
||||||
|
else:
|
||||||
|
choice = self._text_choice(choices)
|
||||||
|
if choice is None:
|
||||||
|
return None
|
||||||
|
self._finish_seen = self._finish_seen or self.finish_reason is not None
|
||||||
|
return choice
|
||||||
|
|
||||||
|
def _text_choice(self, choices: list[Any]) -> dict[str, Any] | None:
|
||||||
|
"""文本模式取 index 缺省或为 0 的首个 choice,不混入其他 choice(n>1)。"""
|
||||||
|
for raw in choices:
|
||||||
|
choice = _object(raw, "choice")
|
||||||
|
index = choice.get("index")
|
||||||
|
# null 视缺省(=0);bool 不得冒充 0(`False == 0` 为真)
|
||||||
|
if index is None or (type(index) is int and index == 0):
|
||||||
|
finish = choice.get("finish_reason")
|
||||||
|
if isinstance(finish, str) and "finish_reason" not in self.metadata:
|
||||||
|
self.metadata["finish_reason"] = finish
|
||||||
|
return choice
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _take_delta(self, choice: dict[str, Any]) -> ChatDelta:
|
||||||
|
"""拆分已知与未知字段;已知字段语义逐字沿用 1.3.9。"""
|
||||||
|
raw_delta = choice.get("delta")
|
||||||
|
delta = _object(raw_delta if raw_delta is not None else {}, "delta")
|
||||||
|
if self.mode == "openai" and "role" in delta:
|
||||||
|
self._take_role(delta["role"])
|
||||||
|
content = self._take_text(delta, "content")
|
||||||
|
reasoning_content = self._take_text(delta, "reasoning_content")
|
||||||
|
alias = delta.get("reasoning") if isinstance(delta.get("reasoning"), str) else ""
|
||||||
|
progress = bool(content or reasoning_content)
|
||||||
|
if "tool_calls" in delta:
|
||||||
|
progress |= self._take_tools(delta["tool_calls"])
|
||||||
|
progress |= self._take_unknown(delta)
|
||||||
|
return ChatDelta(content, reasoning_content, progress, alias)
|
||||||
|
|
||||||
|
def _take_role(self, role: Any) -> None:
|
||||||
|
if role is None:
|
||||||
|
self.message.setdefault("role", None)
|
||||||
|
return
|
||||||
|
if role != "assistant":
|
||||||
|
raise _invalid("role 必须为 assistant")
|
||||||
|
self.message["role"] = role
|
||||||
|
|
||||||
|
def _take_text(self, delta: dict[str, Any], key: str) -> str:
|
||||||
|
if key not in delta:
|
||||||
|
return ""
|
||||||
|
if self.mode == "openai" or delta[key] is not None:
|
||||||
|
return _append(self.message, key, delta[key])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _take_unknown(self, delta: dict[str, Any]) -> bool:
|
||||||
|
"""未知 delta 键: 保真合并进 message;文本只算活性。"""
|
||||||
|
grew = False
|
||||||
|
for key, value in delta.items():
|
||||||
|
if key in _DELTA_KNOWN:
|
||||||
|
continue
|
||||||
|
if self.mode == "openai":
|
||||||
|
grew |= self._merger.merge_key(self.message, key, value, f"delta.{key}")
|
||||||
|
else:
|
||||||
|
grew |= _fragment_progress(key, value)
|
||||||
|
return grew
|
||||||
|
|
||||||
|
def _take_tools(self, tools: Any) -> bool:
|
||||||
|
if self.mode != "openai":
|
||||||
|
return isinstance(tools, list) and self._each_tool(tools, self._text_tool_progress)
|
||||||
|
if tools is None:
|
||||||
|
self.message.setdefault("tool_calls", None)
|
||||||
|
return False
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
raise _invalid("tool_calls 必须为数组")
|
||||||
|
present = [tool for tool in tools if tool is not None]
|
||||||
|
if self.message.get("tool_calls") is None and (present or not tools):
|
||||||
|
# 显式 [] 原样保留;[null] 不造空数组(回传空 tool_calls 会被上游拒)
|
||||||
|
self.message["tool_calls"] = []
|
||||||
|
return self._each_tool(present, lambda raw: self._take_tool(_object(raw, "tool")))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _each_tool(tools: list[Any], take: Any) -> bool:
|
||||||
|
"""逐个非 null 工具增量聚合,返回本帧是否有进展。"""
|
||||||
|
progress = False
|
||||||
|
for raw in tools:
|
||||||
|
if raw is not None:
|
||||||
|
progress |= take(raw)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _text_tool_progress(self, raw: Any) -> bool:
|
||||||
|
"""文本只消费可识别的工具进展,不承诺工具完整性。"""
|
||||||
|
if not isinstance(raw, dict) or type(raw.get("index")) is not int:
|
||||||
|
return False
|
||||||
|
tool = self.tools.setdefault(raw["index"], {})
|
||||||
|
progress = False
|
||||||
|
for key in ("id", "type"):
|
||||||
|
if isinstance(raw.get(key), str) and raw[key] and key not in tool:
|
||||||
|
tool[key] = raw[key]
|
||||||
|
progress = True
|
||||||
|
fn = raw.get("function")
|
||||||
|
if isinstance(fn, dict):
|
||||||
|
progress |= any(
|
||||||
|
isinstance(fn.get(k), str) and bool(fn[k]) for k in ("name", "arguments")
|
||||||
|
)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _take_tool(self, raw: dict[str, Any]) -> bool:
|
||||||
|
index = raw.get("index")
|
||||||
|
if type(index) is not int or index < 0:
|
||||||
|
raise _invalid("tool index 必须为非负整数")
|
||||||
|
tool = self.tools.setdefault(index, {})
|
||||||
|
progress = _identity(tool, "id", raw.get("id"))
|
||||||
|
progress |= _identity(tool, "type", raw.get("type"))
|
||||||
|
if raw.get("function") is not None:
|
||||||
|
progress |= self._take_function(tool, _object(raw["function"], "function"))
|
||||||
|
for key, value in raw.items():
|
||||||
|
if key not in _TOOL_KNOWN:
|
||||||
|
progress |= self._merger.merge_key(tool, key, value, f"tool_calls[].{key}")
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def _take_function(self, tool: dict[str, Any], fn: dict[str, Any]) -> bool:
|
||||||
|
target = tool.setdefault("function", {})
|
||||||
|
progress = False
|
||||||
|
for key, value in fn.items():
|
||||||
|
if key in _FUNCTION_KNOWN:
|
||||||
|
progress |= bool(_append(target, key, value))
|
||||||
|
else:
|
||||||
|
path = f"tool_calls[].function.{key}"
|
||||||
|
progress |= self._merger.merge_key(target, key, value, path)
|
||||||
|
return progress
|
||||||
|
|
||||||
|
def finish(self) -> OpenAIChatCompletion:
|
||||||
|
"""线路完整性由调用者保证;此处只校验最终消息。"""
|
||||||
|
return _completion(self.raw_message(), self.metadata)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_null_tools(message: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""null 工具元素剔除: 与流式跳过同口径,原样回传 [null] 会被上游拒。"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if isinstance(tools, list) and None in tools:
|
||||||
|
kept = [tool for tool in tools if tool is not None]
|
||||||
|
# 全为 null 视同 tool_calls:null,不造会被上游拒收的空数组
|
||||||
|
return {**message, "tool_calls": kept or None}
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _single_choice(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""恰好一个 choice,index 缺省或为 0;拒绝静默截取多 choice。"""
|
||||||
|
choices = body.get("choices")
|
||||||
|
if not isinstance(choices, list) or len(choices) != 1:
|
||||||
|
raise _invalid("仅支持单 choice")
|
||||||
|
choice = _object(choices[0], "choice")
|
||||||
|
if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0):
|
||||||
|
raise _invalid("choice index 必须为 0")
|
||||||
|
return choice
|
||||||
|
|
||||||
|
|
||||||
|
def check_completion_body(body: Any) -> tuple[dict[str, Any], str | None]:
|
||||||
|
"""非流式保真形态校验(不构造快照): 单 choice、index 为 0、finish 为字符串或 null。
|
||||||
|
|
||||||
|
与快照构造拆开: 结局判定要先于构造(截断的半截工具须判 ResultInvalid,
|
||||||
|
构造时抛 Transient 就到不了那一行,设计 §4.3 行 2)。
|
||||||
|
"""
|
||||||
|
choice = _single_choice(_object(body, "body"))
|
||||||
|
finish = choice.get("finish_reason")
|
||||||
|
if finish is not None and not isinstance(finish, str):
|
||||||
|
raise _invalid("finish_reason 必须为字符串或 null")
|
||||||
|
message = _object(choice.get("message"), "message")
|
||||||
|
if message.get("role") != "assistant":
|
||||||
|
# 形态失败先于结局判定,否则缺 role 的截断工具会被归成 ResultInvalid
|
||||||
|
raise _invalid("role 必须为 assistant")
|
||||||
|
return _strip_null_tools(message), finish
|
||||||
|
|
||||||
|
|
||||||
|
def build_completion(
|
||||||
|
message: dict[str, Any], body: dict[str, Any], finish: str | None
|
||||||
|
) -> OpenAIChatCompletion:
|
||||||
|
"""非流式快照构造;保留消息扩展字段。"""
|
||||||
|
return _completion(message, {**body, "finish_reason": finish})
|
||||||
|
|
||||||
|
|
||||||
|
def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion:
|
||||||
|
"""非流式完整消息保留扩展字段,拒绝静默截取多 choice。"""
|
||||||
|
message, finish = check_completion_body(body)
|
||||||
|
return build_completion(message, body, finish)
|
||||||
@@ -9,9 +9,10 @@ SSE 纯函数移植 VT `adapters/llm.py:51-124`;错误翻译移植 CHS
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -37,17 +38,35 @@ from polygateway.thinking import (
|
|||||||
validate_thinking_raw,
|
validate_thinking_raw,
|
||||||
)
|
)
|
||||||
from polygateway.transports._http_errors import compose_message, summarize_body
|
from polygateway.transports._http_errors import compose_message, summarize_body
|
||||||
|
from polygateway.transports._openai_completion import (
|
||||||
|
ChatCompletionAccumulator,
|
||||||
|
ChatDelta,
|
||||||
|
build_completion,
|
||||||
|
check_completion_body,
|
||||||
|
)
|
||||||
from polygateway.types import (
|
from polygateway.types import (
|
||||||
Effort,
|
Effort,
|
||||||
EmbeddingTransportResult,
|
EmbeddingTransportResult,
|
||||||
|
ResponseMode,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
|
StreamEnd,
|
||||||
TransportResult,
|
TransportResult,
|
||||||
|
_classify_finish,
|
||||||
|
_judge_outcome,
|
||||||
|
_stream_is_complete,
|
||||||
|
_summarize_message,
|
||||||
|
_summarize_text,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Callable, Mapping
|
from collections.abc import AsyncIterator, Callable, Mapping
|
||||||
|
|
||||||
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
_THINK_PATTERN = re.compile(r"<think>(.*?)</think>", re.DOTALL)
|
||||||
|
# 方言告警节流上限与 finish 值截断(设计 §5.4): 键由上游决定,必须有界
|
||||||
|
_WARN_CAP = 1024
|
||||||
|
_FINISH_WARN_CHARS = 64
|
||||||
|
_PATH_WARN_CHARS = 128
|
||||||
|
|
||||||
# —— SSE 纯函数(VT llm.py 同款)——
|
# —— SSE 纯函数(VT llm.py 同款)——
|
||||||
|
|
||||||
@@ -60,33 +79,48 @@ def _sse_data_payload(raw: str) -> str | None:
|
|||||||
return line[len("data:") :].strip()
|
return line[len("data:") :].strip()
|
||||||
|
|
||||||
|
|
||||||
def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, str] | None:
|
def _stall_from_chunk_times(
|
||||||
"""从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。"""
|
times: list[float], threshold_ms: int
|
||||||
if chunk.get("usage"):
|
) -> tuple[float | None, float | None]:
|
||||||
usage_sink["usage"] = chunk["usage"]
|
"""增量 chunk 时间戳序列 → (stall_ms, stall_count);chunk <2 → (None, None)。
|
||||||
if "model" not in usage_sink:
|
|
||||||
# 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死
|
沉默 = 相邻间隔**严格大于**阈值(恰好 = 阈值不减,pi-tps 口径);
|
||||||
# sink——否则后续真实版本会丢(issue #3)
|
尾部(末 chunk → 流结束)不计——墙钟基底口径,尾部归活跃侧(设计 §4.2 N1)。
|
||||||
reported = _coerce_model_reported(chunk.get("model"))
|
无超阈段但 chunk ≥2 时返回 (0.0, 0.0) 而非 None——"观测过且零沉默"与
|
||||||
if reported is not None:
|
"无从观测"是两回事,下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
|
||||||
usage_sink["model"] = reported
|
"""
|
||||||
choices = chunk.get("choices") or []
|
if len(times) < 2:
|
||||||
if not choices:
|
return None, None
|
||||||
return None
|
total = 0.0
|
||||||
delta = choices[0].get("delta") or {}
|
count = 0
|
||||||
content = delta.get("content")
|
# strict=False: 两序列恒差 1(times[1:] 少末尾),B905 要求显式表态——不等长是本设计的本意
|
||||||
if content:
|
for prev, cur in zip(times, times[1:], strict=False):
|
||||||
return (True, content)
|
gap_ms = (cur - prev) * 1000
|
||||||
reasoning = delta.get("reasoning_content")
|
if gap_ms > threshold_ms:
|
||||||
if reasoning:
|
total += gap_ms
|
||||||
return (False, reasoning)
|
count += 1
|
||||||
return None
|
return (total, float(count)) if count else (0.0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_chat_error(chunk: Any) -> None:
|
||||||
|
"""所有模式拒绝顶层 error;诊断复用有界摘要。"""
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
raise TransientError("chat 响应必须为 JSON 对象", operation="chat")
|
||||||
|
if "error" in chunk:
|
||||||
|
summary = summarize_body(json.dumps({"error": chunk["error"]}, ensure_ascii=False))
|
||||||
|
raise TransientError(
|
||||||
|
compose_message("上游 chat error 帧", summary), operation="chat", body_text=summary
|
||||||
|
)
|
||||||
|
if chunk.get("usage") is not None and not isinstance(chunk["usage"], dict):
|
||||||
|
raise TransientError("chat usage 必须为对象或 null", operation="chat")
|
||||||
|
|
||||||
|
|
||||||
async def _iter_sse_deltas(
|
async def _iter_sse_deltas(
|
||||||
lines: AsyncIterator[str], usage_sink: dict[str, Any]
|
lines: AsyncIterator[str],
|
||||||
) -> AsyncIterator[tuple[bool, str]]:
|
usage_sink: dict[str, Any],
|
||||||
"""逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。"""
|
accumulator: ChatCompletionAccumulator,
|
||||||
|
) -> AsyncIterator[ChatDelta]:
|
||||||
|
"""解析 SSE,一帧只产生一次进展事件。"""
|
||||||
async for raw in lines:
|
async for raw in lines:
|
||||||
data = _sse_data_payload(raw)
|
data = _sse_data_payload(raw)
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -97,25 +131,165 @@ async def _iter_sse_deltas(
|
|||||||
try:
|
try:
|
||||||
chunk = json.loads(data)
|
chunk = json.loads(data)
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise TransientError(
|
raise TransientError("SSE 帧畸形(malformed_json)", operation="chat") from exc
|
||||||
f"SSE 帧畸形(malformed_json): {data[:80]!r}", operation="chat"
|
_check_chat_error(chunk)
|
||||||
) from exc
|
if chunk.get("usage") is not None:
|
||||||
delta = _sse_delta(chunk, usage_sink)
|
usage_sink["usage"] = chunk["usage"]
|
||||||
if delta is not None:
|
if "model" not in usage_sink:
|
||||||
|
model = _coerce_model_reported(chunk.get("model"))
|
||||||
|
if model is not None:
|
||||||
|
usage_sink["model"] = model
|
||||||
|
delta = accumulator.add_chunk(chunk)
|
||||||
|
if delta.progress:
|
||||||
yield delta
|
yield delta
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _StreamTiming:
|
||||||
|
"""一次流式尝试的计时与文本片段(每尝试新建,不跨重试/对冲共享)。"""
|
||||||
|
|
||||||
|
content_parts: list[str] = field(default_factory=list)
|
||||||
|
reasoning_parts: list[str] = field(default_factory=list)
|
||||||
|
alias_parts: list[str] = field(default_factory=list)
|
||||||
|
chunk_times: list[float] = field(default_factory=list)
|
||||||
|
ttft_ms: float | None = None
|
||||||
|
max_gap: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_reasoning(self) -> bool:
|
||||||
|
"""出现过非空 reasoning_content: 别名 `reasoning` 让位(设计 §4.4)。"""
|
||||||
|
return bool(self.reasoning_parts)
|
||||||
|
|
||||||
|
|
||||||
|
async def _consume_deltas(
|
||||||
|
guarded: AsyncIterator[ChatDelta],
|
||||||
|
started: float,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
) -> _StreamTiming:
|
||||||
|
"""消费进展事件: 计时、置位对冲事件、收集文本片段。"""
|
||||||
|
timing = _StreamTiming()
|
||||||
|
last = started
|
||||||
|
async for delta in guarded:
|
||||||
|
now = time.monotonic()
|
||||||
|
# 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发——
|
||||||
|
# 空 chunk/usage 帧/SSE keepalive 已在聚合器被过滤
|
||||||
|
timing.chunk_times.append(now)
|
||||||
|
if content_event is not None:
|
||||||
|
# 每个增量 chunk 置位一次: 中段沉默对冲的活性复位信号
|
||||||
|
content_event.set()
|
||||||
|
if timing.ttft_ms is None:
|
||||||
|
timing.ttft_ms = (now - started) * 1000
|
||||||
|
# 首个增量即对冲语义上的"首 token"(思考增量同样是存活证据,
|
||||||
|
# 与看门狗活性口径一致);None = 调用方未启用对冲,零分支成本
|
||||||
|
if first_token_event is not None:
|
||||||
|
first_token_event.set()
|
||||||
|
else:
|
||||||
|
timing.max_gap = max(timing.max_gap, (now - last) * 1000)
|
||||||
|
last = now
|
||||||
|
for parts, text in (
|
||||||
|
(timing.content_parts, delta.content),
|
||||||
|
(timing.reasoning_parts, delta.reasoning_content),
|
||||||
|
(timing.alias_parts, delta.reasoning),
|
||||||
|
):
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
return timing
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_unless_deliverable(
|
||||||
|
mode: ResponseMode,
|
||||||
|
message: dict[str, Any] | None,
|
||||||
|
content: str,
|
||||||
|
thinking: str,
|
||||||
|
finish_reason: str | None,
|
||||||
|
source: SourceConfig,
|
||||||
|
) -> None:
|
||||||
|
"""结局判定(设计 §4.3)的唯一翻译点: 判定值 → 领域错误。
|
||||||
|
|
||||||
|
原因码以固定格式进 message(遥测 error 列可检索);不回显推理/工具内容。
|
||||||
|
"""
|
||||||
|
if mode == "openai" and message is not None:
|
||||||
|
summary = _summarize_message(message, content)
|
||||||
|
else:
|
||||||
|
summary = _summarize_text(content, thinking)
|
||||||
|
verdict = _judge_outcome(mode=mode, summary=summary, finish=_classify_finish(finish_reason))
|
||||||
|
if verdict.deliver:
|
||||||
|
return
|
||||||
|
text = (
|
||||||
|
f"{source.name} {verdict.code}: finish_reason={finish_reason!r}, "
|
||||||
|
f"thinking_len={len(thinking)}"
|
||||||
|
)
|
||||||
|
if verdict.error_kind == "result_invalid":
|
||||||
|
raise ResultInvalidError(text, raw_text="", source_name=source.name, operation="chat")
|
||||||
|
raise TransientError(text, source_name=source.name, operation="chat")
|
||||||
|
|
||||||
|
|
||||||
|
def _text_choice(body: dict[str, Any], source: SourceConfig) -> tuple[Any, str | None]:
|
||||||
|
"""文本模式取首个 choice 的 message 与 finish(非字符串 finish 记 None)。"""
|
||||||
|
choices = body.get("choices") or []
|
||||||
|
if not choices:
|
||||||
|
raise TransientError(
|
||||||
|
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
|
if not isinstance(choices, list) or not isinstance(choices[0], dict):
|
||||||
|
raise TransientError("chat choices 形态非法", operation="chat")
|
||||||
|
finish = choices[0].get("finish_reason")
|
||||||
|
return choices[0].get("message", {}), finish if isinstance(finish, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _nonstream_message(
|
||||||
|
body: dict[str, Any], mode: ResponseMode, source: SourceConfig
|
||||||
|
) -> tuple[dict[str, Any], str | None]:
|
||||||
|
"""非流式取单 choice 的原始 message 与 finish;只校验形态,不构造快照。"""
|
||||||
|
if mode == "openai":
|
||||||
|
message, finish = check_completion_body(body)
|
||||||
|
else:
|
||||||
|
message, finish = _text_choice(body, source)
|
||||||
|
if not isinstance(message, dict) or any(
|
||||||
|
message.get(key) is not None and not isinstance(message[key], str)
|
||||||
|
for key in ("content", "reasoning_content")
|
||||||
|
):
|
||||||
|
raise TransientError("chat message 形态非法", operation="chat")
|
||||||
|
return message, finish
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_of(message: dict[str, Any]) -> str:
|
||||||
|
"""非流式思考投影: 非空 reasoning_content 优先,否则 `reasoning` 别名。"""
|
||||||
|
primary = message.get("reasoning_content")
|
||||||
|
if isinstance(primary, str) and primary:
|
||||||
|
return primary
|
||||||
|
alias = message.get("reasoning")
|
||||||
|
return alias if isinstance(alias, str) else ""
|
||||||
|
|
||||||
|
|
||||||
# —— 错误翻译(CHS invokers.py 同款)——
|
# —— 错误翻译(CHS invokers.py 同款)——
|
||||||
|
|
||||||
|
|
||||||
def _parse_retry_after(raw: str | None) -> float | None:
|
def _parse_retry_after(raw: str | None, *, source_name: str) -> float | None:
|
||||||
"""解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。"""
|
"""解析 Retry-After 头;仅支持秒数形态,HTTP-date 返回 None(CHS 同款)。
|
||||||
|
|
||||||
|
**非有限值必须当作"无提示"**(issue F1): `"inf"` / `"1e999"` 能被 `float()`
|
||||||
|
成功解析,又能通过 `seconds > 0`,于是一路变成 `retry_after_s=inf`——而
|
||||||
|
`backoff_delay` 的 `max(delay, retry_after)` 取大之后就是一次**永不醒来**的
|
||||||
|
退避 sleep(库刻意不拿 `backoff_max_s` 去夹它,见设计 §6.2)。
|
||||||
|
|
||||||
|
`source_name` 是**必填** keyword-only 参数: 本函数是私有的,不给默认值,
|
||||||
|
漏传即 `TypeError`,免得将来新增调用点静默丢掉源标识(告警定位不到是哪个源)。
|
||||||
|
`nan` 不新增分支,沿用既有 `seconds > 0` 恒假的值语义。
|
||||||
|
"""
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
seconds = float(raw.strip())
|
seconds = float(raw.strip())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
if math.isinf(seconds):
|
||||||
|
# 只写源名与判据词: 429 风暴下回显原始头会把日志淹掉,也无助于定位
|
||||||
|
logger.warning(
|
||||||
|
"{} 的 Retry-After 非有限值,按无提示处理(retry_after_not_finite)", source_name
|
||||||
|
)
|
||||||
|
return None
|
||||||
return seconds if seconds > 0 else None
|
return seconds if seconds > 0 else None
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +311,7 @@ def _translate_429(
|
|||||||
)
|
)
|
||||||
return TransientError(
|
return TransientError(
|
||||||
compose_message(f"{source.name} 限速: 429", summary),
|
compose_message(f"{source.name} 限速: 429", summary),
|
||||||
retry_after_s=_parse_retry_after(headers.get("retry-after")),
|
retry_after_s=_parse_retry_after(headers.get("retry-after"), source_name=source.name),
|
||||||
**ctx,
|
**ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,6 +520,9 @@ class OpenAICompatTransport:
|
|||||||
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
# 都会连带改掉另一侧的行为。(键空间恰好不相交,故当下**不会**互相压制;
|
||||||
# 分开维护的理由是语义,不是碰撞)
|
# 分开维护的理由是语义,不是碰撞)
|
||||||
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
self._warned_mismatches: set[tuple[str, str, Effort | None]] = set()
|
||||||
|
# 方言告警(1.3.10)另立集合: 冲突删键/未知 finish 与推理对账语义无关
|
||||||
|
self._warned_fields: set[tuple[str, str]] = set()
|
||||||
|
self._warn_saturated = False
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
self._clients: dict[str, httpx.AsyncClient] = {}
|
self._clients: dict[str, httpx.AsyncClient] = {}
|
||||||
|
|
||||||
@@ -415,12 +592,29 @@ class OpenAICompatTransport:
|
|||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
call_id: str,
|
call_id: str,
|
||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
"""一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。
|
||||||
|
|
||||||
`reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级
|
`reasoning_effort` 是**请求级**档位(`None` = 不表态);它与源级配置的优先级
|
||||||
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
|
在 `_build_payload` 里由 `effective_effort` 裁定,本层只负责把它送到。
|
||||||
|
`first_token_event` 为对冲处置位: `None` = 不观测首 token;仅流式路径
|
||||||
|
(`_complete_stream`)在首个增量到达时置位,非流式路径收它但永不置位。
|
||||||
|
`content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个**
|
||||||
|
增量 chunk 置位一次,非流式路径收它但永不置位。
|
||||||
"""
|
"""
|
||||||
|
if response_mode not in ("text", "openai"):
|
||||||
|
raise RequestRejectedError(
|
||||||
|
"非法 response_mode", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
|
if response_mode == "openai":
|
||||||
|
for layer in (source.extra_body, overlay):
|
||||||
|
if "n" in layer and (type(layer["n"]) is not int or layer["n"] != 1):
|
||||||
|
raise RequestRejectedError(
|
||||||
|
"openai 模式仅支持 n=1", source_name=source.name, operation="chat"
|
||||||
|
)
|
||||||
profile = get_provider(source.provider, registry=self._registry)
|
profile = get_provider(source.provider, registry=self._registry)
|
||||||
try:
|
try:
|
||||||
payload, applied_effort = self._build_payload(
|
payload, applied_effort = self._build_payload(
|
||||||
@@ -444,9 +638,31 @@ class OpenAICompatTransport:
|
|||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
try:
|
try:
|
||||||
if stream:
|
if stream:
|
||||||
result = await self._complete_stream(client, url, payload, source, profile)
|
result = await self._complete_stream(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
source,
|
||||||
|
profile,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = await self._complete_once(client, url, payload, source, profile)
|
result = await self._complete_once(
|
||||||
|
client,
|
||||||
|
url,
|
||||||
|
payload,
|
||||||
|
source,
|
||||||
|
profile,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
)
|
||||||
|
except TransientError as exc:
|
||||||
|
if exc.source_name is None:
|
||||||
|
exc.source_name = source.name
|
||||||
|
raise
|
||||||
except StreamLivenessTimeout as exc:
|
except StreamLivenessTimeout as exc:
|
||||||
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
@@ -532,6 +748,9 @@ class OpenAICompatTransport:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
async with client.stream("POST", url, json=payload) as resp:
|
async with client.stream("POST", url, json=payload) as resp:
|
||||||
@@ -541,39 +760,59 @@ class OpenAICompatTransport:
|
|||||||
source, resp.status_code, body, resp.headers, operation="chat"
|
source, resp.status_code, body, resp.headers, operation="chat"
|
||||||
)
|
)
|
||||||
sink: dict[str, Any] = {}
|
sink: dict[str, Any] = {}
|
||||||
|
accumulator = ChatCompletionAccumulator(response_mode=response_mode)
|
||||||
guarded = stream_with_liveness_timeouts(
|
guarded = stream_with_liveness_timeouts(
|
||||||
_iter_sse_deltas(resp.aiter_lines(), sink),
|
_iter_sse_deltas(resp.aiter_lines(), sink, accumulator),
|
||||||
ttft_s=source.ttft_timeout_s or source.timeout_s,
|
ttft_s=source.ttft_timeout_s or source.timeout_s,
|
||||||
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
|
inter_token_s=source.inter_token_timeout_s or source.timeout_s,
|
||||||
total_s=source.timeout_s,
|
total_s=source.timeout_s,
|
||||||
)
|
)
|
||||||
content_parts: list[str] = []
|
timing = await _consume_deltas(guarded, started, first_token_event, content_event)
|
||||||
thinking_parts: list[str] = []
|
return self._stream_result(sink, accumulator, timing, source, profile, response_mode)
|
||||||
ttft_ms: float | None = None
|
|
||||||
last = started
|
def _stream_result(
|
||||||
max_gap = 0.0
|
self,
|
||||||
async for is_content, text in guarded:
|
sink: dict[str, Any],
|
||||||
now = time.monotonic()
|
accumulator: ChatCompletionAccumulator,
|
||||||
if ttft_ms is None:
|
timing: _StreamTiming,
|
||||||
ttft_ms = (now - started) * 1000
|
source: SourceConfig,
|
||||||
else:
|
profile: ProviderProfile,
|
||||||
max_gap = max(max_gap, (now - last) * 1000)
|
response_mode: ResponseMode,
|
||||||
last = now
|
) -> TransportResult:
|
||||||
(content_parts if is_content else thinking_parts).append(text)
|
"""流结束后的统一判定(设计 §4.2-§4.3): 完整性 → 结局 → 组装。"""
|
||||||
salvaged = self._check_done(sink, content_parts, thinking_parts, source)
|
self._warn_dialect(source, accumulator.conflicts, accumulator.finish_reason)
|
||||||
content, thinking = self._finalize_text(content_parts, thinking_parts, profile)
|
thinking_parts = timing.reasoning_parts if timing.has_reasoning else timing.alias_parts
|
||||||
self._reject_empty_completion(content, source)
|
content, thinking = self._finalize_text(timing.content_parts, thinking_parts, profile)
|
||||||
prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged)
|
stream_end = _stream_is_complete(
|
||||||
|
done=bool(sink.get("done")),
|
||||||
|
finish_reason=accumulator.finish_reason,
|
||||||
|
usage_after_finish=accumulator.usage_after_finish,
|
||||||
|
usage_measured=_resolve_usage(sink.get("usage") or {})[2] == "measured",
|
||||||
|
)
|
||||||
|
if stream_end is None:
|
||||||
|
stream_end = self._truncated(timing, thinking_parts, source, response_mode)
|
||||||
|
message = accumulator.raw_message() if response_mode == "openai" else None
|
||||||
|
_raise_unless_deliverable(
|
||||||
|
response_mode, message, content, thinking, accumulator.finish_reason, source
|
||||||
|
)
|
||||||
|
completion_result = accumulator.finish() if response_mode == "openai" else None
|
||||||
|
prompt, completion, usage_source = _resolve_stream_usage(sink, stream_end == "salvaged")
|
||||||
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage"))
|
||||||
|
stall_ms, stall_count = _stall_from_chunk_times(
|
||||||
|
timing.chunk_times, source.stall_threshold_ms
|
||||||
|
)
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content=content,
|
content=content,
|
||||||
thinking=thinking,
|
thinking=thinking,
|
||||||
prompt_tokens=prompt,
|
prompt_tokens=prompt,
|
||||||
completion_tokens=completion,
|
completion_tokens=completion,
|
||||||
usage_source=usage_source,
|
usage_source=usage_source,
|
||||||
ttft_ms=ttft_ms,
|
ttft_ms=timing.ttft_ms,
|
||||||
max_inter_token_ms=(max_gap if ttft_ms is not None else None),
|
max_inter_token_ms=(timing.max_gap if timing.ttft_ms is not None else None),
|
||||||
|
stall_ms=stall_ms,
|
||||||
|
stall_count=stall_count,
|
||||||
raw={"usage": sink.get("usage")},
|
raw={"usage": sink.get("usage")},
|
||||||
|
completion=completion_result,
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")),
|
||||||
model_reported=_coerce_model_reported(sink.get("model")),
|
model_reported=_coerce_model_reported(sink.get("model")),
|
||||||
reasoning_tokens=reasoning_tokens,
|
reasoning_tokens=reasoning_tokens,
|
||||||
@@ -582,38 +821,56 @@ class OpenAICompatTransport:
|
|||||||
thinking_observation=observe_thinking(
|
thinking_observation=observe_thinking(
|
||||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
),
|
),
|
||||||
|
finish_reason=accumulator.finish_reason,
|
||||||
|
stream_end=stream_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _check_done(
|
def _truncated(
|
||||||
self,
|
self,
|
||||||
sink: dict[str, Any],
|
timing: _StreamTiming,
|
||||||
content_parts: list[str],
|
|
||||||
thinking_parts: list[str],
|
thinking_parts: list[str],
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
) -> bool:
|
response_mode: ResponseMode,
|
||||||
"""缺 [DONE] 语义(设计 §6): 零内容恒 retry;有内容按 missing_done 策略。"""
|
) -> StreamEnd:
|
||||||
if sink.get("done"):
|
"""截断流(设计 §4.2): 保真不打捞;文本按 missing_done,零产物恒 retry。"""
|
||||||
return False
|
|
||||||
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
ctx: dict[str, Any] = {"source_name": source.name, "operation": "chat"}
|
||||||
if not content_parts and not thinking_parts:
|
if response_mode == "openai":
|
||||||
|
raise TransientError(f"{source.name} missing_done: 保真响应截断,禁止打捞", **ctx)
|
||||||
|
if not any(timing.content_parts) and not any(thinking_parts):
|
||||||
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
|
raise TransientError(f"{source.name} SSE early_eof: 零内容断流", **ctx)
|
||||||
if source.missing_done == "retry":
|
if source.missing_done == "retry":
|
||||||
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
|
raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx)
|
||||||
return True
|
return "salvaged"
|
||||||
|
|
||||||
def _reject_empty_completion(self, content: str, source: SourceConfig) -> None:
|
def _warn_dialect(
|
||||||
"""空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。
|
self, source: SourceConfig, conflicts: frozenset[str], finish_reason: str | None
|
||||||
|
) -> None:
|
||||||
服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等
|
"""方言告警: 冲突删键与未登记 finish 各按 (源, 路径/值) 只喊一次。"""
|
||||||
网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**;
|
for raw_path in sorted(conflicts):
|
||||||
承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。
|
path = raw_path[:_PATH_WARN_CHARS] # 键名由上游决定,长度同样须有界
|
||||||
"""
|
self._warn_once(
|
||||||
if not content.strip():
|
source.name, f"field:{path}", f"字段 {path} 合并冲突已删除,保真结果缺该字段"
|
||||||
raise TransientError(
|
|
||||||
f"{source.name} 空补全(empty_completion): 流程完整但零内容",
|
|
||||||
source_name=source.name,
|
|
||||||
operation="chat",
|
|
||||||
)
|
)
|
||||||
|
if finish_reason is not None and _classify_finish(finish_reason) == "unknown":
|
||||||
|
value = finish_reason[:_FINISH_WARN_CHARS]
|
||||||
|
self._warn_once(
|
||||||
|
source.name,
|
||||||
|
f"finish:{value}",
|
||||||
|
f"未登记的 finish_reason {value!r},按正常结束交付,该值的结果不缓存",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _warn_once(self, source_name: str, key: str, text: str) -> None:
|
||||||
|
"""有界节流: 键由上游决定,长跑 worker 不能让集合无限增长(设计 §5.4)。"""
|
||||||
|
if (source_name, key) in self._warned_fields:
|
||||||
|
return
|
||||||
|
if len(self._warned_fields) >= _WARN_CAP:
|
||||||
|
if not self._warn_saturated:
|
||||||
|
self._warn_saturated = True
|
||||||
|
logger.warning("方言告警节流集合已饱和({}),后续同类告警省略", _WARN_CAP)
|
||||||
|
return
|
||||||
|
self._warned_fields.add((source_name, key))
|
||||||
|
# 单参数传入 loguru: 文案可能含上游给的花括号(同 `_warn_on_thinking_mismatch`)
|
||||||
|
logger.warning("源 {} —— {}", source_name, text)
|
||||||
|
|
||||||
def _finalize_text(
|
def _finalize_text(
|
||||||
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
|
self, content_parts: list[str], thinking_parts: list[str], profile: ProviderProfile
|
||||||
@@ -633,8 +890,20 @@ class OpenAICompatTransport:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
source: SourceConfig,
|
source: SourceConfig,
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。"""
|
"""非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。
|
||||||
|
|
||||||
|
接收 `first_token_event` 但**永不置位**: 非流式无中途信号,事件自然退化
|
||||||
|
为纯时间阈值(对冲只能靠 `hedge_after_s` 触发)。`content_event`(1.3.8)
|
||||||
|
同款: 接收但**永不置位**——非流式无"中段"概念,stall 两值走
|
||||||
|
TransportResult 默认 None(排除条款)。
|
||||||
|
|
||||||
|
无流完整性可言,直接以 body 的 finish_reason 走结局判定;保真模式**先判定
|
||||||
|
后构造快照**,否则半截工具在构造时就抛 Transient,到不了 `tool_truncated`。
|
||||||
|
"""
|
||||||
resp = await client.post(url, json=payload)
|
resp = await client.post(url, json=payload)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise _status_to_error(
|
raise _status_to_error(
|
||||||
@@ -646,16 +915,17 @@ class OpenAICompatTransport:
|
|||||||
raise TransientError(
|
raise TransientError(
|
||||||
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
|
f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat"
|
||||||
) from exc
|
) from exc
|
||||||
choices = body.get("choices") or []
|
_check_chat_error(body)
|
||||||
if not choices:
|
message, finish = _nonstream_message(body, response_mode, source)
|
||||||
raise TransientError(
|
|
||||||
f"{source.name} 响应缺 choices", source_name=source.name, operation="chat"
|
|
||||||
)
|
|
||||||
message = choices[0].get("message") or {}
|
|
||||||
content, thinking = self._finalize_text(
|
content, thinking = self._finalize_text(
|
||||||
[message.get("content") or ""], [message.get("reasoning_content") or ""], profile
|
[message.get("content") or ""], [_reasoning_of(message)], profile
|
||||||
|
)
|
||||||
|
self._warn_dialect(source, frozenset(), finish)
|
||||||
|
raw_message = message if response_mode == "openai" else None
|
||||||
|
_raise_unless_deliverable(response_mode, raw_message, content, thinking, finish, source)
|
||||||
|
completion_result = (
|
||||||
|
build_completion(message, body, finish) if response_mode == "openai" else None
|
||||||
)
|
)
|
||||||
self._reject_empty_completion(content, source)
|
|
||||||
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
prompt, completion, usage_source = _resolve_usage(body.get("usage") or {})
|
||||||
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
reasoning_tokens = _coerce_reasoning_tokens(body.get("usage"))
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
@@ -667,6 +937,7 @@ class OpenAICompatTransport:
|
|||||||
ttft_ms=None,
|
ttft_ms=None,
|
||||||
max_inter_token_ms=None,
|
max_inter_token_ms=None,
|
||||||
raw={"usage": body.get("usage")},
|
raw={"usage": body.get("usage")},
|
||||||
|
completion=completion_result,
|
||||||
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")),
|
||||||
model_reported=_coerce_model_reported(body.get("model")),
|
model_reported=_coerce_model_reported(body.get("model")),
|
||||||
reasoning_tokens=reasoning_tokens,
|
reasoning_tokens=reasoning_tokens,
|
||||||
@@ -675,6 +946,7 @@ class OpenAICompatTransport:
|
|||||||
thinking_observation=observe_thinking(
|
thinking_observation=observe_thinking(
|
||||||
thinking=thinking, reasoning_tokens=reasoning_tokens
|
thinking=thinking, reasoning_tokens=reasoning_tokens
|
||||||
),
|
),
|
||||||
|
finish_reason=finish,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
|
|||||||
+391
-3
@@ -4,6 +4,7 @@
|
|||||||
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@@ -317,23 +318,57 @@ class CallStats:
|
|||||||
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
|
含缓存 IO、退避等待、准入等待、重问、分批与内联记账。
|
||||||
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
|
"总耗时减最后一次尝试耗时"**不等于**纯等待(含其他本地工作)。"""
|
||||||
|
|
||||||
|
hedges: int = 0
|
||||||
|
"""本次逻辑调用实际并发发出的对冲路数(触发但准入失败静默不计);1.3.6 及以前恒 0。"""
|
||||||
|
generation_ms: int = 0
|
||||||
|
"""净生成时间(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:
|
class _CallContext:
|
||||||
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
|
"""私有可变逻辑调用上下文: 只持计数、单调时钟与终态去重位,不做 I/O。
|
||||||
|
|
||||||
**每调用一个实例**的单任务对象: chat 重试、结构化重问、embedding 分批
|
**每逻辑调用一个实例**,可多任务并发登记(对冲);全部方法无 await,
|
||||||
都在同一任务内串行推进,故计数无需锁。**严禁提升为 client 实例属性**
|
事件循环内任务安全。**严禁提升为 client 实例属性**
|
||||||
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"、
|
——那会让同一 client 的并发调用互相串掉计数与逻辑 ID(库铁律"纯 asyncio 中立"、
|
||||||
VT `evolve_llm = llm` 教训的同一形态)。
|
VT `evolve_llm = llm` 教训的同一形态)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_attempts", "_now", "_started", "_terminal_claimed", "logical_call_id")
|
__slots__ = (
|
||||||
|
"_attempts",
|
||||||
|
"_generation_ms",
|
||||||
|
"_hedge_won",
|
||||||
|
"_hedges",
|
||||||
|
"_now",
|
||||||
|
"_stall_count",
|
||||||
|
"_stall_ms",
|
||||||
|
"_started",
|
||||||
|
"_terminal_claimed",
|
||||||
|
"logical_call_id",
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *, now: Callable[[], float]) -> None:
|
def __init__(self, *, now: Callable[[], float]) -> None:
|
||||||
self.logical_call_id = str(uuid.uuid4())
|
self.logical_call_id = str(uuid.uuid4())
|
||||||
self._now = now
|
self._now = now
|
||||||
self._started = now()
|
self._started = now()
|
||||||
self._attempts = 0
|
self._attempts = 0
|
||||||
|
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
|
self._terminal_claimed = False
|
||||||
|
|
||||||
def register_attempt(self) -> None:
|
def register_attempt(self) -> None:
|
||||||
@@ -344,12 +379,40 @@ class _CallContext:
|
|||||||
"""
|
"""
|
||||||
self._attempts += 1
|
self._attempts += 1
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""对冲路实际发出即计数;赢家裁定后一次性登记。"""
|
||||||
|
self._hedges += 1
|
||||||
|
self._hedge_won = hedge_won
|
||||||
|
|
||||||
def snapshot(self) -> CallStats:
|
def snapshot(self) -> CallStats:
|
||||||
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
|
"""同步冻结当前快照;**绝不 await**,可多次调用。"""
|
||||||
return CallStats(
|
return CallStats(
|
||||||
logical_call_id=self.logical_call_id,
|
logical_call_id=self.logical_call_id,
|
||||||
attempts=self._attempts,
|
attempts=self._attempts,
|
||||||
total_latency_ms=int((self._now() - self._started) * 1000),
|
total_latency_ms=int((self._now() - self._started) * 1000),
|
||||||
|
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:
|
def claim_terminal(self) -> bool:
|
||||||
@@ -360,6 +423,280 @@ class _CallContext:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
ResponseMode = Literal["text", "openai"]
|
||||||
|
|
||||||
|
StreamEnd = Literal["done", "eof_after_finish", "salvaged"]
|
||||||
|
"""流式结束方式(1.3.10, #31): `done` = 收到 `[DONE]`;`eof_after_finish` = 未发
|
||||||
|
`[DONE]` 但 finish_reason 与其后的实测 usage 齐全、连接干净关闭;`salvaged` = 文本
|
||||||
|
模式按 `missing_done=salvage` 打捞的截断结果。非流式/缓存命中/失败行为 `None`。"""
|
||||||
|
|
||||||
|
FinishClass = Literal["natural", "truncated", "filtered", "upstream_error", "unknown", "absent"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OpenAIChatCompletion:
|
||||||
|
"""单 choice 协议结果,构造时快照隔离;嵌套 JSON 仍可变。"""
|
||||||
|
|
||||||
|
message: dict[str, Any]
|
||||||
|
finish_reason: str | None
|
||||||
|
response_id: str | None
|
||||||
|
usage: dict[str, Any] | None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""校验协议快照;transport 翻译错误,缓存坏条目按 miss。"""
|
||||||
|
message = self.message
|
||||||
|
_check_completion_shape(message, self.finish_reason, self.response_id, self.usage)
|
||||||
|
json.dumps({"message": message, "usage": self.usage}, allow_nan=False)
|
||||||
|
object.__setattr__(self, "message", copy.deepcopy(message))
|
||||||
|
object.__setattr__(self, "usage", copy.deepcopy(self.usage))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_completion_shape(message: Any, finish_reason: Any, response_id: Any, usage: Any) -> None:
|
||||||
|
"""快照字段形态校验;违约抛 ValueError(由 transport/缓存各自翻译)。"""
|
||||||
|
if not isinstance(message, dict) or message.get("role") != "assistant":
|
||||||
|
raise ValueError("completion message 必须含 assistant role")
|
||||||
|
if not all(_str_or_none(message.get(key)) for key in ("content", "reasoning_content")):
|
||||||
|
raise ValueError("completion content/reasoning_content 必须为字符串或 null")
|
||||||
|
if not (_str_or_none(finish_reason) and _str_or_none(response_id)):
|
||||||
|
raise ValueError("completion 身份/结束原因必须为字符串或 null")
|
||||||
|
if not isinstance(usage, dict | None):
|
||||||
|
raise ValueError("completion usage 必须为对象或 null")
|
||||||
|
if not isinstance(message.get("tool_calls", []), list | None):
|
||||||
|
raise ValueError("completion tool_calls 必须为数组或 null")
|
||||||
|
if not _tool_snapshot_ok(message):
|
||||||
|
raise ValueError("completion tool 身份/函数字段缺失、冲突或非法")
|
||||||
|
|
||||||
|
|
||||||
|
def _str_or_none(value: Any) -> bool:
|
||||||
|
"""协议字符串字段的形态判据: 字符串或 null。"""
|
||||||
|
return value is None or isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_ok(tool: Any, seen: set[str]) -> bool:
|
||||||
|
"""单个工具条目是否满足快照不变式;成立时登记其 id 供查重。"""
|
||||||
|
if not isinstance(tool, dict):
|
||||||
|
return False
|
||||||
|
identity, fn = tool.get("id"), tool.get("function")
|
||||||
|
ok = (
|
||||||
|
isinstance(identity, str)
|
||||||
|
and bool(identity)
|
||||||
|
and identity not in seen
|
||||||
|
and tool.get("type") == "function"
|
||||||
|
and isinstance(fn, dict)
|
||||||
|
and isinstance(fn.get("name"), str)
|
||||||
|
and bool(fn["name"])
|
||||||
|
and isinstance(fn.get("arguments"), str)
|
||||||
|
)
|
||||||
|
if ok:
|
||||||
|
seen.add(identity)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_snapshot_ok(message: Mapping[str, Any]) -> bool:
|
||||||
|
"""工具快照不变式(1.3.9 规则): 缺省/null 视无工具;null 元素跳过。
|
||||||
|
|
||||||
|
抽成纯判据而非留在快照构造里: 结局判定(#30)要在构造**之前**知道工具是否
|
||||||
|
成立——截断的半截工具归 ResultInvalid,构造时抛 Transient 就到不了那一行。
|
||||||
|
"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if tools is None:
|
||||||
|
return True
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
return False
|
||||||
|
seen: set[str] = set()
|
||||||
|
return all(tool is None or _tool_ok(tool, seen) for tool in tools)
|
||||||
|
|
||||||
|
|
||||||
|
_FINISH_CLASSES: Mapping[str, FinishClass] = MappingProxyType(
|
||||||
|
{
|
||||||
|
"stop": "natural",
|
||||||
|
"tool_calls": "natural",
|
||||||
|
"function_call": "natural",
|
||||||
|
"length": "truncated",
|
||||||
|
"content_filter": "filtered",
|
||||||
|
"sensitive": "filtered", # GLM: 安全审核拦截
|
||||||
|
"network_error": "upstream_error", # GLM: 模型推理异常
|
||||||
|
"error": "upstream_error",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_finish(value: str | None) -> FinishClass:
|
||||||
|
"""finish_reason → 类别;大小写敏感(上游原值),未登记非空值为 unknown。"""
|
||||||
|
if value is None:
|
||||||
|
return "absent"
|
||||||
|
return _FINISH_CLASSES.get(value, "unknown")
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_is_complete(
|
||||||
|
*, done: bool, finish_reason: str | None, usage_after_finish: bool, usage_measured: bool
|
||||||
|
) -> StreamEnd | None:
|
||||||
|
"""流完整性(设计 §4.2): None = 截断。
|
||||||
|
|
||||||
|
缺 `[DONE]` 时只认"上游声明结束 + 其后到达的实测 usage": 前者证明生成结束,
|
||||||
|
后者证明流走到了 include_usage 的自然末尾(finish 之前的累计 usage 不算)。
|
||||||
|
"""
|
||||||
|
if done:
|
||||||
|
return "done"
|
||||||
|
if finish_reason is not None and usage_after_finish and usage_measured:
|
||||||
|
return "eof_after_finish"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _OutputSummary:
|
||||||
|
"""一次结果的产物摘要(设计 §4.3 定义),判定的唯一输入形态。"""
|
||||||
|
|
||||||
|
any_output: bool
|
||||||
|
body_output: bool
|
||||||
|
tools_present: bool
|
||||||
|
tools_ok: bool
|
||||||
|
null_arguments: bool = False
|
||||||
|
"""工具快照仅因 `function.arguments` 全程 null 而不成立(无参函数方言,审查 I2)。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_text(content: str, thinking: str) -> _OutputSummary:
|
||||||
|
"""文本模式: 产物只看投影正文与思考;空白也算产物,正文须非空白。"""
|
||||||
|
return _OutputSummary(
|
||||||
|
any_output=content != "" or thinking != "",
|
||||||
|
body_output=content.strip() != "",
|
||||||
|
tools_present=False,
|
||||||
|
tools_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_string(value: Any) -> bool:
|
||||||
|
"""递归查找任一非空字符串片段。"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value != ""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return any(_has_string(item) for item in value.values())
|
||||||
|
if isinstance(value, list):
|
||||||
|
return any(_has_string(item) for item in value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _summarize_message(message: Mapping[str, Any], projected_content: str) -> _OutputSummary:
|
||||||
|
"""保真模式: 任一非空字符串字段(role 除外)或工具条目即产物。"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
tools_present = isinstance(tools, list) and any(tool is not None for tool in tools)
|
||||||
|
tools_ok = _tool_snapshot_ok(message)
|
||||||
|
fields = {k: v for k, v in message.items() if k not in ("role", "tool_calls")}
|
||||||
|
return _OutputSummary(
|
||||||
|
any_output=tools_present or _has_string(fields),
|
||||||
|
body_output=projected_content.strip() != "" or (tools_present and tools_ok),
|
||||||
|
tools_present=tools_present,
|
||||||
|
tools_ok=tools_ok,
|
||||||
|
null_arguments=not tools_ok and _tool_snapshot_ok(_fill_null_arguments(message)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_null_arguments(message: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
"""仅供判定: 把 null arguments 视作空串后再验快照,区分"只缺参数"与其他缺陷。
|
||||||
|
|
||||||
|
不改写交付值——保真结果原样;此函数的产物只用于分类。
|
||||||
|
"""
|
||||||
|
tools = message.get("tool_calls")
|
||||||
|
if not isinstance(tools, list):
|
||||||
|
return dict(message)
|
||||||
|
filled = []
|
||||||
|
for tool in tools:
|
||||||
|
fn = tool.get("function") if isinstance(tool, dict) else None
|
||||||
|
if isinstance(fn, dict) and "arguments" in fn and fn["arguments"] is None:
|
||||||
|
tool = {**tool, "function": {**fn, "arguments": ""}}
|
||||||
|
filled.append(tool)
|
||||||
|
return {**message, "tool_calls": filled}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Verdict:
|
||||||
|
"""结局判定;`deliver` 为真时另两字段为 None。"""
|
||||||
|
|
||||||
|
deliver: bool
|
||||||
|
error_kind: Literal["transient", "result_invalid"] | None
|
||||||
|
code: str | None
|
||||||
|
|
||||||
|
|
||||||
|
_DELIVER = _Verdict(True, None, None)
|
||||||
|
_EMPTY = _Verdict(False, "transient", "empty_completion")
|
||||||
|
_WITHOUT_CONTENT: Mapping[str, str] = MappingProxyType(
|
||||||
|
{"truncated": "length_without_content", "filtered": "filtered_without_content"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _judge_broken(
|
||||||
|
*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass
|
||||||
|
) -> _Verdict | None:
|
||||||
|
"""行 1-2: 上游自报失败、坏工具——先于产物判断,正文不得掩盖。"""
|
||||||
|
if finish == "upstream_error":
|
||||||
|
return _Verdict(False, "transient", "upstream_finish_error")
|
||||||
|
if mode == "openai" and summary.tools_present and not summary.tools_ok:
|
||||||
|
if finish == "truncated":
|
||||||
|
return _Verdict(False, "result_invalid", "tool_truncated")
|
||||||
|
if summary.null_arguments:
|
||||||
|
# 无参函数只发 arguments:null 是确定性方言: 重拉同样结果,不重试也不改写
|
||||||
|
return _Verdict(False, "result_invalid", "tool_arguments_null")
|
||||||
|
return _Verdict(False, "transient", "tool_incomplete")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _judge_outcome(*, mode: ResponseMode, summary: _OutputSummary, finish: FinishClass) -> _Verdict:
|
||||||
|
"""结局分类(设计 §4.3),自上而下首个匹配行生效。
|
||||||
|
|
||||||
|
确定性结局(length/过滤)在文本模式归 ResultInvalid 而非 Transient: 重抽只会
|
||||||
|
再花一份钱,还会把健康源推向熔断;保真模式原样交回,由下游按 finish 决定。
|
||||||
|
"""
|
||||||
|
broken = _judge_broken(mode=mode, summary=summary, finish=finish)
|
||||||
|
if broken is not None:
|
||||||
|
return broken
|
||||||
|
if summary.body_output:
|
||||||
|
return _DELIVER
|
||||||
|
if finish in _WITHOUT_CONTENT: # 行 3/6/7: 确定性结局
|
||||||
|
if mode == "openai":
|
||||||
|
return _DELIVER
|
||||||
|
return _Verdict(False, "result_invalid", _WITHOUT_CONTENT[finish])
|
||||||
|
# 行 4/8/9: 零产物恒为抖动;有产物无正文时,只有保真且上游明确声明结束才交回
|
||||||
|
if summary.any_output and mode == "openai" and finish != "absent":
|
||||||
|
return _DELIVER
|
||||||
|
return _EMPTY
|
||||||
|
|
||||||
|
|
||||||
|
def _effective_finish(finish_reason: Any, completion: OpenAIChatCompletion | None) -> str | None:
|
||||||
|
"""缓存读写共用的 finish 口径: 顶层优先,缺省回退协议快照(1.3.9 条目无顶层)。"""
|
||||||
|
if finish_reason is None and completion is not None:
|
||||||
|
return completion.finish_reason
|
||||||
|
return finish_reason
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cacheable(
|
||||||
|
*,
|
||||||
|
mode: ResponseMode,
|
||||||
|
content: str,
|
||||||
|
finish_reason: str | None,
|
||||||
|
stream_end: str | None,
|
||||||
|
completion: OpenAIChatCompletion | None,
|
||||||
|
) -> bool:
|
||||||
|
"""可缓存 ⊂ 可交付(设计 §6): 截断、空正文、非自然结束一律不进 TTL。
|
||||||
|
|
||||||
|
缓存回放的是"同一请求的确定答案";length/过滤/未知结束带随机性或不可证明
|
||||||
|
是自然结束,固化进 TTL 会把一次偶然变成必然(无缓存毒化铁律)。
|
||||||
|
"""
|
||||||
|
if stream_end not in (None, "done", "eof_after_finish"):
|
||||||
|
return False
|
||||||
|
if _classify_finish(finish_reason) not in ("natural", "absent"):
|
||||||
|
return False
|
||||||
|
if mode == "openai":
|
||||||
|
# 判原文而非投影: 协议原文为空时不信任缓存里的旧投影(1.3.9 同口径)
|
||||||
|
if completion is None:
|
||||||
|
return False
|
||||||
|
raw = completion.message.get("content")
|
||||||
|
summary = _summarize_message(completion.message, raw if isinstance(raw, str) else "")
|
||||||
|
# 原文与投影都须有正文: 原文空不信任旧投影;投影空(think 标签剥净)不固化空回答
|
||||||
|
has_tools = summary.tools_present and summary.tools_ok
|
||||||
|
return summary.body_output and (content.strip() != "" or has_tools)
|
||||||
|
return content.strip() != ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
"""一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。"""
|
||||||
@@ -425,6 +762,15 @@ class LLMResponse:
|
|||||||
call_stats: CallStats | None = None
|
call_stats: CallStats | None = None
|
||||||
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
"""本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。"""
|
||||||
|
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
|
||||||
|
finish_reason: str | None = None
|
||||||
|
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
|
||||||
|
|
||||||
|
stream_end: StreamEnd | None = None
|
||||||
|
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
|
||||||
|
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ChatRequest:
|
class ChatRequest:
|
||||||
@@ -481,6 +827,8 @@ class ChatRequest:
|
|||||||
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝),
|
||||||
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
重试/重问/分批才能共享同一个逻辑 ID 与计数。"""
|
||||||
|
|
||||||
|
response_mode: ResponseMode = "text"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Usage:
|
class Usage:
|
||||||
@@ -554,6 +902,26 @@ class TransportResult:
|
|||||||
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
|
`medium` → 实发 `low`),中间件事后再算一遍必然算成请求档。默认 `None` 是
|
||||||
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
|
"未表态/不注入推理参数"(OCR、embedding 等 transport 沉默即此)。"""
|
||||||
|
|
||||||
|
stall_ms: float | None = None
|
||||||
|
"""超阈值相邻增量 chunk 沉默总额(1.3.8, issue #27;毫秒,未取整——换算唯一处
|
||||||
|
在 retry 层,设计 §4.2 N8)。
|
||||||
|
|
||||||
|
非流式/增量 chunk <2 → `None`("无从观测");观测过但零超阈沉默是 `0.0`,
|
||||||
|
两者不得混——下游预算公式以 `stall_ms is None` 为分支条件(设计 §4.2 N2)。
|
||||||
|
尾部(末 chunk → 流结束)不计,归活跃侧(设计 §4.2 N1)。"""
|
||||||
|
stall_count: float | None = None
|
||||||
|
"""超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同
|
||||||
|
`stall_ms`。"""
|
||||||
|
|
||||||
|
completion: OpenAIChatCompletion | None = None
|
||||||
|
|
||||||
|
finish_reason: str | None = None
|
||||||
|
"""上游结束原因原值(1.3.10, #30/#31),两模式同口径;`None` = 未上报或不可观测。"""
|
||||||
|
|
||||||
|
stream_end: StreamEnd | None = None
|
||||||
|
"""流式结束方式(1.3.10, #31),见 `StreamEnd`。与 `usage_source` 分工: 那个答
|
||||||
|
"账单可信吗",这个答"内容完整吗";`eof_after_finish` 必为 measured。"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SourceConfig:
|
class SourceConfig:
|
||||||
@@ -607,10 +975,19 @@ class SourceConfig:
|
|||||||
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
|
后者对未知值是 fail-closed(按 `error` 处理),不会替配置兜错,漏判的结果
|
||||||
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
|
就是 `EFFORT_FALLBAK` 这种拼写错误静默失效。"""
|
||||||
|
|
||||||
|
stall_threshold_ms: int = 1000
|
||||||
|
"""流式中段沉默的计量阈值(1.3.8, issue #27): 相邻增量 chunk 间隔**严格大于**
|
||||||
|
本值才计入 `TransportResult.stall_ms`/`stall_count`。
|
||||||
|
|
||||||
|
纯计量口径,不触发任何动作;触发中段对冲的动作阈值是 scope 级
|
||||||
|
`HEDGE__STALL_AFTER_S`(设计 §6 两参数分工)。scope 级键
|
||||||
|
`STREAM__STALL_THRESHOLD_MS` 经装配层 `dataclasses.replace` 分发进本字段。"""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
self._validate_identity()
|
self._validate_identity()
|
||||||
self._validate_gates()
|
self._validate_gates()
|
||||||
self._validate_watchdog()
|
self._validate_watchdog()
|
||||||
|
self._validate_stall_threshold()
|
||||||
self._validate_thinking()
|
self._validate_thinking()
|
||||||
self._freeze_extra_body()
|
self._freeze_extra_body()
|
||||||
|
|
||||||
@@ -649,6 +1026,17 @@ class SourceConfig:
|
|||||||
):
|
):
|
||||||
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
raise ValueError("看门狗不变式要求 0 < inter_token < ttft < timeout_s")
|
||||||
|
|
||||||
|
def _validate_stall_threshold(self) -> None:
|
||||||
|
# bool 是 int 子类: True/False 会被当成 1/0 静默放行,必须显式拒(P5)
|
||||||
|
if (
|
||||||
|
isinstance(self.stall_threshold_ms, bool)
|
||||||
|
or not isinstance(self.stall_threshold_ms, int)
|
||||||
|
or self.stall_threshold_ms <= 0
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"SourceConfig.stall_threshold_ms 必须是正整数: {self.stall_threshold_ms!r}"
|
||||||
|
)
|
||||||
|
|
||||||
def _validate_thinking(self) -> None:
|
def _validate_thinking(self) -> None:
|
||||||
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
|
"""推理两键的**归一化**、值域与互不矛盾(issue #20 设计 §4.2)。
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
|
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
@@ -21,7 +22,13 @@ from polygateway.client import (
|
|||||||
)
|
)
|
||||||
from polygateway.providers import get_provider
|
from polygateway.providers import get_provider
|
||||||
from polygateway.transports.openai_compat import OpenAICompatTransport
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
|
from polygateway.types import (
|
||||||
|
Effort,
|
||||||
|
EmbeddingTransportResult,
|
||||||
|
ResponseMode,
|
||||||
|
SourceConfig,
|
||||||
|
TransportResult,
|
||||||
|
)
|
||||||
from tests.live_evidence import (
|
from tests.live_evidence import (
|
||||||
AttemptEvidence,
|
AttemptEvidence,
|
||||||
HttpEvidence,
|
HttpEvidence,
|
||||||
@@ -290,6 +297,9 @@ class ObservedTransport:
|
|||||||
overlay: dict[str, Any],
|
overlay: dict[str, Any],
|
||||||
call_id: str,
|
call_id: str,
|
||||||
reasoning_effort: Effort | None,
|
reasoning_effort: Effort | None,
|
||||||
|
first_token_event: asyncio.Event | None,
|
||||||
|
content_event: asyncio.Event | None,
|
||||||
|
response_mode: ResponseMode,
|
||||||
) -> TransportResult:
|
) -> TransportResult:
|
||||||
"""与生产端口逐参数同签名。"""
|
"""与生产端口逐参数同签名。"""
|
||||||
with self._capture.attempt_context(call_id):
|
with self._capture.attempt_context(call_id):
|
||||||
@@ -301,6 +311,9 @@ class ObservedTransport:
|
|||||||
overlay=overlay,
|
overlay=overlay,
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
|
first_token_event=first_token_event,
|
||||||
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def embed(
|
async def embed(
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""真实网关工具双轮冒烟;复用安全 Markdown 报告,不运行工具副作用。"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
from polygateway import GatewayClient, GatewaySettings
|
||||||
|
from tests.live_evidence import write_live_round
|
||||||
|
|
||||||
|
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
|
||||||
|
pytestmark = [pytest.mark.slow, pytest.mark.requires_llm]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_tools_two_rounds(stream):
|
||||||
|
"""已配置源中选首源,只改变测试隔离与 raw-only 意图,不收紧生产超时。"""
|
||||||
|
if not any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV):
|
||||||
|
pytest.skip("未配置真实源,未覆盖")
|
||||||
|
settings = GatewaySettings.from_env("LLM", env=_ENV)
|
||||||
|
source = dataclasses.replace(settings.sources[0], enable_thinking=None, reasoning_effort=None)
|
||||||
|
settings = dataclasses.replace(
|
||||||
|
settings,
|
||||||
|
sources=(source,),
|
||||||
|
cache_backend="none",
|
||||||
|
cache_namespace=None,
|
||||||
|
cache_ttl_s=None,
|
||||||
|
telemetry_backend="none",
|
||||||
|
telemetry_auto_migrate=False,
|
||||||
|
limiter_backend="memory",
|
||||||
|
breaker_backend="memory",
|
||||||
|
hedge_after_s=None,
|
||||||
|
hedge_stall_after_s=None,
|
||||||
|
)
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "echo_value",
|
||||||
|
"description": "Return the supplied value",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"value": {"type": "string"}},
|
||||||
|
"required": ["value"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Call echo_value with value hello. After receiving the tool result, reply with hello.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
status, error_type, counts = "FAIL", None, {}
|
||||||
|
try:
|
||||||
|
async with GatewayClient.from_settings(settings) as client:
|
||||||
|
first = await client.chat(
|
||||||
|
messages,
|
||||||
|
stream=stream,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={
|
||||||
|
"tools": tools,
|
||||||
|
"tool_choice": {"type": "function", "function": {"name": "echo_value"}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
completion = first.completion
|
||||||
|
assert completion is not None and completion.message["role"] == "assistant"
|
||||||
|
assert completion.response_id and completion.finish_reason == "tool_calls"
|
||||||
|
calls = completion.message["tool_calls"]
|
||||||
|
counts["tools"] = len(calls)
|
||||||
|
assert calls
|
||||||
|
messages.append(completion.message)
|
||||||
|
for call in calls:
|
||||||
|
assert call["function"]["name"] == "echo_value"
|
||||||
|
arguments = json.loads(call["function"]["arguments"])
|
||||||
|
assert arguments["value"] == "hello"
|
||||||
|
messages.append(
|
||||||
|
{"role": "tool", "tool_call_id": call["id"], "content": arguments["value"]}
|
||||||
|
)
|
||||||
|
second = await client.chat(
|
||||||
|
messages,
|
||||||
|
stream=stream,
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"tools": tools, "tool_choice": "none"},
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
second.completion is not None and second.completion.message["role"] == "assistant"
|
||||||
|
)
|
||||||
|
assert "hello" in (second.completion.message.get("content") or "").lower()
|
||||||
|
counts["attempts"] = first.call_stats.attempts + second.call_stats.attempts
|
||||||
|
status = "PASS"
|
||||||
|
except Exception as exc:
|
||||||
|
error_type = type(exc).__name__
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
write_live_round(
|
||||||
|
Path("tests/outputs/issue29/live"),
|
||||||
|
run_id=uuid4().hex,
|
||||||
|
matrix_id="tools",
|
||||||
|
round_index=1,
|
||||||
|
safe_fields={
|
||||||
|
"status": status,
|
||||||
|
"stream": stream,
|
||||||
|
"error_type": error_type,
|
||||||
|
"counts": counts,
|
||||||
|
"planned_rounds": 2,
|
||||||
|
"completed_rounds": 2 if status == "PASS" else 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -69,6 +69,11 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
# —— 1.3.8 沉默归因两列(issue #27)——
|
||||||
|
"stall_ms",
|
||||||
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
|
# 1.3.5 之前那张表的 27 个物理列(26 个 INSERT 字段 + created_at)。写成固定切片
|
||||||
@@ -167,6 +172,11 @@ def _minimal_fields(call_id: str | None = None, **overrides) -> dict[str, object
|
|||||||
# 逻辑快照两列只属终态行
|
# 逻辑快照两列只属终态行
|
||||||
"attempts": None,
|
"attempts": None,
|
||||||
"total_latency_ms": None,
|
"total_latency_ms": None,
|
||||||
|
# —— 1.3.8 两列: 沉默归因只属流式 chat 成功 attempt 行(issue #27),普通行 NULL ——
|
||||||
|
"stall_ms": None,
|
||||||
|
"stall_count": None,
|
||||||
|
# —— 1.3.10: 结束方式只属流式成功 attempt 行,普通行 NULL ——
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
return fields
|
return fields
|
||||||
@@ -769,7 +779,7 @@ class TestCallerDimensionsAcceptance:
|
|||||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||||
schema,
|
schema,
|
||||||
)
|
)
|
||||||
# 22 → 36 个 recorder 字段(加 created_at 共 37 个物理列),且新列追加在末尾
|
# 22 → 39 个 recorder 字段(加 created_at 共 40 个物理列),且新列追加在末尾
|
||||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
rows = await _fetch(
|
rows = await _fetch(
|
||||||
schema_dsn,
|
schema_dsn,
|
||||||
@@ -1100,7 +1110,7 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
|
async def test_pre_135_table_gains_the_ten_columns_and_old_rows_stay_null(self, pre_135_schema):
|
||||||
"""27 列旧表 auto 补齐到 37 列,新行两类取值读得回,**历史行十列一律 NULL**。
|
"""27 列旧表 auto 补齐到 40 列(1.3.5 十列 + 1.3.8 两列 + 1.3.10 一列),新行两类取值读得回,**历史行新增列一律 NULL**。
|
||||||
|
|
||||||
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
|
旧行不回填是本版的明示决策(设计 §5): NULL 表达的是"补列之前根本没记过
|
||||||
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
|
这件事",与任何哨兵值都不是一回事。若哪天有人给这十列加了 DEFAULT,历史行
|
||||||
@@ -1142,7 +1152,7 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
"WHERE table_schema = $1 AND table_name = 'llm_calls' ORDER BY ordinal_position",
|
||||||
schema,
|
schema,
|
||||||
)
|
)
|
||||||
# 26 → 36 个 recorder 字段(加 created_at 共 37 个物理列),新列追加在末尾:
|
# 26 → 39 个 recorder 字段(加 created_at 共 40 个物理列),新列追加在末尾:
|
||||||
# 列序与新建库一致才不会让两条升级路径分叉
|
# 列序与新建库一致才不会让两条升级路径分叉
|
||||||
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
assert [r["column_name"] for r in cols] == _EXPECTED_COLUMNS
|
||||||
|
|
||||||
@@ -1167,6 +1177,11 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"error_body": "upstream said 503",
|
"error_body": "upstream said 503",
|
||||||
"attempts": None,
|
"attempts": None,
|
||||||
"total_latency_ms": None,
|
"total_latency_ms": None,
|
||||||
|
# 1.3.8 两列: 普通成功 attempt 行无沉默观测 → NULL
|
||||||
|
"stall_ms": None,
|
||||||
|
"stall_count": None,
|
||||||
|
# 1.3.10: 该行未传结束方式 → NULL
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
assert dict(by_id["term"]) == {
|
assert dict(by_id["term"]) == {
|
||||||
"call_id": "term",
|
"call_id": "term",
|
||||||
@@ -1180,6 +1195,11 @@ class TestCallObservabilityColumnsAcceptance:
|
|||||||
"error_body": None,
|
"error_body": None,
|
||||||
"attempts": 3,
|
"attempts": 3,
|
||||||
"total_latency_ms": 4200,
|
"total_latency_ms": 4200,
|
||||||
|
# 1.3.8 两列: 终态行无 transport 观测 → NULL
|
||||||
|
"stall_ms": None,
|
||||||
|
"stall_count": None,
|
||||||
|
# 1.3.10: 终态行无流可言 → NULL
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
|
# 历史行: 十列逐列 NULL(整体比对,漏掉任一列都红)
|
||||||
assert dict(by_id["pre135"]) == {
|
assert dict(by_id["pre135"]) == {
|
||||||
@@ -1337,7 +1357,7 @@ class TestPublishedSchemaScript:
|
|||||||
|
|
||||||
await _execute_script(fresh_dsn, script)
|
await _execute_script(fresh_dsn, script)
|
||||||
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
actual = [r["column_name"] for r in await _fetch(fresh_dsn, _PHYSICAL_COLUMNS_SQL, schema)]
|
||||||
# 物理列 = 36 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
# 物理列 = 39 个 INSERT 字段 + 库从不显式写的 created_at;对着库常量比,不另抄一份
|
||||||
assert set(actual) == set(COLUMNS) | {"created_at"}
|
assert set(actual) == set(COLUMNS) | {"created_at"}
|
||||||
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
# 列序也不许漂: 新列必须排在 created_at 之后,否则新建库与 ALTER 升级的列序分叉
|
||||||
assert actual == _EXPECTED_COLUMNS
|
assert actual == _EXPECTED_COLUMNS
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ def _resp(content="cached"):
|
|||||||
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
|
return LLMResponse(content, "", "m", "p", 1, 2, 30, None, None, False, "orig")
|
||||||
|
|
||||||
|
|
||||||
|
def _const(response):
|
||||||
|
async def terminal(request):
|
||||||
|
return response
|
||||||
|
|
||||||
|
return terminal
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def redis_cache():
|
async def redis_cache():
|
||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
@@ -82,6 +89,23 @@ class TestRealRedisRoundtrip:
|
|||||||
assert first.cache_hit is False and second.cache_hit is True
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
assert calls["n"] == 1
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
async def test_completion_verdict_fields_roundtrip(self, redis_cache, namespace):
|
||||||
|
"""1.3.10: finish_reason/stream_end 经真实 Redis 往返;不可缓存结果不落库。"""
|
||||||
|
mw = CacheMW(
|
||||||
|
backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60
|
||||||
|
)
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
ok = replace(_resp(), finish_reason="stop", stream_end="eof_after_finish")
|
||||||
|
first = await mw(ChatRequest(messages=_MSGS), _const(ok))
|
||||||
|
second = await mw(ChatRequest(messages=_MSGS), _const(ok))
|
||||||
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
|
assert second.finish_reason == "stop" and second.stream_end is None
|
||||||
|
truncated = replace(_resp(), stream_end="salvaged")
|
||||||
|
other = [{"role": "user", "content": "integration-salvaged"}]
|
||||||
|
await mw(ChatRequest(messages=other), _const(truncated))
|
||||||
|
assert await redis_cache.get(build_cache_key("m", other, namespace, None)) is None
|
||||||
|
|
||||||
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
|
async def test_namespace_isolation_on_shared_instance(self, redis_cache, namespace):
|
||||||
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
|
"""多项目共用同一 Redis 时,namespace 不同绝不互相命中(防毒化铁律)。"""
|
||||||
other = f"pgw:test:{uuid.uuid4().hex}"
|
other = f"pgw:test:{uuid.uuid4().hex}"
|
||||||
@@ -172,3 +196,22 @@ class TestClientWithRealRedis:
|
|||||||
second = await client.chat(_MSGS)
|
second = await client.chat(_MSGS)
|
||||||
assert first.cache_hit is False and second.cache_hit is True
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
assert second.content == "hello"
|
assert second.content == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_completion_roundtrip(redis_cache, namespace):
|
||||||
|
"""真实 Redis 保存完整嵌套协议对象,命中仍为值类型。"""
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
from tests.unit.test_openai_completion import accumulate
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
mw = CacheMW(backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60)
|
||||||
|
|
||||||
|
async def terminal(_):
|
||||||
|
return replace(_resp(), completion=completion)
|
||||||
|
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
first = await mw(request, terminal)
|
||||||
|
second = await mw(request, terminal)
|
||||||
|
assert second.cache_hit and second.completion == first.completion
|
||||||
|
assert second.completion is not first.completion
|
||||||
|
|||||||
@@ -72,10 +72,25 @@ class ScriptedTransport:
|
|||||||
def __init__(self, hang: bool = False):
|
def __init__(self, hang: bool = False):
|
||||||
self.hang = hang
|
self.hang = hang
|
||||||
self.calls: list[str] = []
|
self.calls: list[str] = []
|
||||||
|
# 取消用例的确定性窗口(同 test_retry FakeTransport): 进入挂起即置位
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
self.calls.append(source.name)
|
self.calls.append(source.name)
|
||||||
if self.hang:
|
if self.hang:
|
||||||
|
self.entered.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
return TransportResult(
|
return TransportResult(
|
||||||
content="ok",
|
content="ok",
|
||||||
@@ -225,7 +240,32 @@ async def test_cancel_in_flight_releases_lease(clients):
|
|||||||
assert (await limiter.source_stats("s1")).inflight == 0
|
assert (await limiter.source_stats("s1")).inflight == 0
|
||||||
|
|
||||||
|
|
||||||
# —— 掉线方向(fail-closed 集成证据)——
|
async def test_cancel_in_flight_keeps_the_reservation(clients):
|
||||||
|
"""1.3.6 §6.3 S3 在**真实 Redis** 上: 端口已开始、用量未知 → 保留 est 预扣。
|
||||||
|
|
||||||
|
内存后端与 Lua 后端的 `settle(delta)` 算术必须同口径——取消时凭空退款
|
||||||
|
在分布式部署下就是几个 worker 一起击穿 TPM 闸。本用例不改 Lua、不改契约套件。
|
||||||
|
"""
|
||||||
|
a_cli, _ = clients
|
||||||
|
scope = f"t{uuid4().hex[:8]}"
|
||||||
|
sources = [make_source(max_concurrency=1, tpm=1000, est_tokens=400)]
|
||||||
|
limiter = _limiter(a_cli, scope, sources, GlobalLimits(0, 0, 0))
|
||||||
|
transport = ScriptedTransport(hang=True)
|
||||||
|
client = _client(
|
||||||
|
scope,
|
||||||
|
sources,
|
||||||
|
limiter,
|
||||||
|
RedisGate(config=_CFG, redis=a_cli, scope=scope),
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(client.chat([{"role": "user", "content": "hi"}]))
|
||||||
|
await transport.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
stats = await limiter.source_stats("s1")
|
||||||
|
assert stats.tpm_used == 400 # 预扣保留, 不回退到 0
|
||||||
|
assert stats.inflight == 0
|
||||||
|
|
||||||
|
|
||||||
async def test_redis_down_admission_fails_closed():
|
async def test_redis_down_admission_fails_closed():
|
||||||
|
|||||||
@@ -210,7 +210,19 @@ class ClockAdvancingTransport:
|
|||||||
self.clock = clock
|
self.clock = clock
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
advance, action = self.script.pop(0)
|
advance, action = self.script.pop(0)
|
||||||
self.clock.advance(advance)
|
self.clock.advance(advance)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from polygateway.types import (
|
|||||||
ChatRequest,
|
ChatRequest,
|
||||||
Effort,
|
Effort,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
SourceConfig,
|
SourceConfig,
|
||||||
ThinkingObservation,
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
@@ -891,3 +892,238 @@ class TestCallStatsNotPoisoned:
|
|||||||
without = ChatRequest(messages=_MSGS)
|
without = ChatRequest(messages=_MSGS)
|
||||||
assert with_ctx.cache_namespace == without.cache_namespace
|
assert with_ctx.cache_namespace == without.cache_namespace
|
||||||
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|
assert digest_messages(with_ctx.messages) == digest_messages(without.messages)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAICompletionCache:
|
||||||
|
"""协议缓存不得混模式或把字典冒充公共值对象。"""
|
||||||
|
|
||||||
|
def test_mode_isolation_preserves_text_key(self):
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None) == build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None, response_mode="text"
|
||||||
|
)
|
||||||
|
assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key(
|
||||||
|
"m", _MSGS, "proj", None, response_mode="openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"completion",
|
||||||
|
[
|
||||||
|
None,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
"message": {"content": "ok"},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"response_id": None,
|
||||||
|
"usage": None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_missing_or_bad_completion_misses(self, completion):
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = CacheMW(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=60)
|
||||||
|
raw = json.loads(mw._serialize(_resp()))
|
||||||
|
raw["completion"] = completion
|
||||||
|
assert (
|
||||||
|
mw._rehydrate(json.dumps(raw), ChatRequest(messages=_MSGS, response_mode="openai"))
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_serialization_failure_does_not_fail_call(self):
|
||||||
|
mw = CacheMW(
|
||||||
|
backend=InMemoryCache(), model_fingerprint="m", default_namespace="proj", ttl_s=60
|
||||||
|
)
|
||||||
|
response = _resp(cost=object())
|
||||||
|
|
||||||
|
async def terminal(_):
|
||||||
|
return response
|
||||||
|
|
||||||
|
assert await mw(ChatRequest(messages=_MSGS), terminal) is response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"message",
|
||||||
|
[
|
||||||
|
{"role": "assistant"},
|
||||||
|
{"role": "assistant", "content": None, "tool_calls": []},
|
||||||
|
{"role": "assistant", "content": "", "tool_calls": []},
|
||||||
|
{"role": "assistant", "content": " \n", "tool_calls": []},
|
||||||
|
{"role": "assistant", "reasoning_content": "reason only"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("cached_content", ["", "stale projection"])
|
||||||
|
async def test_openai_cache_without_output_misses_and_refills(message, cached_content):
|
||||||
|
"""空协议产物不能靠旧投影正文伪装成功;miss 后回源并覆写。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
|
||||||
|
bad = _resp(
|
||||||
|
content=cached_content,
|
||||||
|
completion=OpenAIChatCompletion(message, "stop", "bad", None),
|
||||||
|
)
|
||||||
|
await backend.set(key, mw._serialize(bad), ttl_s=60)
|
||||||
|
fresh = _resp(
|
||||||
|
completion=OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "content": "cached"}, "stop", "fresh", None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
terminal = _Terminal(fresh)
|
||||||
|
result = await mw(request, terminal)
|
||||||
|
assert terminal.calls == 1 and result is fresh and not result.cache_hit
|
||||||
|
hit = await mw(request, terminal)
|
||||||
|
assert terminal.calls == 1 and hit.cache_hit
|
||||||
|
assert hit.completion.response_id == "fresh"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_tool_only_cache_hits_without_origin():
|
||||||
|
"""有效工具本身是产物,空文本投影不妨碍命中。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
completion = OpenAIChatCompletion(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": "call-1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": "{}"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"tool_calls",
|
||||||
|
"tool-response",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode="openai")
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai")
|
||||||
|
await backend.set(key, mw._serialize(_resp(content="", completion=completion)), ttl_s=60)
|
||||||
|
terminal = _Terminal(_resp())
|
||||||
|
hit = await mw(request, terminal)
|
||||||
|
assert hit.cache_hit and terminal.calls == 0
|
||||||
|
assert hit.completion == completion
|
||||||
|
|
||||||
|
|
||||||
|
# —— 1.3.10(#30/#31): 可缓存 ⊂ 可交付,读写共用同一判据(设计 §6)——
|
||||||
|
|
||||||
|
_OK_COMPLETION = OpenAIChatCompletion({"role": "assistant", "content": "ok"}, "stop", "up", None)
|
||||||
|
_THINK_LENGTH = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "reasoning_content": "t"}, "length", "up", None
|
||||||
|
)
|
||||||
|
_THINK_STOP = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "reasoning_content": "t"}, "stop", "up", None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "response"),
|
||||||
|
[
|
||||||
|
("text", _resp(stream_end="salvaged")),
|
||||||
|
("text", _resp(finish_reason="length")),
|
||||||
|
("text", _resp(finish_reason="content_filter")),
|
||||||
|
("text", _resp(finish_reason="end_turn")),
|
||||||
|
("openai", _resp(content="", finish_reason="length", completion=_THINK_LENGTH)),
|
||||||
|
("openai", _resp(content="", finish_reason="stop", completion=_THINK_STOP)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_uncacheable_results_are_delivered_but_not_written(mode, response):
|
||||||
|
"""截断/空正文/非自然结束: 照常交回,但不写缓存,下次回源。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(response)
|
||||||
|
request = ChatRequest(messages=_MSGS, response_mode=mode)
|
||||||
|
assert (await mw(request, terminal)) is response
|
||||||
|
assert (await mw(request, terminal)).cache_hit is False
|
||||||
|
assert terminal.calls == 2 and not backend._store
|
||||||
|
|
||||||
|
|
||||||
|
def _stored(mode, **fields):
|
||||||
|
"""直接注入一条缓存原文(模拟旧版本或别的进程写入)。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
data = dataclasses.asdict(_resp(**fields))
|
||||||
|
data.pop("structured_data", None)
|
||||||
|
data.pop("call_stats", None)
|
||||||
|
key = build_cache_key("m", _MSGS, "proj", None, response_mode=mode)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
return backend, key
|
||||||
|
|
||||||
|
|
||||||
|
async def _hit(backend, mode):
|
||||||
|
terminal = _Terminal(_resp(content="fresh", completion=_OK_COMPLETION, finish_reason="stop"))
|
||||||
|
response = await _mw(backend)(ChatRequest(messages=_MSGS, response_mode=mode), terminal)
|
||||||
|
return response, terminal.calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "fields"),
|
||||||
|
[
|
||||||
|
("text", {"stream_end": "salvaged"}),
|
||||||
|
("text", {"stream_end": "bogus"}),
|
||||||
|
("text", {"finish_reason": "length"}),
|
||||||
|
("text", {"finish_reason": 5}),
|
||||||
|
("openai", {"content": "", "completion": _THINK_LENGTH, "finish_reason": "length"}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_injected_uncacheable_entries_read_as_miss(mode, fields):
|
||||||
|
"""读路径对存储原值判定(覆盖之前): 不可缓存即 miss 回源。"""
|
||||||
|
backend, _ = _stored(mode, **fields)
|
||||||
|
response, calls = await _hit(backend, mode)
|
||||||
|
assert response.cache_hit is False and calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_fidelity_length_entry_falls_back_to_completion_finish():
|
||||||
|
"""1.3.9 保真条目无顶层 finish: 回退 completion.finish_reason 识别为不可缓存。"""
|
||||||
|
completion = OpenAIChatCompletion({"role": "assistant", "content": "a"}, "length", None, None)
|
||||||
|
backend, key = _stored("openai", completion=completion)
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
for name in ("finish_reason", "stream_end"):
|
||||||
|
data.pop(name)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "openai")
|
||||||
|
assert response.cache_hit is False and calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_text_entry_without_new_fields_still_hits():
|
||||||
|
"""1.3.9 文本条目无新键: 仍命中;命中 stream_end=None。"""
|
||||||
|
backend, key = _stored("text")
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
for name in ("finish_reason", "stream_end"):
|
||||||
|
data.pop(name)
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "text")
|
||||||
|
assert response.cache_hit is True and calls == 0
|
||||||
|
assert response.stream_end is None and response.finish_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_hit_resets_stream_end_and_keeps_effective_finish():
|
||||||
|
"""命中: stream_end 置 None;finish_reason 取判定所用值(顶层缺省时回退)。"""
|
||||||
|
backend, key = _stored("openai", completion=_OK_COMPLETION, stream_end="eof_after_finish")
|
||||||
|
data = json.loads(backend._store[key][1])
|
||||||
|
data.pop("finish_reason")
|
||||||
|
backend._store[key] = (float("inf"), json.dumps(data))
|
||||||
|
response, calls = await _hit(backend, "openai")
|
||||||
|
assert response.cache_hit is True and calls == 0
|
||||||
|
assert response.stream_end is None and response.finish_reason == "stop"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_new_fields_roundtrip_through_cache():
|
||||||
|
"""两新字段随序列化往返;命中只重置 stream_end。"""
|
||||||
|
backend = InMemoryCache()
|
||||||
|
mw = _mw(backend)
|
||||||
|
terminal = _Terminal(_resp(finish_reason="stop", stream_end="eof_after_finish"))
|
||||||
|
await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
stored = json.loads(next(iter(backend._store.values()))[1])
|
||||||
|
assert stored["finish_reason"] == "stop" and stored["stream_end"] == "eof_after_finish"
|
||||||
|
hit = await mw(ChatRequest(messages=_MSGS), terminal)
|
||||||
|
assert hit.cache_hit is True and hit.finish_reason == "stop" and hit.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fidelity_think_only_projection_not_cached():
|
||||||
|
"""审查 I1: qwen think 标签剥离后投影为空的保真结果不写缓存。"""
|
||||||
|
completion = OpenAIChatCompletion(
|
||||||
|
{"role": "assistant", "content": "<think>x</think>"}, "stop", "up", None
|
||||||
|
)
|
||||||
|
response = _resp(content="", thinking="x", finish_reason="stop", completion=completion)
|
||||||
|
backend = InMemoryCache()
|
||||||
|
await _mw(backend)(ChatRequest(messages=_MSGS, response_mode="openai"), _Terminal(response))
|
||||||
|
assert not backend._store
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""GatewayClient 装配与端到端(fake 后端 + MockTransport)测试(设计 §2.4)。"""
|
"""GatewayClient 装配与端到端(fake 后端 + MockTransport)测试(设计 §2.4)。"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import gc
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -10,10 +12,12 @@ import pytest
|
|||||||
|
|
||||||
from polygateway import (
|
from polygateway import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
|
CallDeadlineExceeded,
|
||||||
GatewayClient,
|
GatewayClient,
|
||||||
GatewaySettings,
|
GatewaySettings,
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
|
TransientError,
|
||||||
gather_bounded,
|
gather_bounded,
|
||||||
)
|
)
|
||||||
from polygateway.backends.memory.breaker import InMemoryGate
|
from polygateway.backends.memory.breaker import InMemoryGate
|
||||||
@@ -33,6 +37,10 @@ from polygateway.types import (
|
|||||||
SourceConfig,
|
SourceConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 复用 RetryMW 那份可编程 fake transport(含确定性 `entered` 窗口),不再造第二份;
|
||||||
|
# `tests/unit/test_backpressure.py:34` 已是同款复用
|
||||||
|
from tests.unit.test_retry import FakeTransport, _ok
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[2]
|
_REPO = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
_ENV = {
|
_ENV = {
|
||||||
@@ -136,6 +144,102 @@ class TestChatEndToEnd:
|
|||||||
await client.chat([{"role": "user", "content": "hi"}], structured="json")
|
await client.chat([{"role": "user", "content": "hi"}], structured="json")
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeParams:
|
||||||
|
"""对冲两个 keyword-only 参数的 client 入口校验(issue #24 H4;计划批次 H)。
|
||||||
|
|
||||||
|
编排行为本身见 tests/unit/test_hedge.py;这里只钉装配面: 入口即校、
|
||||||
|
值域/交叉守卫与 settings 共用同一份 `check_hedge_assembly`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_client_hedge_params_entry_validation(self):
|
||||||
|
# 值域: 0/负/非有限当场 ValueError(不经 settings 那道守卫)
|
||||||
|
with pytest.raises(ValueError, match=r"GatewayClient\(hedge_after_s"):
|
||||||
|
_client(hedge_after_s=0)
|
||||||
|
with pytest.raises(ValueError, match="hedge_max_extra"):
|
||||||
|
_client(hedge_max_extra=0)
|
||||||
|
with pytest.raises(ValueError, match="hedge_max_extra"):
|
||||||
|
_client(hedge_max_extra=True) # bool 不是 int 档位
|
||||||
|
# 交叉: 阈值 ≥ 源 timeout_s(10)= 对冲永不可能触发
|
||||||
|
with pytest.raises(ValueError, match="timeout_s"):
|
||||||
|
_client(hedge_after_s=99.0)
|
||||||
|
# 合法值透传到 RetryMW(单源 scope 的装配 warning 是预期噪音,不断言)
|
||||||
|
client = _client(hedge_after_s=0.05)
|
||||||
|
assert client._hedge_after_s == 0.05
|
||||||
|
assert client._terminal._hedge_after_s == 0.05
|
||||||
|
# 未启用(缺省): 行为逐字等于 1.3.6
|
||||||
|
assert _client()._terminal._hedge_after_s is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerationMsClient:
|
||||||
|
"""裸生成时间的 client 级口径(1.3.7 批次 C2/F)。
|
||||||
|
|
||||||
|
`_ScriptedGenClockTransport` 在每次 transport 调用内推进注入钟,
|
||||||
|
使"时间花在哪"可断言(同 test_backpressure.ClockAdvancingTransport 范式)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MSG = [{"role": "user", "content": "hi"}]
|
||||||
|
|
||||||
|
async def test_generation_ms_structured_last_round_wins(self):
|
||||||
|
"""结构化重问覆盖而非累加: 首轮坏 JSON 推进 1s,重问轮推进 0.25s → 250。
|
||||||
|
|
||||||
|
推进量取二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms。
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class Answer(BaseModel):
|
||||||
|
answer: int
|
||||||
|
|
||||||
|
clock = _StatsClock()
|
||||||
|
transport = _ScriptedGenClockTransport(
|
||||||
|
[_ok("not json at all"), _ok('{"answer": 1}')], [1.0, 0.25], clock
|
||||||
|
)
|
||||||
|
async with _client(transport=transport, structured_max_retries=1, now=clock) as client:
|
||||||
|
resp = await client.chat(self._MSG, structured=Answer)
|
||||||
|
assert resp.content == '{"answer": 1}' and len(transport.calls) == 2
|
||||||
|
assert resp.call_stats is not None
|
||||||
|
assert resp.call_stats.attempts == 2
|
||||||
|
assert resp.call_stats.generation_ms == 250
|
||||||
|
|
||||||
|
async def test_cache_hit_generation_ms_zero(self):
|
||||||
|
"""缓存命中不产生 transport 调用: generation_ms 恒 0(0 是实测,非"未知")。"""
|
||||||
|
client = _client(cache=InMemoryCache(), cache_namespace="proj", cache_ttl_s=3600)
|
||||||
|
async with client:
|
||||||
|
first = await client.chat(self._MSG)
|
||||||
|
second = await client.chat(self._MSG)
|
||||||
|
assert first.cache_hit is False and second.cache_hit is True
|
||||||
|
assert second.call_stats is not None
|
||||||
|
assert second.call_stats.generation_ms == 0
|
||||||
|
assert second.call_stats.attempts == 0
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedGenClockTransport:
|
||||||
|
"""脚本化假 transport: 每次成功调用在返回前按脚本推进注入钟(批次 C2/F)。"""
|
||||||
|
|
||||||
|
def __init__(self, results, advances, clock):
|
||||||
|
self._results = list(results)
|
||||||
|
self._advances = list(advances)
|
||||||
|
self._clock = clock
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
|
self.calls.append(call_id)
|
||||||
|
result = self._results.pop(0)
|
||||||
|
self._clock.advance(self._advances.pop(0))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class TestSamplingOverlay:
|
class TestSamplingOverlay:
|
||||||
"""调用级采样参数入口(issue #4 Task 3)。"""
|
"""调用级采样参数入口(issue #4 Task 3)。"""
|
||||||
|
|
||||||
@@ -1717,3 +1821,219 @@ class TestTerminalEmitDegradation:
|
|||||||
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
|
error=AllSourcesExhausted(scope="llm", reason="retry_exhausted", retry_after_s=1.0),
|
||||||
operation="chat",
|
operation="chat",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _ClockJumpTransport:
|
||||||
|
"""假 transport: 只推进**注入钟**,真实墙钟几乎不走。
|
||||||
|
|
||||||
|
用于把"期限读哪只钟"与"统计读哪只钟"两件事分开断言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, clock, *, jump):
|
||||||
|
self._clock = clock
|
||||||
|
self._jump = jump
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
|
self.calls.append(call_id)
|
||||||
|
self._clock.advance(self._jump)
|
||||||
|
return _ok()
|
||||||
|
|
||||||
|
|
||||||
|
class _SlowRecorder:
|
||||||
|
"""假 recorder: 每写一行真实等待一段,用于量化"清理不被期限截断"。"""
|
||||||
|
|
||||||
|
def __init__(self, delay=0.15):
|
||||||
|
self._delay = delay
|
||||||
|
self.rows = []
|
||||||
|
|
||||||
|
async def record_llm_call(self, **fields):
|
||||||
|
await asyncio.sleep(self._delay)
|
||||||
|
self.rows.append(fields)
|
||||||
|
|
||||||
|
|
||||||
|
class _SlowSetCache:
|
||||||
|
"""假缓存后端: `set` 慢于期限,用于构造"已产出、已计费的成功被丢弃"。"""
|
||||||
|
|
||||||
|
def __init__(self, delay=0.5):
|
||||||
|
self._delay = delay
|
||||||
|
self.data = {}
|
||||||
|
self.sets = 0
|
||||||
|
|
||||||
|
async def get(self, key):
|
||||||
|
return self.data.get(key)
|
||||||
|
|
||||||
|
async def set(self, key, value, ttl_s):
|
||||||
|
self.sets += 1
|
||||||
|
await asyncio.sleep(self._delay)
|
||||||
|
self.data[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatCallDeadline:
|
||||||
|
"""chat 链路的期限覆盖面与到期代价(计划 §5 批次 D/D2)。
|
||||||
|
|
||||||
|
真实事件循环时钟: 期限 0.05s 对被治理的等待(退避 5s、轮询 300s、慢 IO 0.5s)
|
||||||
|
有 10 倍以上余量,故不标 slow。
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MSG = [{"role": "user", "content": "hi"}]
|
||||||
|
_DEADLINE = 0.05
|
||||||
|
|
||||||
|
def _rows(self, recorder, kind):
|
||||||
|
return [r for r in recorder.rows if r["event_kind"] == kind]
|
||||||
|
|
||||||
|
# —— 批次 D: 期限落点覆盖面 ——
|
||||||
|
|
||||||
|
async def test_deadline_fires_during_backoff_sleep(self):
|
||||||
|
"""退避 sleep 是等待的大头(429 序列可睡到小时级),期限必须能在它中间落地。"""
|
||||||
|
transport = FakeTransport([TransientError("boom", operation="chat"), _ok()])
|
||||||
|
async with _client(transport=transport, retry=RetryPolicy(3, 5.0, 30.0)) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
assert exc.value.scope == "llm" and exc.value.deadline_s == self._DEADLINE
|
||||||
|
# 第二次尝试还压在 5s 退避里,期限确实落在 sleep 上而非 transport 上
|
||||||
|
assert len(transport.calls) == 1
|
||||||
|
|
||||||
|
async def test_deadline_fires_while_queued_for_quota(self):
|
||||||
|
"""准入排队(配额满轮询)是第二类长等待: 一次 transport 都没打出去也要能到期。"""
|
||||||
|
source = _source(tpm=1, est_tokens=1000) # 预扣量恒超本源 TPM → 六闸永不放行
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(0, 0, 0)
|
||||||
|
)
|
||||||
|
transport = FakeTransport([_ok()])
|
||||||
|
async with _client([source], transport=transport, limiter=limiter) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
assert transport.calls == [] # 期限落在轮询里,尝试从未开始
|
||||||
|
|
||||||
|
async def test_deadline_fires_during_structured_re_ask(self):
|
||||||
|
"""结构化重问共享同一份期限: 阶梯不得按轮数各起一份,否则期限被放大 N 倍。
|
||||||
|
|
||||||
|
窗口用"第三轮挂起"构造而非 sleep 猜时长——前两轮瞬时返回坏 JSON,
|
||||||
|
期限只可能落在第三轮上,断言因此与机器负载无关。
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class Answer(BaseModel):
|
||||||
|
answer: int
|
||||||
|
|
||||||
|
transport = FakeTransport([_ok("not json at all"), _ok("not json at all"), "hang"])
|
||||||
|
async with _client(
|
||||||
|
transport=transport, structured_max_retries=5, structured_strategy=JsonRepairStrategy()
|
||||||
|
) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, structured=Answer, call_deadline_s=0.05)
|
||||||
|
# 到期发生在第三轮: 期限确实跨过了两次重问,而不是在首轮就截断
|
||||||
|
assert len(transport.calls) == 3
|
||||||
|
|
||||||
|
# —— 批次 D2: 到期代价 ——
|
||||||
|
|
||||||
|
async def test_expiry_writes_one_terminal_row_and_a_cancelled_attempt(self):
|
||||||
|
"""到期恰好一条终态行 + 被取消的 attempt 行,两行同一 logical_call_id。"""
|
||||||
|
recorder = _MemoryRecorder()
|
||||||
|
transport = FakeTransport(["hang"])
|
||||||
|
async with _client(transport=transport, telemetry=recorder) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
attempts = self._rows(recorder, "attempt")
|
||||||
|
terminals = self._rows(recorder, "terminal_failure")
|
||||||
|
assert len(terminals) == 1
|
||||||
|
assert terminals[0]["error_type"] == "CallDeadlineExceeded"
|
||||||
|
assert len(attempts) == 1 and attempts[0]["error"] == "cancelled"
|
||||||
|
assert attempts[0]["logical_call_id"] == terminals[0]["logical_call_id"]
|
||||||
|
|
||||||
|
# 零新增遥测列: 期限终态行的列集合与既有失败路径的终态行逐字相同
|
||||||
|
baseline = _MemoryRecorder()
|
||||||
|
async with _client(
|
||||||
|
handler=lambda request: httpx.Response(400, json={"error": {"message": "bad"}}),
|
||||||
|
telemetry=baseline,
|
||||||
|
) as client:
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await client.chat(self._MSG)
|
||||||
|
assert set(terminals[0]) == set(self._rows(baseline, "terminal_failure")[0])
|
||||||
|
|
||||||
|
async def test_cleanup_is_not_cut_short_by_the_expiry(self):
|
||||||
|
"""返回时刻 = 期限 + 清理耗时: 只断下界(> 期限 × 2),不断上界。"""
|
||||||
|
recorder = _SlowRecorder(delay=0.15) # attempt 行与终态行各付一次
|
||||||
|
transport = FakeTransport(["hang"])
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started = loop.time()
|
||||||
|
async with _client(transport=transport, telemetry=recorder) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
elapsed = loop.time() - started
|
||||||
|
assert len(recorder.rows) == 2 # 清理照常写完两行,没被期限截断
|
||||||
|
assert elapsed > self._DEADLINE * 2, f"清理疑似被截断: {elapsed}s"
|
||||||
|
|
||||||
|
async def test_expiry_discards_a_success_that_was_already_billed(self):
|
||||||
|
"""到期 ≠ 未产出、未计费: transport 已成功一次,结果仍被丢弃。"""
|
||||||
|
transport = FakeTransport([_ok()])
|
||||||
|
cache = _SlowSetCache(delay=0.5) # 写缓存慢于期限 → 到期落在成功之后
|
||||||
|
async with _client(
|
||||||
|
transport=transport, cache=cache, cache_namespace="proj", cache_ttl_s=600
|
||||||
|
) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
assert len(transport.calls) == 1 # 上游已经产出并计费
|
||||||
|
assert cache.sets == 1 and cache.data == {} # 结果既没回给调用方也没落缓存
|
||||||
|
|
||||||
|
# —— 批次 E: 注入钟与期限正交 ——
|
||||||
|
|
||||||
|
async def test_injected_clock_jump_does_not_trigger_the_deadline(self):
|
||||||
|
"""期限只认真实墙钟: 注入钟跳 10^6 秒也不该凭空到期(不换算绝对截止时刻)。"""
|
||||||
|
clock = _StatsClock()
|
||||||
|
transport = _ClockJumpTransport(clock, jump=1_000_000.0)
|
||||||
|
async with _client(transport=transport, now=clock) as client:
|
||||||
|
resp = await client.chat(self._MSG, call_deadline_s=5.0)
|
||||||
|
assert resp.content == "ok"
|
||||||
|
# 而统计仍逐字读注入钟(10^6 s = 10^9 ms),两只钟各司其职
|
||||||
|
assert resp.call_stats is not None
|
||||||
|
assert resp.call_stats.total_latency_ms == 1_000_000_000
|
||||||
|
|
||||||
|
async def test_expiry_latency_still_reads_the_injected_clock(self):
|
||||||
|
"""期限由真实钟触发,终态行的耗时仍取自注入钟(真实耗时只有几十毫秒)。"""
|
||||||
|
clock = _StatsClock()
|
||||||
|
recorder = _TickingRecorder(clock, tick=0.5)
|
||||||
|
transport = FakeTransport(["hang"])
|
||||||
|
async with _client(transport=transport, telemetry=recorder, now=clock) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=self._DEADLINE)
|
||||||
|
terminal = [r for r in recorder.rows if r["event_kind"] == "terminal_failure"][0]
|
||||||
|
assert terminal["total_latency_ms"] == 500 # attempt 行那一次 tick,不是真实的 ~50ms
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatCallDeadlineEntryGuards:
|
||||||
|
"""per-call 入口校验的两条硬红线(计划 §3.4/§5 批次 E)。"""
|
||||||
|
|
||||||
|
_MSG = [{"role": "user", "content": "hi"}]
|
||||||
|
|
||||||
|
async def test_illegal_per_call_value_leaves_no_un_awaited_coroutine(self):
|
||||||
|
"""校验先于构造 awaitable: 否则非法值抛错时遗留未 await 的协程(资源不释放)。"""
|
||||||
|
transport = FakeTransport([_ok()])
|
||||||
|
async with _client(transport=transport) as client:
|
||||||
|
with warnings.catch_warnings(record=True) as caught:
|
||||||
|
warnings.simplefilter("always")
|
||||||
|
with pytest.raises(ValueError, match=r"chat\(call_deadline_s"):
|
||||||
|
await client.chat(self._MSG, call_deadline_s=0)
|
||||||
|
gc.collect() # 未 await 的协程在回收时才发 RuntimeWarning
|
||||||
|
assert [w for w in caught if "never awaited" in str(w.message)] == []
|
||||||
|
assert transport.calls == []
|
||||||
|
|
||||||
|
async def test_per_call_none_inherits_the_assembled_deadline(self):
|
||||||
|
"""`None` = 继承装配值(不提供"本次关闭"): 装配了期限就照样到期。"""
|
||||||
|
transport = FakeTransport(["hang"])
|
||||||
|
async with _client(transport=transport, call_deadline_s=0.05) as client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat(self._MSG)
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"""统一完成判定在 transport 两条路径上的落点(#30/#31,设计 §4.2-§4.4、§5.4)。
|
||||||
|
|
||||||
|
假 SSE 按 magpie/glm 实测形态二次构造: 末块 finish_reason + usage 后干净关闭、
|
||||||
|
不发 [DONE];以及只有思考的 length 截断、null/未知字段等方言。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from polygateway.errors import AllSourcesExhausted, ResultInvalidError, TransientError
|
||||||
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
||||||
|
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
|
||||||
|
|
||||||
|
_USAGE = {"prompt_tokens": 11, "completion_tokens": 7}
|
||||||
|
_TOOL = {
|
||||||
|
"index": 0,
|
||||||
|
"id": "call_1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "lookup", "arguments": '{"q": 1}'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _f(delta=None, finish=None, usage=None, choices=True):
|
||||||
|
"""单 choice 帧;`choices=False` 构造 usage-only 帧。"""
|
||||||
|
body = {"id": "up-1"}
|
||||||
|
if choices:
|
||||||
|
choice = {"index": 0, "delta": delta or {}}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
body["choices"] = [choice]
|
||||||
|
else:
|
||||||
|
body["choices"] = []
|
||||||
|
if usage is not None:
|
||||||
|
body["usage"] = usage
|
||||||
|
return f"data: {json.dumps(body)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _assistant(**delta):
|
||||||
|
return _f({"role": "assistant", **delta})
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(frames, *, done, mode="text", **source_kw):
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*frames, done=done))
|
||||||
|
try:
|
||||||
|
return await _complete(transport, _source(**source_kw), response_mode=mode)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_once(body, *, mode="text"):
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, json=body))
|
||||||
|
try:
|
||||||
|
return await _complete(transport, _source(), stream=False, response_mode=mode)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# —— #31: 缺 [DONE] 但 finish + 其后实测 usage 完整 ——
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
@pytest.mark.parametrize("finish", ["stop", "length"])
|
||||||
|
async def test_eof_after_finish_with_usage_succeeds(mode, finish):
|
||||||
|
"""验收 1: 末块 finish + usage 后干净关闭 → 成功、measured、eof_after_finish。"""
|
||||||
|
frames = [_assistant(content="hel"), _f({"content": "lo"}, finish=finish, usage=_USAGE)]
|
||||||
|
result = await _run(frames, done=False, mode=mode)
|
||||||
|
assert result.content == "hello" and result.stream_end == "eof_after_finish"
|
||||||
|
assert result.usage_source == "measured" and result.finish_reason == finish
|
||||||
|
assert (result.prompt_tokens, result.completion_tokens) == (11, 7)
|
||||||
|
if mode == "openai":
|
||||||
|
assert result.completion.finish_reason == finish
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eof_after_finish_usage_only_frame_and_tools():
|
||||||
|
"""验收 2: tool_calls 结束、usage 在其后的 usage-only 帧 → 工具完整交回。"""
|
||||||
|
frames = [
|
||||||
|
_assistant(content=None, tool_calls=[_TOOL]),
|
||||||
|
_f({}, finish="tool_calls"),
|
||||||
|
_f(choices=False, usage=_USAGE),
|
||||||
|
]
|
||||||
|
result = await _run(frames, done=False, mode="openai")
|
||||||
|
tool = result.completion.message["tool_calls"][0]
|
||||||
|
assert tool["function"] == {"name": "lookup", "arguments": '{"q": 1}'}
|
||||||
|
assert result.stream_end == "eof_after_finish" and result.usage_source == "measured"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"frames",
|
||||||
|
[
|
||||||
|
[_assistant(content="partial"), _f(choices=False, usage=_USAGE)], # 验收 3: 无 finish
|
||||||
|
[_assistant(content="partial"), _f({}, finish="stop")], # 验收 4: 无 usage
|
||||||
|
[_assistant(content="a", **{}), _f({}, usage=_USAGE), _f({}, finish="stop")], # usage 早
|
||||||
|
[_assistant(content="a"), _f({}, finish="stop", usage={})], # usage 不可用
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_incomplete_eof_still_fails(frames):
|
||||||
|
"""截断不放宽: 保真仍不打捞,文本 retry 档仍抛。"""
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await _run(frames, done=False, mode="openai", missing_done="salvage")
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await _run(frames, done=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_salvage_marks_stream_end():
|
||||||
|
"""文本 salvage 打捞 → stream_end=salvaged,usage 降为 estimated。"""
|
||||||
|
frames = [_assistant(content="partial"), _f(choices=False, usage=_USAGE)]
|
||||||
|
result = await _run(frames, done=False, missing_done="salvage")
|
||||||
|
assert result.stream_end == "salvaged" and result.usage_source == "estimated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_error_mid_stream_fails():
|
||||||
|
"""验收 5: 读取中途 RemoteProtocolError → 失败,不当干净 EOF。"""
|
||||||
|
|
||||||
|
class Broken(httpx.AsyncByteStream):
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield _f({"role": "assistant", "content": "a"}, finish="stop", usage=_USAGE).encode()
|
||||||
|
raise httpx.RemoteProtocolError("peer closed")
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, stream=Broken()))
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError, match="网络错误"):
|
||||||
|
await _complete(transport, _source(), response_mode="openai")
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_done_stream_marks_done(mode):
|
||||||
|
"""验收 6: 带 [DONE] 的正常流行为不变,stream_end=done。"""
|
||||||
|
result = await _run([_assistant(content="ok"), _f({}, "stop", _USAGE)], done=True, mode=mode)
|
||||||
|
assert result.stream_end == "done" and result.finish_reason == "stop"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("done", [True, False])
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_upstream_error_finish_is_transient(done, mode):
|
||||||
|
"""GLM network_error: 上游自报失败,有正文也不交付。"""
|
||||||
|
frames = [_assistant(content="half"), _f({}, finish="network_error", usage=_USAGE)]
|
||||||
|
with pytest.raises(TransientError, match="upstream_finish_error"):
|
||||||
|
await _run(frames, done=done, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
# —— #30 与结局表(设计 §4.3)——
|
||||||
|
|
||||||
|
_THINK = [_assistant(reasoning_content="long thought")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("done", [True, False])
|
||||||
|
async def test_openai_reasoning_only_length_delivered(done):
|
||||||
|
"""#30 验收: 只有思考 + length → 交回,content 空,思考完整。"""
|
||||||
|
result = await _run([*_THINK, _f({}, "length", _USAGE)], done=done, mode="openai")
|
||||||
|
message = result.completion.message
|
||||||
|
assert message.get("content") in (None, "") and message["reasoning_content"] == "long thought"
|
||||||
|
assert result.finish_reason == "length" and result.thinking == "long thought"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("frames", "finish", "openai_ok", "text_error"),
|
||||||
|
[
|
||||||
|
(_THINK, "length", True, (ResultInvalidError, "length_without_content")),
|
||||||
|
([_assistant()], "length", True, (ResultInvalidError, "length_without_content")),
|
||||||
|
(_THINK, "content_filter", True, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
([_assistant()], "sensitive", True, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
(_THINK, "stop", True, (TransientError, "empty_completion")),
|
||||||
|
([_assistant(refusal="no")], "stop", True, (TransientError, "empty_completion")),
|
||||||
|
([_assistant()], "stop", False, (TransientError, "empty_completion")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_outcome_rows_stream(frames, finish, openai_ok, text_error):
|
||||||
|
"""行 3/4/6/7/8: 两模式的分叉。"""
|
||||||
|
full = [*frames, _f({}, finish, _USAGE)]
|
||||||
|
if openai_ok:
|
||||||
|
assert (await _run(full, done=True, mode="openai")).finish_reason == finish
|
||||||
|
else:
|
||||||
|
with pytest.raises(TransientError, match="empty_completion"):
|
||||||
|
await _run(full, done=True, mode="openai")
|
||||||
|
error, code = text_error
|
||||||
|
with pytest.raises(error, match=code) as caught:
|
||||||
|
await _run(full, done=True)
|
||||||
|
assert caught.type is error # ResultInvalid 不得被 Transient 子类掩盖
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["text", "openai"])
|
||||||
|
async def test_reasoning_only_without_finish_is_transient(mode):
|
||||||
|
"""行 9: 只靠 DONE 收尾、无 finish → 无法区分抖动与结局,维持瞬时。"""
|
||||||
|
with pytest.raises(TransientError, match="empty_completion"):
|
||||||
|
await _run([*_THINK, _f(choices=False, usage=_USAGE)], done=True, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_whitespace_content_stop_delivered():
|
||||||
|
"""正文只有空白 + stop: 保真交回(#30 一-2)。"""
|
||||||
|
result = await _run(
|
||||||
|
[_assistant(content=" "), _f({}, "stop", _USAGE)], done=True, mode="openai"
|
||||||
|
)
|
||||||
|
assert result.completion.message["content"] == " "
|
||||||
|
|
||||||
|
|
||||||
|
async def test_truncated_tool_is_result_invalid():
|
||||||
|
"""行 2: length 截断的半截工具(缺 name)→ ResultInvalid tool_truncated。"""
|
||||||
|
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": '{"q'}}
|
||||||
|
frames = [_assistant(tool_calls=[broken]), _f({}, "length", _USAGE)]
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_truncated"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_content_cannot_mask_broken_tool():
|
||||||
|
"""行 2: 正文非空 + 坏工具 + stop → Transient tool_incomplete。"""
|
||||||
|
broken = {"index": 0, "id": "c", "type": "function", "function": {"arguments": "{}"}}
|
||||||
|
frames = [_assistant(content="text", tool_calls=[broken]), _f({}, "stop", _USAGE)]
|
||||||
|
with pytest.raises(TransientError, match="tool_incomplete"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_null_refusal_and_tool_calls_stream():
|
||||||
|
"""#30 验收二: delta 带 refusal:null 与 tool_calls:null → 正常交回。"""
|
||||||
|
frames = [_assistant(content="ok", refusal=None, tool_calls=None), _f({}, "stop", _USAGE)]
|
||||||
|
result = await _run(frames, done=True, mode="openai")
|
||||||
|
assert result.completion.message["content"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# —— 非流式镜像 ——
|
||||||
|
|
||||||
|
|
||||||
|
def _body(message, finish):
|
||||||
|
choice = {"index": 0, "message": {"role": "assistant", **message}}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
return {"id": "up", "choices": [choice], "usage": _USAGE}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_null_tool_calls_and_refusal():
|
||||||
|
"""非流式 message 带 tool_calls:null + refusal:null → 成功。"""
|
||||||
|
body = _body({"content": "hi", "tool_calls": None, "refusal": None}, "stop")
|
||||||
|
result = await _run_once(body, mode="openai")
|
||||||
|
assert result.completion.message["tool_calls"] is None and result.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("message", "finish", "openai_expect", "text_expect"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"reasoning_content": "t"},
|
||||||
|
"length",
|
||||||
|
None,
|
||||||
|
(ResultInvalidError, "length_without_content"),
|
||||||
|
),
|
||||||
|
({"content": ""}, "content_filter", None, (ResultInvalidError, "filtered_without_content")),
|
||||||
|
({"reasoning_content": "t"}, "stop", None, (TransientError, "empty_completion")),
|
||||||
|
({"reasoning_content": "t"}, None, (TransientError, "empty_completion"), None),
|
||||||
|
({"content": "a"}, "network_error", (TransientError, "upstream_finish_error"), None),
|
||||||
|
({"content": "a"}, "length", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_outcome_rows_nonstream(message, finish, openai_expect, text_expect):
|
||||||
|
"""非流式跳过流完整性,直接以 body finish 走结局表。"""
|
||||||
|
body = _body(message, finish)
|
||||||
|
for mode, expect in (("openai", openai_expect), ("text", text_expect or openai_expect)):
|
||||||
|
if expect is None:
|
||||||
|
result = await _run_once(body, mode=mode)
|
||||||
|
assert result.finish_reason == finish and result.stream_end is None
|
||||||
|
else:
|
||||||
|
with pytest.raises(expect[0], match=expect[1]) as caught:
|
||||||
|
await _run_once(body, mode=mode)
|
||||||
|
assert caught.type is expect[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_truncated_tool_is_result_invalid():
|
||||||
|
"""非流式先判定后构造快照: 缺 name + length → tool_truncated 而非构造期 Transient。"""
|
||||||
|
tool = {"id": "c", "type": "function", "function": {"arguments": "{"}}
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_truncated"):
|
||||||
|
await _run_once(_body({"tool_calls": [tool]}, "length"), mode="openai")
|
||||||
|
|
||||||
|
|
||||||
|
# —— reasoning 别名(设计 §4.4)——
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reasoning_alias_projects_thinking():
|
||||||
|
"""只有 reasoning 字段: 文本 thinking 取别名;非空 reasoning_content 优先。"""
|
||||||
|
alias = await _run(
|
||||||
|
[_assistant(reasoning="r1"), _f({"content": "a"}, "stop", _USAGE)], done=True
|
||||||
|
)
|
||||||
|
assert alias.thinking == "r1"
|
||||||
|
both = [
|
||||||
|
_assistant(reasoning="r1", reasoning_content="rc"),
|
||||||
|
_f({"content": "a"}, "stop", _USAGE),
|
||||||
|
]
|
||||||
|
assert (await _run(both, done=True)).thinking == "rc"
|
||||||
|
empty_rc = [_assistant(reasoning="r1", reasoning_content=""), _f({"content": "a"}, "stop")]
|
||||||
|
assert (await _run(empty_rc, done=True)).thinking == "r1"
|
||||||
|
nonstream = await _run_once(_body({"content": "a", "reasoning": "nr"}, "stop"))
|
||||||
|
assert nonstream.thinking == "nr"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reasoning_alias_keeps_ttft_alive():
|
||||||
|
"""只有 reasoning 的思考期刷新活性,不被 TTFT 看门狗误杀。"""
|
||||||
|
|
||||||
|
class Slow(httpx.AsyncByteStream):
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield _assistant().encode()
|
||||||
|
for _ in range(5): # 0.5s 只有 reasoning,远超 ttft,但每帧间隔 < inter_token
|
||||||
|
yield _f({"reasoning": "t"}).encode()
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
yield _f({"content": "answer"}, "stop", _USAGE).encode()
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
transport = _transport_for(lambda _: httpx.Response(200, stream=Slow()))
|
||||||
|
try:
|
||||||
|
source = _source(ttft_timeout_s=0.3, inter_token_timeout_s=0.25)
|
||||||
|
result = await _complete(transport, source)
|
||||||
|
assert result.content == "answer" and result.thinking == "ttttt"
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# —— 有界告警节流(设计 §5.4)——
|
||||||
|
|
||||||
|
|
||||||
|
def _sink():
|
||||||
|
records = []
|
||||||
|
handle = logger.add(lambda m: records.append(m.record["message"]), level="WARNING")
|
||||||
|
return records, handle
|
||||||
|
|
||||||
|
|
||||||
|
async def test_conflict_and_unknown_finish_warn_once():
|
||||||
|
"""同源同路径冲突、同一未知 finish 值各只告警一次,不回显字段值。"""
|
||||||
|
frames = [
|
||||||
|
_assistant(content="a", ext=1),
|
||||||
|
_f({"ext": 2}),
|
||||||
|
_f({}, "end_turn", _USAGE),
|
||||||
|
]
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*frames))
|
||||||
|
records, handle = _sink()
|
||||||
|
try:
|
||||||
|
for _ in range(2):
|
||||||
|
result = await _complete(transport, _source(), response_mode="openai")
|
||||||
|
assert "ext" not in result.completion.message
|
||||||
|
finally:
|
||||||
|
logger.remove(handle)
|
||||||
|
await transport.aclose()
|
||||||
|
conflict = [r for r in records if "delta.ext" in r]
|
||||||
|
unknown = [r for r in records if "end_turn" in r]
|
||||||
|
assert len(conflict) == 1 and len(unknown) == 1 and "不缓存" in unknown[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_warning_set_is_bounded():
|
||||||
|
"""节流集合饱和后只记一次饱和告警,不再增长。"""
|
||||||
|
transport = OpenAICompatTransport()
|
||||||
|
transport._warned_fields.update((f"s{i}", "k") for i in range(1024))
|
||||||
|
records, handle = _sink()
|
||||||
|
try:
|
||||||
|
transport._warn_once("qwen_1", "field:x", "a")
|
||||||
|
transport._warn_once("qwen_1", "field:y", "b")
|
||||||
|
finally:
|
||||||
|
logger.remove(handle)
|
||||||
|
await transport.aclose()
|
||||||
|
assert len(transport._warned_fields) == 1024
|
||||||
|
assert sum("饱和" in r for r in records) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# —— 治理链: 确定性结局不重试、不计熔断 ——
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_length_without_content_not_retried_nor_breaker_failure():
|
||||||
|
"""文本 length 无正文 → ResultInvalid 直抛,attempts=1,熔断不记失败。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(*_THINK, _f({}, "length", _USAGE))
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
failures = []
|
||||||
|
original = client._breaker_backend.record_failure
|
||||||
|
|
||||||
|
async def spy(*args, **kwargs):
|
||||||
|
failures.append(args)
|
||||||
|
return await original(*args, **kwargs)
|
||||||
|
|
||||||
|
client._breaker_backend.record_failure = spy
|
||||||
|
try:
|
||||||
|
with pytest.raises(ResultInvalidError, match="length_without_content"):
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and failures == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_reasoning_length_single_attempt_via_client():
|
||||||
|
"""#30 验收经治理链: attempts=1;#31 形态经治理链不再重试到耗尽。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(*_THINK, _f({}, "length", _USAGE), done=False)
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat([{"role": "user", "content": "hi"}], response_mode="openai")
|
||||||
|
except AllSourcesExhausted: # pragma: no cover - 失败即回归
|
||||||
|
pytest.fail("reasoning-only length 不应重试到耗尽")
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and response.call_stats.attempts == 1
|
||||||
|
assert response.stream_end == "eof_after_finish" and response.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_eof_after_finish_stop_single_attempt_via_client():
|
||||||
|
"""#31 验收 1 经治理链: stop + usage 无 DONE,attempts=1、不重试到耗尽。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
calls.append(1)
|
||||||
|
return _sse_stream(_assistant(content="ok"), _f({}, "stop", _USAGE), done=False)
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat([{"role": "user", "content": "hi"}])
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
assert len(calls) == 1 and response.call_stats.attempts == 1
|
||||||
|
assert response.stream_end == "eof_after_finish" and response.usage_source == "measured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_text_salvage_with_length_partial_content_delivered():
|
||||||
|
"""文本 salvage + length + 部分正文(finish 后无 usage): 交付、salvaged。"""
|
||||||
|
frames = [_assistant(content="part"), _f({}, finish="length")]
|
||||||
|
result = await _run(frames, done=False, missing_done="salvage")
|
||||||
|
assert result.content == "part" and result.stream_end == "salvaged"
|
||||||
|
assert result.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_null_arguments_tool_is_result_invalid_stream():
|
||||||
|
"""审查 I2: 无参函数 arguments 全程 null → ResultInvalid,不重试。"""
|
||||||
|
tool = {"index": 0, "id": "c", "type": "function", "function": {"name": "f", "arguments": None}}
|
||||||
|
frames = [_assistant(tool_calls=[tool]), _f({}, "tool_calls", _USAGE)]
|
||||||
|
with pytest.raises(ResultInvalidError, match="tool_arguments_null"):
|
||||||
|
await _run(frames, done=True, mode="openai")
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""统一完成判定(#30/#31): 内核纯函数真值表。"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.types import (
|
||||||
|
LLMResponse,
|
||||||
|
OpenAIChatCompletion,
|
||||||
|
TransportResult,
|
||||||
|
_classify_finish,
|
||||||
|
_effective_finish,
|
||||||
|
_is_cacheable,
|
||||||
|
_judge_outcome,
|
||||||
|
_OutputSummary,
|
||||||
|
_stream_is_complete,
|
||||||
|
_summarize_message,
|
||||||
|
_summarize_text,
|
||||||
|
_tool_snapshot_ok,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TOOL = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[
|
||||||
|
("stop", "natural"),
|
||||||
|
("tool_calls", "natural"),
|
||||||
|
("function_call", "natural"),
|
||||||
|
("length", "truncated"),
|
||||||
|
("content_filter", "filtered"),
|
||||||
|
("sensitive", "filtered"),
|
||||||
|
("network_error", "upstream_error"),
|
||||||
|
("error", "upstream_error"),
|
||||||
|
("end_turn", "unknown"),
|
||||||
|
("STOP", "unknown"),
|
||||||
|
(None, "absent"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_classify_finish(value, expected):
|
||||||
|
assert _classify_finish(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("done", "finish", "after", "measured", "expected"),
|
||||||
|
[
|
||||||
|
(True, None, False, False, "done"),
|
||||||
|
(True, "stop", True, True, "done"),
|
||||||
|
(False, "stop", True, True, "eof_after_finish"),
|
||||||
|
(False, "length", True, True, "eof_after_finish"),
|
||||||
|
(False, None, True, True, None), # 无 finish 即截断
|
||||||
|
(False, "stop", False, True, None), # usage 早于 finish(累计 usage)
|
||||||
|
(False, "stop", True, False, None), # finish 后 usage 不可用
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_stream_is_complete(done, finish, after, measured, expected):
|
||||||
|
got = _stream_is_complete(
|
||||||
|
done=done, finish_reason=finish, usage_after_finish=after, usage_measured=measured
|
||||||
|
)
|
||||||
|
assert got == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_snapshot_ok():
|
||||||
|
assert _tool_snapshot_ok({"role": "assistant"})
|
||||||
|
assert _tool_snapshot_ok({"tool_calls": None})
|
||||||
|
assert _tool_snapshot_ok({"tool_calls": [None, _TOOL]})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": {}})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": [{"id": "t1", "type": "function"}]})
|
||||||
|
assert not _tool_snapshot_ok({"tool_calls": [_TOOL, _TOOL]}) # 重复 id
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_text():
|
||||||
|
assert _summarize_text("", "") == _OutputSummary(False, False, False, True)
|
||||||
|
assert _summarize_text(" ", "") == _OutputSummary(True, False, False, True)
|
||||||
|
assert _summarize_text("", "think") == _OutputSummary(True, False, False, True)
|
||||||
|
assert _summarize_text("hi", "") == _OutputSummary(True, True, False, True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("message", "projected", "expected"),
|
||||||
|
[
|
||||||
|
({"role": "assistant"}, "", _OutputSummary(False, False, False, True)),
|
||||||
|
({"role": "assistant", "content": None}, "", _OutputSummary(False, False, False, True)),
|
||||||
|
({"role": "assistant", "refusal": "no"}, "", _OutputSummary(True, False, False, True)),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "reasoning_content": "r"},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, False, True),
|
||||||
|
),
|
||||||
|
({"role": "assistant", "content": " "}, " ", _OutputSummary(True, False, False, True)),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "x": {"y": ["", "z"]}},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, False, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [None]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(False, False, False, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [_TOOL]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, True, True, True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"role": "assistant", "tool_calls": [{"id": "t"}]},
|
||||||
|
"",
|
||||||
|
_OutputSummary(True, False, True, False),
|
||||||
|
),
|
||||||
|
({"role": "assistant", "content": "a"}, "a", _OutputSummary(True, True, False, True)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_summarize_message(message, projected, expected):
|
||||||
|
assert _summarize_message(message, projected) == expected
|
||||||
|
|
||||||
|
|
||||||
|
_NONE = _OutputSummary(False, False, False, True)
|
||||||
|
_THINK = _OutputSummary(True, False, False, True)
|
||||||
|
_BODY = _OutputSummary(True, True, False, True)
|
||||||
|
_BAD_TOOL = _OutputSummary(True, True, True, False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mode", "summary", "finish", "expected"),
|
||||||
|
[
|
||||||
|
# 行 1: upstream_error 优先于一切
|
||||||
|
("openai", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
|
||||||
|
("text", _BODY, "upstream_error", ("transient", "upstream_finish_error")),
|
||||||
|
# 行 2: 坏工具(仅保真),正文不得掩盖
|
||||||
|
("openai", _BAD_TOOL, "truncated", ("result_invalid", "tool_truncated")),
|
||||||
|
("openai", _BAD_TOOL, "natural", ("transient", "tool_incomplete")),
|
||||||
|
("text", _BAD_TOOL, "natural", None),
|
||||||
|
# 行 3: 无产物 + truncated/filtered
|
||||||
|
("openai", _NONE, "truncated", None),
|
||||||
|
("openai", _NONE, "filtered", None),
|
||||||
|
("text", _NONE, "truncated", ("result_invalid", "length_without_content")),
|
||||||
|
("text", _NONE, "filtered", ("result_invalid", "filtered_without_content")),
|
||||||
|
# 行 4: 零增量抖动(2026-07-20 裁决)
|
||||||
|
("openai", _NONE, "natural", ("transient", "empty_completion")),
|
||||||
|
("openai", _NONE, "absent", ("transient", "empty_completion")),
|
||||||
|
("openai", _NONE, "unknown", ("transient", "empty_completion")),
|
||||||
|
("text", _NONE, "absent", ("transient", "empty_completion")),
|
||||||
|
# 行 5: 有正文
|
||||||
|
("openai", _BODY, "truncated", None),
|
||||||
|
("text", _BODY, "filtered", None),
|
||||||
|
("text", _BODY, "absent", None),
|
||||||
|
# 行 6-9: 有产物无正文
|
||||||
|
("openai", _THINK, "truncated", None),
|
||||||
|
("text", _THINK, "truncated", ("result_invalid", "length_without_content")),
|
||||||
|
("openai", _THINK, "filtered", None),
|
||||||
|
("text", _THINK, "filtered", ("result_invalid", "filtered_without_content")),
|
||||||
|
("openai", _THINK, "natural", None),
|
||||||
|
("openai", _THINK, "unknown", None),
|
||||||
|
("text", _THINK, "natural", ("transient", "empty_completion")),
|
||||||
|
("openai", _THINK, "absent", ("transient", "empty_completion")),
|
||||||
|
("text", _THINK, "absent", ("transient", "empty_completion")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_judge_outcome_rows(mode, summary, finish, expected):
|
||||||
|
verdict = _judge_outcome(mode=mode, summary=summary, finish=finish)
|
||||||
|
if expected is None:
|
||||||
|
assert verdict.deliver and verdict.error_kind is None and verdict.code is None
|
||||||
|
else:
|
||||||
|
assert not verdict.deliver
|
||||||
|
assert (verdict.error_kind, verdict.code) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _completion(content="a", finish="stop", tools=None):
|
||||||
|
message = {"role": "assistant", "content": content}
|
||||||
|
if tools is not None:
|
||||||
|
message["tool_calls"] = tools
|
||||||
|
return OpenAIChatCompletion(message, finish, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kwargs", "expected"),
|
||||||
|
[
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "done"}, True),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": None}, True),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"mode": "text",
|
||||||
|
"content": "a",
|
||||||
|
"finish_reason": "stop",
|
||||||
|
"stream_end": "eof_after_finish",
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": None, "stream_end": "salvaged"}, False),
|
||||||
|
({"mode": "text", "content": " ", "finish_reason": "stop", "stream_end": "done"}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "length", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "sensitive", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "end_turn", "stream_end": None}, False),
|
||||||
|
({"mode": "text", "content": "a", "finish_reason": "stop", "stream_end": "bogus"}, False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_cacheable_text(kwargs, expected):
|
||||||
|
assert _is_cacheable(completion=None, **kwargs) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_cacheable_openai():
|
||||||
|
ok = _completion()
|
||||||
|
assert _is_cacheable(
|
||||||
|
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=ok
|
||||||
|
)
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai", content="a", finish_reason="stop", stream_end="done", completion=None
|
||||||
|
)
|
||||||
|
# 原文为空且无工具: 不信任旧投影
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai",
|
||||||
|
content="a",
|
||||||
|
finish_reason="stop",
|
||||||
|
stream_end="done",
|
||||||
|
completion=_completion(content=""),
|
||||||
|
)
|
||||||
|
tool_only = _completion(content=None, finish="tool_calls", tools=[_TOOL])
|
||||||
|
assert _is_cacheable(
|
||||||
|
mode="openai", content="", finish_reason="tool_calls", stream_end=None, completion=tool_only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_effective_finish():
|
||||||
|
assert _effective_finish("stop", _completion(finish="length")) == "stop"
|
||||||
|
assert _effective_finish(None, _completion(finish="length")) == "length"
|
||||||
|
assert _effective_finish(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_accepts_null_tool_calls():
|
||||||
|
snap = OpenAIChatCompletion({"role": "assistant", "tool_calls": None}, "stop", None, None)
|
||||||
|
assert "tool_calls" in snap.message and snap.message["tool_calls"] is None
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
OpenAIChatCompletion({"role": "assistant", "tool_calls": {}}, "stop", None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_response_fields_default_none():
|
||||||
|
result = TransportResult("a", "", 0, 0, "unavailable", None, None, {})
|
||||||
|
assert result.finish_reason is None and result.stream_end is None
|
||||||
|
response = LLMResponse("a", "", "m", "p", 0, 0, 0, None, None, False, "c")
|
||||||
|
assert response.finish_reason is None and response.stream_end is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_end_exported():
|
||||||
|
import polygateway
|
||||||
|
|
||||||
|
assert "StreamEnd" in polygateway.__all__
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_cacheable_requires_projected_body():
|
||||||
|
"""审查 I1: think 标签剥离后投影为空,即使原文非空也不可缓存。"""
|
||||||
|
think_only = _completion(content="<think>x</think>")
|
||||||
|
assert not _is_cacheable(
|
||||||
|
mode="openai", content="", finish_reason="stop", stream_end="done", completion=think_only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_arguments_only_tool_is_result_invalid():
|
||||||
|
"""审查 I2: 无参函数 arguments 全程 null(其余齐全)→ 确定性方言,ResultInvalid。"""
|
||||||
|
tool = {"id": "t1", "type": "function", "function": {"name": "f", "arguments": None}}
|
||||||
|
summary = _summarize_message({"role": "assistant", "tool_calls": [tool]}, "")
|
||||||
|
assert summary.null_arguments and not summary.tools_ok
|
||||||
|
verdict = _judge_outcome(mode="openai", summary=summary, finish="natural")
|
||||||
|
assert (verdict.error_kind, verdict.code) == ("result_invalid", "tool_arguments_null")
|
||||||
|
broken = {"id": "t1", "type": "function", "function": {"arguments": None}}
|
||||||
|
assert not _summarize_message({"role": "assistant", "tool_calls": [broken]}, "").null_arguments
|
||||||
@@ -1041,3 +1041,409 @@ def test_live_unknown_wire_assembly_is_local_only():
|
|||||||
GatewayClient.from_settings(
|
GatewayClient.from_settings(
|
||||||
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
|
dataclasses.replace(settings, sources=(source,)), registry=register_provider(mystery)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCallDeadlineConfig:
|
||||||
|
"""`{SCOPE}__CALL_DEADLINE_S` 与三个 client 入口参数的值域四条路(issue #22)。"""
|
||||||
|
|
||||||
|
def test_key_unset_means_disabled(self):
|
||||||
|
assert GatewaySettings.from_env("LLM", env=_env()).call_deadline_s is None
|
||||||
|
|
||||||
|
def test_env_key_parsed(self):
|
||||||
|
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "30"}))
|
||||||
|
assert s.call_deadline_s == 30.0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["0", "-1", "nan", "inf", "abc"])
|
||||||
|
def test_env_illegal_value_reports_the_actual_key_name(self, bad):
|
||||||
|
"""origin 必须是实际命中的 env 键名,多 scope 部署里才定位得到是哪个键。"""
|
||||||
|
with pytest.raises(ValueError, match="LLM__CALL_DEADLINE_S"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": bad}))
|
||||||
|
|
||||||
|
def test_direct_construction_is_guarded(self):
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
|
||||||
|
dataclasses.replace(base, call_deadline_s=0)
|
||||||
|
|
||||||
|
def test_plain_constructor_call_is_guarded_too(self):
|
||||||
|
"""`dataclasses.replace` 与直接构造是两条路: 守卫在 `__post_init__` 才两条都盖住。
|
||||||
|
|
||||||
|
只在 `from_env` 里校验的话,直接 `GatewaySettings(...)` 装配的下游(测试/高级
|
||||||
|
注入路径,CLAUDE.md §4.5 的第二条装配路)会把非法期限一路带到第一次调用才炸。
|
||||||
|
"""
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
fields = {f.name: getattr(base, f.name) for f in dataclasses.fields(base)}
|
||||||
|
with pytest.raises(ValueError, match="GatewaySettings.call_deadline_s"):
|
||||||
|
GatewaySettings(**{**fields, "call_deadline_s": float("inf")})
|
||||||
|
# 合法值走同一条路不受影响(守卫对合法值是幂等空操作)
|
||||||
|
assert GatewaySettings(**{**fields, "call_deadline_s": 7}).call_deadline_s == 7.0
|
||||||
|
|
||||||
|
def test_replace_with_legal_value_is_idempotent(self):
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
assert dataclasses.replace(base, call_deadline_s=5).call_deadline_s == 5.0
|
||||||
|
|
||||||
|
def test_deadline_shorter_than_timeout_is_legal(self):
|
||||||
|
"""期限短于单次 timeout_s 是调用方的合法选择,不做跨字段耦合校验。"""
|
||||||
|
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "1"}))
|
||||||
|
assert s.call_deadline_s == 1.0 and s.sources[0].timeout_s == 120.0
|
||||||
|
|
||||||
|
def test_from_settings_propagates_to_client(self):
|
||||||
|
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__CALL_DEADLINE_S": "12"}))
|
||||||
|
assert GatewayClient.from_settings(s)._call_deadline_s == 12.0
|
||||||
|
|
||||||
|
def test_client_init_validates_at_entry(self):
|
||||||
|
"""三个 client 的 `__init__` 直传非法值也当场报错(不经 settings 那道守卫)。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r"GatewayClient\(call_deadline_s"):
|
||||||
|
_client(call_deadline_s=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeConfig:
|
||||||
|
"""`{SCOPE}__HEDGE__AFTER_S`/`{SCOPE}__HEDGE__MAX_EXTRA` 两键与装配守卫(issue #24 H4)。
|
||||||
|
|
||||||
|
对冲默认关闭: 键未设 = None/1,行为逐字等于 1.3.6。守卫四路覆盖
|
||||||
|
(env/直接构造/dataclasses.replace/client 直传),单一定义点是
|
||||||
|
`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_hedge_keys_from_env_skip_source_loader(self):
|
||||||
|
"""两键为 3 段键,天然不被 `_load_sources` 当源字段;`HEDGE` 进保留段防 4 段撞名。"""
|
||||||
|
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8", "LLM__HEDGE__MAX_EXTRA": "2"})
|
||||||
|
with _captured_warnings(): # max_extra>1 的 v1 单路 warning, 本用例不断言它
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.hedge_after_s == 8.0
|
||||||
|
assert s.hedge_max_extra == 2
|
||||||
|
assert {src.name for src in s.sources} == {"qwen_1", "qwen_2"} # HEDGE 键未造源
|
||||||
|
# `HEDGE` 在保留段: `LLM__HEDGE__1__*` 四段键不得造出一个名为 hedge_1 的源
|
||||||
|
env_collision = _env(
|
||||||
|
**{
|
||||||
|
"LLM__HEDGE__1__BASE_URL": "https://gw-c.example/v1",
|
||||||
|
"LLM__HEDGE__1__API_KEY": "sk-c",
|
||||||
|
"LLM__HEDGE__1__MODEL": "m-c",
|
||||||
|
"LLM__HEDGE__1__TIMEOUT_S": "60",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
s2 = GatewaySettings.from_env("LLM", env=env_collision)
|
||||||
|
assert [src.name for src in s2.sources] == ["qwen_1"]
|
||||||
|
|
||||||
|
def test_hedge_keys_unset_mean_disabled(self):
|
||||||
|
"""默认关闭: 两键未设 = None/1,且不产生任何 warning。"""
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
s = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
assert s.hedge_after_s is None and s.hedge_max_extra == 1
|
||||||
|
assert not warnings
|
||||||
|
|
||||||
|
def test_hedge_after_s_domain_four_paths(self):
|
||||||
|
"""非法值四条装配路全部当场 ValueError(消息须定位得到是哪个键/参数)。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
# 路 1: env(origin 是实际命中的键名)
|
||||||
|
with pytest.raises(ValueError, match="LLM__HEDGE__AFTER_S"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "0"}))
|
||||||
|
with pytest.raises(ValueError, match="LLM__HEDGE__AFTER_S"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "abc"}))
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
# 路 2: 直接构造
|
||||||
|
fields = {f.name: getattr(base, f.name) for f in dataclasses.fields(base)}
|
||||||
|
with pytest.raises(ValueError, match="hedge_after_s"):
|
||||||
|
GatewaySettings(**{**fields, "hedge_after_s": float("nan")})
|
||||||
|
# 路 3: dataclasses.replace
|
||||||
|
with pytest.raises(ValueError, match="hedge_after_s"):
|
||||||
|
dataclasses.replace(base, hedge_after_s=-1)
|
||||||
|
# 路 4: client 直传(不经 settings 那道守卫)
|
||||||
|
with pytest.raises(ValueError, match=r"GatewayClient\(hedge_after_s"):
|
||||||
|
_client(hedge_after_s=0)
|
||||||
|
|
||||||
|
def test_hedge_guard_empty_sources_raises_with_hedge_location(self):
|
||||||
|
"""直传路空 sources + 设阈值: ValueError 且消息定位到 hedge(不是裸 min() 报错)。"""
|
||||||
|
from polygateway.config import check_hedge_assembly
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="sources") as ei:
|
||||||
|
check_hedge_assembly(
|
||||||
|
hedge_after_s=8,
|
||||||
|
hedge_max_extra=1,
|
||||||
|
sources=[],
|
||||||
|
call_deadline_s=None,
|
||||||
|
origin="GatewayClient(hedge_after_s=8)",
|
||||||
|
)
|
||||||
|
assert "hedge_after_s" in str(ei.value) # 定位得到是哪个键
|
||||||
|
# 未启用对冲(两阈值皆 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)。"""
|
||||||
|
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "90"}) # min(timeout)=90
|
||||||
|
with pytest.raises(ValueError, match="timeout_s"):
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
# 边界内侧合法(89 < 90)
|
||||||
|
s = GatewaySettings.from_env(
|
||||||
|
"LLM", env=self._two_source_env(**{"LLM__HEDGE__AFTER_S": "89"})
|
||||||
|
)
|
||||||
|
assert s.hedge_after_s == 89.0
|
||||||
|
|
||||||
|
def test_hedge_guard_ttft_warns(self):
|
||||||
|
"""阈值 ≥ 最小已设 ttft_timeout_s: 流式被看门狗先切,装配期 warning 而非 ValueError。"""
|
||||||
|
env = self._two_source_env(
|
||||||
|
**{
|
||||||
|
"LLM__QWEN__1__TTFT_TIMEOUT_S": "30",
|
||||||
|
"LLM__QWEN__1__INTER_TOKEN_TIMEOUT_S": "15",
|
||||||
|
"LLM__HEDGE__AFTER_S": "35", # ≥ ttft 30, < timeout 90
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.hedge_after_s == 35.0 # warning 不是拒绝: 非流式仍有效
|
||||||
|
assert any("ttft_timeout_s" in m for m in warnings)
|
||||||
|
# 阈值低于看门狗时不告警
|
||||||
|
with _captured_warnings() as warnings2:
|
||||||
|
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__AFTER_S": "25",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert not warnings2
|
||||||
|
|
||||||
|
def test_hedge_guard_single_source_warns(self):
|
||||||
|
"""单源 scope 设阈值: 装配期 warning 放行,运行期拿不到候选自然静默。"""
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__AFTER_S": "8"}))
|
||||||
|
assert s.hedge_after_s == 8.0
|
||||||
|
assert any("单源" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_hedge_guard_deadline_conflict_raises(self):
|
||||||
|
"""阈值 ≥ call_deadline_s: 期限先于对冲触发,对冲形同虚设 → ValueError(§6)。"""
|
||||||
|
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "35", "LLM__CALL_DEADLINE_S": "30"})
|
||||||
|
with pytest.raises(ValueError, match="call_deadline_s"):
|
||||||
|
GatewaySettings.from_env("LLM", env=env)
|
||||||
|
# 边界值(恰好相等)同样拒绝
|
||||||
|
with pytest.raises(ValueError, match="call_deadline_s"):
|
||||||
|
GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=self._two_source_env(
|
||||||
|
**{"LLM__HEDGE__AFTER_S": "30", "LLM__CALL_DEADLINE_S": "30"}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 阈值 < 期限是合法组合
|
||||||
|
s = GatewaySettings.from_env(
|
||||||
|
"LLM",
|
||||||
|
env=self._two_source_env(**{"LLM__HEDGE__AFTER_S": "29", "LLM__CALL_DEADLINE_S": "30"}),
|
||||||
|
)
|
||||||
|
assert s.hedge_after_s == 29.0 and s.call_deadline_s == 30.0
|
||||||
|
|
||||||
|
def test_hedge_max_extra_v1_cap(self):
|
||||||
|
"""max_extra 值域 [1,3] 的四路校验;>1 已接受但 warning 声明 v1 仅单路生效(H5)。"""
|
||||||
|
# 域外值无条件拒绝(即使对冲未启用: 非法值没有"惰性"豁免)
|
||||||
|
with pytest.raises(ValueError, match="hedge_max_extra"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "0"}))
|
||||||
|
with pytest.raises(ValueError, match="LLM__HEDGE__MAX_EXTRA"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "x"}))
|
||||||
|
with pytest.raises(ValueError, match="hedge_max_extra"):
|
||||||
|
GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__MAX_EXTRA": "4"}))
|
||||||
|
base = GatewaySettings.from_env("LLM", env=_env())
|
||||||
|
with pytest.raises(ValueError, match="hedge_max_extra"):
|
||||||
|
dataclasses.replace(base, hedge_max_extra=0)
|
||||||
|
# 2/3 接受 + warning: v1 运行期恒单路(对冲任务不再携带首 token 观测,
|
||||||
|
# 行为面由 test_hedge.py 的 ft_events 断言钉住),梯次追加为 H5 预留
|
||||||
|
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8", "LLM__HEDGE__MAX_EXTRA": "2"})
|
||||||
|
with _captured_warnings() as warnings:
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
assert s.hedge_max_extra == 2
|
||||||
|
assert any("单路" in m for m in warnings)
|
||||||
|
|
||||||
|
def test_from_settings_propagates_hedge_to_client(self):
|
||||||
|
"""from_settings 透传: RetryMW 拿到归一化阈值;max_extra 不下传(v1 无消费者)。"""
|
||||||
|
env = self._two_source_env(**{"LLM__HEDGE__AFTER_S": "8"})
|
||||||
|
s = GatewaySettings.from_env("LLM", env=env)
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""`deadline.py` 值域校验与五种形态区分测试(计划 §5 批次 A/B)。
|
||||||
|
|
||||||
|
用真实事件循环时钟(期限 0.05s、体 0.3s,4-10 倍余量),不标 slow:
|
||||||
|
被测对象是"哪一种 TimeoutError"的身份判据,注入钟无法覆盖 `asyncio.timeout`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.deadline import ensure_call_deadline, with_call_deadline
|
||||||
|
from polygateway.errors import CallDeadlineExceeded
|
||||||
|
|
||||||
|
# —— 批次 A: 值域 ——
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_call_deadline_accepts_none_and_positive():
|
||||||
|
assert ensure_call_deadline(None, "origin") is None
|
||||||
|
assert ensure_call_deadline(3, "origin") == 3.0
|
||||||
|
assert ensure_call_deadline(0.5, "origin") == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bad",
|
||||||
|
[0, 0.0, -1, -0.5, float("nan"), float("inf"), float("-inf"), "1", True, False, object(), []],
|
||||||
|
)
|
||||||
|
def test_ensure_call_deadline_rejects_out_of_range(bad):
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
ensure_call_deadline(bad, "GatewayClient(call_deadline_s=...)")
|
||||||
|
assert "GatewayClient(call_deadline_s=...)" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_call_deadline_rejects_huge_int_without_leaking_overflow():
|
||||||
|
"""超出 float 值域的巨大 int 也统一 ValueError,不泄漏 OverflowError。"""
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
ensure_call_deadline(10**400, "origin")
|
||||||
|
assert "origin" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
# —— 批次 B: 五种形态 ——
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deadline_expiry_raises_call_deadline_exceeded():
|
||||||
|
async def body():
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||||
|
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
|
||||||
|
assert exc.value.scope == "llm"
|
||||||
|
assert exc.value.deadline_s == 0.05
|
||||||
|
|
||||||
|
|
||||||
|
async def test_inner_timeout_before_expiry_propagates_as_is():
|
||||||
|
async def body():
|
||||||
|
async with asyncio.timeout(0.01):
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
with pytest.raises(TimeoutError) as exc:
|
||||||
|
await with_call_deadline(body(), deadline_s=5.0, scope="llm")
|
||||||
|
assert not isinstance(exc.value, CallDeadlineExceeded)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cleanup_timeout_after_expiry_is_not_relabelled():
|
||||||
|
"""到期后清理路径自抛 TimeoutError → 原样上抛(钉住身份比较,不看 expired())。"""
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise TimeoutError("cleanup") from None
|
||||||
|
|
||||||
|
with pytest.raises(TimeoutError) as exc:
|
||||||
|
await with_call_deadline(body(), deadline_s=0.05, scope="llm")
|
||||||
|
assert not isinstance(exc.value, CallDeadlineExceeded)
|
||||||
|
assert str(exc.value) == "cleanup"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_external_cancel_before_expiry_propagates_cancelled():
|
||||||
|
entered = asyncio.Event()
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
entered.set()
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
task = asyncio.create_task(with_call_deadline(body(), deadline_s=5.0, scope="llm"))
|
||||||
|
await entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_external_cancel_after_expiry_propagates_cancelled():
|
||||||
|
"""到期已在途、外部又取消 → 仍是 CancelledError(取消优先,不被改标)。"""
|
||||||
|
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await asyncio.sleep(0.2) # 清理期,期间遭外部取消
|
||||||
|
raise
|
||||||
|
|
||||||
|
task = asyncio.create_task(with_call_deadline(body(), deadline_s=0.05, scope="llm"))
|
||||||
|
await started.wait()
|
||||||
|
await asyncio.sleep(0.1) # 让期限先到期,进入清理
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_narrow_success_returns_value_without_pending_cancellation():
|
||||||
|
async def body():
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
async def runner():
|
||||||
|
return await with_call_deadline(body(), deadline_s=0.2, scope="llm")
|
||||||
|
|
||||||
|
task = asyncio.create_task(runner())
|
||||||
|
assert await task == "ok"
|
||||||
|
assert task.cancelling() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_domain_error_inside_window_propagates():
|
||||||
|
"""计时器已触发但取消尚未投递的窗口内,体内先抛领域异常 → 原样上抛,期限静默让位。
|
||||||
|
|
||||||
|
忙等超过期限: 计时器回调已在 loop 上触发,但任务不挂起取消就投递不进来,
|
||||||
|
此刻体内同步抛出的领域异常必须原样逃逸(设计 §5.2 形态四)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BoomError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
end = time.monotonic() + 0.1
|
||||||
|
while time.monotonic() < end:
|
||||||
|
pass
|
||||||
|
raise BoomError("boom")
|
||||||
|
|
||||||
|
with pytest.raises(BoomError):
|
||||||
|
await with_call_deadline(body(), deadline_s=0.02, scope="llm")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_none_deadline_takes_the_legacy_path():
|
||||||
|
async def body():
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
assert await with_call_deadline(body(), deadline_s=None, scope="llm") == "ok"
|
||||||
@@ -13,6 +13,7 @@ import pytest
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
|
CallDeadlineExceeded,
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
@@ -197,6 +198,8 @@ class ScriptedEmbedTransport:
|
|||||||
def __init__(self, script):
|
def __init__(self, script):
|
||||||
self.script = list(script)
|
self.script = list(script)
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
# 取消用例的确定性窗口: 进入 hang 分支即置位, 不用 sleep 撞窗口
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
|
||||||
async def embed(self, *, texts, source, call_id):
|
async def embed(self, *, texts, source, call_id):
|
||||||
self.calls.append((source.name, list(texts), call_id))
|
self.calls.append((source.name, list(texts), call_id))
|
||||||
@@ -204,6 +207,7 @@ class ScriptedEmbedTransport:
|
|||||||
if isinstance(action, Exception):
|
if isinstance(action, Exception):
|
||||||
raise action
|
raise action
|
||||||
if action == "hang":
|
if action == "hang":
|
||||||
|
self.entered.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
if action == "ok":
|
if action == "ok":
|
||||||
return _vec_for(texts)
|
return _vec_for(texts)
|
||||||
@@ -315,6 +319,20 @@ class TestEmbedBatching:
|
|||||||
assert resp.cost is None
|
assert resp.cost is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmbedGenerationMs:
|
||||||
|
"""裸生成时间按批累加(1.3.7 H8): generation_ms = 各批 transport 耗时之和。"""
|
||||||
|
|
||||||
|
async def test_generation_ms_sums_batch_transports(self):
|
||||||
|
# 0.25s 为二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms
|
||||||
|
clock = FakeClock()
|
||||||
|
transport = _ClockAdvancingEmbedTransport([(0.25, "ok"), (0.25, "ok")], clock)
|
||||||
|
client, _ = _embed_client([_src()], [], transport=transport, now=clock)
|
||||||
|
resp = await client.embed(["a", "bb", "ccc", "dddd"])
|
||||||
|
assert resp.call_stats is not None
|
||||||
|
assert resp.call_stats.attempts == 2
|
||||||
|
assert resp.call_stats.generation_ms == 500
|
||||||
|
|
||||||
|
|
||||||
class TestEmbedPostProcess:
|
class TestEmbedPostProcess:
|
||||||
async def test_normalize_l2(self):
|
async def test_normalize_l2(self):
|
||||||
raw = EmbeddingTransportResult(
|
raw = EmbeddingTransportResult(
|
||||||
@@ -365,6 +383,20 @@ class TestEmbedGovernance:
|
|||||||
await task
|
await task
|
||||||
assert (await limiter.source_stats("e1")).inflight == 0
|
assert (await limiter.source_stats("e1")).inflight == 0
|
||||||
|
|
||||||
|
async def test_cancel_in_flight_keeps_the_reservation(self):
|
||||||
|
"""S7(与 chat 同口径): transport 在途被取消 → 用量未知 → 保留预扣而非退成 0。"""
|
||||||
|
transport = ScriptedEmbedTransport(["hang"])
|
||||||
|
client, limiter = _embed_client(
|
||||||
|
[_src(max_concurrency=1, tpm=1000, est_tokens=400)], [], transport=transport
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(client.embed(["a"]))
|
||||||
|
await transport.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
stats = await limiter.source_stats("e1")
|
||||||
|
assert stats.tpm_used == 400 and stats.inflight == 0
|
||||||
|
|
||||||
async def test_single_timeout_does_not_exhaust_stall_budget(self):
|
async def test_single_timeout_does_not_exhaust_stall_budget(self):
|
||||||
"""issue #8: 一次耗满 timeout 的尝试不得吃掉 stall 预算而使重试失效。
|
"""issue #8: 一次耗满 timeout 的尝试不得吃掉 stall 预算而使重试失效。
|
||||||
|
|
||||||
@@ -645,3 +677,70 @@ class TestEmbedLogicalCallStats:
|
|||||||
assert resp.call_stats.attempts == 0
|
assert resp.call_stats.attempts == 0
|
||||||
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
|
assert resp.call_stats.logical_call_id # 真实 ID,不是空串
|
||||||
assert rec.rows == [] # 零遥测行
|
assert rec.rows == [] # 零遥测行
|
||||||
|
|
||||||
|
|
||||||
|
class _SlowEmbedTransport:
|
||||||
|
"""假 embedding transport: 每批真实耗时 `delay` 秒。
|
||||||
|
|
||||||
|
"N 批共享一份期限"只能用真实等待来证——`asyncio.timeout` 认的是事件循环
|
||||||
|
时钟,注入钟推不动它(计划 §5 批次 D)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, delay):
|
||||||
|
self._delay = delay
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def embed(self, *, texts, source, call_id):
|
||||||
|
self.calls.append(list(texts))
|
||||||
|
await asyncio.sleep(self._delay)
|
||||||
|
return _vec_for(texts)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmbedCallDeadline:
|
||||||
|
"""embedding 的期限语义: 整次调用一份,空输入豁免(计划 §5 批次 D/E)。"""
|
||||||
|
|
||||||
|
async def test_one_deadline_is_shared_across_all_batches(self):
|
||||||
|
"""按批各起一份会让期限被批数放大 N 倍: 单批 0.05s 远小于期限 0.5s 时将永不到期。
|
||||||
|
|
||||||
|
余量刷到 10 倍(单批 0.05s vs 期限 0.5s): 要报假结论得单批慢 10 倍,
|
||||||
|
而不是机器抳一下就变色。
|
||||||
|
"""
|
||||||
|
transport = _SlowEmbedTransport(delay=0.05)
|
||||||
|
client, _ = _embed_client([_src()], [], batch_size=1, transport=transport)
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started = loop.time()
|
||||||
|
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||||
|
await client.embed([str(i) for i in range(20)], call_deadline_s=0.5)
|
||||||
|
elapsed = loop.time() - started
|
||||||
|
assert exc.value.scope == "embed"
|
||||||
|
# 按批计的话 20 批全都能跑完(根本不会抛),共享一份则跑不到头
|
||||||
|
assert 1 <= len(transport.calls) < 20
|
||||||
|
assert elapsed < 20 * 0.05, f"总时长疑似随批数放大: {elapsed}s"
|
||||||
|
|
||||||
|
async def test_empty_input_is_exempt_from_the_deadline(self):
|
||||||
|
"""`texts == []` 早返回在 try 之外(零尝试、无等待可治),再小的期限也不该拦它。"""
|
||||||
|
transport = ScriptedEmbedTransport([])
|
||||||
|
client, _ = _embed_client([_src()], [], transport=transport)
|
||||||
|
resp = await client.embed([], call_deadline_s=1e-6)
|
||||||
|
assert resp.vectors == []
|
||||||
|
assert resp.call_stats is not None and resp.call_stats.attempts == 0
|
||||||
|
assert transport.calls == []
|
||||||
|
|
||||||
|
async def test_the_same_tiny_deadline_does_fire_on_a_non_empty_input(self):
|
||||||
|
"""对照组: 上一条用的 1e-6 秒确实是会到期的值,豁免不是因为期限没生效。"""
|
||||||
|
transport = _SlowEmbedTransport(delay=0.05)
|
||||||
|
client, _ = _embed_client([_src()], [], transport=transport)
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.embed(["a"], call_deadline_s=1e-6)
|
||||||
|
|
||||||
|
async def test_illegal_per_call_value_is_rejected_at_the_entry(self):
|
||||||
|
"""per-call 非法值当场 ValueError,且消息指向 `embed(...)` 而非某个 env 键。"""
|
||||||
|
transport = ScriptedEmbedTransport([])
|
||||||
|
client, _ = _embed_client([_src()], [], transport=transport)
|
||||||
|
with pytest.raises(ValueError, match=r"embed\(call_deadline_s"):
|
||||||
|
await client.embed(["a"], call_deadline_s=0)
|
||||||
|
assert transport.calls == []
|
||||||
|
|
||||||
|
def test_illegal_constructor_value_is_rejected_at_assembly(self):
|
||||||
|
with pytest.raises(ValueError, match=r"EmbeddingClient\(call_deadline_s"):
|
||||||
|
_embed_client([_src()], [], call_deadline_s=-1)
|
||||||
|
|||||||
@@ -0,0 +1,645 @@
|
|||||||
|
"""对冲编排测试(issue #24 设计 §4.6 / 计划批次 G)。
|
||||||
|
|
||||||
|
设施纪律(计划 §5): 两源 scope、事件驱动假 transport(每源一对 entered/release
|
||||||
|
Event + 可脚本化"先置首 token 再挂起")、**真实 loop 钟**(不注入 FakeClock)、
|
||||||
|
`hedge_after_s=0.05`、断言容差 4–10×;取消窗口用 entered 双 Event 栅栏钉死,
|
||||||
|
禁 sleep 撞窗口;计时只断言下界与相对比较,不断言精确值。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway import CallDeadlineExceeded
|
||||||
|
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_retry import RecordingSelector, 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)
|
||||||
|
_HEDGE_AFTER_S = 0.05 # 真实 loop 钟阈值;断言上界取 10×(0.5s)
|
||||||
|
|
||||||
|
|
||||||
|
class HedgeTransport:
|
||||||
|
"""事件驱动假 transport: 按源剧本精确控制首 token 置位与完成时刻。
|
||||||
|
|
||||||
|
剧本动作(每源一条队列,耗尽后重复最后一项——429 风暴用例需无限供应):
|
||||||
|
("hang",) — 置位该源 entered,挂起直到该源 release(或被取消)
|
||||||
|
("token_then_hang",) — 先置 first_token_event 再 hang(首 token 已至)
|
||||||
|
("succeed", content) — 立即成功
|
||||||
|
("succeed_after", delay, content)— 真实 loop 钟睡 delay 后成功
|
||||||
|
("fail_after", delay, factory) — 睡 delay 后抛 `factory()` 新造的异常
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, scripts: dict[str, list[tuple]]):
|
||||||
|
self._scripts = {name: list(actions) for name, actions in scripts.items()}
|
||||||
|
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,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
|
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]
|
||||||
|
if kind == "hang":
|
||||||
|
self.entered[source.name].set()
|
||||||
|
await self.release[source.name].wait()
|
||||||
|
return _ok(f"ok-{source.name}")
|
||||||
|
if kind == "token_then_hang":
|
||||||
|
if first_token_event is not None:
|
||||||
|
first_token_event.set()
|
||||||
|
self.entered[source.name].set()
|
||||||
|
await self.release[source.name].wait()
|
||||||
|
return _ok(f"ok-{source.name}")
|
||||||
|
if kind == "succeed":
|
||||||
|
return _ok(action[1])
|
||||||
|
if kind == "succeed_after":
|
||||||
|
await asyncio.sleep(action[1])
|
||||||
|
return _ok(action[2])
|
||||||
|
if kind == "fail_after":
|
||||||
|
await asyncio.sleep(action[1])
|
||||||
|
raise action[2]()
|
||||||
|
raise AssertionError(f"未知剧本动作: {action!r}")
|
||||||
|
|
||||||
|
|
||||||
|
class BlockingLimiter:
|
||||||
|
"""限流包装: 挂起指定源的 try_acquire 直到测试放行(确定性复现"对冲准入挂起")。
|
||||||
|
|
||||||
|
`acquiring` 置位 = 对冲准入已停在该源闸内;`allow` 置位后才继续。只拦对冲
|
||||||
|
会走到的源,原路准入不受影响;其余方法逐字委托内层 InMemoryLimiter。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, inner: InMemoryLimiter, block_source: str):
|
||||||
|
self._inner = inner
|
||||||
|
self._block_source = block_source
|
||||||
|
self.acquiring = asyncio.Event()
|
||||||
|
self.allow = asyncio.Event()
|
||||||
|
|
||||||
|
async def try_acquire(self, source_key, est_tokens):
|
||||||
|
if source_key == self._block_source:
|
||||||
|
self.acquiring.set()
|
||||||
|
await self.allow.wait()
|
||||||
|
return await self._inner.try_acquire(source_key, est_tokens)
|
||||||
|
|
||||||
|
async def acquire(self, source_key, est_tokens):
|
||||||
|
return await self._inner.acquire(source_key, est_tokens)
|
||||||
|
|
||||||
|
async def source_stats(self, source_key):
|
||||||
|
return await self._inner.source_stats(source_key)
|
||||||
|
|
||||||
|
async def mark_progress(self):
|
||||||
|
return await self._inner.mark_progress()
|
||||||
|
|
||||||
|
async def progress_age_s(self):
|
||||||
|
return await self._inner.progress_age_s()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingEmitter:
|
||||||
|
"""逐次遥测假 emitter: 记录每行的源/错误标签/逻辑调用 ID/attempt call_id。"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rows: list[dict] = []
|
||||||
|
|
||||||
|
async def emit_attempt(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request,
|
||||||
|
source,
|
||||||
|
call_id,
|
||||||
|
latency_ms,
|
||||||
|
response,
|
||||||
|
error,
|
||||||
|
reasoning_applies,
|
||||||
|
operation,
|
||||||
|
# 1.3.8: `_emit` 显式转发 stall 两值(审查 B1)——显式全签名 fake 不加则 TypeError;
|
||||||
|
# 默认 None 只护调用方,护不住被调的显式签名
|
||||||
|
stall_ms=None,
|
||||||
|
stall_count=None,
|
||||||
|
):
|
||||||
|
self.rows.append(
|
||||||
|
{
|
||||||
|
"source": source.name,
|
||||||
|
"call_id": call_id,
|
||||||
|
"logical_call_id": request.call_context.logical_call_id
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _harness(
|
||||||
|
sources,
|
||||||
|
transport,
|
||||||
|
*,
|
||||||
|
hedge_after_s=_HEDGE_AFTER_S,
|
||||||
|
max_attempts=3,
|
||||||
|
emitter=None,
|
||||||
|
selector=None,
|
||||||
|
limiter=None,
|
||||||
|
gate=None,
|
||||||
|
stall_window_s=300.0,
|
||||||
|
now=None,
|
||||||
|
):
|
||||||
|
"""真实 loop 钟装配(对冲计时纪律: 只用 loop 相对时长,不注入 FakeClock)。
|
||||||
|
|
||||||
|
`now` 仅供"注入钟与对冲触发正交"用例注入 FakeClock——触发路径结构性不读
|
||||||
|
它,注入只是为了证明这一点。
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
**({"now": now} if now is not None else {}),
|
||||||
|
)
|
||||||
|
return mw, limiter, gate
|
||||||
|
|
||||||
|
|
||||||
|
def _req(*, stream=False, ctx=None):
|
||||||
|
return ChatRequest(
|
||||||
|
messages=[{"role": "user", "content": "hi"}], stream=stream, call_context=ctx
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
return _CallContext(now=time.monotonic)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeTrigger:
|
||||||
|
"""触发两形态(验收矩阵 ①): 非流式纯时间阈值;流式以首 token 未至为判据。"""
|
||||||
|
|
||||||
|
async def test_non_stream_triggers_hedge_and_fast_leg_wins(self):
|
||||||
|
"""s1 挂起、s2 即时成功: 对冲截断长尾,赢家为对冲路(⑩: 计时不含触发前等待)。"""
|
||||||
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed_after", 0.02, "fast")]})
|
||||||
|
mw, _, _ = _harness([s1, s2], transport)
|
||||||
|
ctx = _ctx()
|
||||||
|
started = time.monotonic()
|
||||||
|
# wait_for 是防挂安全带(红相位无实现时 5s 判负),不是计时断言
|
||||||
|
resp = await asyncio.wait_for(mw(_req(stream=False, ctx=ctx)), timeout=5)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
assert resp.content == "fast" and resp.source_name == "s2"
|
||||||
|
assert elapsed < 10 * _HEDGE_AFTER_S # 挂起路被对冲截断,而非等到释放
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 1 and stats.hedge_won is True
|
||||||
|
# 赢家裸生成时间: 下界 = s2 实际 transport 耗时(0.02s 留截断余量),
|
||||||
|
# 且严格小于总时长(不含 0.05s 触发窗等待)
|
||||||
|
assert stats.generation_ms >= 15
|
||||||
|
assert stats.generation_ms < stats.total_latency_ms
|
||||||
|
|
||||||
|
async def test_stream_triggers_only_when_first_token_absent(self):
|
||||||
|
"""两例(①): 首 token 未至 → 触发;先置首 token 再挂起 → 不触发。"""
|
||||||
|
# 例一: 流式但首 token 未至,阈值到 → 对冲触发
|
||||||
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "fast")]})
|
||||||
|
mw, _, _ = _harness([s1, s2], transport)
|
||||||
|
ctx = _ctx()
|
||||||
|
resp = await asyncio.wait_for(mw(_req(stream=True, ctx=ctx)), timeout=5)
|
||||||
|
assert resp.source_name == "s2"
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 1 and stats.hedge_won is True and stats.attempts == 2
|
||||||
|
|
||||||
|
# 例二: 首 token 已至(假 transport 先 set 再挂起)→ 不触发,误杀慢生成即此处
|
||||||
|
transport2 = HedgeTransport({"s1": [("token_then_hang",)], "s2": [("succeed", "other")]})
|
||||||
|
mw2, _, _ = _harness([_src("s1"), _src("s2")], transport2)
|
||||||
|
ctx2 = _ctx()
|
||||||
|
|
||||||
|
async def release_later():
|
||||||
|
await asyncio.sleep(4 * _HEDGE_AFTER_S) # 4× 余量确认窗口已过
|
||||||
|
transport2.release["s1"].set()
|
||||||
|
|
||||||
|
releaser = asyncio.create_task(release_later())
|
||||||
|
resp2 = await asyncio.wait_for(mw2(_req(stream=True, ctx=ctx2)), timeout=5)
|
||||||
|
await releaser
|
||||||
|
assert resp2.source_name == "s1"
|
||||||
|
assert transport2.calls == ["s1"] # 对冲从未发出
|
||||||
|
stats2 = ctx2.snapshot()
|
||||||
|
assert stats2.hedges == 0 and stats2.hedge_won is False and stats2.attempts == 1
|
||||||
|
|
||||||
|
async def test_injected_clock_jump_does_not_trigger_hedge(self):
|
||||||
|
"""对冲触发只认真实 loop 钟: 注入钟跳 10^6 秒不得触发对冲(设计 §8 验收矩阵)。
|
||||||
|
|
||||||
|
s1 挂起剧本 + 触发窗内注入钟拨快 10^6 秒: 若触发路径误读注入钟,对冲会
|
||||||
|
**立即**发出;断言对冲实际发出时刻不早于真实 loop 阈值(下界断言,不断
|
||||||
|
精确值),形态同 test_client.py:1974 deadline 的注入钟对应用例。
|
||||||
|
"""
|
||||||
|
clock = FakeClock()
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedged")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, now=clock)
|
||||||
|
ctx = _ctx()
|
||||||
|
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
|
||||||
|
await transport.entered["s1"].wait() # 原路在途,触发窗计时中
|
||||||
|
clock.advance(1_000_000.0) # 跳变落在窗内: 误读注入钟即立刻触发
|
||||||
|
started = time.monotonic()
|
||||||
|
resp = await asyncio.wait_for(task, timeout=5)
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
assert resp.source_name == "s2" # 对冲确由真实 loop 阈值触发并截断长尾
|
||||||
|
assert transport.calls == ["s1", "s2"]
|
||||||
|
# 下界留 20% 调度余量;误读注入钟的触发是毫秒级,与此差一个数量级以上
|
||||||
|
assert elapsed >= _HEDGE_AFTER_S * 0.8
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 1 and stats.hedge_won is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeRouting:
|
||||||
|
"""异源排除与静默放弃(验收矩阵 ②③)。"""
|
||||||
|
|
||||||
|
async def test_hedge_goes_to_other_source(self):
|
||||||
|
"""对冲请求落在另一源;两 attempt 行共享同一 logical_call_id(②⑤)。"""
|
||||||
|
emitter = RecordingEmitter()
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedged")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
|
||||||
|
ctx = _ctx()
|
||||||
|
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||||
|
assert resp.source_name == "s2"
|
||||||
|
assert transport.calls == ["s1", "s2"] # 第二请求落在异源
|
||||||
|
# H5 接缝: 原路携带首 token 观测,对冲路恒 None(v1 单路,不再梯次)
|
||||||
|
assert [e is not None for e in transport.ft_events] == [True, False]
|
||||||
|
assert len(emitter.rows) == 2
|
||||||
|
assert {r["logical_call_id"] for r in emitter.rows} == {ctx.logical_call_id}
|
||||||
|
assert emitter.rows[0]["call_id"] != emitter.rows[1]["call_id"] # 各 attempt 独立 ID
|
||||||
|
|
||||||
|
async def test_hedge_silent_when_no_candidate(self):
|
||||||
|
"""异源配额被占满 → 准入失败静默放弃: 不对冲、不抛错、原请求照等(③)。"""
|
||||||
|
s1 = _src("s1")
|
||||||
|
s2 = _src("s2", max_concurrency=1)
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={"s1": s1, "s2": s2}, global_limits=_NO_GLOBAL, lease_ttl_s=100.0
|
||||||
|
)
|
||||||
|
held = await limiter.try_acquire("s2", 0) # 外部预占满 s2 并发
|
||||||
|
assert held is not None
|
||||||
|
try:
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "x")]})
|
||||||
|
mw, _, _ = _harness([s1, s2], transport, limiter=limiter)
|
||||||
|
ctx = _ctx()
|
||||||
|
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
|
||||||
|
await transport.entered["s1"].wait()
|
||||||
|
# 4× 余量: 给对冲窗与那次注定失败的准入留足发生时间
|
||||||
|
await asyncio.sleep(4 * _HEDGE_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 and stats.hedge_won is False
|
||||||
|
finally:
|
||||||
|
await held.release()
|
||||||
|
|
||||||
|
async def test_pick_exclude_all_is_not_a_rejection(self):
|
||||||
|
"""exclude 覆盖全源 → 返回 None 且 gate_rejections==0、reasons 不写(排除 ≠ 拒绝)。
|
||||||
|
|
||||||
|
admission 级直接钉(admission.py:192 `continue` 语义): 若未来重构把排除计入
|
||||||
|
gate_rejections,`on_no_runnable` 的"全源熔断类拒绝"判据会被污染,此钉当场报警。
|
||||||
|
"""
|
||||||
|
transport = HedgeTransport({"s1": [("succeed", "x")], "s2": [("succeed", "y")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport)
|
||||||
|
reasons = {"prior": "rate_limited"} # 既有原因须原样保留
|
||||||
|
picked, gate_rejections = await mw._admission.pick(
|
||||||
|
reasons, {}, exclude=frozenset({"s1", "s2"})
|
||||||
|
)
|
||||||
|
assert picked is None
|
||||||
|
assert gate_rejections == 0
|
||||||
|
assert reasons == {"prior": "rate_limited"}
|
||||||
|
|
||||||
|
async def test_hedge_silent_when_candidate_circuit_open(self):
|
||||||
|
"""对冲候选被熔断开路 → 静默放弃: 不对冲、不抛错、原请求照等(②③的另一形态)。
|
||||||
|
|
||||||
|
现有限流闸用例只钉了"配额占满"一条静默路径;开路/pacer 拒绝走 pick 的另一
|
||||||
|
分支(gate_rejections 计数、reasons 写 circuit_open、settle_and_release 后
|
||||||
|
返回 None),同样不得发出对冲请求。
|
||||||
|
"""
|
||||||
|
gate = InMemoryGate(config=_BREAKER)
|
||||||
|
entry = await gate.try_enter("s2", "test-owner")
|
||||||
|
assert entry.allowed
|
||||||
|
await gate.record_failure(entry, "source_dead", True) # SourceDead 一击即熔
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "x")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, gate=gate)
|
||||||
|
ctx = _ctx()
|
||||||
|
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
|
||||||
|
await transport.entered["s1"].wait()
|
||||||
|
# 4× 余量: 给对冲窗与那次注定被开路拒绝的准入留足发生时间
|
||||||
|
await asyncio.sleep(4 * _HEDGE_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 and stats.hedge_won is False
|
||||||
|
|
||||||
|
async def test_primary_done_during_hedge_admission_sends_no_hedge(self):
|
||||||
|
"""原路在对冲准入 await 期间已完成: 释放对冲准入直接裁定,一个对冲请求都不发。
|
||||||
|
|
||||||
|
剧本钉死窗口(禁 sleep 猜): s2 的 try_acquire 挂起(对冲准入停在闸内) →
|
||||||
|
放行 s1 → 轮询 s1 inflight 归零(_attempt finally 结算完,primary 必 done)
|
||||||
|
→ 此刻才放行对冲准入。pick 返回时原路已了结,编排必须不落 create_task。
|
||||||
|
"""
|
||||||
|
s1 = _src("s1", tpm=1000, est_tokens=400)
|
||||||
|
s2 = _src("s2", tpm=1000, est_tokens=400)
|
||||||
|
inner = InMemoryLimiter(
|
||||||
|
scope="llm",
|
||||||
|
sources={"s1": s1, "s2": s2},
|
||||||
|
global_limits=_NO_GLOBAL,
|
||||||
|
lease_ttl_s=100.0,
|
||||||
|
)
|
||||||
|
limiter = BlockingLimiter(inner, "s2")
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "hedge")]})
|
||||||
|
mw, _, _ = _harness([s1, s2], transport, limiter=limiter)
|
||||||
|
ctx = _ctx()
|
||||||
|
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
|
||||||
|
await transport.entered["s1"].wait() # 原路在途
|
||||||
|
await limiter.acquiring.wait() # 对冲准入停在 s2 闸内(触发窗已过)
|
||||||
|
transport.release["s1"].set() # 原路放行完成
|
||||||
|
while (await inner.source_stats("s1")).inflight != 0:
|
||||||
|
await asyncio.sleep(0.001) # 结算完 = primary 已 done(同一任务步内返回)
|
||||||
|
limiter.allow.set() # pick 此刻才返回: primary.done() 已成立
|
||||||
|
resp = await asyncio.wait_for(task, timeout=5)
|
||||||
|
assert resp.content == "ok-s1" and resp.source_name == "s1"
|
||||||
|
assert transport.calls == ["s1"] # 对冲 HTTP 从未发出(未修前这里会看到 s2)
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
|
||||||
|
s2_stats = await inner.source_stats("s2")
|
||||||
|
assert s2_stats.inflight == 0 and s2_stats.tpm_used == 0 # 对冲准入按 0 结算释放
|
||||||
|
|
||||||
|
async def test_hedge_silent_when_single_source(self):
|
||||||
|
"""单源 scope: 运行期拿不到异源候选自然静默,行为与不配阈值逐字相同(②)。"""
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)]})
|
||||||
|
mw, _, _ = _harness([_src("s1")], transport)
|
||||||
|
ctx = _ctx()
|
||||||
|
task = asyncio.ensure_future(mw(_req(ctx=ctx)))
|
||||||
|
await transport.entered["s1"].wait()
|
||||||
|
await asyncio.sleep(4 * _HEDGE_AFTER_S) # 窗口已过,仍无候选
|
||||||
|
assert transport.calls == ["s1"]
|
||||||
|
transport.release["s1"].set()
|
||||||
|
resp = await asyncio.wait_for(task, timeout=5)
|
||||||
|
assert resp.content == "ok-s1"
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 0 and stats.attempts == 1 and stats.hedge_won is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeSettlementAndSignals:
|
||||||
|
"""赢输记账与熔断/健康信号(验收矩阵 ④⑤;设计 §3 关键判断: 挂起 ≠ 源死亡)。"""
|
||||||
|
|
||||||
|
async def test_winner_settles_actual_loser_keeps_est(self):
|
||||||
|
"""赢家按真实 usage 结算;输家取消落 1.3.6 S3 格: est 预扣保留(④)。"""
|
||||||
|
s1 = _src("s1", tpm=1000, est_tokens=400)
|
||||||
|
s2 = _src("s2", tpm=1000, est_tokens=400)
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
|
||||||
|
mw, limiter, _ = _harness([s1, s2], transport)
|
||||||
|
resp = await asyncio.wait_for(mw(_req()), timeout=5)
|
||||||
|
assert resp.source_name == "s2"
|
||||||
|
winner_stats = await limiter.source_stats("s2")
|
||||||
|
loser_stats = await limiter.source_stats("s1")
|
||||||
|
assert winner_stats.tpm_used == 15 # 预扣 400,实测 10+5 → settle 后只记 15
|
||||||
|
assert loser_stats.tpm_used == 400 # 输家 est 保留(可能被上游计费,保守下限)
|
||||||
|
assert winner_stats.inflight == 0 and loser_stats.inflight == 0
|
||||||
|
|
||||||
|
async def test_loser_row_labelled_hedge_cancelled(self):
|
||||||
|
"""输家 attempt 行 error=='hedge_cancelled',赢家行无 error,同行逻辑调用(④⑤)。
|
||||||
|
|
||||||
|
终态行是 client 级语义且成功调用本就不写终态行(emit_terminal_once 只在
|
||||||
|
异常/取消路径触发),MW 级可观测面即这两条 attempt 行。
|
||||||
|
"""
|
||||||
|
emitter = RecordingEmitter()
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
|
||||||
|
ctx = _ctx()
|
||||||
|
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||||
|
assert len(emitter.rows) == 2
|
||||||
|
loser = next(r for r in emitter.rows if r["source"] == "s1")
|
||||||
|
winner = next(r for r in emitter.rows if r["source"] == "s2")
|
||||||
|
assert loser["error"] == "hedge_cancelled"
|
||||||
|
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()
|
||||||
|
gate = InMemoryGate(config=_BREAKER)
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, selector=selector, gate=gate)
|
||||||
|
await asyncio.wait_for(mw(_req()), timeout=5)
|
||||||
|
gate_s1 = gate._gates["s1"]
|
||||||
|
assert gate_s1.a0 + gate_s1.a1 == 0 # 熔断失败率窗口无样本
|
||||||
|
assert selector.outcomes == [("s2", True)] # 健康喂数只有赢家的成功
|
||||||
|
assert (await gate.try_enter("s1", "w")).allowed # 挂起源未被标记
|
||||||
|
|
||||||
|
async def test_attempts_two_and_no_task_leak(self):
|
||||||
|
"""快照 attempts==2(含输家);返回后无本调用残留任务(⑤)。"""
|
||||||
|
before = asyncio.all_tasks()
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("succeed", "win")]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport)
|
||||||
|
ctx = _ctx()
|
||||||
|
await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||||
|
assert ctx.snapshot().attempts == 2
|
||||||
|
assert asyncio.all_tasks() == before
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeCancellation:
|
||||||
|
"""取消穿透(⑦)与期限组合(⑧): 两任务同消、不 shield、不留后台任务。"""
|
||||||
|
|
||||||
|
async def test_external_cancel_cancels_both_legs(self):
|
||||||
|
"""两路均在途时外部取消: CancelledError 上抛,两 permit 释放。
|
||||||
|
|
||||||
|
输家标记只在赢家产生后才置位——外部取消下没有赢家,两行都是普通
|
||||||
|
"cancelled"(⑦;竞速误贴属设计 §4.5 已批准残留)。
|
||||||
|
"""
|
||||||
|
emitter = RecordingEmitter()
|
||||||
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("hang",)]})
|
||||||
|
mw, limiter, _ = _harness([s1, s2], transport, emitter=emitter)
|
||||||
|
task = asyncio.ensure_future(mw(_req()))
|
||||||
|
# 双 Event 栅栏: 确认对冲已触发、两路均在途,再取消(禁 sleep 猜窗口)
|
||||||
|
await transport.entered["s1"].wait()
|
||||||
|
await transport.entered["s2"].wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert {r["source"]: r["error"] for r in emitter.rows} == {
|
||||||
|
"s1": "cancelled",
|
||||||
|
"s2": "cancelled",
|
||||||
|
}
|
||||||
|
assert (await limiter.source_stats("s1")).inflight == 0
|
||||||
|
assert (await limiter.source_stats("s2")).inflight == 0
|
||||||
|
|
||||||
|
async def test_deadline_cuts_hedged_tree(self):
|
||||||
|
"""client 级 call_deadline_s=0.2 + 两路挂起 → CallDeadlineExceeded,permit 全释放(⑧)。"""
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
|
||||||
|
s1, s2 = _src("s1"), _src("s2")
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={"s1": s1, "s2": s2}, global_limits=_NO_GLOBAL
|
||||||
|
)
|
||||||
|
transport = HedgeTransport({"s1": [("hang",)], "s2": [("hang",)]})
|
||||||
|
client = _client(
|
||||||
|
sources=[s1, s2],
|
||||||
|
transport=transport,
|
||||||
|
limiter=limiter,
|
||||||
|
call_deadline_s=0.2,
|
||||||
|
hedge_after_s=_HEDGE_AFTER_S,
|
||||||
|
)
|
||||||
|
async with client:
|
||||||
|
with pytest.raises(CallDeadlineExceeded):
|
||||||
|
await client.chat([{"role": "user", "content": "hi"}], stream=False)
|
||||||
|
assert transport.calls == ["s1", "s2"] # 期限截止前对冲确已触发
|
||||||
|
assert (await limiter.source_stats("s1")).inflight == 0
|
||||||
|
assert (await limiter.source_stats("s2")).inflight == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeWinnerAdjudication:
|
||||||
|
"""赢家裁定(⑩): 取快者,含原路后发先至的对称面。"""
|
||||||
|
|
||||||
|
async def test_primary_late_success_wins_back(self):
|
||||||
|
"""s1 挂 0.3s(6× 阈值)后成功、s2 对冲路在途: 原路先完成 → 原路赢。"""
|
||||||
|
emitter = RecordingEmitter()
|
||||||
|
transport = HedgeTransport({"s1": [("succeed_after", 0.3, "late")], "s2": [("hang",)]})
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, emitter=emitter)
|
||||||
|
ctx = _ctx()
|
||||||
|
resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5)
|
||||||
|
assert resp.content == "late" and resp.source_name == "s1"
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 1 and stats.hedge_won is False
|
||||||
|
# 裸生成时间为原路那次 transport 时长(≈300ms,只断言下界与相对关系)
|
||||||
|
assert 250 <= stats.generation_ms <= stats.total_latency_ms
|
||||||
|
loser = next(r for r in emitter.rows if r["source"] == "s2")
|
||||||
|
assert loser["error"] == "hedge_cancelled" # 在途对冲路被裁为输家
|
||||||
|
|
||||||
|
|
||||||
|
class TestHedgeFailureCombination:
|
||||||
|
"""两败汇合(H6): 只计一次重试预算;429 分账逐字沿用 attempt 级机制。"""
|
||||||
|
|
||||||
|
async def test_both_fail_counts_budget_once(self):
|
||||||
|
"""两路 Transient: max_attempts=2 时恰进第二轮(两败只计一次),第二轮两败后才耗尽。"""
|
||||||
|
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=2)
|
||||||
|
ctx = _ctx()
|
||||||
|
with pytest.raises(AllSourcesExhausted) as ei:
|
||||||
|
await mw(_req(ctx=ctx))
|
||||||
|
assert ei.value.reason == "retry_exhausted"
|
||||||
|
# 若两败计两次预算,第一轮即耗尽,这些调用根本不会发生
|
||||||
|
assert transport.calls == ["s1", "s2", "s1", "s2"]
|
||||||
|
# 两败轮次同样登记对冲路数(设计 §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 而非耗尽。"""
|
||||||
|
|
||||||
|
def _429():
|
||||||
|
return TransientError("throttled", status_code=429, retry_after_s=0.01)
|
||||||
|
|
||||||
|
transport = HedgeTransport(
|
||||||
|
{"s1": [("fail_after", 0.1, _429)], "s2": [("fail_after", 0.12, _429)]}
|
||||||
|
)
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1, stall_window_s=0.3)
|
||||||
|
with pytest.raises(AllSourcesExhausted) as ei:
|
||||||
|
await mw(_req())
|
||||||
|
# max_attempts=1: 任一路计预算都会当场 retry_exhausted;
|
||||||
|
# 两 429 免预算 → 循环到 stall 窗口判死
|
||||||
|
assert ei.value.reason == "stalled"
|
||||||
|
|
||||||
|
async def test_mixed_429_and_failure_counts_budget(self):
|
||||||
|
"""一路 429 一路 Transient → 计一次预算、不退还 stall 账(_combine_failures)。"""
|
||||||
|
transport = HedgeTransport(
|
||||||
|
{
|
||||||
|
"s1": [
|
||||||
|
(
|
||||||
|
"fail_after",
|
||||||
|
0.1,
|
||||||
|
lambda: TransientError("rl", status_code=429, retry_after_s=0.01),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
"s2": [("fail_after", 0.12, lambda: TransientError("boom", source_name="s2"))],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mw, _, _ = _harness([_src("s1"), _src("s2")], transport, max_attempts=1, stall_window_s=0.3)
|
||||||
|
with pytest.raises(AllSourcesExhausted) as ei:
|
||||||
|
await mw(_req())
|
||||||
|
assert ei.value.reason == "retry_exhausted"
|
||||||
|
assert transport.calls == ["s1", "s2"] # 恰一轮两路: 计一次预算即耗尽
|
||||||
@@ -277,6 +277,9 @@ async def _complete(observed, *, call_id="a", stream=False):
|
|||||||
overlay={},
|
overlay={},
|
||||||
call_id=call_id,
|
call_id=call_id,
|
||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1266,6 +1269,9 @@ async def test_structured_first_attempt_requires_exact_initial_messages():
|
|||||||
overlay={},
|
overlay={},
|
||||||
call_id="first",
|
call_id="first",
|
||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
|
event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0]
|
||||||
assert not request_is_valid(event)
|
assert not request_is_valid(event)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from polygateway.backends.memory.breaker import InMemoryGate
|
|||||||
from polygateway.backends.memory.limiter import InMemoryLimiter
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
from polygateway.errors import (
|
from polygateway.errors import (
|
||||||
AllSourcesExhausted,
|
AllSourcesExhausted,
|
||||||
|
CallDeadlineExceeded,
|
||||||
CircuitOpenError,
|
CircuitOpenError,
|
||||||
RequestRejectedError,
|
RequestRejectedError,
|
||||||
ResultInvalidError,
|
ResultInvalidError,
|
||||||
@@ -60,6 +61,8 @@ class ScriptedOcrTransport:
|
|||||||
def __init__(self, script):
|
def __init__(self, script):
|
||||||
self.script = list(script)
|
self.script = list(script)
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
# 取消用例的确定性窗口: 进入 hang 分支即置位, 不用 sleep 撞窗口
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
|
||||||
async def _next(self, method, source, call_id):
|
async def _next(self, method, source, call_id):
|
||||||
self.calls.append((method, source.name, call_id))
|
self.calls.append((method, source.name, call_id))
|
||||||
@@ -67,6 +70,7 @@ class ScriptedOcrTransport:
|
|||||||
if isinstance(action, Exception):
|
if isinstance(action, Exception):
|
||||||
raise action
|
raise action
|
||||||
if action == "hang":
|
if action == "hang":
|
||||||
|
self.entered.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
return _TEXT_OK if action == "text" else _LAYOUT_OK
|
return _TEXT_OK if action == "text" else _LAYOUT_OK
|
||||||
|
|
||||||
@@ -192,6 +196,21 @@ class TestSuccessPaths:
|
|||||||
await client.parse_layout(b"")
|
await client.parse_layout(b"")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcrGenerationMs:
|
||||||
|
"""OCR 裸生成时间单次覆盖(1.3.7 H8): 计时只包 transport 调用本身。"""
|
||||||
|
|
||||||
|
async def test_generation_ms_single_transport_call(self):
|
||||||
|
# 0.5s 为二进制可精确表示值: int 截断下非精确值会因浮点误差少 1ms
|
||||||
|
clock = FakeClock()
|
||||||
|
transport = ClockAdvancingOcrTransport([(0.5, "text")], clock)
|
||||||
|
client, _, _ = _client([_src()], [], now=clock, transport=transport)
|
||||||
|
r = await client.recognize_text(b"jpg")
|
||||||
|
assert r.text == "LINE-1"
|
||||||
|
assert r.call_stats is not None
|
||||||
|
assert r.call_stats.attempts == 1
|
||||||
|
assert r.call_stats.generation_ms == 500
|
||||||
|
|
||||||
|
|
||||||
class TestFailover:
|
class TestFailover:
|
||||||
async def test_transient_retries_with_backoff(self):
|
async def test_transient_retries_with_backoff(self):
|
||||||
sleeps = []
|
sleeps = []
|
||||||
@@ -395,6 +414,20 @@ class TestCancellation:
|
|||||||
stats = await limiter.source_stats("m1")
|
stats = await limiter.source_stats("m1")
|
||||||
assert stats.inflight == 0 # permit 在 finally 释放
|
assert stats.inflight == 0 # permit 在 finally 释放
|
||||||
|
|
||||||
|
async def test_cancel_in_flight_still_settles_zero(self):
|
||||||
|
"""S6: OCR 的 0 token 是**事实**而非"未知", 取消也不得改成按 est 结算。"""
|
||||||
|
transport = ScriptedOcrTransport(["hang"])
|
||||||
|
client, limiter, _ = _client(
|
||||||
|
[_src(max_concurrency=1, tpm=1000, est_tokens=400)], [], transport=transport
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(client.recognize_text(b"jpg"))
|
||||||
|
await transport.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
stats = await limiter.source_stats("m1")
|
||||||
|
assert stats.tpm_used == 0 and stats.inflight == 0
|
||||||
|
|
||||||
|
|
||||||
class TestCheckHealth:
|
class TestCheckHealth:
|
||||||
class _HealthTransport(ScriptedOcrTransport):
|
class _HealthTransport(ScriptedOcrTransport):
|
||||||
@@ -652,3 +685,29 @@ class TestOcrLogicalCallStats:
|
|||||||
await client.recognize_text("not-bytes")
|
await client.recognize_text("not-bytes")
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await client.recognize_text(b"")
|
await client.recognize_text(b"")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOcrCallDeadline:
|
||||||
|
"""OCR 两个公开入口的期限与 per-call 校验(计划 §3.4/§5 批次 D/E)。"""
|
||||||
|
|
||||||
|
async def test_expiry_on_a_hanging_transport(self):
|
||||||
|
client, limiter, _ = _client([_src()], ["hang"])
|
||||||
|
with pytest.raises(CallDeadlineExceeded) as exc:
|
||||||
|
await client.recognize_text(b"jpg", call_deadline_s=0.05)
|
||||||
|
assert exc.value.scope == "ocr" and exc.value.deadline_s == 0.05
|
||||||
|
# 清理照常在 finally 完成: 在途计数必须归零(OCR 无 token,结算恒 0)
|
||||||
|
stats = await limiter.source_stats("m1")
|
||||||
|
assert stats.inflight == 0 and stats.tpm_used == 0
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["recognize_text", "parse_layout"])
|
||||||
|
async def test_illegal_per_call_value_names_the_entry_it_came_from(self, method):
|
||||||
|
"""两个入口各自报自己的名字: 多入口部署里才定位得到是哪次调用传错了。"""
|
||||||
|
transport = ScriptedOcrTransport([])
|
||||||
|
client, _, _ = _client([_src()], [], transport=transport)
|
||||||
|
with pytest.raises(ValueError, match=rf"{method}\(call_deadline_s"):
|
||||||
|
await getattr(client, method)(b"jpg", call_deadline_s=float("inf"))
|
||||||
|
assert transport.calls == []
|
||||||
|
|
||||||
|
def test_illegal_constructor_value_is_rejected_at_assembly(self):
|
||||||
|
with pytest.raises(ValueError, match=r"OcrClient\(call_deadline_s"):
|
||||||
|
_client([_src()], [], call_deadline_s=0)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。
|
SSE 帧样本按三项目真实网关响应形态二次构造(OpenAI 兼容 chunk 结构)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -14,6 +15,7 @@ from polygateway.errors import (
|
|||||||
SourceDeadError,
|
SourceDeadError,
|
||||||
TransientError,
|
TransientError,
|
||||||
)
|
)
|
||||||
|
from polygateway.middleware.retry import backoff_delay
|
||||||
from polygateway.middleware.telemetry import TelemetryEmitter
|
from polygateway.middleware.telemetry import TelemetryEmitter
|
||||||
from polygateway.pricing import ModelPrice, PricingTable
|
from polygateway.pricing import ModelPrice, PricingTable
|
||||||
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
from polygateway.providers import ProviderProfile, ThinkingWire, register_provider
|
||||||
@@ -21,9 +23,19 @@ from polygateway.transports._http_errors import summarize_body
|
|||||||
from polygateway.transports.openai_compat import (
|
from polygateway.transports.openai_compat import (
|
||||||
OpenAICompatTransport,
|
OpenAICompatTransport,
|
||||||
_iter_sse_deltas,
|
_iter_sse_deltas,
|
||||||
|
_parse_retry_after,
|
||||||
_sse_data_payload,
|
_sse_data_payload,
|
||||||
|
_stall_from_chunk_times,
|
||||||
|
_translate_429,
|
||||||
|
)
|
||||||
|
from polygateway.types import (
|
||||||
|
ChatRequest,
|
||||||
|
Effort,
|
||||||
|
LLMResponse,
|
||||||
|
RetryPolicy,
|
||||||
|
SourceConfig,
|
||||||
|
ThinkingObservation,
|
||||||
)
|
)
|
||||||
from polygateway.types import ChatRequest, Effort, LLMResponse, SourceConfig, ThinkingObservation
|
|
||||||
|
|
||||||
|
|
||||||
def _source(**overrides):
|
def _source(**overrides):
|
||||||
@@ -69,7 +81,17 @@ def _transport_for(handler, *, registry=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _complete(transport, source, *, stream=True, overlay=None, reasoning_effort=None):
|
async def _complete(
|
||||||
|
transport,
|
||||||
|
source,
|
||||||
|
*,
|
||||||
|
stream=True,
|
||||||
|
overlay=None,
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
|
):
|
||||||
return await transport.complete(
|
return await transport.complete(
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
source=source,
|
source=source,
|
||||||
@@ -77,9 +99,21 @@ async def _complete(transport, source, *, stream=True, overlay=None, reasoning_e
|
|||||||
overlay=overlay or {},
|
overlay=overlay or {},
|
||||||
call_id="cid-1",
|
call_id="cid-1",
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
|
first_token_event=first_token_event,
|
||||||
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_complete_helper_forwards_response_mode():
|
||||||
|
"""测试封装不得将显式保真模式静默退化成文本模式。"""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
transport = AsyncMock()
|
||||||
|
await _complete(transport, _source(), response_mode="openai")
|
||||||
|
assert transport.complete.call_args.kwargs["response_mode"] == "openai"
|
||||||
|
|
||||||
|
|
||||||
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
|
# 单价刻意取"输出贵于输入"的真实形态: est_tokens 兜底把整估值塞进 completion
|
||||||
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
|
# 时,虚高才显形(设计 §1 的 26 倍算例即此单价)。
|
||||||
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
_PRICING = PricingTable({"qwen-max": ModelPrice(input_per_1m=1.0, output_per_1m=8.0)})
|
||||||
@@ -138,6 +172,8 @@ class TestSsePureFunctions:
|
|||||||
assert _sse_data_payload("data: [DONE]") == "[DONE]"
|
assert _sse_data_payload("data: [DONE]") == "[DONE]"
|
||||||
|
|
||||||
async def test_iter_deltas_yields_and_flags_done(self):
|
async def test_iter_deltas_yields_and_flags_done(self):
|
||||||
|
from polygateway.transports._openai_completion import ChatCompletionAccumulator, ChatDelta
|
||||||
|
|
||||||
async def lines():
|
async def lines():
|
||||||
for raw in [
|
for raw in [
|
||||||
_chunk(content="he"),
|
_chunk(content="he"),
|
||||||
@@ -150,16 +186,28 @@ class TestSsePureFunctions:
|
|||||||
yield line
|
yield line
|
||||||
|
|
||||||
sink = {}
|
sink = {}
|
||||||
out = [d async for d in _iter_sse_deltas(lines(), sink)]
|
out = [
|
||||||
assert out == [(True, "he"), (False, "think")]
|
d
|
||||||
|
async for d in _iter_sse_deltas(
|
||||||
|
lines(), sink, ChatCompletionAccumulator(response_mode="text")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert out == [ChatDelta("he", "", True), ChatDelta("", "think", True)]
|
||||||
assert sink["done"] is True and sink["usage"] == _USAGE
|
assert sink["done"] is True and sink["usage"] == _USAGE
|
||||||
|
|
||||||
async def test_malformed_frame_raises_transient(self):
|
async def test_malformed_frame_raises_transient(self):
|
||||||
|
from polygateway.transports._openai_completion import ChatCompletionAccumulator
|
||||||
|
|
||||||
async def lines():
|
async def lines():
|
||||||
yield "data: {not-json"
|
yield "data: {not-json"
|
||||||
|
|
||||||
with pytest.raises(TransientError, match="malformed"):
|
with pytest.raises(TransientError, match="malformed"):
|
||||||
_ = [d async for d in _iter_sse_deltas(lines(), {})]
|
_ = [
|
||||||
|
d
|
||||||
|
async for d in _iter_sse_deltas(
|
||||||
|
lines(), {}, ChatCompletionAccumulator(response_mode="text")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestStreamHappyPath:
|
class TestStreamHappyPath:
|
||||||
@@ -204,6 +252,254 @@ class TestStreamHappyPath:
|
|||||||
assert await _recorded_cost(result, source) is None
|
assert await _recorded_cost(result, source) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirstTokenEvent:
|
||||||
|
"""首 token 处置位(1.3.7 对冲 H2): 流式置位、非流式永不置位、None 不观测。"""
|
||||||
|
|
||||||
|
async def test_stream_sets_first_token_event(self):
|
||||||
|
"""流式首 token(内容或思考增量)到达即置位——对冲触发窗的取消信号。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="ponder"), _chunk(content="hi"), _chunk(usage=_USAGE)
|
||||||
|
)
|
||||||
|
|
||||||
|
event = asyncio.Event()
|
||||||
|
result = await _complete(_transport_for(handler), _source(), first_token_event=event)
|
||||||
|
assert result.content == "hi"
|
||||||
|
assert event.is_set()
|
||||||
|
|
||||||
|
async def test_non_stream_never_sets_first_token_event(self):
|
||||||
|
"""非流式物理上无中途信号: 即使调用方给了事件,本路径也永不置位。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
)
|
||||||
|
|
||||||
|
event = asyncio.Event()
|
||||||
|
result = await _complete(
|
||||||
|
_transport_for(handler), _source(), stream=False, first_token_event=event
|
||||||
|
)
|
||||||
|
assert result.content == "42"
|
||||||
|
assert not event.is_set()
|
||||||
|
|
||||||
|
async def test_none_first_token_event_keeps_behavior(self):
|
||||||
|
"""`None` = 调用方不观测首 token(未启用对冲): 行为与旧版逐字相同。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), first_token_event=None)
|
||||||
|
assert result.content == "ok"
|
||||||
|
assert result.ttft_ms is not None
|
||||||
|
|
||||||
|
async def test_first_token_event_is_required_keyword(self):
|
||||||
|
"""端口必填约定: 漏传必须 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CountingEvent(asyncio.Event):
|
||||||
|
"""统计 set() 次数的 Event: content_event "每增量 chunk 置位一次"的观测设施。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.set_count = 0
|
||||||
|
|
||||||
|
def set(self) -> None:
|
||||||
|
self.set_count += 1
|
||||||
|
super().set()
|
||||||
|
|
||||||
|
|
||||||
|
def _gated_sse_response(*items):
|
||||||
|
"""事件驱动的 SSE 响应体: str 段直接产出,asyncio.Event 段等待置位。
|
||||||
|
|
||||||
|
停顿由测试体用真实 `asyncio.sleep` 制造(计划 §5 真实 loop 钟范式,4× 余量);
|
||||||
|
闸门只决定"停顿发生在哪两帧之间",不断定时长,两个方向都不撞窗口。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, asyncio.Event):
|
||||||
|
await item.wait()
|
||||||
|
else:
|
||||||
|
yield item.encode()
|
||||||
|
|
||||||
|
return httpx.Response(200, content=gen(), headers={"content-type": "text/event-stream"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestContentEvent:
|
||||||
|
"""增量 chunk 处置位(1.3.8, issue #27/#28): 中段沉默计量与对冲的信号源。"""
|
||||||
|
|
||||||
|
async def test_stream_sets_content_event_per_chunk(self):
|
||||||
|
"""流式每个增量 chunk(内容或思考)置位一次——对冲触发窗的沉默复位信号。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="ponder"),
|
||||||
|
_chunk(content="hi"),
|
||||||
|
_chunk(content=" there"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
event = _CountingEvent()
|
||||||
|
result = await _complete(_transport_for(handler), _source(), content_event=event)
|
||||||
|
assert result.content == "hi there"
|
||||||
|
assert event.set_count == 3 # 3 个增量 chunk 各置位一次
|
||||||
|
|
||||||
|
async def test_reasoning_chunk_counts_as_liveness(self):
|
||||||
|
"""reasoning_content 增量同样置位并进 chunk 序列(B1: 思考增量是存活证据)。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(
|
||||||
|
_chunk(reasoning="step1"),
|
||||||
|
_chunk(reasoning="step2"),
|
||||||
|
_chunk(content="ok"),
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
)
|
||||||
|
|
||||||
|
event = _CountingEvent()
|
||||||
|
result = await _complete(_transport_for(handler), _source(), content_event=event)
|
||||||
|
assert result.thinking == "step1step2"
|
||||||
|
assert event.set_count == 3
|
||||||
|
# 快速投递下零超阈沉默: "观测过且零沉默"(0.0) ≠ "无从观测"(None);
|
||||||
|
# 两值为 0.0 而非 None 同时证明 chunk 序列 ≥2(思考增量也入了序列)
|
||||||
|
assert result.stall_ms == 0.0 and result.stall_count == 0.0
|
||||||
|
|
||||||
|
async def test_non_stream_never_sets_content_event(self):
|
||||||
|
"""非流式无中途信号: 给了事件也永不置位;stall 两值恒 None(排除条款)。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "42"}}], "usage": _USAGE}
|
||||||
|
)
|
||||||
|
|
||||||
|
event = _CountingEvent()
|
||||||
|
result = await _complete(
|
||||||
|
_transport_for(handler), _source(), stream=False, content_event=event
|
||||||
|
)
|
||||||
|
assert result.content == "42"
|
||||||
|
assert event.set_count == 0
|
||||||
|
assert result.stall_ms is None and result.stall_count is None
|
||||||
|
|
||||||
|
async def test_none_content_event_keeps_behavior(self):
|
||||||
|
"""`None` = 调用方不观测增量 chunk(未启用中段对冲): 行为与旧版逐字相同。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
result = await _complete(_transport_for(handler), _source(), content_event=None)
|
||||||
|
assert result.content == "ok"
|
||||||
|
assert result.ttft_ms is not None
|
||||||
|
|
||||||
|
async def test_content_event_is_required_keyword(self):
|
||||||
|
"""端口必填约定同 first_token_event: 漏传必须 TypeError。"""
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _sse_stream(_chunk(content="ok"), _chunk(usage=_USAGE))
|
||||||
|
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
await transport.complete(
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
source=_source(),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="cid-1",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStallFromChunkTimes:
|
||||||
|
"""沉默计量纯函数(设计 §4.2): 严格大于阈值才减、chunk<2 排除、尾部不计。"""
|
||||||
|
|
||||||
|
def test_gap_above_threshold_accumulates(self):
|
||||||
|
stall_ms, stall_count = _stall_from_chunk_times([0.0, 0.3, 2.0, 2.1], 1000)
|
||||||
|
assert stall_ms == pytest.approx(1700.0) # 仅 0.3→2.0 的 1.7s 段超阈
|
||||||
|
assert stall_count == 1.0
|
||||||
|
|
||||||
|
def test_gap_exactly_at_threshold_not_deducted(self):
|
||||||
|
"""间隔恰好 == 阈值不减(严格大于,pi-tps 口径)。"""
|
||||||
|
assert _stall_from_chunk_times([0.0, 1.0], 1000) == (0.0, 0.0)
|
||||||
|
|
||||||
|
def test_fewer_than_two_chunks_returns_none(self):
|
||||||
|
"""0/1 个 chunk → (None, None): 无从观测,不得伪造 0(vLLM 排除条款)。"""
|
||||||
|
assert _stall_from_chunk_times([], 1000) == (None, None)
|
||||||
|
assert _stall_from_chunk_times([0.5], 1000) == (None, None)
|
||||||
|
|
||||||
|
async def test_tail_not_counted(self):
|
||||||
|
"""末 chunk → 流结束(usage/[DONE])的间隔不进 stall(尾部归活跃侧,N1)。"""
|
||||||
|
gate = asyncio.Event()
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _gated_sse_response(
|
||||||
|
_chunk(content="a"),
|
||||||
|
_chunk(content="b"),
|
||||||
|
gate,
|
||||||
|
_chunk(usage=_USAGE),
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_after_pause():
|
||||||
|
await asyncio.sleep(0.2) # 真实尾部停顿 200ms(阈值 50ms 的 4×;方向安全: 只会更大)
|
||||||
|
gate.set()
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
|
||||||
|
)
|
||||||
|
await asyncio.gather(task, open_after_pause())
|
||||||
|
result = task.result()
|
||||||
|
assert result.content == "ab"
|
||||||
|
# 唯一的相邻 chunk 间隔是快速段;200ms 的尾部停顿若被计入即超阈暴露
|
||||||
|
assert result.stall_ms == 0.0 and result.stall_count == 0.0
|
||||||
|
|
||||||
|
async def test_empty_or_usage_frames_not_timed(self):
|
||||||
|
"""空 chunk/usage 帧/keepalive 不产生时间戳记录点: 三段停顿只算一次 chunk 间隔。"""
|
||||||
|
gates = [asyncio.Event() for _ in range(3)]
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
return _gated_sse_response(
|
||||||
|
_chunk(content="a"),
|
||||||
|
gates[0],
|
||||||
|
'data: {"choices":[{"delta":{}}]}\n\n', # 空 chunk
|
||||||
|
gates[1],
|
||||||
|
": ping\n\n", # SSE keepalive
|
||||||
|
gates[2],
|
||||||
|
_chunk(usage=_USAGE), # usage-only 帧
|
||||||
|
_chunk(content="b"),
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def open_gates():
|
||||||
|
for gate in gates:
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
gate.set()
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
_complete(_transport_for(handler), _source(stall_threshold_ms=50), content_event=None)
|
||||||
|
)
|
||||||
|
await asyncio.gather(task, open_gates())
|
||||||
|
result = task.result()
|
||||||
|
assert result.content == "ab"
|
||||||
|
# 三个非增量帧若被计时,三段 200ms 间隔各自超阈(count==3);
|
||||||
|
# 真相: chunk1→chunk2 仅一个间隔(~600ms),count==1
|
||||||
|
assert result.stall_count == 1.0
|
||||||
|
assert result.stall_ms >= 400.0 # 下界证明停顿真实发生(慢机器只会更大,不设上界)
|
||||||
|
|
||||||
|
|
||||||
class TestMissingDoneSemantics:
|
class TestMissingDoneSemantics:
|
||||||
def _no_done_handler(self, request):
|
def _no_done_handler(self, request):
|
||||||
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
|
return _sse_stream(_chunk(content="partial"), _chunk(usage=_USAGE), done=False)
|
||||||
@@ -1144,3 +1440,67 @@ async def test_custom_profile_raw_roots_cannot_override_managed_intent(key):
|
|||||||
assert sent == []
|
assert sent == []
|
||||||
finally:
|
finally:
|
||||||
await transport.aclose()
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryAfterNonFinite:
|
||||||
|
"""F1: `Retry-After` 非有限值必须当作"无提示"(计划 §3.6 / §5 批次 G)。
|
||||||
|
|
||||||
|
`float("inf")` 能被 `float()` 成功解析,又能通过既有的 `seconds > 0`——
|
||||||
|
它会一路变成 `retry_after_s=inf`,而 `backoff_delay` 的 `max(delay, retry_after)`
|
||||||
|
取大之后就是一次**永不醒来**的退避 sleep(库不夹 `backoff_max_s`)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _translate(self, raw, *, name="qwen_1"):
|
||||||
|
return _translate_429(_source(name=name), "{}", {"retry-after": raw}, {"body_text": "{}"})
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["inf", "-inf", "1e999", "Infinity"])
|
||||||
|
def test_non_finite_becomes_no_hint_with_exactly_one_warning(self, raw):
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
exc = self._translate(raw)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert exc.retry_after_s is None
|
||||||
|
hits = [m for m in messages if "retry_after_not_finite" in m]
|
||||||
|
assert len(hits) == 1
|
||||||
|
assert "qwen_1" in hits[0] # 告警要能定位到源
|
||||||
|
assert raw not in hits[0] # 但不回显原始头字符串(不拼接、不截断)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw", ["nan", "", " ", "-1", "0", "Wed, 21 Oct 2026 07:28:00 GMT", "soon"]
|
||||||
|
)
|
||||||
|
def test_other_unusable_values_stay_silent(self, raw):
|
||||||
|
"""429 风暴下逐次告警会淹掉真信号: 只有非有限值这一类新增告警。
|
||||||
|
|
||||||
|
`nan` 仍走既有的 `seconds > 0` 恒假值语义,本版**不为它新增分支**。
|
||||||
|
"""
|
||||||
|
messages: list[str] = []
|
||||||
|
sink_id = logger.add(messages.append, level="WARNING")
|
||||||
|
try:
|
||||||
|
exc = self._translate(raw)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
assert exc.retry_after_s is None
|
||||||
|
assert [m for m in messages if "retry_after_not_finite" in m] == []
|
||||||
|
|
||||||
|
def test_finite_positive_still_reaches_backoff(self):
|
||||||
|
"""有限正数一字不改地保留,并照常参与 `max(delay, retry_after)` 取大。"""
|
||||||
|
exc = self._translate("2.5")
|
||||||
|
assert exc.retry_after_s == 2.5
|
||||||
|
policy = RetryPolicy(max_attempts=3, backoff_base_s=0.001, backoff_max_s=0.01)
|
||||||
|
assert backoff_delay(policy, 1, exc, lambda: 0.5) == 2.5
|
||||||
|
# 而非有限值被吃掉之后,退避退回纯指数,不会变成永不醒来的 sleep
|
||||||
|
assert backoff_delay(policy, 1, self._translate("inf"), lambda: 0.5) < 1.0
|
||||||
|
|
||||||
|
def test_source_name_is_a_required_keyword(self):
|
||||||
|
"""私有函数的必填 kw: 漏传即 `TypeError`,不给默认值掩盖调用点漏改。"""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
_parse_retry_after("2.5")
|
||||||
|
assert _parse_retry_after("2.5", source_name="qwen_1") == 2.5
|
||||||
|
|
||||||
|
def test_insufficient_quota_is_still_source_dead(self):
|
||||||
|
"""分类判据不受本次改动影响(告警只加在 429 限速那一支)。"""
|
||||||
|
body = json.dumps({"error": {"type": "insufficient_quota"}})
|
||||||
|
exc = _translate_429(_source(), body, {"retry-after": "inf"}, {"body_text": body})
|
||||||
|
assert isinstance(exc, SourceDeadError)
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"""协议聚合器的分片、防御与快照契约。"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import TransientError
|
||||||
|
from polygateway.transports._openai_completion import (
|
||||||
|
ChatCompletionAccumulator,
|
||||||
|
parse_chat_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def chunk(delta, **extra):
|
||||||
|
"""构造单 choice 协议帧。"""
|
||||||
|
return (
|
||||||
|
"data: "
|
||||||
|
+ json.dumps({"id": "upstream-1", "choices": [{"index": 0, "delta": delta, **extra}]})
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tool_frames():
|
||||||
|
"""两个交错分片工具,保留空白及 Unicode。"""
|
||||||
|
return [
|
||||||
|
chunk({"role": "assistant", "content": None}),
|
||||||
|
chunk(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": 1,
|
||||||
|
"id": "b",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": '{"v":'},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"id": "a",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "echo", "arguments": "{"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
chunk(
|
||||||
|
{
|
||||||
|
"tool_calls": [
|
||||||
|
{"index": 0, "function": {"arguments": '"v": "中" }'}},
|
||||||
|
{"index": 1, "function": {"arguments": "2}"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
finish_reason="tool_calls",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def accumulate():
|
||||||
|
"""复用双工具网关帧样本。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
for frame in tool_frames():
|
||||||
|
state.add_chunk(json.loads(frame.removeprefix("data: ")))
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [None, "user", "tool"])
|
||||||
|
def test_role_is_required_everywhere(role):
|
||||||
|
"""缺角色不能被协议成功掩盖。"""
|
||||||
|
state = accumulate()
|
||||||
|
state.message.pop("role")
|
||||||
|
if role:
|
||||||
|
state.message["role"] = role
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
state.finish()
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
parse_chat_completion({"choices": [{"message": state.message}]})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"change",
|
||||||
|
[
|
||||||
|
lambda t: t.pop("id"),
|
||||||
|
lambda t: t.update(type="custom"),
|
||||||
|
lambda t: t["function"].pop("name"),
|
||||||
|
lambda t: t["function"].pop("arguments"),
|
||||||
|
lambda t: t["function"].update(arguments=12),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_incomplete_tool_is_not_success(change):
|
||||||
|
"""工具必需字段不能靠正文兜底。"""
|
||||||
|
state = accumulate()
|
||||||
|
state.message["content"] = "text"
|
||||||
|
change(state.tools[0])
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
state.finish()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"delta",
|
||||||
|
[
|
||||||
|
[],
|
||||||
|
{"tool_calls": {}},
|
||||||
|
{"content": []},
|
||||||
|
{"role": "user"},
|
||||||
|
{"tool_calls": [{"index": True}]},
|
||||||
|
{"tool_calls": [{"index": -1}]},
|
||||||
|
{"tool_calls": [{"index": None}]},
|
||||||
|
{"tool_calls": [{"index": 0, "function": {"arguments": 12}}]},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_delta_is_domain_error(delta):
|
||||||
|
"""畸形输入不泄漏裸类型错误。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
state.add_chunk({"choices": [{"index": 0, "delta": delta}]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_identity_repeat_does_not_refresh_progress():
|
||||||
|
"""身份只初次生效算进展;同值重复不能续命。"""
|
||||||
|
state = accumulate()
|
||||||
|
repeated = {
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "a", "type": "function"}]}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert not state.add_chunk(repeated).progress
|
||||||
|
repeated["choices"][0]["delta"]["tool_calls"][0]["id"] = "other"
|
||||||
|
with pytest.raises(TransientError, match="冲突"):
|
||||||
|
state.add_chunk(repeated)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("delta", [{"role": "assistant"}, {}, {"content": ""}])
|
||||||
|
def test_metadata_not_progress(delta):
|
||||||
|
"""空帧和角色不算 token。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk({"choices": [{"index": 0, "delta": delta}]}).progress
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_snapshot_and_length_preserved():
|
||||||
|
"""非流式扩展字段与参数原文独立保存,不修复 length 产物。"""
|
||||||
|
message = accumulate().finish().message
|
||||||
|
message["extension"] = {"nested": [1]}
|
||||||
|
message["tool_calls"][0]["function"]["arguments"] = '{"unfinished":'
|
||||||
|
original = copy.deepcopy(message)
|
||||||
|
result = parse_chat_completion(
|
||||||
|
{
|
||||||
|
"id": "id",
|
||||||
|
"choices": [{"message": message, "finish_reason": "length"}],
|
||||||
|
"usage": {"extra": 3},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message["extension"]["nested"].append(2)
|
||||||
|
assert result.message == original
|
||||||
|
assert result.finish_reason == "length" and result.usage == {"extra": 3}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("choices", [[], [{"index": 1, "message": {}}], [{}, {}]])
|
||||||
|
def test_nonstream_single_choice(choices):
|
||||||
|
"""不会静默取第一个 choice。"""
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
parse_chat_completion({"choices": choices})
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_empty_tools_remains_present():
|
||||||
|
"""显式空工具列表与缺省不同,不删除已接收字段。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"index": 0, "delta": {"role": "assistant", "content": "ok", "tool_calls": []}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fragmented_name_and_arguments_with_usage():
|
||||||
|
"""名称/参数分片逐字拼接,usage 扩展原样快照。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
state.add_chunk(
|
||||||
|
{
|
||||||
|
"id": "response",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"role": "assistant",
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"id": "call",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": "ec", "arguments": " "},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
progress = state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"content": "text",
|
||||||
|
"reasoning_content": "reason",
|
||||||
|
"tool_calls": [
|
||||||
|
{"index": 3, "function": {"name": "ho", "arguments": "{} "}}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"finish_reason": "length",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
progress.content == "text" and progress.reasoning_content == "reason" and progress.progress
|
||||||
|
)
|
||||||
|
usage = {"prompt_tokens": 7, "completion_tokens": 2, "extra": {"nested": 9}}
|
||||||
|
assert not state.add_chunk({"choices": [], "usage": usage}).progress
|
||||||
|
result = state.finish()
|
||||||
|
usage["extra"]["nested"] = 10
|
||||||
|
assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "}
|
||||||
|
assert result.usage["extra"]["nested"] == 9
|
||||||
|
assert result.finish_reason == "length"
|
||||||
|
|
||||||
|
|
||||||
|
# —— 1.3.10(#30): null 优先、未知字段无损合并、finish/usage 位置 ——
|
||||||
|
|
||||||
|
|
||||||
|
def _feed(state, *chunks):
|
||||||
|
"""依次喂入已解码帧,返回每帧的 ChatDelta。"""
|
||||||
|
return [state.add_chunk(c) for c in chunks]
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(delta, finish=None, **top):
|
||||||
|
"""单 choice 已解码帧。"""
|
||||||
|
choice = {"index": 0, "delta": delta}
|
||||||
|
if finish is not None:
|
||||||
|
choice["finish_reason"] = finish
|
||||||
|
return {"choices": [choice], **top}
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_known_and_unknown_fields_are_absent():
|
||||||
|
"""refusal/tool_calls/role/arguments 为 null 不判失败,键按缺省保留。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": None, "refusal": None, "tool_calls": None, "content": None}),
|
||||||
|
_frame({"role": "assistant", "content": "hi"}, finish="stop"),
|
||||||
|
)
|
||||||
|
message = state.finish().message
|
||||||
|
assert message["refusal"] is None and message["content"] == "hi"
|
||||||
|
assert message["role"] == "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_only_null_fails_at_finish():
|
||||||
|
"""全程只有 role:null 即缺 role,照旧失败。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(state, _frame({"role": None, "content": "x"}, finish="stop"))
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
state.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_arguments_and_null_tool_elements():
|
||||||
|
"""arguments:null 视缺省;tool_calls 中 null 元素跳过。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f"}}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": [None, tool]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": None}}]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}"}}]}, "tool_calls"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"][0]["function"] == {"name": "f", "arguments": "{}"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_choices_and_delta_are_absent():
|
||||||
|
"""chunk 层 choices:null 与 choice 层 delta:null 视缺省。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk({"choices": None}).progress
|
||||||
|
assert not state.add_chunk({"choices": [{"index": 0, "delta": None}]}).progress
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_alias_is_progress_and_preserved():
|
||||||
|
"""只有 reasoning 字段: 计活性、交别名片段、保真原键保留。"""
|
||||||
|
for mode in ("openai", "text"):
|
||||||
|
state = ChatCompletionAccumulator(response_mode=mode)
|
||||||
|
delta = state.add_chunk(_frame({"role": "assistant", "reasoning": "think"}))
|
||||||
|
assert delta.progress and delta.reasoning == "think" and delta.reasoning_content == ""
|
||||||
|
state.add_chunk(_frame({"reasoning": " more"}))
|
||||||
|
openai = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(openai, _frame({"role": "assistant", "reasoning": "a"}), _frame({"reasoning": "b"}))
|
||||||
|
assert openai.raw_message()["reasoning"] == "ab"
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(text, **extra):
|
||||||
|
return {"type": "reasoning.text", "index": 0, "format": "x-v1", "text": text, **extra}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_details_merge_by_index_without_label_concat():
|
||||||
|
"""OpenRouter reasoning_details 分片: index 合并,format/id/type 不拼接。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "reasoning_details": [_detail("he", id="r1")]}),
|
||||||
|
_frame({"reasoning_details": [_detail("llo", id="r1")]}),
|
||||||
|
_frame({"content": "ok"}, finish="stop"),
|
||||||
|
)
|
||||||
|
details = state.finish().message["reasoning_details"]
|
||||||
|
assert details == [
|
||||||
|
{"type": "reasoning.text", "index": 0, "format": "x-v1", "text": "hello", "id": "r1"}
|
||||||
|
]
|
||||||
|
assert not state.conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_level_nested_extension_preserved():
|
||||||
|
"""工具层未知嵌套对象(如 thought signature)原样保留以便回传。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
extra = {"google": {"thought_signature": "sig"}}
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "extra_content": extra}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": [{**tool, "function": {"name": "f"}}]}),
|
||||||
|
_frame({"tool_calls": [{"index": 0, "function": {"arguments": "{}", "strict": True}}]}),
|
||||||
|
)
|
||||||
|
snapshot = state.finish().message["tool_calls"][0]
|
||||||
|
assert snapshot["extra_content"] == extra and snapshot["function"]["strict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("first", "second"),
|
||||||
|
[(1, 2), (True, False), ("s", {"o": 1}), ([{"index": 0, "t": "a"}], ["plain"])],
|
||||||
|
)
|
||||||
|
def test_conflict_drops_key_and_bans_path(first, second):
|
||||||
|
"""数值/布尔/形态冲突: 删键、登记路径、后续同路径丢弃、调用成功。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "content": "a", "ext": first}),
|
||||||
|
_frame({"ext": second}),
|
||||||
|
_frame({"ext": first}, finish="stop"),
|
||||||
|
)
|
||||||
|
assert "ext" not in state.finish().message
|
||||||
|
assert state.conflicts == frozenset({"delta.ext"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_arrays_append_and_same_scalar_idempotent():
|
||||||
|
"""普通数组追加;同值标量幂等;追加数组中的字符串算活性。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
deltas = _feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "notes": ["a"], "n": 1}),
|
||||||
|
_frame({"notes": ["b", None], "n": 1}),
|
||||||
|
)
|
||||||
|
message = state.raw_message()
|
||||||
|
assert message["notes"] == ["a", "b"] and message["n"] == 1
|
||||||
|
assert all(d.progress for d in deltas[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_progress_per_frame_with_many_fragments():
|
||||||
|
"""同帧多个字段只产生一次进展事件(ChatDelta 单值)。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(_frame({"content": "a", "reasoning_content": "b", "x": "c"}))
|
||||||
|
assert delta.progress is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_label_only_unknown_is_not_progress():
|
||||||
|
"""未知层仅标签键(type/format/id/index)不刷新活性。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
assert not state.add_chunk(_frame({"meta": {"type": "x", "format": "y"}})).progress
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("frames", "after"),
|
||||||
|
[
|
||||||
|
([_frame({"content": "a"}, usage={"x": 1}), _frame({}, finish="stop")], False),
|
||||||
|
([_frame({"content": "a"}, finish="stop", usage={"x": 1})], True),
|
||||||
|
([_frame({"content": "a"}, finish="stop"), {"choices": [], "usage": {"x": 1}}], True),
|
||||||
|
([_frame({"content": "a"}, finish="stop")], False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_usage_after_finish_tracking(frames, after):
|
||||||
|
"""usage 必须在 finish 同帧或之后到达才算流走到自然末尾。"""
|
||||||
|
for mode in ("openai", "text"):
|
||||||
|
state = ChatCompletionAccumulator(response_mode=mode)
|
||||||
|
_feed(state, *frames)
|
||||||
|
assert state.finish_seen and state.finish_reason == "stop"
|
||||||
|
assert state.usage_after_finish is after
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_mode_ignores_other_choice_index():
|
||||||
|
"""文本模式只取 index 缺省或为 0 的 choice,不混入 index=1。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(
|
||||||
|
{
|
||||||
|
"choices": [
|
||||||
|
{"index": 1, "delta": {"content": "B"}, "finish_reason": "length"},
|
||||||
|
{"index": 0, "delta": {"content": "A"}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert delta.content == "A" and state.finish_reason is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_mode_finish_non_string_ignored():
|
||||||
|
"""文本模式 finish 非字符串记 None,不判失败。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
state.add_chunk(_frame({"content": "a"}, finish=5))
|
||||||
|
assert state.finish_reason is None and not state.finish_seen
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_null_tool_elements_stripped():
|
||||||
|
"""非流式 tool_calls 中 null 元素剔除(回传 [null] 会被上游拒),null 列表原样保留。"""
|
||||||
|
tool = {"id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
body = {
|
||||||
|
"choices": [{"message": {"role": "assistant", "tool_calls": [None, tool], "refusal": None}}]
|
||||||
|
}
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] == [tool]
|
||||||
|
body["choices"][0]["message"]["tool_calls"] = None
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("index", "taken"), [(None, True), (0, True), (False, False), (1, False)])
|
||||||
|
def test_text_mode_choice_index_null_is_zero(index, taken):
|
||||||
|
"""审查 I3: 文本模式 index:null 视缺省(=0);bool 不冒充 0。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="text")
|
||||||
|
delta = state.add_chunk(
|
||||||
|
{"choices": [{"index": index, "delta": {"content": "hi"}, "finish_reason": "stop"}]}
|
||||||
|
)
|
||||||
|
assert (delta.content == "hi") is taken and (state.finish_reason == "stop") is taken
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_only_tool_elements_do_not_create_empty_list():
|
||||||
|
"""审查 Minor1: tool_calls null 后 [null] 不得产出会被上游拒收的空数组。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "content": "a", "tool_calls": None}),
|
||||||
|
_frame({"tool_calls": [None]}, finish="stop"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"] is None
|
||||||
|
body = {"choices": [{"message": {"role": "assistant", "content": "a", "tool_calls": [None]}}]}
|
||||||
|
assert parse_chat_completion(body).message["tool_calls"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_calls_null_then_list_merges():
|
||||||
|
"""tool_calls 先 null 后列表: 正常聚合工具。"""
|
||||||
|
state = ChatCompletionAccumulator(response_mode="openai")
|
||||||
|
tool = {"index": 0, "id": "a", "type": "function", "function": {"name": "f", "arguments": "{}"}}
|
||||||
|
_feed(
|
||||||
|
state,
|
||||||
|
_frame({"role": "assistant", "tool_calls": None}),
|
||||||
|
_frame({"tool_calls": [tool]}, finish="tool_calls"),
|
||||||
|
)
|
||||||
|
assert state.finish().message["tool_calls"][0]["id"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nonstream_role_checked_before_verdict():
|
||||||
|
"""审查 Minor3: 非流式缺 role 是形态失败,先于结局判定。"""
|
||||||
|
from polygateway.transports._openai_completion import check_completion_body
|
||||||
|
|
||||||
|
with pytest.raises(TransientError, match="role"):
|
||||||
|
check_completion_body(
|
||||||
|
{"choices": [{"message": {"content": "a"}, "finish_reason": "length"}]}
|
||||||
|
)
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
"""OpenAI 消息保真回归:按网关 Chat Completions 形态构造。"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from polygateway.errors import AllSourcesExhausted, TransientError
|
||||||
|
from tests.unit.test_client import _client
|
||||||
|
from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for
|
||||||
|
from tests.unit.test_openai_completion import accumulate, chunk, tool_frames
|
||||||
|
|
||||||
|
|
||||||
|
async def test_error_before_done_is_failure():
|
||||||
|
"""文本模式也不能接受 error 后的 DONE。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: _sse_stream(chunk({"content": "partial"}), 'data: {"error": null}\n\n')
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await _complete(transport, _source())
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mixed_content_reasoning_survive():
|
||||||
|
"""同一帧双文本不互斥。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: _sse_stream(chunk({"content": "answer", "reasoning_content": "reason"}))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await _complete(transport, _source())
|
||||||
|
assert (result.content, result.thinking) == ("answer", "reason")
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tools_roundtrip_and_retry():
|
||||||
|
"""真实 transport 经过治理,失败重试不污染工具,消息可直接往返。"""
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
requests.append(json.loads(request.content))
|
||||||
|
if len(requests) == 1:
|
||||||
|
return _sse_stream(
|
||||||
|
chunk({"content": "partial"}),
|
||||||
|
'data: {"error": {"code": "upstream_stream_read_error"}}\n\n',
|
||||||
|
)
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
|
||||||
|
client = _client(handler=handler)
|
||||||
|
try:
|
||||||
|
response = await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}],
|
||||||
|
response_mode="openai",
|
||||||
|
overlay={"reasoning_effort": "max"},
|
||||||
|
)
|
||||||
|
assert response.call_stats.attempts == 2
|
||||||
|
message = response.completion.message
|
||||||
|
assert message["role"] == "assistant" and message["content"] is None
|
||||||
|
assert [t["id"] for t in message["tool_calls"]] == ["a", "b"]
|
||||||
|
assert message["tool_calls"][0]["function"]["arguments"] == '{"v": "中" }'
|
||||||
|
assert response.completion.response_id == "upstream-1"
|
||||||
|
assert response.completion.finish_reason == "tool_calls"
|
||||||
|
messages = [message, {"role": "tool", "tool_call_id": "a", "content": "ok"}]
|
||||||
|
await client.chat(messages, response_mode="openai")
|
||||||
|
assert requests[-1]["messages"] == messages
|
||||||
|
assert requests[0]["reasoning_effort"] == "max"
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", [None, "user"])
|
||||||
|
async def test_final_role_required(role):
|
||||||
|
"""非流式缺失或错误 role 必须协议失败。"""
|
||||||
|
message = {"content": "ok"}
|
||||||
|
if role:
|
||||||
|
message["role"] = role
|
||||||
|
client = _client(
|
||||||
|
handler=lambda _: httpx.Response(200, json={"choices": [{"message": message}]})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(AllSourcesExhausted):
|
||||||
|
await client.chat(
|
||||||
|
[{"role": "user", "content": "hi"}], stream=False, response_mode="openai"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_completion_cache_roundtrip(stream):
|
||||||
|
"""完整结果回放,修改返回对象不毒化缓存;模式隔离。"""
|
||||||
|
from polygateway.backends.memory.cache import InMemoryCache
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
if stream:
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"id": "upstream-1",
|
||||||
|
"choices": [{"message": completion.message, "finish_reason": "tool_calls"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _client(
|
||||||
|
handler=handler, cache=InMemoryCache(), cache_namespace="tools", cache_ttl_s=60
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
first = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
second = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
assert not first.cache_hit and second.cache_hit
|
||||||
|
assert second.completion == first.completion
|
||||||
|
assert second.call_id != first.call_id and second.call_stats.attempts == 0
|
||||||
|
second.completion.message["tool_calls"].clear()
|
||||||
|
third = await client.chat([], stream=stream, response_mode="openai")
|
||||||
|
assert len(third.completion.message["tool_calls"]) == 2
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"kwargs",
|
||||||
|
[
|
||||||
|
{"response_mode": "bad"},
|
||||||
|
{"response_mode": "openai", "structured": "json"},
|
||||||
|
{"response_mode": "openai", "overlay": {"n": 2}},
|
||||||
|
{"response_mode": "openai", "overlay": {"n": True}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_invalid_mode_combinations_fail_before_http(kwargs):
|
||||||
|
"""组合错误入口拒绝。"""
|
||||||
|
|
||||||
|
def unexpected(_):
|
||||||
|
pytest.fail("不应发送 HTTP")
|
||||||
|
|
||||||
|
client = _client(handler=unexpected)
|
||||||
|
try:
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await client.chat([], **kwargs)
|
||||||
|
finally:
|
||||||
|
await client._transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_never_salvages():
|
||||||
|
"""无 finish 后 usage 的截断流即使收全工具也不打捞(1.3.10: 判据由 DONE 改为完成判定)。"""
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False))
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError, match="missing_done"):
|
||||||
|
await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(missing_done="salvage"),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_finish_with_trailing_usage_is_complete_without_done():
|
||||||
|
"""对照: 同一工具流补上 finish 之后的 usage 帧,即使无 DONE 也完整交回(#31)。"""
|
||||||
|
usage = 'data: {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 4}}\n\n'
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames(), usage, done=False))
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
assert result.stream_end == "eof_after_finish"
|
||||||
|
assert [t["id"] for t in result.completion.message["tool_calls"]] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_progress_sets_events():
|
||||||
|
"""工具首进展驱动两个事件与时间观测。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
first, progress = asyncio.Event(), asyncio.Event()
|
||||||
|
transport = _transport_for(lambda _: _sse_stream(*tool_frames()))
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=True,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=first,
|
||||||
|
content_event=progress,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
assert first.is_set() and progress.is_set()
|
||||||
|
assert result.ttft_ms is not None and result.stall_count == 0
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_real_transport_stall_hedge_cancels_loser():
|
||||||
|
"""真实工具流沉默触发异源对冲,连接关闭且赢家不混入半成品。"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
from polygateway.sources import RoundRobinSelector
|
||||||
|
from polygateway.types import ChatRequest, _CallContext
|
||||||
|
from tests.unit.test_retry import _src
|
||||||
|
from tests.unit.test_stall_hedge import _harness
|
||||||
|
|
||||||
|
entered, closed = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
class Hanging(httpx.AsyncByteStream):
|
||||||
|
"""工具首帧后阻塞,取消由真实 httpx 流处理。"""
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
yield tool_frames()[0].encode()
|
||||||
|
yield tool_frames()[1].encode()
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
def handler(_):
|
||||||
|
nonlocal count
|
||||||
|
count += 1
|
||||||
|
if count == 1:
|
||||||
|
return httpx.Response(200, stream=Hanging())
|
||||||
|
return _sse_stream(*tool_frames())
|
||||||
|
|
||||||
|
transport = _transport_for(handler)
|
||||||
|
sources = [_src("s1"), _src("s2")]
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
sources, transport, stall_after_s=0.05, selector=RoundRobinSelector()
|
||||||
|
)
|
||||||
|
context = _CallContext(now=time.monotonic)
|
||||||
|
try:
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
mw(ChatRequest(messages=[], response_mode="openai", call_context=context)), 3
|
||||||
|
)
|
||||||
|
assert entered.is_set() and closed.is_set()
|
||||||
|
assert response.source_name == "s2" and len(response.completion.message["tool_calls"]) == 2
|
||||||
|
assert context.snapshot().hedges == 1 and context.snapshot().attempts == 2
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stream", [True, False])
|
||||||
|
async def test_bad_usage_is_domain_failure(stream):
|
||||||
|
"""坏 usage 不能穿透成裸 AttributeError,包括默认文本模式。"""
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: (
|
||||||
|
_sse_stream(chunk({"content": "ok"}), 'data: {"choices": [], "usage": [1]}\n\n')
|
||||||
|
if stream
|
||||||
|
else httpx.Response(
|
||||||
|
200, json={"choices": [{"message": {"content": "ok"}}], "usage": [1]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with pytest.raises(TransientError):
|
||||||
|
await _complete(transport, _source(), stream=stream)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nonstream_tool_result_has_no_liveness_events():
|
||||||
|
"""非流式工具不制造流式观测。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
completion = accumulate().finish()
|
||||||
|
first, progress = asyncio.Event(), asyncio.Event()
|
||||||
|
transport = _transport_for(
|
||||||
|
lambda _: httpx.Response(
|
||||||
|
200, json={"choices": [{"message": completion.message, "finish_reason": "tool_calls"}]}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(),
|
||||||
|
stream=False,
|
||||||
|
overlay={},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=first,
|
||||||
|
content_event=progress,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
assert result.completion.message == completion.message
|
||||||
|
assert not first.is_set() and not progress.is_set()
|
||||||
|
assert result.ttft_ms is None and result.stall_ms is None
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_openai_does_not_bypass_managed_conflict():
|
||||||
|
"""保真模式不静默撤销源级受管意图。"""
|
||||||
|
from polygateway.errors import RequestRejectedError
|
||||||
|
|
||||||
|
def unexpected(_):
|
||||||
|
pytest.fail("冲突请求不应发送 HTTP")
|
||||||
|
|
||||||
|
transport = _transport_for(unexpected)
|
||||||
|
try:
|
||||||
|
with pytest.raises(RequestRejectedError):
|
||||||
|
await transport.complete(
|
||||||
|
messages=[],
|
||||||
|
source=_source(enable_thinking=True),
|
||||||
|
stream=True,
|
||||||
|
overlay={"reasoning_effort": "max"},
|
||||||
|
call_id="c",
|
||||||
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="openai",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await transport.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_tool_stream_releases_resources():
|
||||||
|
"""显式取消真实工具流,连接关闭且配额归还。"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from polygateway.backends.memory.limiter import InMemoryLimiter
|
||||||
|
from polygateway.types import GlobalLimits
|
||||||
|
|
||||||
|
entered, closed = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
class Hanging(httpx.AsyncByteStream):
|
||||||
|
"""可确定取消窗口的 HTTP 流。"""
|
||||||
|
|
||||||
|
async def __aiter__(self):
|
||||||
|
for frame in tool_frames()[:2]:
|
||||||
|
yield frame.encode()
|
||||||
|
entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def aclose(self):
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
source = _source(max_concurrency=1)
|
||||||
|
limiter = InMemoryLimiter(
|
||||||
|
scope="llm", sources={source.name: source}, global_limits=GlobalLimits(1, 0, 0)
|
||||||
|
)
|
||||||
|
client = _client(
|
||||||
|
sources=[source], limiter=limiter, handler=lambda _: httpx.Response(200, stream=Hanging())
|
||||||
|
)
|
||||||
|
task = asyncio.create_task(client.chat([], response_mode="openai"))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(entered.wait(), 2)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert closed.is_set()
|
||||||
|
assert (await limiter.source_stats(source.name)).inflight == 0
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
await client._transport.aclose()
|
||||||
@@ -88,3 +88,15 @@ def test_every_promised_export_is_actually_importable():
|
|||||||
"""
|
"""
|
||||||
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
|
missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)]
|
||||||
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
|
assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_apache_license_metadata():
|
||||||
|
"""许可正文和包元数据同时存在,不以版本说明替代许可。"""
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
assert "Apache License" in (root / "LICENSE").read_text()
|
||||||
|
assert "Version 2.0" in (root / "LICENSE").read_text()
|
||||||
|
project = tomllib.loads((root / "pyproject.toml").read_text())["project"]
|
||||||
|
assert project["license"] == {"file": "LICENSE"}
|
||||||
|
|||||||
@@ -72,7 +72,19 @@ class _DummyMw:
|
|||||||
|
|
||||||
|
|
||||||
class _DummyTransport:
|
class _DummyTransport:
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +148,9 @@ class _DummyRecorder:
|
|||||||
error_body,
|
error_body,
|
||||||
attempts,
|
attempts,
|
||||||
total_latency_ms,
|
total_latency_ms,
|
||||||
|
stall_ms,
|
||||||
|
stall_count,
|
||||||
|
stream_end,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
@@ -308,7 +323,7 @@ class TestTelemetryRecorderSignature:
|
|||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
} <= set(params)
|
} <= set(params)
|
||||||
assert len(params) - 1 == 36 # 减掉 self
|
assert len(params) - 1 == 39 # 减掉 self
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"name",
|
"name",
|
||||||
@@ -327,6 +342,7 @@ class TestTelemetryRecorderSignature:
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
"stream_end",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_caller_dimensions_have_no_default(self, name):
|
def test_caller_dimensions_have_no_default(self, name):
|
||||||
|
|||||||
+292
-4
@@ -76,18 +76,110 @@ class FakeTransport:
|
|||||||
self.script = list(script)
|
self.script = list(script)
|
||||||
self.calls = []
|
self.calls = []
|
||||||
self.efforts = []
|
self.efforts = []
|
||||||
|
# 取消用例的确定性窗口: 进入 hang 分支即置位, 用例据此取消而非 sleep 猜时长
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
|
||||||
async def complete(self, *, messages, source, stream, overlay, call_id, reasoning_effort):
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
self.calls.append((source.name, call_id))
|
self.calls.append((source.name, call_id))
|
||||||
self.efforts.append(reasoning_effort)
|
self.efforts.append(reasoning_effort)
|
||||||
action = self.script.pop(0)
|
action = self.script.pop(0)
|
||||||
if isinstance(action, Exception):
|
if isinstance(action, Exception):
|
||||||
raise action
|
raise action
|
||||||
if action == "hang":
|
if action == "hang":
|
||||||
|
self.entered.set()
|
||||||
await asyncio.Event().wait()
|
await asyncio.Event().wait()
|
||||||
return action
|
return action
|
||||||
|
|
||||||
|
|
||||||
|
class _GenClockTransport:
|
||||||
|
"""委托 FakeTransport 的薄包装: 每次调用返回前按脚本推进注入钟(1.3.7 批次 C)。
|
||||||
|
|
||||||
|
generation_ms 的口径是"只计 transport 调用本身",故推进必须发生在被包
|
||||||
|
transport 内部;退避耗时由用例自带的 sleep 闭包推进,与本包装无关。
|
||||||
|
`stalls`(1.3.8 计划 §3.3): 逐次自控上报的 (stall_ms, stall_count),
|
||||||
|
fake 自己报数、不经真实计时;None(默认)= 该次调用无沉默观测。
|
||||||
|
"""
|
||||||
|
|
||||||
|
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):
|
||||||
|
return self._inner.calls
|
||||||
|
|
||||||
|
async def complete(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
messages,
|
||||||
|
source,
|
||||||
|
stream,
|
||||||
|
overlay,
|
||||||
|
call_id,
|
||||||
|
reasoning_effort,
|
||||||
|
first_token_event,
|
||||||
|
content_event,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
stream=stream,
|
||||||
|
overlay=overlay,
|
||||||
|
call_id=call_id,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
first_token_event=first_token_event,
|
||||||
|
content_event=content_event,
|
||||||
|
response_mode=response_mode,
|
||||||
|
)
|
||||||
|
if stall is not None:
|
||||||
|
result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1])
|
||||||
|
self._clock.advance(advance)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class HangingGate(InMemoryGate):
|
||||||
|
"""在指定记账写回处永久挂起的门控: 把"取消落在某个 await 上"变成确定性事件。
|
||||||
|
|
||||||
|
只覆盖 `record_success` / `record_failure` 两个写回点, 其余行为沿用真实内存实现。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *, hang_on, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._hang_on = hang_on
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
|
||||||
|
async def _hang(self):
|
||||||
|
self.entered.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
async def record_success(self, entry, *, count_attempt=True):
|
||||||
|
if self._hang_on == "success":
|
||||||
|
await self._hang()
|
||||||
|
return await super().record_success(entry, count_attempt=count_attempt)
|
||||||
|
|
||||||
|
async def record_failure(self, entry, reason, force_open):
|
||||||
|
if self._hang_on == "failure":
|
||||||
|
await self._hang()
|
||||||
|
return await super().record_failure(entry, reason, force_open)
|
||||||
|
|
||||||
|
|
||||||
class FakeSleep:
|
class FakeSleep:
|
||||||
"""记录退避时长,立即返回(不真等)。"""
|
"""记录退避时长,立即返回(不真等)。"""
|
||||||
|
|
||||||
@@ -109,6 +201,9 @@ def _harness(
|
|||||||
rng=lambda: 0.0,
|
rng=lambda: 0.0,
|
||||||
selector=None,
|
selector=None,
|
||||||
pacer=None,
|
pacer=None,
|
||||||
|
gate=None,
|
||||||
|
transport=None,
|
||||||
|
sleep=None,
|
||||||
):
|
):
|
||||||
clock = clock or FakeClock()
|
clock = clock or FakeClock()
|
||||||
limiter = InMemoryLimiter(
|
limiter = InMemoryLimiter(
|
||||||
@@ -118,9 +213,9 @@ def _harness(
|
|||||||
lease_ttl_s=100.0,
|
lease_ttl_s=100.0,
|
||||||
now=clock,
|
now=clock,
|
||||||
)
|
)
|
||||||
gate = InMemoryGate(config=_BREAKER, now=clock)
|
gate = gate if gate is not None else InMemoryGate(config=_BREAKER, now=clock)
|
||||||
transport = FakeTransport(script)
|
transport = transport if transport is not None else FakeTransport(script)
|
||||||
sleep = FakeSleep()
|
sleep = sleep if sleep is not None else FakeSleep()
|
||||||
mw = RetryMW(
|
mw = RetryMW(
|
||||||
scope="llm",
|
scope="llm",
|
||||||
sources=sources,
|
sources=sources,
|
||||||
@@ -467,6 +562,111 @@ class TestScopeUnavailable:
|
|||||||
assert resp.content == "ok" and released["done"]
|
assert resp.content == "ok" and released["done"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCancellationSettlement:
|
||||||
|
"""1.3.6 §6.3 结算矩阵: 取消时 `settle()` 的取值只由"该刻库知道什么"决定。
|
||||||
|
|
||||||
|
取消窗口一律用真实 `asyncio.Event` 钉死(不用 sleep 撞窗口), 否则红绿都不可信。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_cancel_in_flight_keeps_the_reservation(self):
|
||||||
|
"""S3: transport 在途被取消 → 端口已开始、用量未知 → 保留预扣(不凭空退款)。"""
|
||||||
|
mw, limiter, _, transport, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], ["hang"]
|
||||||
|
)
|
||||||
|
task = asyncio.ensure_future(mw(_REQ))
|
||||||
|
await transport.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
stats = await limiter.source_stats("a")
|
||||||
|
assert stats.tpm_used == 400 # est 保留, 而非退成 0
|
||||||
|
assert stats.inflight == 0
|
||||||
|
|
||||||
|
async def test_cancel_after_usage_known_keeps_real_usage(self):
|
||||||
|
"""S4: 真实 usage 已算出后被取消 → 结算仍是真实值, 不被 est 覆写。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
gate = HangingGate(hang_on="success", config=_BREAKER, now=clock)
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||||
|
[_ok()],
|
||||||
|
clock=clock,
|
||||||
|
gate=gate,
|
||||||
|
)
|
||||||
|
task = asyncio.ensure_future(mw(_REQ))
|
||||||
|
await gate.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 15 # 10+5 实测
|
||||||
|
|
||||||
|
async def test_cancel_in_dead_failure_branch_keeps_full_refund(self):
|
||||||
|
"""S5-dead: 源已判死时的既有 `0` 不得因取消退化成 est(不得继续占额度)。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||||
|
[SourceDeadError("401", source_name="a", status_code=401)],
|
||||||
|
clock=clock,
|
||||||
|
gate=gate,
|
||||||
|
)
|
||||||
|
task = asyncio.ensure_future(mw(_REQ))
|
||||||
|
await gate.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 0
|
||||||
|
|
||||||
|
async def test_cancel_in_transient_failure_branch_keeps_the_reservation(self):
|
||||||
|
"""S5-transient: 瞬时失败的结算决定在首个 await 之前定死, 取消拿到同一个 est。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
gate = HangingGate(hang_on="failure", config=_BREAKER, now=clock)
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)],
|
||||||
|
[TransientError("boom", source_name="a", status_code=500)],
|
||||||
|
clock=clock,
|
||||||
|
gate=gate,
|
||||||
|
)
|
||||||
|
task = asyncio.ensure_future(mw(_REQ))
|
||||||
|
await gate.entered.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 400
|
||||||
|
|
||||||
|
async def test_unclassified_exception_still_refunds_in_full(self):
|
||||||
|
"""S8 防越界: 未分类异常(无 except 接住)仍逐字走 1.3.5 的全额退还。"""
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=1000, est_tokens=400)], [RuntimeError("boom")]
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
await mw(_REQ)
|
||||||
|
stats = await limiter.source_stats("a")
|
||||||
|
assert stats.tpm_used == 0 and stats.inflight == 0
|
||||||
|
|
||||||
|
async def test_real_zero_usage_success_settles_zero(self):
|
||||||
|
"""防越界: 真实 usage 恰为 0 是**已知事实**, 不得被当成"未知"改按 est 结算。"""
|
||||||
|
zero = dataclasses.replace(_ok(), prompt_tokens=0, completion_tokens=0)
|
||||||
|
mw, limiter, *_ = _harness([_src("a", tpm=1000, est_tokens=400)], [zero])
|
||||||
|
await mw(_REQ)
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 0
|
||||||
|
|
||||||
|
async def test_circuit_open_rejection_settles_zero_end_to_end(self):
|
||||||
|
"""S1 端到端: 开路拒绝的 pick 预扣后按 0 结算, 不给 tpm_used 增加任何量。"""
|
||||||
|
clock = FakeClock()
|
||||||
|
script = [TransientError(str(i)) for i in range(9)]
|
||||||
|
mw, limiter, *_ = _harness(
|
||||||
|
[_src("a", max_concurrency=1, tpm=10000, est_tokens=400)],
|
||||||
|
script,
|
||||||
|
clock=clock,
|
||||||
|
max_attempts=99,
|
||||||
|
)
|
||||||
|
# 3 次瞬时失败后 a 开路 → 第 4 次 pick 被拒绝
|
||||||
|
with pytest.raises(CircuitOpenError):
|
||||||
|
await mw(_REQ)
|
||||||
|
# 三次瞬时失败各保留 est = 1200; 开路那次 pick 若漏了 settle(0) 会再 +400
|
||||||
|
assert (await limiter.source_stats("a")).tpm_used == 1200
|
||||||
|
|
||||||
|
|
||||||
class TestCancellation:
|
class TestCancellation:
|
||||||
async def test_cancel_mid_flight_releases_permit(self):
|
async def test_cancel_mid_flight_releases_permit(self):
|
||||||
mw, limiter, _, _, _, _ = _harness([_src("a", max_concurrency=1)], ["hang"])
|
mw, limiter, _, _, _, _ = _harness([_src("a", max_concurrency=1)], ["hang"])
|
||||||
@@ -779,6 +979,94 @@ class TestRateLimitPushback:
|
|||||||
assert len(transport.calls) == 3
|
assert len(transport.calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestGenerationMs:
|
||||||
|
"""裸生成时间(1.3.7 H8): 只计 transport 调用本身,不含退避/准入/遥测收尾。"""
|
||||||
|
|
||||||
|
def _ctx(self, clock):
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
return _CallContext(now=clock)
|
||||||
|
|
||||||
|
async def test_generation_ms_excludes_backoff_and_admission(self):
|
||||||
|
"""[Transient, ok] 脚本: 退避推进 5s、成功次 transport 推进 0.25s。
|
||||||
|
|
||||||
|
generation_ms 恒等于成功次 transport 的 250ms;若口径混入了退避,
|
||||||
|
它会涨到 5250ms 量级——与 total_latency_ms 的下界断言互为对偶。
|
||||||
|
推进量取二进制可精确表示值(0.25/5.0): int 截断下 0.2 之类会因浮点
|
||||||
|
误差落到 199,断言随之抖动(同 test_types 既有用例只用 1.5/2.0 的惯例)。
|
||||||
|
"""
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
async def advancing_sleep(seconds):
|
||||||
|
clock.advance(seconds)
|
||||||
|
|
||||||
|
transport = _GenClockTransport(
|
||||||
|
[TransientError("boom", source_name="a"), _ok()], [0.0, 0.25], clock
|
||||||
|
)
|
||||||
|
mw, *_ = _harness(
|
||||||
|
[_src("a")],
|
||||||
|
[],
|
||||||
|
clock=clock,
|
||||||
|
transport=transport,
|
||||||
|
sleep=advancing_sleep,
|
||||||
|
rng=lambda: 2.0, # backoff = 2.0 * (0.5 + 2.0) = 5.0s
|
||||||
|
)
|
||||||
|
ctx = self._ctx(clock)
|
||||||
|
resp = await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
assert resp.content == "ok" and len(transport.calls) == 2
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.generation_ms == 250
|
||||||
|
assert stats.total_latency_ms >= 5250
|
||||||
|
|
||||||
|
async def test_generation_ms_zero_hedge_flags_without_hedging(self):
|
||||||
|
"""无对冲时 hedges/hedge_won 恒 0/False(对冲登记是 T3 的事)。"""
|
||||||
|
mw, *_ = _harness([_src("a")], [_ok()])
|
||||||
|
ctx = self._ctx(FakeClock())
|
||||||
|
await mw(dataclasses.replace(_REQ, call_context=ctx))
|
||||||
|
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:
|
class TestLogicalAttemptCounting:
|
||||||
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
|
"""尝试登记在 transport 调用**之前**(1.3.5 设计 §4)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
"""中段沉默对冲编排测试(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,
|
||||||
|
response_mode,
|
||||||
|
):
|
||||||
|
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]
|
||||||
+242
-14
@@ -89,6 +89,11 @@ _EXPECTED_COLUMNS = [
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
# —— 1.3.8 沉默归因两列(issue #27)——
|
||||||
|
"stall_ms",
|
||||||
|
"stall_count",
|
||||||
|
# —— 1.3.10 结束方式(#31)——
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
|
# 1.3.5 之前的 26 个 INSERT 字段(旧表形态),供冻结 INSERT 语句与旧表补列用例复用
|
||||||
@@ -181,6 +186,11 @@ async def _record_minimal(recorder, call_id="c1", **overrides):
|
|||||||
# 逻辑快照两列只属终态行
|
# 逻辑快照两列只属终态行
|
||||||
"attempts": None,
|
"attempts": None,
|
||||||
"total_latency_ms": None,
|
"total_latency_ms": None,
|
||||||
|
# —— 1.3.8 沉默归因两列: 默认 None = 非流式/无沉默观测(合法事实)——
|
||||||
|
"stall_ms": None,
|
||||||
|
"stall_count": None,
|
||||||
|
# —— 1.3.10 结束方式: 默认 None = 非流式/缓存命中/失败行 ——
|
||||||
|
"stream_end": None,
|
||||||
}
|
}
|
||||||
fields.update(overrides)
|
fields.update(overrides)
|
||||||
await recorder.record_llm_call(**fields)
|
await recorder.record_llm_call(**fields)
|
||||||
@@ -262,7 +272,7 @@ class TestSchemaModule:
|
|||||||
|
|
||||||
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
|
# COLUMNS 是 INSERT 字段序,不含数据库自填的 created_at
|
||||||
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
|
assert list(COLUMNS) == [c for c in _EXPECTED_COLUMNS if c != "created_at"]
|
||||||
assert len(COLUMNS) == 36
|
assert len(COLUMNS) == 39
|
||||||
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
|
# 两端 DDL 的列出现顺序 == 物理列序(created_at 在第 19 位)
|
||||||
for ddl in (SQLITE_DDL, PG_DDL):
|
for ddl in (SQLITE_DDL, PG_DDL):
|
||||||
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
assert _first_occurrence_order(ddl, _EXPECTED_COLUMNS) == _EXPECTED_COLUMNS
|
||||||
@@ -276,8 +286,8 @@ class TestSchemaModule:
|
|||||||
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
|
"ALTER TABLE llm_calls ADD COLUMN cached_prompt_tokens INTEGER",
|
||||||
)
|
)
|
||||||
assert PG_BACKFILL[-1] == (
|
assert PG_BACKFILL[-1] == (
|
||||||
"total_latency_ms",
|
"stream_end",
|
||||||
"ALTER TABLE llm_calls ADD COLUMN total_latency_ms INTEGER",
|
"ALTER TABLE llm_calls ADD COLUMN stream_end TEXT",
|
||||||
)
|
)
|
||||||
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
|
assert all("IF NOT EXISTS" not in stmt for _, stmt in PG_BACKFILL)
|
||||||
|
|
||||||
@@ -291,10 +301,10 @@ class TestSchemaModule:
|
|||||||
|
|
||||||
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
|
assert insert_sql("sqlite", _PRE_135_COLUMNS) == _FROZEN_SQLITE_INSERT
|
||||||
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
|
assert insert_sql("postgres", _PRE_135_COLUMNS) == _FROZEN_PG_INSERT
|
||||||
# 全量 36 列: 占位符随列数增长,且不留空洞
|
# 全量 39 列: 占位符随列数增长,且不留空洞
|
||||||
assert insert_sql("postgres", COLUMNS).count("$") == 36
|
assert insert_sql("postgres", COLUMNS).count("$") == 39
|
||||||
assert "$36)" in insert_sql("postgres", COLUMNS)
|
assert "$39)" in insert_sql("postgres", COLUMNS)
|
||||||
assert insert_sql("sqlite", COLUMNS).count("?") == 36
|
assert insert_sql("sqlite", COLUMNS).count("?") == 39
|
||||||
# 裁剪列表按位置占位符重新编号,不留空洞
|
# 裁剪列表按位置占位符重新编号,不留空洞
|
||||||
assert insert_sql("postgres", ["call_id", "model"]) == (
|
assert insert_sql("postgres", ["call_id", "model"]) == (
|
||||||
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
|
"INSERT INTO llm_calls (call_id, model) VALUES ($1, $2) ON CONFLICT DO NOTHING"
|
||||||
@@ -371,7 +381,131 @@ class TestBackendColumnParity:
|
|||||||
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
"""新列只能追加在末尾: 旧表经 ALTER 补列必落末尾,插在中间会让两条路径分叉。"""
|
||||||
from polygateway.telemetry.schema import COLUMNS
|
from polygateway.telemetry.schema import COLUMNS
|
||||||
|
|
||||||
assert COLUMNS[-4:] == ("cause_type", "error_body", "attempts", "total_latency_ms")
|
assert COLUMNS[-7:] == (
|
||||||
|
"cause_type",
|
||||||
|
"error_body",
|
||||||
|
"attempts",
|
||||||
|
"total_latency_ms",
|
||||||
|
"stall_ms",
|
||||||
|
"stall_count",
|
||||||
|
"stream_end",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 1.3.7(1.3.8 补列前)的表形态: 36 个 INSERT 字段 + created_at = 37 物理列。
|
||||||
|
# 冻结字面量而非由常量切片派生: 后者会随 schema 改动静默漂移到另一张表上
|
||||||
|
# (与 _PRE_TENANT_DDL 同款纪律)。
|
||||||
|
_PRE_138_DDL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_calls (
|
||||||
|
call_id TEXT PRIMARY KEY,
|
||||||
|
parent_call_id TEXT,
|
||||||
|
session_id TEXT,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
source_name TEXT NOT NULL,
|
||||||
|
messages TEXT NOT NULL,
|
||||||
|
response TEXT NOT NULL,
|
||||||
|
thinking TEXT NOT NULL DEFAULT '',
|
||||||
|
prompt_tokens INTEGER NOT NULL,
|
||||||
|
completion_tokens INTEGER NOT NULL,
|
||||||
|
usage_source TEXT NOT NULL,
|
||||||
|
latency_ms INTEGER NOT NULL,
|
||||||
|
ttft_ms REAL,
|
||||||
|
max_inter_token_ms REAL,
|
||||||
|
cache_hit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
cost REAL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
cached_prompt_tokens INTEGER,
|
||||||
|
model_reported TEXT,
|
||||||
|
sampling TEXT,
|
||||||
|
reasoning_tokens INTEGER,
|
||||||
|
tenant_id TEXT NOT NULL DEFAULT '',
|
||||||
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
|
thinking_observation TEXT,
|
||||||
|
reasoning_effort TEXT,
|
||||||
|
scope TEXT,
|
||||||
|
operation TEXT,
|
||||||
|
logical_call_id TEXT,
|
||||||
|
event_kind TEXT,
|
||||||
|
http_status_code INTEGER,
|
||||||
|
error_type TEXT,
|
||||||
|
cause_type TEXT,
|
||||||
|
error_body TEXT,
|
||||||
|
attempts INTEGER,
|
||||||
|
total_latency_ms INTEGER
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestStallColumns:
|
||||||
|
"""1.3.8 沉默归因两列(issue #27): 落行、缺省 NULL、迁移幂等与 manual 告警。
|
||||||
|
|
||||||
|
新列可空、无默认值、追加末尾——旧行的 NULL 表达"补列之前根本没记过这件事"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def test_stall_values_round_trip_and_default_is_null(self, tmp_path):
|
||||||
|
"""stall 值原样落行读回;未观测行(默认 None)两列是 NULL 而非 0。"""
|
||||||
|
db = tmp_path / "stall.db"
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id="c-stall", stall_ms=3200, stall_count=2)
|
||||||
|
await _record_minimal(recorder, call_id="c-plain")
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-stall'"
|
||||||
|
).fetchone() == (3200, 2)
|
||||||
|
# "观测过且零沉默"(0)与"无从观测"(NULL)是两回事: 未传值的行必须是 NULL
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-plain'"
|
||||||
|
).fetchone() == (None, None)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_auto_migrate_backfills_the_two_columns_idempotently(self, tmp_path):
|
||||||
|
"""1.3.7 形态旧表(37 物理列)经 auto 补两列;二次初始化幂等不炸。"""
|
||||||
|
db = tmp_path / "pre138.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_138_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id="c-new", stall_ms=7, stall_count=1)
|
||||||
|
recorder.close()
|
||||||
|
# 二次初始化: 探测到列已齐,一条 ALTER 都不发(幂等)
|
||||||
|
recorder2 = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder2, call_id="c-new2")
|
||||||
|
recorder2.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT stall_ms, stall_count FROM llm_calls WHERE call_id = 'c-new'"
|
||||||
|
).fetchone() == (7, 1)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_manual_mode_warning_names_both_new_alters(self, tmp_path, captured_warnings):
|
||||||
|
"""manual 档缺列告警逐字含两条新 ALTER,物理列原封不动。"""
|
||||||
|
db = tmp_path / "manual138.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_138_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder, call_id="c-manual", stall_ms=9, stall_count=3)
|
||||||
|
recorder.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 37
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert len(captured_warnings) == 1
|
||||||
|
message = captured_warnings[0]
|
||||||
|
assert "stall_ms" in message and "stall_count" in message
|
||||||
|
assert "ALTER TABLE llm_calls ADD COLUMN stall_ms INTEGER" in message
|
||||||
|
assert "ALTER TABLE llm_calls ADD COLUMN stall_count INTEGER" in message
|
||||||
|
|
||||||
|
|
||||||
class TestSQLiteRecorder:
|
class TestSQLiteRecorder:
|
||||||
@@ -647,7 +781,7 @@ class TestSQLiteCallerDimensionsAcceptance:
|
|||||||
|
|
||||||
conn = sqlite3.connect(db)
|
conn = sqlite3.connect(db)
|
||||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]
|
||||||
assert cols == _EXPECTED_COLUMNS # 22 → 36 个 recorder 字段(+ created_at 共 37 物理列)
|
assert cols == _EXPECTED_COLUMNS # 22 → 39 个 recorder 字段(+ created_at 共 40 物理列)
|
||||||
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
rows = dict(conn.execute("SELECT call_id, tenant_id FROM llm_calls").fetchall())
|
||||||
assert rows["new-row"] == "tenant-a"
|
assert rows["new-row"] == "tenant-a"
|
||||||
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
assert rows["old-row"] == "" # 不是 None: NULL 会被 RLS 静默吞掉
|
||||||
@@ -697,7 +831,7 @@ class TestSQLiteSchemaMode:
|
|||||||
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
"""issue #13: `auto_migrate` 两档——auto 保持自动补列,manual 只裁剪写入不发 DDL。
|
||||||
|
|
||||||
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
列数断言一律按**物理列数**写: 旧表 22 个 INSERT 字段 + `created_at` = 23,
|
||||||
补齐后 36 + `created_at` = 37。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
补齐后 39 + `created_at` = 40。混用 INSERT 字段数与物理列数是本处最易错的地方。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def _physical_columns(self, db: Path) -> list[str]:
|
def _physical_columns(self, db: Path) -> list[str]:
|
||||||
@@ -736,7 +870,7 @@ class TestSQLiteSchemaMode:
|
|||||||
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
assert "ALTER TABLE" in message # 给出可直接执行的补列 SQL
|
||||||
|
|
||||||
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
async def test_auto_mode_still_upgrades_the_legacy_table(self, tmp_path):
|
||||||
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 37。"""
|
"""auto + 同款旧表: 现状回归,补列后物理列数 23 → 40。"""
|
||||||
db = tmp_path / "auto_legacy.db"
|
db = tmp_path / "auto_legacy.db"
|
||||||
_make_pre_tenant_db(db)
|
_make_pre_tenant_db(db)
|
||||||
|
|
||||||
@@ -745,10 +879,10 @@ class TestSQLiteSchemaMode:
|
|||||||
recorder.close()
|
recorder.close()
|
||||||
|
|
||||||
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
assert self._physical_columns(db) == _EXPECTED_COLUMNS
|
||||||
assert len(self._physical_columns(db)) == 37
|
assert len(self._physical_columns(db)) == 40
|
||||||
|
|
||||||
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
async def test_manual_mode_still_creates_a_fresh_table(self, tmp_path):
|
||||||
"""manual 只管 ALTER,不管 CREATE: 全新库照建,37 个物理列齐全(设计 §4.2)。"""
|
"""manual 只管 ALTER,不管 CREATE: 全新库照建,40 个物理列齐全(设计 §4.2)。"""
|
||||||
db = tmp_path / "manual_fresh.db"
|
db = tmp_path / "manual_fresh.db"
|
||||||
recorder = SQLiteRecorder(db, auto_migrate=False)
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
await _record_minimal(recorder, call_id="c-fresh", tenant_id="tenant-a")
|
||||||
@@ -985,6 +1119,11 @@ class TestPostgresBackfillDiscipline:
|
|||||||
"error_body",
|
"error_body",
|
||||||
"attempts",
|
"attempts",
|
||||||
"total_latency_ms",
|
"total_latency_ms",
|
||||||
|
# 1.3.8 两列: 稳态定义随补列前移,否则每进程首写都去抢锁
|
||||||
|
"stall_ms",
|
||||||
|
"stall_count",
|
||||||
|
# 1.3.10 一列,理由同上
|
||||||
|
"stream_end",
|
||||||
]
|
]
|
||||||
|
|
||||||
def _recorder(self, conn):
|
def _recorder(self, conn):
|
||||||
@@ -3353,7 +3492,7 @@ class TestRecorderShapeGate:
|
|||||||
class NarrowRecorder:
|
class NarrowRecorder:
|
||||||
async def record_llm_call(self, *, call_id, model) -> None: ...
|
async def record_llm_call(self, *, call_id, model) -> None: ...
|
||||||
|
|
||||||
# 若闸内硬编码了 36 个名字,这里必然拒绝
|
# 若闸内硬编码了 38 个名字,这里必然拒绝
|
||||||
tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None)
|
tele.TelemetryEmitter(NarrowRecorder(), scope="LLM", text_cap=None)
|
||||||
|
|
||||||
|
|
||||||
@@ -3454,3 +3593,92 @@ class TestTerminalRowSqlSemantics:
|
|||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
== 0
|
== 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 1.3.8 形态(1.3.10 补列前)的表: 38 个 INSERT 字段 + created_at = 39 物理列。
|
||||||
|
# 冻结字面量(由 _PRE_138_DDL 末行补两列),不从常量切片派生,理由同 _PRE_138_DDL。
|
||||||
|
_PRE_1310_DDL = _PRE_138_DDL.replace(
|
||||||
|
" total_latency_ms INTEGER\n);",
|
||||||
|
" total_latency_ms INTEGER,\n stall_ms INTEGER,\n"
|
||||||
|
" stall_count INTEGER\n);",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamEndColumn:
|
||||||
|
"""1.3.10 结束方式列(#31): 落行、缺省 NULL、补列幂等、manual 告警、各入口取值。"""
|
||||||
|
|
||||||
|
async def test_value_round_trips_and_default_is_null(self, tmp_path):
|
||||||
|
db = tmp_path / "stream_end.db"
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id="c-eof", stream_end="eof_after_finish")
|
||||||
|
await _record_minimal(recorder, call_id="c-none")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
rows = dict(conn.execute("SELECT call_id, stream_end FROM llm_calls").fetchall())
|
||||||
|
conn.close()
|
||||||
|
assert rows == {"c-eof": "eof_after_finish", "c-none": None}
|
||||||
|
|
||||||
|
async def test_auto_migrate_backfills_from_138_table(self, tmp_path):
|
||||||
|
db = tmp_path / "pre1310.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_1310_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
for call_id in ("c1", "c2"): # 二次初始化幂等
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=True)
|
||||||
|
await _record_minimal(recorder, call_id=call_id, stream_end="done")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert [r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")] == _EXPECTED_COLUMNS
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
async def test_manual_mode_names_the_new_alter(self, tmp_path, captured_warnings):
|
||||||
|
db = tmp_path / "manual1310.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(_PRE_1310_DDL)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
recorder = SQLiteRecorder(db, auto_migrate=False)
|
||||||
|
await _record_minimal(recorder, call_id="c-manual", stream_end="done")
|
||||||
|
recorder.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert len([r[1] for r in conn.execute("PRAGMA table_info(llm_calls)")]) == 39
|
||||||
|
assert conn.execute("SELECT call_id FROM llm_calls").fetchone() == ("c-manual",)
|
||||||
|
conn.close()
|
||||||
|
assert "ALTER TABLE llm_calls ADD COLUMN stream_end TEXT" in captured_warnings[0]
|
||||||
|
|
||||||
|
async def test_attempt_takes_stream_end_from_response(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
await TelemetryEmitter(rec, text_cap=None, scope="LLM").emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=_resp(stream_end="eof_after_finish"),
|
||||||
|
error=None,
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
|
)
|
||||||
|
assert rec.rows[0]["stream_end"] == "eof_after_finish"
|
||||||
|
|
||||||
|
async def test_failed_hit_and_terminal_rows_are_null(self):
|
||||||
|
rec = _MemoryRecorder()
|
||||||
|
emitter = TelemetryEmitter(rec, text_cap=None, scope="LLM")
|
||||||
|
await emitter.emit_attempt(
|
||||||
|
request=_REQ,
|
||||||
|
source=_source(),
|
||||||
|
call_id="c",
|
||||||
|
latency_ms=1,
|
||||||
|
response=None,
|
||||||
|
error="boom",
|
||||||
|
reasoning_applies=True,
|
||||||
|
operation="chat",
|
||||||
|
)
|
||||||
|
# 命中行显式不信任 response 上的值(即使上游误带)
|
||||||
|
await emitter.emit_cache_hit(
|
||||||
|
request=_REQ, response=_resp(cache_hit=True, stream_end="done"), operation="chat"
|
||||||
|
)
|
||||||
|
await emitter.emit_terminal_failure(
|
||||||
|
request=_REQ, call_id="c", error="dead", operation="chat", stats=_MIGRATED_STATS
|
||||||
|
)
|
||||||
|
assert [row["stream_end"] for row in rec.rows] == [None, None, None]
|
||||||
|
|||||||
@@ -185,6 +185,19 @@ class TestSourceConfig:
|
|||||||
_make_source(missing_done="ignore")
|
_make_source(missing_done="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_config_stall_threshold_validation():
|
||||||
|
"""`stall_threshold_ms`(1.3.8 沉默计量阈值): 默认 1000;0/负/bool → ValueError。"""
|
||||||
|
assert _make_source().stall_threshold_ms == 1000
|
||||||
|
assert _make_source(stall_threshold_ms=50).stall_threshold_ms == 50
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_make_source(stall_threshold_ms=0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_make_source(stall_threshold_ms=-5)
|
||||||
|
# bool 是 int 子类: True 会被当成 1 静默放行,必须显式拒(P5)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
_make_source(stall_threshold_ms=True)
|
||||||
|
|
||||||
|
|
||||||
class TestEffectiveEstTokens:
|
class TestEffectiveEstTokens:
|
||||||
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
|
"""TPM 入场预扣量的派生(est_tokens 解耦设计 §2.2)。"""
|
||||||
|
|
||||||
@@ -293,6 +306,12 @@ class TestAuxTypes:
|
|||||||
assert s.thinking_observation is ThinkingObservation.UNKNOWN
|
assert s.thinking_observation is ThinkingObservation.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_transport_result_stall_fields_default_none():
|
||||||
|
"""TransportResult 沉默两字段(1.3.8)默认 None;旧位置构造不炸(只增不改)。"""
|
||||||
|
result = TransportResult("c", "t", 1, 2, "measured", None, None, {})
|
||||||
|
assert result.stall_ms is None and result.stall_count is None
|
||||||
|
|
||||||
|
|
||||||
class TestOcrTypes:
|
class TestOcrTypes:
|
||||||
"""M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。"""
|
"""M3 OCR 五类型(设计 §3.1): 可构造、frozen、全必填无默认。"""
|
||||||
|
|
||||||
@@ -622,6 +641,85 @@ class TestCallStatsAndContext:
|
|||||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
stats.attempts = 3
|
stats.attempts = 3
|
||||||
|
|
||||||
|
def test_callstats_hedge_fields_default(self):
|
||||||
|
"""1.3.7 三字段全带默认值: 仅旧三参数构造不炸,无对冲恒 0/0/False。"""
|
||||||
|
from polygateway.types import CallStats
|
||||||
|
|
||||||
|
stats = CallStats(logical_call_id="lc-1", attempts=2, total_latency_ms=15)
|
||||||
|
assert stats.hedges == 0
|
||||||
|
assert stats.generation_ms == 0
|
||||||
|
assert stats.hedge_won is False
|
||||||
|
explicit = CallStats(
|
||||||
|
logical_call_id="lc-2",
|
||||||
|
attempts=2,
|
||||||
|
total_latency_ms=15,
|
||||||
|
hedges=1,
|
||||||
|
generation_ms=42,
|
||||||
|
hedge_won=True,
|
||||||
|
)
|
||||||
|
assert (explicit.hedges, explicit.generation_ms, explicit.hedge_won) == (1, 42, True)
|
||||||
|
|
||||||
|
def test_callcontext_record_generation_overwrite_and_accumulate(self):
|
||||||
|
"""chat/OCR 覆盖(结构化重问最后一轮为准);embedding 分批累加。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
ctx.record_generation(100, accumulate=False)
|
||||||
|
ctx.record_generation(30, accumulate=False)
|
||||||
|
assert ctx.snapshot().generation_ms == 30
|
||||||
|
ctx.record_generation(50, accumulate=True)
|
||||||
|
assert ctx.snapshot().generation_ms == 80
|
||||||
|
|
||||||
|
def test_callcontext_register_hedge_counts(self):
|
||||||
|
"""对冲路实际发出即计数;赢家裁定后一次性登记赢家身份。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
ctx.register_hedge(hedge_won=False)
|
||||||
|
ctx.register_hedge(hedge_won=True)
|
||||||
|
stats = ctx.snapshot()
|
||||||
|
assert stats.hedges == 2 and stats.hedge_won is True
|
||||||
|
|
||||||
|
def test_snapshot_includes_hedge_fields(self):
|
||||||
|
"""快照把三字段带出: 裸生成时间与对冲计数不停留在内部状态里。"""
|
||||||
|
from polygateway.types import _CallContext
|
||||||
|
|
||||||
|
ctx = _CallContext(now=_FakeMonotonic())
|
||||||
|
ctx.record_generation(200, accumulate=False)
|
||||||
|
ctx.register_hedge(hedge_won=True)
|
||||||
|
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):
|
def test_context_counts_attempts_and_freezes_elapsed(self):
|
||||||
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
|
"""快照是同步冻结的时间切片: 登记两次尝试后耗时按注入钟折算成毫秒。"""
|
||||||
from polygateway.types import _CallContext
|
from polygateway.types import _CallContext
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ async def test_salvage_override_stays_in_domain(usage):
|
|||||||
overlay={},
|
overlay={},
|
||||||
call_id="cid",
|
call_id="cid",
|
||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
|
first_token_event=None,
|
||||||
|
content_event=None,
|
||||||
|
response_mode="text",
|
||||||
)
|
)
|
||||||
assert result.usage_source in USAGE_SOURCES
|
assert result.usage_source in USAGE_SOURCES
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user