返回博客列表
最佳实践2026-08-1912 分钟阅读

三张意图表合并成一张:一次「单源化」重构,如何用常驻断言把漂移锁死

单一事实来源 · 派生视图 · 常驻不变量 · 等价搬迁 · 基线纪律

WeClaw_89|三张意图表合并成一张:一次「单源化」重构,如何用常驻断言把漂移锁死

系列文章第 89 篇 - 单一事实来源 · 派生视图 · 常驻不变量 · 等价搬迁 · 基线纪律


📚 专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏

专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用

本文记录一次教科书级的「多源漂移」治理。WeClaw 的意图-工具路由知识同时住在三张手写表里,改了 A 忘了 B 就出运行时事故。这篇讲我们如何把三表合并成一张带四键域的单表(工具/推荐/备选/互斥),用字典推导保住全部旧调用方,再用「校验脚本 + pytest 红线 + 行为基线」三层常驻断言把不变量焊死——重构后 validate_tool_chain 的历史失败项当场清零。


👨‍💻 作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 三张表各管一摊的原始设计(问题的形态)→ 一次真实的运行时事故如何暴露漂移(动机)→ 单表 + 四键域 + 派生视图的方案设计 → 与 git HEAD 旧表逐意图 diff 的等价性验证 → 三层常驻断言体系 → 裁定口径 B:如何允许「正确的变化」通过门禁。

核心结论

  1. 多源数据结构的漂移不是纪律问题,是结构问题——只要存在第二份拷贝,事故只是时间问题;
  2. 安全的单源化 = 单表 + 派生视图(旧调用方零改动)+ 不变量断言(防止退化回多源);
  3. 门禁的价值不在「全绿」,在于失败清单可解释——每一条差异要么归入裁定白名单,要么修掉。

一、三张表,三个「以为自己是权威」的编辑入口

WeClaw 的意图引擎识别出 25 类意图后,需要回答三个问题:这个意图能用哪些工具?优先用哪个?哪些工具禁止碰? 这三个问题的答案分别住在三张手写字典里:

# intent_engine.py(重构前,三张表各 ~150 行)
INTENT_TOOL_MAPPING = {          # 问题 1:意图 → 可用工具全集
    "research": ["search", "knowledge_rag", "ai_detection", ...],
    ...
}
INTENT_PRIORITY_MAP = {          # 问题 2:意图 → 推荐/备选工具
    "research": {"recommended": [...], "alternative": [...]},
    ...
}
INTENT_EXCLUSIVE_TOOLS = {       # 问题 3:意图 → 互斥工具
    "research": {"exclude": [...]},
    ...
}

三张表的消费方遍布全链路:tool_exposure.py 用它们做分层暴露和 Schema 标注,agent.py 用它们做工具偏离的前置拒绝,validate_tool_chain.py 用它们做工具链完整性校验。

1.1 漂移的形态

同义漂移:给 research 意图新增一个工具,在 INTENT_TOOL_MAPPING 里加了,忘了在 INTENT_PRIORITY_MAP 里标注优先级——工具能调但永远拿不到「推荐」前缀,模型无从知晓它是一等公民。

死引用:工具下线了,INTENT_PRIORITY_MAP 里的标注还指着它。这个错误安静地潜伏着,直到 validate_tool_chain 的标注可达性检查把它揪出来——校验失败,但没人知道失败是从哪次改动开始的

互斥失配INTENT_EXCLUSIVE_TOOLS 的 exclude 列表与 tools 列表悄悄重叠,模型调一个「既推荐又禁止」的工具,前置验证直接拒绝自己人。

1.2 为什么纪律解决不了

团队可以约定「改 A 必须查 B 和 C」,但这个约定的执行者是。人的检查清单会随时间衰减,而代码库的修改频率不会。只要第二份拷贝存在,每一次修改都是掷骰子。这是结构问题,不是态度问题——解法只能是让第二份拷贝物理上不存在


