--- 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 确认无误后直接执行",不再走人类设计审阅。第一轮独立审查 3 阻塞 + 9 应修已逐条核实并修订(见 §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 交付、**不可缓存**、按源+值告警一次 | | 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,则以该 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 规则**优先于**已知字段类型校验: 任意层任意键值为 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` 由此计入);同帧只记一次。文本模式同口径。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` 保留存储值 | | 序列化 | `_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 不标不可缓存(上游确定性,非毒化) |