# ADR-0012: billcost dispatch = sub-prompt 进化机 - 按 (仓库ID, 快递类型ID, 网点代码ID/MSN) 复用收敛 prompt + 缓存存结构不存数值 + 收敛≠正确需真值晋升闸 - **Status**: Accepted as direction (PM 拍板方向 2026-05-30); 实现进度: **§2.3 main 跨轮记忆已建** (2026-05-30, 引擎 session 复用 + 每轮全量 eval; 当日演进 手搓轨迹→session 复用→删原表 delta→撤回 delta, 见修订 v2-v4); §2.1 三元组缓存 + §2.2 真值晋升闸 + §2.5 资产分层 + §2.6 硬闸软确认 属 roadmap 未建. **§2.7 收敛前冷眼验收 gate 已建** (2026-05-31, 见修订 v6). **§2.5 前置 — await 人工答复结构化落库已建** (2026-05-31, await 每次迭代 (question, answer) 对落 `quote_dispatch_rounds.human_answers`, 跟同行 main verdict 并置, 是 §2.5 资产分层 / 复用缓存的底料; 资产分层本身 (拆三层入缓存去数值) 仍 roadmap, 见 CHANGELOG 同日段). **xlsx 内嵌图通知 VLM 解析已建** (2026-05-31 follow-up, xl/media 内嵌调价公告图经 MiniMax vlm 转录作**独立平行输出**, 绝不进 SHEET_DUMP / sub-main-验收 loop — 对齐 bill-recon C6 vision 独立 AdjustmentBundle; merge 进报价 vs 永久独立由 PM 拍). **v5 (2026-05-30): 三元组写法对齐 PM 标准 (仓库ID/快递类型ID/网点代码ID, msnID=网点代码非商家) + 缓存存结构不存数值 + 进化资产分三层 + 复用判断硬闸软确认 + 验证需双表 + R4 回归是 main 自伤实证, 见修订 v5. v6 (2026-05-31): bb34a117 实证记忆让能力够的 main 也盖章 (§5.3) -> 拆出独立验收 agent 硬约束 (§2.7), 已实现.** - **Date**: 2026-05-30 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `platform/common/quotedispatch/dispatch.go` (Run loop / sub session rebuild / main fresh session), `platform/common/agentprompt/agentprompt.go` (Verdict / human_questions), `platform/common/internal/server/quotedispatch_handler.go` (dispatch endpoint / session store) - **Related ADRs**: ADR-0008 (quote-probe 协议多轮修订 - 本 ADR 描述的进化 loop 就是 ADR-0008 那套 main↔sub dispatch 的**目的论重定位**), ADR-0007 (capability tracking - 选型实证基础) - **Related memory**: `project_billcost_quote_extraction.md` / `project_evolve_rl_stance.md` / `feedback_verify_against_truth.md` / `feedback_verify_extraction_against_prompt_rules.md` - **Commit chain**: 无代码改动 (方向 / 目的论归档); ADR-0008 v3.5 多问 await (`9d7eaee`) 是当前进化 loop 的底座 --- ## 1. 背景 / Context ### 1.1 dispatch loop 的真实目的不是 "这次抽取", 是 "进化出可复用的 sub prompt" ADR-0008 那套 main↔sub dispatch loop, 表面看是 "多轮把一张报价表抽成 WMS JSON". PM 澄清了真实目的论: **loop 的产物不是这次的抽取结果, 是 main 通过反复调试 sub 的 system prompt **进化**出来的最优 prompt**. 抽取是副产品, **进化出来的 prompt 才是资产**. The real telos of the ADR-0008 main↔sub dispatch loop is not "extract this quote table once". The PM clarified it: the loop's product is not this run's extraction; it is the **optimal sub system prompt that main evolves by repeatedly debugging sub**. The extraction is a byproduct; the **evolved prompt is the asset**. ### 1.2 终态: 按 (仓库ID, 快递类型ID, 网点代码ID) 三元组缓存 + 复用 进化出的收敛 prompt 绑定到一个业务三元组 `(仓库ID, 快递类型ID, 网点代码ID)` (PM 标准写法; 第三位 MSN = 网点代码, 不是商家; 中位 快递类型ID 即早期记录的 shiptypeId). 同一三元组 = 同仓 + 同快递类型 + 同网点的报价表, 格式稳定. 终态: **首次为某三元组进化收敛后, 把那份 prompt 缓存; 下次同三元组再来, 直接复用, 跳过整个多轮进化** - 一次进化, 永久复用, 之后又快又便宜又确定. 多轮的贵是**一次性摊销成本**, 不是每单都付. **当前测试版还没接这三个 ID, 每次都从零重新进化.** > 复用安全性来自三元组的稳定性: 同 (仓库, 快递类型, 网点) 下报价格式与计费约定稳定, 所以这一格进化出的结构性人答 (如 "上海单带费按 3kg 首重") 在同三元组内复用是安全的; 换三元组则另起一格, 不串用 (§2.6 硬闸). ### 1.3 sub 无记忆是设计如此 sub 每个 retry 轮全新 session (dispatch.go: verdict=retry+new_prompt 时关旧 session+engine 用新 system prompt 重建). sub 看不到自己上一轮输出, 跨轮无记忆 - 这是**有意设计**: 进化的载体是 system prompt 本身, 不是 sub 的对话历史. sub 每轮拿当前最优 prompt 从零重抽, 是 "这版 prompt 跑出来什么" 的干净测量. ### 1.4 实证暴露的两个范式级事实 (2026-05-30 选型实证 + 取证) 跨模型选型实证 (deepseek / gemma moe-a4b q6+bf16 / gemma dense 31B; main↔sub 四格矩阵) 暴露: - **收敛 ≠ 正确**: main 自己 prompt 里就写 "你没有 ground truth". main 能收敛到一份**自洽但错**的 prompt. 实证: 一张新表偏远省 `base_weight` 被抽成 3000, 但原表表头明写 "首重1公斤" (=1000) 且建模约定明写偏远 base_weight=1000 - main 说 ok 了, 反射器过了, **产出是错的**. 反射器只查结构 (段位接吻 / 单调 / 单位), 不查 "是否忠于原表明示值"; main cross-check 漏了. - **main 跨轮无状态导致空转 / 震荡 / 回归**: main 每轮 (甚至 await 内每次重 eval) 全新 session, 跨轮完全失忆. 实证: 同一个 "海南拆两行" 错被 main 在 R2/R3/R5 反复重新诊断 (它不记得上轮已下达过同一指令); 偏远 base_weight 在 R1 本是对的 1000, R2 起被 sub 越界套用单带费 3kg 默认改成 3000, 之后再没救回 (取证: 逐轮 sub 输出 trace). 详见 § 5. --- ## 2. 决策 / Decision 把 billcost dispatch loop 正式定位为 **sub-prompt 进化机**, 拍三个方向 (实现属 roadmap): ### 2.1 资产是收敛 prompt, 按三元组缓存复用 durable asset = 收敛后的 sub prompt, 索引键 = `(仓库ID, 快递类型ID, 网点代码ID)`. 首次进化 -> 收敛 -> 缓存; 同三元组复用. 进化成本一次性摊销. **但收敛 prompt 不是整份原样入缓存 - 要先按 §2.5 拆三层, 只把"通用规则"上提基础 prompt + 把"结构性人答"入三元组缓存, 丢弃"一次性草稿".** The durable asset is the converged sub prompt, keyed by `(warehouseID, expressTypeID, siteID/MSN)`. First evolution converges and caches; the same tuple reuses it. The evolution cost is amortized once. **But the converged prompt is not cached verbatim - it is first decomposed per §2.5 into three layers; only the "general rules" are promoted into the base prompt and the "structural human-answers" enter the tuple cache, while the "one-shot scratch" is discarded.** ### 2.2 收敛 prompt 晋升进缓存前必须过 "真值闸" main 无 ground truth, 光自评收敛会把**带病 prompt** (如 base_weight=3000) 缓存后**在该三元组未来每单上规模化重放**. 因此: **一份 prompt 从 "收敛" 晋升为 "该三元组可复用资产" 前, 需过一次真值校验** (人工 / golden 对真值确认一次). 没过真值闸的收敛 prompt 不进缓存. > **人工答复 + 一次性人工复核 = 给进化资产盖质检章的环节, 不是每单都要的负担.** 首次为某三元组进化时人介一次, 确认无误晋升; 之后复用已验证 prompt, 不再麻烦人. ### 2.3 记忆按三层处理, 答案不同 - **圈内 turn (await 来回): 维持现状, 不加记忆.** main 每次重 eval 是全新 session, 但操作员答复以文本累加进 prompt (`BuildMainAwaitAnswerMessage`). 圈内上下文靠 prompt 累加, 够了. - **跨 round: 给 main 加一份**压缩的进化轨迹记忆** (✅ 已实现 2026-05-30).** 进化本质是带轨迹的搜索; 失忆 main = 每圈随机重启, 导致重复诊断 + 震荡. 给 main 一份压缩轨迹 (每轮 verdict + 一句 reason + 反射器结果), 让它定向改进、不重复已下达诊断、察觉 "我是否把上步改回去了". **关键约束: 必须是压缩摘要, 不是把每圈 ~20K sub 原文 + ~18K prompt 全塞回去** (8 圈就 300K token, 既贵又让 main 锚定旧判断). 实现 (2026-05-30): **初版手搓压缩轨迹 (roundSummary/buildTrajectoryBlock) 同日被引擎 session 复用取代** — 见修订 v3. 当前实现 = main 跨轮复用一个 session (历史天然带往轮 eval/verdict/await 问答, remember-by-default) + **每轮发全量 eval (含原表)**. 取代手搓的原因: 手搓 forget-by-default 漏了 await 答复 (苏州被问两次), 而 main = 在线 1M 上下文 + 廉价长寿 cache 使"全量历史"既装得下又便宜, 故无需压缩. (v3 曾为省 token 在 round 2+ 删原表走 delta, 削弱了对照原表的 cross-check, v4 同日撤回, 见修订.) - **跨 run (不同 session 同三元组): 真正的长期记忆是缓存的那份已验证 prompt.** 不需 main 内存记 - 持久化的、按三元组索引的 prompt 即系统长期记忆 (= § 2.1). ### 2.4 词汇钉死: round vs turn - **round** = dispatch 大循环第几圈 = sub 抽一次 + 反射器 + main 判一次; main 说 retry -> 下一圈 (sub 拿新 prompt 重抽). - **turn** = 一圈内某 agent 被调用第几次; 最典型在 await: 一圈内 main 问 (turn 1) -> 操作员答 -> main 重 eval (turn 2), 同 round_num 多个 main turn. ### 2.5 进化资产分三层, 缓存存结构不存数值 收敛 prompt 不是一坨, 是**三类性质完全不同**的内容被 main 平铺进了同一份 sub_prompt_used (5f2a7145 取证). 晋升进缓存 = 把三类拆开: 1. **全局通用规则** - 表无关的抽取约定 (一口价段 base_weight=段顶 limit_top / 续重段 base_weight=段起点 limit_bottom 且 base_amount=0 / 原表标 "首重0kg" 的段 base_weight=0). 适用任何表, **回写进基础 sub_agent.md, 不进三元组缓存**. 2. **三元组级结构性人答** - main 问操作员得到的歧义解 (上海单带费按 3kg 首重 等). **进三元组缓存, 但只存结构, 不存数值**. 3. **一次性输出草稿** - 这一次跑出来的具体值 (全省 一区0.6/二区1.5/三区2.0 价目清单等). **纯废料, 丢弃**. **存结构不存数值 (PM 2026-05-30 拍板, R5 prompt 过拟合的病根)**: 第 2 类人答要再切一刀 - 结构性事实 (上海单带费按 3kg 首重: 原表常年不写首重重量, 是稳定约定) vs 数值快照 (`base_weight=3000, base_amount=0.5, increment_price=0.1`). **只有结构进缓存; 数值永远从新表现读** - 同三元组下月调价是常态 (0.5→0.6), 但 "按 3kg 首重" 这个结构不变. 所以缓存的人答必须写成逼 main 回原表核对的**条件句**: "上海单带费: 原表若仍未写明首重重量, 则按 3 公斤; 若写明, 以原表为准" - 是带条件的规则, 不是死值. R5 prompt 坏在把数值也腌进去 + 把全省价目清单当规则塞进 sub_prompt_used, 换表必灌错价. The converged prompt is not one blob - it is three kinds of content that main flattened into one sub_prompt_used. Promotion = separating them: (1) **general rules** (table-independent conventions) promoted into the base prompt; (2) **structural human-answers** entering the tuple cache, **structure only, no values**; (3) **one-shot scratch** discarded. Values are always re-read from the new table because they drift month to month while the structure is stable; cached answers are phrased as conditionals that force main to re-check against the new table's actual text. ### 2.6 复用判断 = 硬闸 (三元组精确匹配) + 软确认 (main 回原表核对) 新表来时是否复用缓存人答: **硬闸 = 三元组精确匹配** (仓库ID + 快递类型ID + 网点代码ID 全等才命中); **软确认 = main 看一眼缓存人答适不适合这张新表**. 软确认有**盖章风险** - 我们亲眼见 main 用 "其余一致" 盖章放过回归 (§5), 这个 fit 判断有一模一样的风险. 缓解: §2.5 已把人答写成逼 main 回原表核对的条件句, 让软确认退化成 "原表这处到底写没写", 而不是 "我觉得差不多就套". **别让 main 单凭软判断就盲套数值** - 硬闸是结构性命中, 软确认只确认结构仍适用, 数值仍回原表现读. Reuse decision = hard gate (exact tuple match) + soft confirm (main re-checks the cached answer against the new table). The soft confirm carries the same rubber-stamp risk we observed in §5, mitigated by phrasing cached answers as conditionals (per §2.5) so the soft check degrades to "does the new table state this or not" rather than a blind "close enough, apply it". ### 2.7 收敛前冷眼验收 gate: 拆出独立验收 agent 防 main 记忆偏置盖章 (已实现) **问题 (bb34a117 实证, §5.3)**: main 同时是提示词进化者 (带状态 — 跨轮记忆才不重复诊断 / 不重问已答, §2.3) 和忠实度验收者 (需冷眼复核). 这两个角色对记忆的要求相反, 塞进一个 agent 必然冲突: 一个**能力够**的 main 在 R4 诊断 "标准段 base_weight 应为 0", 之后因自己历史说 "我办过了" 再没复查, R7 用 "忠实于原表" 把 25 省的 bw=3000 盖章放过. 软提示 ("核对你让 sub 改的有没有改") 斗不过这个记忆偏置 — 它不是能力问题, 是**记忆让 main 主动停止复查**. **硬约束**: 把验收者拆成独立 agent (架构上的第 4 个 agent, 与 sub / 反射器 / main 并列). main 给 verdict=ok 时, dispatch **不直接收敛**, 先强制跑一道验收: 同一模型 (默认), 但在**零历史 session** 里, 对照原表逐字段重核最终 JSON. 验收也 ok 才真收敛; 验收挑出不符 → 路由回 main (main 拥有 sub prompt) 改 sub prompt → 走现有 retry. **是代码强制的必经步骤, main 跳不过 — 这才叫硬约束, 区别于 §2.6/L136 那种 main 能用记忆绕过的软提示.** 关键: 验收是**同模型去掉记忆**, 不是换更强的模型 — main 当初没抓不是能力不行, 是记忆偏置, 零历史就抓得住. The hard constraint splits the verifier into a dedicated 4th agent (alongside sub / reflector / main). When main returns ok, dispatch does NOT converge directly — it forces an audit pass in a ZERO-HISTORY session (same model by default) that re-checks the final JSON against the source table field by field. Converge only if the auditor also says ok; on dissent the findings route back to main (which owns the sub prompt) and flow through the existing retry path. It is a code-enforced mandatory step main cannot skip — that is what makes it a HARD constraint, unlike the §2.6/L136 soft prompt instruction that main's memory overrode. The auditor is the same model MINUS the memory, not a stronger one: main missed the gap from memory bias, not incapacity. **四个 agent 各司一职** (各自记忆需求不同, 这正是要拆开的理由): | agent | 职责 | 看原表 | 带记忆 | |---|---|---|---| | sub | 抽取 | 看 | 不带 (单次) | | 反射器 | 验**结构** (段位 / 单调 / 白名单, 纯 Go 规则) | **不看** | 不带 | | main | 进化 + 编排 | 看 | **带** (§2.3) | | **验收 (本节)** | 验**忠于原表** | 看 | **不带 (冷眼)** | 验收 agent 是反射器的 LLM 版兄弟: 反射器查代码能查的结构, 验收查 "数值忠不忠于原表" (代码跨表干不了, 必须看表理解的). 补上了过去没人干的那块 - 反射器干不了 (不看表), main 干不可靠 (记忆偏置). **配置 + 可观察 (PM 硬要求)**: 可开关 (`AuditEnabled`, 默认开; 关 = 回到 main ok 即收敛的旧行为, 纯叠加无回归); provider/model 默认 = main (可单独指); 独立 audit 提示词 `_audit.md` (不碰 main/sub). 验收每次作为独立一轮落库 `source="auditor"` (verdict + 不符项进 `main_verdict` 列) + 走 OnEvent + 日志 - 不藏代码里, `review-dispatch.py` 与 UI 都看得到验收核了啥, 判了啥. **防死循环 (plan A)**: `MaxAuditDisagreements` (默认 2) 限验收驳回次数; 超限则停止升级, 靠整体 `MaxRounds` 终止为**未收敛** (surface 出来, 不是假 ok). **plan B** (验收↔main 僵持 -> `await_human` 拉人工裁定) 是 v1.1 follow-up. **fail-open**: 验收 transport 错 / verdict 解析失败不阻断已收敛的 run (验收是安全叠加件, 基础设施抖动翻好 run 为 error 比它防的盖章更糟), skip 大声记进 trace + reason. **实现 (2026-05-31)**: `quotedispatch/dispatch.go` Request 加 `AuditEnabled/AuditProvider/AuditModel/AuditPrompt/AuditTemperature/AuditDisableJSONSchema/MaxAuditDisagreements`; ok 分支插 gate (fresh session 每次新建); `RoundTrace` 加 `AuditText/AuditVerdict/AuditDone/AuditElapsed`; `agentprompt.BuildAuditorFeedbackMessage`; `quotedispatchstore.SourceAuditor`; handler `persistRoundTraces` 写 auditor 行 + `QuoteDispatchConfig.AuditEnabled/DefaultAuditPrompt` (cmd/common 从 `QUOTE_AUDIT_ENABLED` 设, 默认开, 与已加载 `_audit.md` 相与); probe 加 `--audit*` flag (顺带修 probe 对 deepseek 的 `MainDisableJSONSchema`). 4 个 gate 测试 + 1 个 persist 测试, 全模块 -race 绿. --- ## 3. 替代方案 / Alternatives - **每次从零重进化 (当前态)**: 简单但每单付全额进化成本, 否决为长期模式 (只作 ID 接入前的临时态). - **整份收敛 prompt 原样入缓存**: 否决 - 收敛 prompt 混了通用规则 + 结构性人答 + 一次性草稿三类, 原样缓存等于把这张表的全省价目清单 (一次性草稿) 一起腌进去, 换表灌错值 (§2.5). 必须先拆三层. - **人答存数值快照 (base_weight=3000 等)**: 否决 - 同三元组下月调价是常态, 数值快照换表即过期. 只存结构 (按 3kg 首重), 数值永远从新表现读 (§2.5). - **复用只靠 main 软判断 (无硬闸)**: 否决 - 软判断有盖章风险 (§2.6), 必须三元组精确匹配做硬闸, 软确认只做结构复核. - **信 main 收敛不设真值闸**: 否决 - 收敛≠正确, 缓存会把错规模化 (§ 1.4 实证). - **给 main 灌全量跨轮原始历史**: 否决 - 8 圈 300K+ token, 上下文膨胀 + 锚定旧判断. 取压缩轨迹 (§ 2.3). - **直接微调专属抽取模型, 不走 prompt 进化**: 见 `project_evolve_rl_stance` - prompt 进化是 v0.x 路径, 专属小模型 + 局部 RL 是 v1.x 路线, 两者不互斥, 进化产出的 (prompt, 真值) 对将来正是微调语料. --- ## 4. 影响 / Consequences - **正面**: 进化成本一次性摊销; 同三元组复用确定/快/省; loop 从 "一次性抽取器" 升级为 "资产生产机". - **风险**: 带病收敛 prompt 入缓存 = 错误规模化 (真值闸缓解); 缓存存数值快照会换表灌错值 (存结构缓解, §2.5); 复用软判断有盖章风险 (硬闸缓解, §2.6); 三元组键需消费者在 dispatch 端点传 仓库ID/快递类型ID/网点代码ID (handler API 变更). - **抬高了 main cross-check 与现存 bug 的严重性**: 收敛 prompt 被复用后, main cross-check 盲区 (如漏核偏远 base_weight) 不再只伤一单, 而是伤该格式未来每单. 故 main 逐字段对原表 cross-check 的完备性 + 真值闸是这套设计成立的前提, 不是可选优化. --- ## 5. 验证 / Validation 2026-05-30 跨模型选型实证 (ytosample + 一张 43.6MB 账单内 "报价" sheet 两张表): - **main 能力主导** (四格矩阵): gemma-both 10 轮零收敛 / dense-both 反复同问 / **deepseek main + gemma sub 8 轮干净收敛对真值** / deepseek-both 3 轮. 同一跑不动的 gemma sub 换强 main 即收敛 - 变量是 main. - **收敛≠正确**: 新表偏远 base_weight=3000 (应 1000), main 说 ok + 反射器过, 但对原表表头 "首重1公斤" + 建模约定 + 抽取字面三源比对确认是错的. - **跨轮失忆致回归** (逐轮 sub 输出取证): R1 偏远 bw=1000 正确; R2 (操作员答单带费3kg + main 重写后) sub 把**限 is_strip_fee 的 3kg 默认规则越界套到偏远** -> bw=3000; R3/R4 没救回. main 重写规则本身带作用域 (没写错), 是 sub 越界 + main cross-check 未逐圈复核. ### 5.1 提示词演化取证: R4 回归是 main 自伤, 不是 sub 凭空犯 (session 5f2a7145, 2026-05-30) 逐轮 dump main 写给 sub 的 `sub_prompt_used` 并 diff (诊断工具 `deploy/review-dispatch.py`), 5 轮收敛, 暴露: - **R4 标准 3.01段 base_weight 全 0→3000 的回归, 根因在 main 自己写的规则**: main 在 R3→R4 加了条**过宽**规则 "续重段 base_weight 应等于段起点 limit_bottom" (本为修海南二段). 标准 3.01段 (limit_bottom=3000) 也有续重, 长得就像续重段 -> sub 照办 -> 25 省 base_weight 全渗成 3000 (回归值 3000 正好 = limit_bottom, 完全吻合该规则). 这不是 "sub 凭空越界", 是 sub 老实执行了 main 的过宽规则. - **R4→R5 main 加反向规则打补丁救回**: main 抓到输出回归后, 加了条 "不得因续重段规则而自动改为段起点(3000)" 的例外规则, 把标准第5段从过宽规则里挖出来. 救回了 (R5 全 0, 对真值), **但这是打补丁, 不是范式级修法** - 两条规则在 prompt 里彼此拉扯 (A: 续重段=段起点; B: 标准第5段例外不准=段起点). 正解是一条规则: base_weight 跟原表对首重的明示走 (标 "0kg"→0 / 含首重前段→续重段=段起点), 而不是 "续重段一律=段起点" 这种过宽写法. - **据此修正叙述**: 早先一度把 R4 这次说成 "main 英雄般抓住了 sub 的回归 (验证了 PM 调优的 prompt)"; 提示词演化取证后修正为 **"main 用一条过宽规则自捅, 再用例外规则自堵"**. PM 调优的 "并核对原表语意" 确实让 main 这次没盖章放过 (对比早先 f767b22e: 同样的渗透, main 一句 "其余一致" 盖章 -> 错误结果直接出厂), 但根因是 main 该一次写对规则而非过宽后打补丁. ### 5.2 复用本身的验证必须双表 (尚未具备) §2.1 复用命题 = 缓存三元组 X 后, 来**第二张同三元组**的表, 不问人, 不重跑多轮, 直接抽对. **一张表验证不了复用** - 手上只有 ytosample 一张. 要验证必须有第二张同三元组的表: 要么等下月真数据, 要么拿 ytosample 派生两张 - **① 改价不改结构** (验 §2.5 "人答扛得住调价") + **② 换结构 / 换承运商** (验 §2.6 软确认该拒时真拒). 两个失败方向都得测: **该复用时没复用 (白问人, 浪费)** + **不该复用时硬套 (抽错, 更危险)**. 后者是缓存最大的风险面. ### 5.3 记忆让能力够的 main 也盖章 (session bb34a117, 2026-05-31) -> 触发 §2.7 验收 gate 本地 gemma sub + 云端 deepseek main 冷跑, 7 轮 "收敛", main 判 ok, 但 dump R7 对真值: **25 个标准 3.01段 base_weight 全 = 3000 (应为 0)** + 崇明单带费 bw 错. 逐轮取证: - main 在 **R4** 明确诊断 "标准成本最后一段...子 agent 填了 base_weight=3000, 应填 0". - 但 gemma sub **R4-R7 一直填 3000 没改** (CHANGED 列里这些段全程不变), main R5/R6 注意力全在 海南 / 偏远, **再没回去复查 bw**. - **R7** main 却说 "语义层 cross-check 确认所有省份、段位、价格均忠实于原表", 把 25 省的核心错盖章放过. **关键结论 (PM 点透)**: 这**不是 sub 弱导致漏检** — 复查是 main 的活, main (deepseek) 在全 deepseek 两跑里能抓住同类渗透. 漏检根因是 **§2.3 加的跨轮记忆让 main "我办过了" 的错觉盖过了复查** — 越能推理的模型越会 "理性地" 得出 "处理过了不必重看". 强 sub (交代即改) 时这个错觉恰好成真不露馅; 弱 sub (没改) 时记忆把持续错误盖住 -> 假收敛. 已写的软提示 (main_agent.md L136 "核对你让 sub 改的有没有改") 在场仍失灵 (近因偏向 + 例子偏向 "改了又改回" 而非 "从没改" + 记忆 "已办" 错觉三者叠加). 这把 "main 的 ok 不可信" 从 "弱 sub 边缘情况" 升级为**通用风险** -> 必须 §2.7 的代码强制冷眼验收, 且 §2.2 真值闸非可选. --- ## 6. 触发重新评估的条件 / Trigger conditions - 消费者侧 (仓库ID, 快递类型ID, 网点代码ID) 接入就绪 -> 落地 § 2.1 缓存 + § 2.2 真值闸. - 出现强到不需多轮进化的 sub 模型 -> 进化 loop 可能退化为单轮 + 真值闸, 跨圈轨迹记忆价值下降. - 专属微调模型成熟 (project_evolve_rl_stance v1.x) -> 重估 prompt 进化与微调的分工. --- ## 7. 工程量 / Engineering footprint 本 ADR 0 行代码 (方向 / 目的论归档). 底座是 ADR-0008 现有 loop (含 v3.5 多问 await). roadmap 实现粗估: - handler dispatch 端点接 `仓库ID` / `快递类型ID` / `网点代码ID` 三字段 + prompt 缓存表 (按三元组索引, 复用 quotedispatchstore 模式). - 收敛后 "晋升" 流程 + 真值闸 (人工确认 / golden 比对) 才写缓存. - **晋升时按 §2.5 拆三层**: 把收敛 prompt 切成 通用规则 (上提基础 sub_agent.md) / 结构性人答条件句 (入三元组缓存, 去数值) / 一次性草稿 (丢). 这步是 "晋升" 的实质, 不是简单 copy 收敛 prompt. 拆分可半自动 (main/工具按段分类) + 人工核. - **复用判断 §2.6**: 硬闸 = 三元组查表精确命中; 软确认 = 命中后 main eval prompt 注入缓存条件句, 让 main 回原表核对结构是否仍适用. - 诊断工具 `deploy/review-dispatch.py` (2026-05-30): 给 session-id (支持 UI 显示的 8 位前缀) 出 forensic - 逐轮 detail 字段演变 (自动标跨轮变过的=回归嫌疑) + 逐轮 sub_prompt_used 演变 diff (看 main 怎么进化提示词) + 每轮 main verdict/reason/提问. 是审 "进化质量" 的工具, 不判对真值 (那需原表 + 人读). - (历史/可选) main 跨圈压缩进化轨迹记忆 - 已被 §2.3 引擎 session 复用取代. --- ## 8. 修订记录 / Revision history ### v1 (2026-05-30): 初版 - 目的论重定位 + 三方向 + round/turn 词汇 捕获自 PM 设计意图讨论 + 2026-05-30 gemma/deepseek 选型实证. 确立: loop 产物是可复用进化 prompt (按三元组缓存) / 收敛≠正确需真值晋升闸 / 三层记忆模型 / round-turn 词汇. 实现属 roadmap, 当前测试版未接三元组 ID. ### v2 (2026-05-30): §2.3 main 跨圈进化轨迹记忆落地 (1 commit) PM 在三选项中选 §2.3 先做 (最干净: 纯 quotedispatch 引擎层, 不动 handler API / UI / prompt, schema-agnostic). `dispatch.go` 加 `roundSummary` (round + verdict + 一句 reason + 反射器 pass/violations) + `buildTrajectoryBlock` (渲成 evalPrompt 一段, round 1 空, 末附"别重复诊断 / 别震荡"两条用法指令) + `collapseReason` (rune-safe 压成单行). loop 外 `var trajectory`, 每轮终态 (await loop 后) append, 注入 evalPrompt. 新增 `TestRun_FeedsTrajectoryToMain`. 全模块 -race 绿. **此版同日被 v3 取代.** ### v3 (2026-05-30): §2.3 改用引擎 session 复用 + delta eval, 取代 v2 手搓轨迹 (1 commit) v2 手搓压缩轨迹有效 (8->5 轮) 但暴露 **forget-by-default 缺陷**: 它只记 verdict+reason, **漏了 await 操作员答复** -> PM 实测苏州单带费被问两次 (R2 问答过, R3 又问, 因答复跨轮没留痕). PM 追问"是否该用引擎能力 / 成本如何", 逐层厘清: - 引擎有现成 session 多轮 (`Session.Send` 自动带历史 + append, sub 已用) + 压缩 (`pkg/context` Compact, 模型驱动有损) + memory (ScopeRoot). session 复用 = **remember-by-default**, 结构上修掉"忘记没枚举到的"这类 gap. - 成本: main = deepseek 在线 1M 上下文 (全量历史装得下) + **长寿廉价 cache** (实测一天 input cache hit 456K / miss 580K, output 占成本 ~74% 且与记忆机制无关). 稳定历史前缀被 cache 廉价吸收, 复用甚至比每轮 fresh session 更省 (后者吃不到跨轮 cache). 当前设计还把大块原表 dump 放在每轮变动的 sub 输出之后, 浪费缓存; delta 模式把表放 round 1 进 history 后缓存. 实现: main 跨所有轮 (含 await 重 eval) 复用一个 session; eval delta 模式 (round 1 全量 / round 2+ 增量); await 答复当下一 turn Send 在同 session; 删 v2 手搓 (roundSummary/buildTrajectoryBlock/collapseReason/trajectory). sub 不动 (单次/fresh/本地), prompt 不碰. 全模块 -race 绿. **(注: 本版的 delta eval 同日被 v4 撤回 — 见下.)** ### v4 (2026-05-30): 撤回 v3 的删原表 delta + 修 Run 耗时永远 0s (1 commit) **撤回 delta (恢复每轮全量 eval)**: v3 为省 token 让 round 2+ 不发原表 (只在 round 1 history). PM 实测 (gemma sub 多轮) base_weight 该填 0 (原表头 "面单首重(0kg)") 却被填 3000 — 因为原表不再紧贴 sub 输出, main 对照原表的 source-faithfulness cross-check 被削弱; 且这是没人要求的优化. 反射器只看抽出的 JSON 不看原表, 管不了这种对照. PM 拍 "原表肯定要恢复". 改回**每轮发全量 eval** (sub prompt + 输出 + violations + 原表), 保留 session 复用 (await 问答跨轮记忆仍在, 苏州不重问). 原表重发能被主 provider cache 吸收 (实测全 deepseek 2 轮 ds 后台账单 ¥0.05). 测试 `TestRun_MainSessionContinuity` 断言改回 "每轮全量". dispatch.go 留 LEGACY 注释记此撤回. **修 Run 耗时永远 0s**: `func Run(...) Result` 未具名返回, `return res` 先拷返回槽再跑 defer `res.Elapsed=time.Since(...)`, 改的是脱钩本地 → 调用方 Elapsed 永远 0 (日志 elapsed=0ms, UI 显示 0s). 改具名返回 `func Run(...) (res Result)`. 加 `TestRun_ElapsedPopulated` 回归 (现有测试都不碰 Elapsed, 故 bug 在绿测试下出厂). 纯后端, handler / 前端读的字段本来就对. **关键澄清**: base_weight=0 最终跑对, 真功臣是 **PM 调优 main prompt** (加 "收敛前必须核对你让 sub 改的到底改没改 — sub 当轮改了下轮可能改回去"), 不是撤 delta 本身. 撤 delta 是恢复 cross-check 的脚手架 (让原表每轮在场), prompt 那条是让 main 真去用它复查. 两者配合. Commit: `5e2d66e` (撤 delta + 修 0s) + `31b1f13` (PM 调优 main prompt). **遗留 (tracked debt)**: (a) "操作员已确认事实"目前靠 session history 隐式携带, 若将来 compaction 触发可能丢, 需 PostCompactRestorer 钉住 (现成 seam, policy 消费者填) — 短 run 不触发, 暂不做; (b) "复用便宜"依赖主 provider 廉价长寿 cache (deepseek 满足), 换 provider 需重算; (c) 这套"按保真度分级的跨轮记忆"机制是 **candidate engine seam**, 第二消费者出现时再把机制提引擎 / policy 留消费者 (现 n=1 不抽). §2.1 缓存 + §2.2 真值闸仍 roadmap. ### v5 (2026-05-30): 三元组中位修正 + 缓存存结构不存数值 + 资产分三层 + 硬闸软确认 + 验证需双表 + R4 自伤实证 (0 代码, 设计深化) PM review "main 给 sub 进化提示词" 维度 (逐轮 dump `sub_prompt_used` diff, session 5f2a7145) 后拍定的设计深化, 0 行代码 (roadmap 细化). 五条: 1. **三元组写法对齐 PM 标准**: `(仓库ID, 快递类型ID, 网点代码ID)`. 真正的修正是**第三位 msnID = 网点代码, 不是商家** (原 ADR gloss "msnID 商家" 写错); 中位 **快递类型ID 原记录 (shiptypeId) 就对**. (本会话曾一度把中位误改成"快递公司ID"又改回, 不留痕, 以此条为准.) 2. **缓存存结构不存数值** (§2.5): R5 prompt 过拟合病根 = 把数值快照 (base_weight=3000) + 全省价目清单都腌进 sub_prompt_used. 只存结构 (按3kg首重), 数值永远从新表现读 (下月调价是常态). 人答写成逼 main 回原表核对的条件句. 3. **进化资产分三层** (§2.5): 通用规则 (上提基础 sub_agent.md) / 三元组级结构性人答 (入缓存去数值) / 一次性草稿 (丢). 复用设计崩在 main 把三类平铺进同一份 prompt; 晋升 = 拆三层. 4. **复用判断硬闸 + 软确认** (§2.6): 硬闸=三元组精确匹配, 软确认=main 回原表核对结构仍适用. 软确认有盖章风险 (§5 实证 main 会 "其余一致" 盖章), 靠条件句逼复查缓解. 5. **复用验证必须双表** (§5.2): 一张表验不了复用. 需第二张同三元组表 (改价不改结构 / 换结构换承运商), 测两个失败向: 该复用没复用 (白问人) + 不该复用硬套 (抽错, 更危险). **附带修正叙述** (§5.1): R4 标准段 bw 全 0→3000 回归, 根因是 **main 自己写的过宽规则** "续重段=段起点" (3000 正好=limit_bottom), 不是 sub 凭空越界; main 再加例外规则打补丁救回 = **打补丁非范式级修法**. 早先一度说成 "main 英雄抓 sub 回归", 据提示词 diff 修正为 "自捅自堵". PM 调优的 "并核对原表语意" 确让 main 这次没盖章放过 (对比 f767b22e 同样渗透被盖章出厂), 但正解是一次写对规则. 诊断工具 `deploy/review-dispatch.py` 落地 (审进化质量: 逐轮字段演变标回归 + sub_prompt_used diff + main verdict, 不判真值). ### v6 (2026-05-31): 收敛前冷眼验收 gate (§2.7) 落地 — 拆出独立验收 agent 防 main 记忆偏置盖章 bb34a117 (local gemma sub + deepseek main, 7 轮 "收敛" 实则 25 省标准段 bw 全错, §5.3) 暴露: **§2.3 加的跨轮记忆让能力够的 main 也会因 "我办过了" 错觉停止复查, 把自己 R4 诊断过的错 R7 盖章放过**. 软提示 (main_agent.md L136) 斗不过记忆偏置. PM 拍板硬约束: 把忠实度验收者从 main 拆成**独立的第 4 个 agent**, 收敛前强制在**零历史 session** 重核一遍 (同模型去记忆, 非换模型), 验收也 ok 才收敛, 验收驳回则路由回 main 改 sub prompt. 详见 §2.7. 实现: `quotedispatch/dispatch.go` (Request 7 个 audit 字段 + ok 分支插 fresh-session gate + `DefaultMaxAuditDisagreements=2` plan A 兜底 + fail-open); `result.go` (RoundTrace `AuditText/AuditVerdict/AuditDone/AuditElapsed`); `agentprompt.BuildAuditorFeedbackMessage`; `quotedispatchstore.SourceAuditor`; handler (`persistRoundTraces` auditor 行 + `QuoteDispatchConfig.AuditEnabled/DefaultAuditPrompt`, cmd/common `QUOTE_AUDIT_ENABLED` 默认开 + 与 `_audit.md` 加载相与的安全守卫); 新增独立 `prompts/_audit.md` (不碰 main/sub); probe `--audit*` flag (顺带修 probe 对 deepseek 的 `MainDisableJSONSchema`, 修了 §2.7 测试时撞的 standalone-probe deepseek-main `response_format unavailable`). 配置 (开关 / provider-model / prompt) + 可观察 (source=auditor 落库 + OnEvent + 日志) 满足 PM 两条硬要求. 测试: 4 个 gate 测试 (确认 ok 收敛 / 关闭走旧行为 / 抓不符弹回 main / 驳回超上限未收敛) + 1 个 persist auditor 行测试, 全模块 -race 绿. **plan B follow-up (v1.1)**: 验收↔main 反复僵持 -> 升级 `await_human_input` 拉人工裁定 (当前 plan A 靠 MaxRounds 终止为未收敛); audit transport 加固后可把 fail-open 改 fail-closed.