二、方案设计:单表为源,旧表为派生视图

2.1 合并策略:一个意图一行,四个键域

把三张表按意图对齐,合并成一张 INTENT_TOOL_PROFILES,每个意图一个条目、四个键域:

INTENT_TOOL_PROFILES: dict[str, dict[str, list[str]]] = {
    "research": {
        "tools":       ["search", "knowledge_rag", "oss_admin", ...],   # 可用全集
        "recommended": ["search", "knowledge_rag"],                     # 一等公民
        "alternative": ["oss_admin"],                                   # 备选
        "exclude":     ["meal_menu", "games"],                          # 互斥
    },
    "casual_chat": {
        "tools": [], "recommended": [], "alternative": [], "exclude": ["shell"],
    },
    # ... 共 25 个意图
}

四个键域之间天然存在约束关系,而这些约束现在可以在同一个字典条目内一眼看全:标注(recommended/alternative)必须是可用集(tools)的子集,互斥(exclude)不得与可用集相交。编辑者不需要「记得去查别的表」,因为别的表已经不存在了。

2.2 兼容策略:旧三表变成一行推导

直接删掉旧三表会引爆十几个调用点。我们选择让旧表退化为单表的派生视图——名字还在,语义还在,但不再持有独立数据:

INTENT_TOOL_MAPPING = {
    intent: profile["tools"]
    for intent, profile in INTENT_TOOL_PROFILES.items()
}
INTENT_PRIORITY_MAP = {
    intent: {"recommended": p["recommended"], "alternative": p["alternative"]}
    for intent, p in INTENT_TOOL_PROFILES.items()
}
INTENT_EXCLUSIVE_TOOLS = {
    intent: {"exclude": p["exclude"]}
    for intent, p in INTENT_TOOL_PROFILES.items()
    if p.get("exclude")   # 闲聊等无互斥的意图不生成空条目,保持旧行为
}

调用方一行不用改,但「改 A 忘改 B」从可能变成了不可能——B 是 A 算出来的。

2.3 顺手清掉的死引用

合并过程中做了一次全量工具名核对(对照 tools.json),清掉了全部死引用:7 个意图的 tools 死标注、4 个未覆盖意图的归属修正、2 个标注死引用。这不是重构的副产品,而是重构的红利——多源结构下没人敢做这种全量核对,因为改一处就要同步三处;单源结构下,核对就是改一个字典条目。


三、等价性验证:与 git HEAD 旧表逐意图 diff

重构最危险的时刻是「我确定没改行为,但拿不出证据」。我们的证据链是一次性冒烟脚本,直接从 git HEAD:intent_engine.py 提取旧三表(注意旧表是带类型注解的 AnnAssign 节点,AST 提取要两个分支都覆盖),与新单表派生出的视图逐意图比对:

PM(优先级表):逐意图零差异(死引用删除除外)   ✅
EX(互斥表):逐意图零差异                        ✅
TM(工具映射):差异意图数 8/25,全部命中预期白名单 ✅
意图顺序:完全保持                                ✅

8 个 TM 差异意图逐条归因:+7 死标注并入 +4 未覆盖归属 −2 死引用删除。没有一条差异无法解释——这是放行重构的唯一标准。


四、三层常驻断言:把不变量焊进流水线

单次冒烟只能证明「这次重构是对的」,常驻断言才能证明「未来不会退化回多源」。我们把不变量固化在三个层次:

4.1 校验脚本层:check_9 单表不变量

validate_tool_chain.py 新增一组纯内存断言(零 IO,秒级):

  • 单表键集与 INTENT_CATEGORIES 的 25 个意图精确对齐(不多不少)
  • 每个意图的 tools 无重复
  • recommended ∪ alternative ⊆ tools ∪ CORE_TOOLS ∪ EXTENDED_TOOLS(标注必须可达)
  • exclude ∩ tools = ∅(互斥不相交)
  • 三个派生视图与单表逐键一致(防止有人绕过单表直接改派生表)

