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

否决一个看起来很美的方案:五道闸门与「证伪优先」的选型方法

技术选型方法学 · 证伪优先 · 跨语料基准不可迁移 · 天花板效应 · 依赖真实账单 · 零成本取证

WeClaw_84|否决一个看起来很美的方案:五道闸门与「证伪优先」的选型方法

系列文章第 84 篇 - 技术选型方法学 · 证伪优先 · 跨语料基准不可迁移 · 天花板效应 · 依赖真实账单 · 零成本取证


📚 专栏信息

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

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

本文记录一次"决定不做"的完整决策过程:GitHub 上 40k+ star 的记忆层框架 Mem0,与 WeClaw 自研的跨会话经验系统 EBEAC 看起来天生互补——一个有多信号检索和成熟 API,一个有零 LLM 成本的自动采集。方案写完了,评审提出 5 个 Critical,但评审只能提问不能回答。于是我们写了 5 个探针脚本、零新增依赖、不装 mem0ai,在 4 小时内拿到了否决所需的全部证据。最关键的一个数字是 0.0%


👨‍💻 作者与项目

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


📝 摘要

本文结构概览: 一个看起来天生互补的集成提案 → 三视角评审提出 5 个 Critical 但无法自行裁决 → 把"要不要引入"翻译成五道可执行闸门 → 五道闸门的实测数据(3 道明确 FAIL)→ 决策:不引入,转向三条本地优先项 → 沉淀三条可迁移的方法论 → 诚实复盘我在这轮里犯的错。

核心问题:如何在不安装、不改生产代码、不投入迭代周期的前提下,判断一个热门框架值不值得引入?以及更难的一问——当引入方案的收益论证建立在别人的 benchmark 上时,怎么验证那些数字对你的场景成立?

关键成果

  • 用 5 个探针脚本(约 900 行,零新增依赖)在集成前拿到否决证据,未写一行生产代码
  • 实测证伪多信号检索的收益前提:英文空白分词在中文语料上的词表命中率 0.0%
  • 实测发现基线天花板效应:裸向量检索 Recall@3 已 100%,任何引擎替换都无法在该指标上证明收益
  • 量化依赖真实账单:强制升级 posthog、无条件新装 qdrant-client、>=0.1.100 实际解析到 2.0.15
  • 定位真正瓶颈:175 条经验只有 24 个 distinct pattern,问题在采集端不在检索端

适合读者:需要为项目做框架选型的技术决策者;做 RAG / 记忆系统 / 检索的工程师;所有面对"这个开源项目好像很适合我们"时想要证据的人

阅读时长:约 16 分钟

关键词技术选型证伪优先Mem0EBEACBM25中文分词RRF依赖冲突


一、一个看起来天生互补的提案

WeClaw 有一套自研的跨会话经验系统 EBEAC(Event-Based Experience Auto-Capture):工具调用失败后重试成功的"踩坑-爬坑"序列会被自动记录进 SQLite + ChromaDB,下次遇到相似任务时语义检索召回、注入系统提示词。它的核心设计主张是 zero additional LLM cost——采集与检索全程不调用大模型,这是它能写进论文的地方。

调研 GitHub 记忆层生态时,Mem0 几乎是绕不过去的项目:40k+ star、Apache 2.0、活跃维护,LoCoMo 基准 92.5、LongMemEval 94.4。它的能力清单看起来正好补上 EBEAC 的短板:

维度EBEAC 现状Mem0 提供
检索信号纯语义向量语义 + BM25 + 实体,多信号融合
记忆演化无(只增不改)V3 additive pipeline
容量LRU 上限 500 条无硬上限
生态自研 API成熟 SDK + 多 vector store 适配

于是有了一份集成方案:以 EBEAC 为采集端和事实源,Mem0 作为检索增强层,用 infer=False 免 LLM 直存以保住"零额外 LLM 成本"的论文主张。

方案写得很完整——技术栈对齐、挂载点、配置项、文件清单、回滚方案。看起来很美。

二、评审提出 5 个 Critical,但评审不能替代证据

