- fidelity cacheability also requires a non-empty projected body, so think-tag-only answers are no longer cached - a no-argument tool sent with arguments null only is a deterministic dialect: ResultInvalid tool_arguments_null instead of retried - text mode treats choice index null as 0 and no longer lets False pose as 0 - null-only tool_calls elements no longer produce an empty tool_calls list; non-stream fidelity checks role before the verdict; warning paths are length-bounded - docs: ARCHITECTURE 7.8 field counts, README cache/watchdog wording, CHANGELOG notes, review record in the design, hook note in the plan Red: 8 new tests failed before this change (e.g. test_openai_cacheable_requires_projected_body, test_null_arguments_tool_is_result_invalid_stream, test_text_mode_choice_index_null_is_zero[None-True]).
21 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 确认无误后直接执行",不再走人类设计审阅。两轮独立审查已闭合(R1 3 阻塞 + 9 应修;R2 通过,2 应修已并入),见 §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 交付、不可缓存、按源+值告警一次(文案写明"该值不缓存",便于下游定位命中率下降);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 与 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 不标不可缓存(上游确定性,非毒化) |
| 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 提醒 |