同时根治了一个潜伏问题:脚本里的「已知工具前缀」列表原本是 tool_exposure.py 的硬编码副本(又一个多源!),改为 AST 解析源文件动态提取——副本脱节的病根直接拔掉。改造后,validate_tool_chain 的 4 项历史失败当场清零,9 项检查全绿

4.2 pytest 红线层:四条不可绕过的失败

tests/test_intent_guardrails.py §3 用 pytest 固化同样的四条不变量。CI 里任何一条红了,PR 合不进去——不变量从「脚本跑一下看看」升级为「合并门禁」

4.3 行为基线层:G4 提示词行为 diff

静态断言管不了「语义变了」:比如给 research 意图换了推荐工具,单表内部完全自洽,但下游装配出的 System Prompt 和工具标注会变。这靠行为基线兜底——冻结 20 组代表场景(意图+置信度组合)的装配输出,重构后 diff。

diff 结果只有 4 条 query 的 mapped_tools 漂移,全部是死引用清理的预期结果(research 丢掉死标注、补上真工具)。基线重建走 --rebuild-baseline + 人工审阅 diff 的纪律流程——基线不是橡皮图章,重建必须逐条解释


五、踩坑实录:三个差点翻车的细节

坑 1:旧表是 AnnAssign 不是 Assign。 冒烟脚本用 ast.Assign 提取 git HEAD 的旧表,结果为空。旧表写法是 INTENT_TOOL_MAPPING: dict[str, list[str]] = {...}——带类型注解的赋值是 AnnAssign 节点。教训:AST 提取字典字面量时,两种节点都要处理。

坑 2:裁定口径必须先于实施。 「PM 零变更」是验收标准之一,但死引用清理必然改动 PM。如果口径没有事先写明「死引用删除属裁定的一部分」,冒烟会 FAIL,实施者会在「改回去」和「改口径」之间摇摆。口径写进方案,实施只是执行——这是多 Phase 重构不返工的关键纪律。

坑 3:tests/ 目录被 .gitignore 整体忽略。 重构提交后才发现,三个护栏测试文件从未入库——git status 对已 ignore 的目录不显示未跟踪文件,所以「测试都在跑、都是绿的」和「测试根本不在仓库里」可以同时为真。补口方式:参照仓库既有先例 git add -f 强制跟踪。教训:护栏类文件必须显式入库并纳入 CI,ignore 规则要有豁免清单


六、可复用的方法论

  1. 第二份拷贝就是定时炸弹:任何「同步维护两份数据」的约定,最终都会被某次匆忙的修改击穿。能合并就合并,合并的优先级高于任何功能开发。
  2. 派生视图是重构的安全带:不要求调用方一次迁移到位,旧接口变成新数据的投影,迁移可以按自己的节奏分批发生。
  3. 断言要分层次:脚本断言管快速自查,pytest 红线管合并门禁,行为基线管语义漂移——三层各管一段,缺一层就有盲区。
  4. 门禁失败必须可解释:不是「全绿才合并」,而是「每一条红都能归因」。解释不了的失败才是真风险,解释得了的失败是已知变更。
  5. 结构约束优于流程约束:把「记得同步三处」变成「只有一处可改」,前者依赖人,后者依赖编译器。

七、总结

三张意图表合并成一张单表,代码量净增两行(派生推导比原表还短),但把一类事故从「可能发生」变成了「结构上不可能」。重构的真正成本不在写新代码,而在等价性证明(逐意图 diff)和防退化建设(三层断言)——这两样东西做扎实了,重构才配叫重构,否则只是一次更危险的修改。

下期预告:《WeClaw_90|给 LLM 快路径上保险:6 秒最坏情况是怎么设计出来的》

  • 意图识别的 LLM 增强路径为什么不能复用主对话的重试策略
  • 超时、快失败重试、熔断器三件套的参数推导
  • 一次真实的服务抖动如何验证了熔断的必要性

敬请期待!


版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。