Files
PolyGateway/research-wiki/designs/2026-10-14-issue30-31-completion-verdict-design.md
T

20 KiB

type, node_id, title, date
type node_id title date
design design:2026-10-14-issue30-31-completion-verdict-design 统一完成判定设计(#30/#31) 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、#31。 修订对象: 1.3.9 保真设计 §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 与 lib/streaming/_deltas.py;vLLM 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 不标不可缓存(上游确定性,非毒化)