我们没有直接开工,先做了一轮三视角并行评审(完备性 / 正确性 / 影响面),合并后是 5 个 Critical、9 个 Warning、5 个 Suggestion。其中三条最扎心:

  1. 立论自相矛盾:为保住"零 LLM 成本"必须用 infer=False 原文直存,但多信号检索的收益恰恰依赖 Mem0 的 LLM 抽取管线。两个收益不能同时成立。
  2. 分数尺度不可比:方案打算把 Mem0 返回的 score 接进 EBEAC 的 0.55 阈值过滤,但两者量纲根本不同。
  3. 没有验收标准:方案用 LoCoMo 92.5 做收益论证,而那是英文对话记忆基准,与"中文工具失败经验召回"是两个任务。

评审做到这里就到极限了。评审能指出"你没有证据",但它自己也没有证据。 三个子评审在 infer=False 这一点上甚至给出了不同倾向——这恰恰说明纯靠阅读和推理无法裁决。

所以决策变成:先取证,证据到位前不启动实施。

三、方法学:把决策翻译成五道可执行闸门

关键动作是把一个模糊的"要不要引入"拆成五个能被脚本回答的是非问题,并且按依赖关系排序——前一道不通过,后面的对比就不成立。

探针回答的问题闸门标准
01 语料诊断语料本身有没有可提升空间?去重后有效条目 ≥ 50、模板化率 ≤ 50%
02 检索基线现有检索的真实水平是多少?建立基线,替代 LoCoMo 类比
03 依赖探测引入会动到哪些既有包?无强制升级、无无条件新装
04 BM25 信号中文语料上词面信号存在吗?空白分词 semantic R@3 > 5%
05 融合增量多信号相对纯语义能加多少?R@3 或 MRR 有正增量

三条约束让这套探针的成本几乎为零:

约束一:零新增依赖。 全部探针只用当前 venv 已有的包。这是个意外之喜——检查后发现 jiebarank_bm25 早已安装,意味着"多信号融合"这个核心卖点可以完全不依赖 Mem0 就地验证。03 只读 PyPI 的 JSON 元数据,绝不执行 pip install

约束二:零人工标注。 沿用 WeClaw_71 建立的方法学——真值取自 experiences.tool_names 字段:查询针对某工具的失败场景,则 tool_names 含该工具的经验即为相关。Hit@1 / Recall@3 / MRR 全部脚本自动算。

约束三:查询分两组。 这是本轮最有效的一个设计:

# lexical:含工具名字面量 → 词面检索的主场
Query(f"{tool} 连续失败后重试成功", tool, "lexical")
# semantic:中文同义表述、无字面重叠 → 只能靠语义向量
Query("终端指令跑不起来一直返回错误", "shell", "semantic")

两组指标之差,就是"多信号融合"能带来的理论上限。 后面会看到,这一刀切得很值。

还有一个容易忽略的安全细节:recall() 会自增 hit_count,直接在生产库上跑评测会污染真实使用统计。所以所有需要实例化 ExperienceStore 的探针都先克隆数据:

shutil.copy2(EXP_DB, db_copy)
# SQLite WAL 模式下的旁挂文件一并复制,避免丢失未 checkpoint 的数据
for suffix in ("-wal", "-shm"):
    side = Path(str(EXP_DB) + suffix)
    if side.exists():
        shutil.copy2(side, str(db_copy) + suffix)

四、五道闸门的实测结果

数据源:~/.weclaw/experiences.db,175 条真实经验,2026-05-30 ~ 2026-07-31。

4.1 闸门 01:真正的瓶颈不在检索,在采集

经验总数            : 175
distinct pattern    : 24     ← 去重后 recall 的可选池上限
去重坍缩率          : 86.3%
outcome 分布        : {'success': 175}
距离触发 LRU 淘汰   : 325 条

三个发现直接改写了问题定义:

  • 175 条经验只有 24 个 distinct abstract_patternrecall() 管道里有一步 _deduplicate_by_pattern(同一 pattern 只保留一条),所以检索的可选池上限就是 24 条。Top 3 pattern(shell 47 条 / paper_lifecycle 36 条 / wechat 32 条)覆盖 65.7% 的语料。
  • outcome 全部是 success,意味着 _filter_by_outcome 目前是个空操作。
  • 距 LRU 上限还差 325 条,从未触发过淘汰——方案里"解除 500 条容量上限"这条收益,根本不是当前的瓶颈

第一道闸门就 FAIL 了,而且它给出的启示比"不引入 Mem0"更重要:真正的瓶颈在采集端ExperienceAutoRecorder 生成的经验高度模板化(数字归一后重复率 36%),24 个 pattern 撑不起任何检索优化的收益空间。

换检索引擎解决不了语料只有 24 种花样的问题。这就像给一个只有 24 本书的图书馆升级检索系统。

4.2 闸门 02:基线已经触顶

方案lex R@3sem R@3ALL H@1ALL MRR
A. recall() 完整流水线100.0%100.0%45.8%0.701
B. 裸向量检索(无过滤)100.0%100.0%95.8%0.979
C. SQLite LIKE 词面检索0.0%0.0%0.0%0.000

B 行是决定性的:裸向量检索的 Recall@3 已经是 100%。 这叫天花板效应——当对照组满分时,任何替换方案都不可能在该指标上证明收益。方案里所有"提升检索精度"的论证,在这张表面前失去了着力点。

C 行的 0.0% 起初像是脚本 bug,核对源码后确认是真实行为:

def _sqlite_fallback_recall(self, query: str, top_k: int) -> list[Experience]:
    kw = f"%{query}%"        # ← 把整条查询当子串匹配
    rows = self._db_conn.execute(
        """SELECT * FROM experiences
           WHERE (trigger LIKE ? OR diagnosis LIKE ? ...)""", ...)

它把整条查询字符串当子串去匹配,对任何自然语言查询必然零命中。这不是词面检索,是一个早就失效的降级兵——好在它只在 vector_store 不可用时才触发。

A 行与 B 行之间那个 50 个百分点的 H@1 落差是本轮最大的意外收获,它值得单独一篇,见下期。

4.3 闸门 03:依赖的真实账单

方案写的是 mem0ai>=0.1.100。实际查询 PyPI:

最新版本            : 2.0.15
存在的主版本号       : [0, 1, 2]
[!] 方案写 mem0ai>=0.1.100,但 >= 会直接解析到最新的 2.0.15,
    跨越了主版本边界。

>=0.1.100 会直接装上 2.x。 方案中所有基于 0.1.x 行为得出的 API 设计(V3 流水线、search() 参数形状、infer=False 语义)都未在该主版本上验证过。

再看它对既有环境的影响,脚本把重叠依赖分了级:

[强依赖] posthog>=7.14.0
    本地 = 7.7.0        判定 = 将强制升级
[强依赖] qdrant-client>=1.12.0
    本地 = 未安装        判定 = 将新装
[强依赖] sqlalchemy>=2.0.31
    本地 = 2.0.49       判定 = 已满足

两笔硬成本:强制升级 posthog(chromadb 的遥测依赖,波及 EBEAC 运行时基座)、无条件新装 qdrant-client(即使我们打算继续用 ChromaDB)。

这里要诚实标注探针的能力边界:它只能证伪不能证实requires_dist 没重叠 ≠ 安装不冲突,传递依赖和 resolver 回溯只有真实解析才暴露。所以脚本的结论是给出下一步而不是拍板:

python -m pip install --dry-run --report - mem0ai   # 需批准后执行
python -m venv .venv_mem0_probe                     # 一次性隔离环境

4.4 闸门 04:0.0%——多信号在中文语料上退化为单信号

这是全场最关键的一个探针。Mem0 的核心卖点是"语义 + BM25 + 实体"多信号融合,而它的 BM25 走英文分词路线。中文经验文本没有空格——那么分出来的 token 还能与查询产生词面重叠吗?

不需要安装 mem0ai 就能回答,因为问题可以拆成纯分词层面的实验:

分词器平均 token 数词表大小语义查询命中词表率sem R@3空结果率
WS(空白,近似 Mem0 默认)22.75170.0%0.0%50.0%
REGEX(\w+27.85460.0%0.0%50.0%
JIEBA(中文分词)76.972350.8%41.7%4.2%

0.0% 的词表命中率意味着 BM25 对中文语义查询完全无输出。 原因很朴素:text.split()re.findall(r"\w+") 都会把整段连续 CJK 吞成一个巨型 token(\w 匹配 CJK),而用户的查询是另一句中文,两者不可能在 token 级别重叠。

结论是硬的:Mem0 的"语义 + BM25 + 实体"三信号,在中文语料上退化为纯语义单信号。 方案援引的 LoCoMo 92.5 / LongMemEval 94.4 全部来自英文语料——它们不是"参考值偏高"的问题,而是测的根本不是同一件事

顺带还测出一个尺度事实,正好回答评审的第 2 个 Critical:

BM25 原始分数量纲(对比向量相似度的 0~1):
  JIEBA    n=192   min=1.70  p50=9.43  max=23.41

BM25 分数无上限且随语料漂移,把它和 0~1 的相似度混在一个 0.55 阈值里判断是没有意义的。任何融合都必须先做秩结合或分数归一。

4.5 闸门 05:融合的真实增量是 0

前四道闸门已经把方案的收益论证拆得差不多了,但还剩最后一个、也是唯一与投入产出直接相关的问题:在真实语料上,"语义 + 词面"融合相对纯语义到底能提升多少?

既然 jieba + rank_bm25 本地就有,我们直接实现了 Mem0 卖点的本地等价版——RRF 秩结合,它只用排名不用分数,天然免疫量纲差异:

RRF_K = 60

def rrf_fuse(*ranked_lists: list[int]) -> list[int]:
    """Reciprocal Rank Fusion:只用排名,不用分数,天然免疫量纲差异。"""
    scores: dict[int, float] = {}
    for lst in ranked_lists:
        for rank, doc_id in enumerate(lst, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (RRF_K + rank)
    return [doc for doc, _ in sorted(scores.items(), key=lambda kv: -kv[1])]

四路同台:

方案lex R@3sem R@3ALL H@1ALL MRRp50
A. 向量 only100.0%100.0%95.8%0.97939.2ms
B. BM25/jieba only100.0%41.7%70.8%0.7080.3ms
C. RRF 融合100.0%100.0%95.8%0.97236.4ms
D. recall() 生产现状100.0%100.0%45.8%0.70137.9ms
C 融合 - A 纯向量        R@3 +0.0%    H@1 +0.0%    MRR -0.007

融合相对纯语义零增量,MRR 甚至微降。 原因在闸门 01 里就埋好了:纯语义已经触顶,词面信号只是重复命中同一批经验——24 个 pattern 的语料给不出两种信号互补的机会。

五、决策与替代路线

五道闸门中 01 / 03 / 04 明确 FAIL,02 / 05 显示现有向量检索已触顶而生产排序正在损失精度。

决策:不引入 Mem0。

但这轮取证的产出远不止一个"不"字。证据同时给出了一份按收益排序的本地优先项:

  1. _apply_time_decay 的排序损失(约 4 行,无新依赖)——A 与 D 之间 50 个百分点的 H@1 落差
  2. 改造 ExperienceAutoRecorder 的经验生成质量——24 个 pattern 才是真正的天花板
  3. 修或移除失效的 _sqlite_fallback_recall
  4. 引入 Mem0 —— 当前无证据支持

顺序很说明问题:排在前面的三项全部是本地的、零依赖的、几十行以内的。 我们原本准备用一个 40k star 的框架去解决的问题,真身是自家管道里的三个小缺陷。

六、三条可迁移的方法论

6.1 证伪优先:否决的成本远低于验证

引入一个框架要真装、真接、真测,成本是周期级的。而否决它往往只需要一个数字。本轮 5 个探针、约 900 行脚本、零新增依赖、零安装,4 小时拿到全部否决证据。

关键在于顺序:先找最便宜的证伪路径。04 探针是全场性价比最高的——它没装 mem0ai,只是把"Mem0 的 BM25 用英文分词"这个已知事实和"我的语料是中文"这个已知事实放到一起做了个分词实验。很多集成方案的收益前提,可以在不接触该框架的情况下被证伪。

6.2 别人的 benchmark 不是你的验收标准

LoCoMo 92.5 是真实的、可复现的、没有夸大的。它唯一的问题是:它测的是英文长对话记忆,而我们要做的是中文工具失败经验召回。

判断一个外部基准能不能迁移,看三件事:语料语言、任务形态、评测指标口径。本轮三项全不匹配,而 04 探针把"不匹配"量化成了 0.0%。

一个数字要成为你的决策依据,它必须在你的数据上被重新测一遍。 这也是 02 探针存在的全部理由——方案里那句"精度未知(无基准)"随后接了个 LoCoMo 类比,正确的做法是把"未知"变成"已测"。

6.3 先定位瓶颈,再选工具

这轮最反直觉的收获来自 01 探针:我们讨论了很久"哪个检索引擎更强",而真正的约束是语料只有 24 种花样

顺序应该反过来:先量化现状的天花板在哪,再看候选工具能不能碰到那个天花板。 02 探针里裸向量 R@3 = 100% 这一行,本身就宣告了"提升检索精度"这条收益路径不存在——不管候选是 Mem0 还是别的什么。

七、诚实复盘:我在这轮里犯的错

方法论讲得再顺,过程里的错也要记下来,否则下次还犯。

错误一:把依赖版本记错了,并且传播了。 前期分析一直按 chromadb 1.5.5 / sentence-transformers 5.2.3 陈述,评审报告里也是这两个数字。03 探针实测是 1.5.9 / 5.5.1。修法不只是改数字,而是把脚本里写死的版本改成运行时读取:

print(f"      chromadb {current.get('chromadb') or '?'} 与 "
      f"sentence-transformers {current.get('sentence-transformers') or '?'} "
      "是 EBEAC 的运行时基座,")

教训:凡是能从环境读到的事实,就不要在文档和脚本里抄一遍。抄一次就是一个会过期的副本。

错误二:p95 掩盖了冷启动。 02 探针第一版指标表只有 p50 / p95,n=24 时 p95 的索引落在第 22 位,正好跳过了唯一的冷启动异常点。后来补上 maxms 列,真实数字露出来了:9175.8ms——嵌入模型首次加载 9.2 秒。

教训:小样本下的分位数会系统性地藏住尾部异常。指标表里保留一列原始 max,成本一个字段,收益是不被自己的统计骗。

错误三:脚本写了会误导的提示。 01 探针的报错分支提示用 --db 指定路径,但那个版本压根没有 argparse。02 探针最初的结语是"若 C 在 lexical 组接近满分,则 BM25 收益有限"——一句预设了结论的条件句,而实测 C 是 0.0%。后来改成由当轮数据直接判定:

if c.empty_rate > 0.9:
    print("[!] LIKE 路径几乎全空:_sqlite_fallback_recall 用 LIKE '%整条查询%' 做子串匹配,")

教训:取证脚本的输出不该包含"如果……那么……"式的预判。让数据自己说话,脚本只负责判定。

八、总结

3 个关键点

  1. 证伪优先:引入的验证成本是周期级的,否决往往只需一个数字。优先寻找最便宜的证伪路径——很多收益前提可以在不接触该框架的前提下被推翻
  2. 基准不可迁移:别人的 benchmark 只在它的语料、任务、指标口径下成立。判断迁移性看语言、形态、口径三项,并把"不匹配"量化出来
  3. 先量瓶颈后选工具:对照组已经满分时,任何替换方案都无法在该指标上证明收益。天花板的位置决定了候选工具有没有发挥空间

1 个核心公式

引入决策 = 收益前提在我的数据上成立 × 真实依赖账单可接受 × 现有基线未触顶
(三项任一为假,结论即为不引入)

互动环节

思考题

  1. 如果你的项目也在用"某框架的 benchmark 分数"作为引入依据,那个基准的语料语言和任务形态与你的场景一致吗?把它在自己数据上重测一遍需要多少成本?
  2. 本文的 04 探针在不安装 mem0ai 的前提下证伪了它的核心收益。你手上待评估的框架,有哪些收益前提可以用类似的"拆解到已知事实"方式提前验证?

讨论话题

你有过"准备引入一个大框架,最后发现真正要修的是自家几十行代码"的经历吗?


下期预告:《时间衰减的第二层:修好误杀之后,它开始悄悄毁掉排序》

  • WeClaw_72 修好了"衰减在阈值前"的误杀,为什么 H@1 还是只有 45.8%
  • 非空返回率 100% 与 Hit@1 45.8% 同时成立:验收指标的盲区
  • "考古层位"第三层:一个缺陷如何靠上一次修复的验收口径存活下来

敬请期待!


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