首页
/ claude-mem LoCoMo 评测实践:双评分引擎(Token 级 F1 + LLM-as-a-Judge)的设计与实现

claude-mem LoCoMo 评测实践:双评分引擎(Token 级 F1 + LLM-as-a-Judge)的设计与实现

2026-09-04 16:19:33作者:翟江哲Frasier

本文以 claude-mem 仓库中 LoCoMo 评测项目的 Phase 04 阶段文档为主体,完整拆解其"双评分引擎"的设计:如何用标准化的 Token 级 F1 指标与原 LoCoMo 论文基线对齐,如何用 LLM-as-a-Judge(J-score)与 Mem0 等现代记忆系统可比,以及评分聚合、基线对比报表和配套测试策略的具体实现。读完后你将掌握可复用的评测打分方法论——从文本归一化管道、多集合词元交集的 F1 计算,到多次判分取均值±标准差的统计设计。

1. 背景:为什么需要双指标评分

claude-mem 的 LoCoMo 评测(工作目录为 .maestro/playbooks/Wizard-2026-02-22/2026-02-22-LoCoMo-Eval/)是一套对持久记忆系统的系统性基准测试,分六个阶段推进:数据摄取(Phase 01)、数据集加载(Phase 02)、QA 回答管道(Phase 03)、双评分引擎(Phase 04)、完整评测运行器(Phase 05)、结果分析与发布(Phase 06)。Phase 04 是本篇的主体,它解决一个核心问题:两套历史悠久的评分体系不可互相替代,必须同时实现

  • Token 级 F1:沿用原 LoCoMo 论文(ACL 2024)的评分方式,用于与历史基线(LoCoMo 原论文及 Mem0 论文中的 F1 表)对比;
  • LLM-as-a-Judge(J-score):沿用 Mem0 论文(arXiv 2504.19413,ECAI 已接收)的方法,用于与现代记忆系统对比——Mem0(66.88%)、Mem0g(68.44%)、Zep(65.99%)、全上下文上限(full-context,72.90%)。

J-score 判分器刻意选用 Claude Sonnet 4.6(模型 ID claude-sonnet-4-6),与回答器使用的 Opus 4.6(claude-opus-4-6)不同模型,以避免"自己判自己"的偏差(self-judging bias);每题进行 10 次独立判分以获得统计显著性,与 Mem0 的统计口径保持一致。完整方法学记录见同目录的 LOCOMO-EVAL-06.md 中规划的 methodology.md,其明确写道:"F1 存在已知的长度偏差(LoCoMo-Plus, arXiv 2602.10715v1),J-score 是现代记忆系统对比的标准做法。双指标同时提供向后兼容性与现代可比性。"

适用前提说明:本文的评分实现细节(模块路径、函数签名、参数)均以 Phase 04 阶段文档中定义的规格为准;评测代码位于独立的 evals/locomo/ 工作目录,未包含在当前仓库主目录的源码树中,因此下文的实现引用均指向阶段文档中的规划路径。

2. Token 级 F1 评分模块(evals/locomo/src/scoring/f1.ts

F1 模块的核心思想是:把"预测答案"与"标准答案"当作两个词元多重集(multiset),计算交集相对各自规模的比例,再合成 F1。模块定义并导出以下四个函数供测试使用。

2.1 归一化管道 normalizeAnswer

normalizeAnswer(text: string) 实现抽取式 QA 基准测试的标准归一化流程,顺序固定:

  1. 转小写;
  2. 移除冠词:"a""an""the"
  3. 移除所有标点(仅保留字母数字与空格);
  4. 将连续空白折叠为单个空格;
  5. 修剪首尾空白。

2.2 Porter 词干提取 porterStem

porterStem(word: string) 要求实现一个最小化 Porter 词干提取器——至少覆盖 Step 1a/1b/1c:复数、-ed-ing-ness。文档同时给出了一个务实的替代方案:检查 LoCoMo 官方仓库(规划中的 data/locomo-repo/)是否自带 Python 评分脚本,若有则严格匹配其归一化管道,以保证与论文数字对比的公平性。这一点体现了基准测试的可比性原则——自己的归一化细节差异足以造成分数不可比。

2.3 分词与 F1 计算 tokenize / computeTokenF1

tokenize(normalizedText: string):按空白切分,对每个词元应用 Porter 词干提取。

computeTokenF1(predicted: string, groundTruth: string) 是核心打分函数,完整计算步骤:

  1. 对两个字符串执行归一化;
  2. 对两个字符串执行分词(含词干提取);
  3. 计算词元重叠:common = 预测词元与标准答案词元的交集——必须使用多集合交集(multiset intersection),即按每个词元出现次数计数,例如 "the the the""the" 只能取到 1 个 the
  4. Precision = |common| / |predicted_tokens|(预测为空时取 0);
  5. Recall = |common| / |ground_truth_tokens|(标准答案为空白取 0);
  6. F1 = 2 * P * R / (P + R),当 P + R = 0 时取 0;
  7. 特殊情形:归一化后双方都是空串时,F1 = 1.0(都"没说话",视为一致)。

多集合交集这一细节直接决定了重复词的处理行为,是 F1 测试集中专门用 "the the the" vs "the" 验证的点。

3. LLM-as-a-Judge 评分模块(evals/locomo/src/scoring/judge.ts

J-score 模块基于 Anthropic SDK(Phase 03 已安装 @anthropic-ai/sdk),由三个部分组成。

3.1 判分提示词

判分系统提示词(Judge system prompt):指示 Claude Sonnet 4.6 扮演公正的评估者,在 0–100 分制上给预测答案打分,评估维度与 Mem0 的判分标准一致,共四个维度:

  • 事实准确性(Factual accuracy):预测答案基于标准答案是否事实正确;
  • 完整性(Completeness):是否覆盖标准答案中的关键信息;
  • 相关性(Relevance):回答是否与所问问题相关;
  • 语境恰当性(Contextual appropriateness):回答是否扎根于对话上下文。

判分用户提示词 buildJudgePrompt(question, groundTruth, predictedAnswer, category)

  • 同时包含问题、标准答案、预测答案;
  • 附带类别标签(category label),让判分器按类别施加不同标准——例如时序(temporal)类问题需要精确的日期/顺序匹配;
  • 要求结构化输出:JSON 对象 {"score": <0-100>, "explanation": "<1-2 句理由>"}

3.2 单次判分 judgeAnswer

judgeAnswer(question, groundTruth, predictedAnswer, category) 的关键 API 参数与容错策略:

参数/行为 取值 设计意图
model claude-sonnet-4-6 与回答器(Opus 4.6)不同模型,避免自判偏差
max_tokens 256 只需输出短 JSON
temperature 0.5 保留判分器跨次运行的方差以估计波动,同时维持合理一致性——Mem0 报告 10 次运行的标准差为 ±0.15 至 ±0.75
返回值 JudgeResult(score 0–100,explanation 字符串)

容错处理按三级降级:解析 JSON 响应提取 score 与 explanation → 若 JSON 解析失败,尝试从纯文本中抽取数字分 → 仍失败则返回 score = -1 并附带错误说明。-1 是"判分失败"的哨兵值,供聚合阶段过滤。

3.3 多次判分聚合 judgeAnswerMultipleRuns

judgeAnswerMultipleRuns(question, groundTruth, predictedAnswer, category, numRuns) 默认执行 10 次(numRuns 可配):

  • 并行执行:用 Promise.all每批 3 个的节奏并发,平衡速度与速率限制;
  • 过滤掉所有 score = -1 的失败运行;
  • 对成功运行计算:均值(mean score)、标准差(std dev)、个体分数数组;
  • 返回 JudgeAggregation{ mean_score, std_dev, run_count, individual_scores }
  • 若成功运行少于 5 次,记录警告——这通常意味着系统性的判分失败(如 API 持续错误),而非随机波动。

这个"10 次采样 + 均值±标准差"的设计,正是 J-score 能跨系统横向比较的统计基础:Mem0、Zep 等基线数字本身就是同样口径的均值。

4. 结果聚合与双指标报表(evals/locomo/src/scoring/reporter.ts

报表模块从 evals/locomo/src/types.ts 导入类型,职责是把逐题结果聚合为可按类别对比的指标。

4.1 聚合函数

函数 语义
scoreResultsF1(results) 对每条 {predicted_answer, ground_truth, category} 应用 computeTokenF1,产出填充了 f1_scoreQAResult 数组
aggregateF1ByCategory(results) 按类别分组,返回每个类别的 {mean_f1, count, min_f1, max_f1} 映射
computeOverallF1(results) 宏平均(macro average):所有题目 F1 之和 ÷ 总题数
aggregateJudgeByCategory(results) 按类别分组,对每题的 mean_score 再求均值,返回 {mean_j, pooled_std_dev, count}
computeOverallJudge(results) 每题均值 J 分的宏平均,附合并标准差(pooled standard deviation)

4.2 F1 基线(F1_BASELINES

来自原 LoCoMo 论文与 Mem0 论文,用于历史对比:

F1_BASELINES = {
  "Human": { overall: 87.9 },
  "Mem0": { overall: null, single_hop: 38.72, multi_hop: 28.64, temporal: 48.93, open_domain: 47.65 },
  "Mem0g": { overall: null, single_hop: 38.09, multi_hop: 24.32, temporal: 51.55, open_domain: 49.27 },
  "Zep": { overall: null, single_hop: 35.74, multi_hop: 19.37, temporal: 42.00, open_domain: 49.56 },
  "LangMem": { overall: null, single_hop: 35.51, multi_hop: 26.04, temporal: 30.75, open_domain: 40.91 },
  "OpenAI Memory": { overall: null, single_hop: 34.30, multi_hop: 20.09, temporal: 14.04, open_domain: 39.31 },
  "A-Mem": { overall: null, single_hop: 20.76, multi_hop: 9.22, temporal: 35.40, open_domain: 33.34 },
  "GPT-3.5-turbo-16K": { overall: 37.8 },
  "RAG-observations (original paper)": { overall: 41.4 },
  "GPT-4-turbo": { overall: 32.1 }
}

4.3 J-score 基线(J_BASELINES

来自 Mem0 论文(arXiv 2504.19413),不含对抗类(adversarial)(该类在 Mem0 口径下无标准答案):

J_BASELINES = {
  "Full-context": { overall: 72.90 },
  "Mem0g": { overall: 68.44, single_hop: 65.71, multi_hop: 47.19, temporal: 58.13, open_domain: 75.71 },
  "Mem0": { overall: 66.88, single_hop: 67.13, multi_hop: 51.15, temporal: 55.51, open_domain: 72.93 },
  "Zep": { overall: 65.99, single_hop: 61.70, multi_hop: 41.35, temporal: 49.31, open_domain: 76.60 },
  "RAG (best, k=2 256-tok)": { overall: 60.97 },
  "LangMem": { overall: 58.10, single_hop: 62.23, multi_hop: 47.92, temporal: 23.43, open_domain: 71.12 },
  "OpenAI Memory": { overall: 52.90, single_hop: 63.79, multi_hop: 42.92, temporal: 21.71, open_domain: 62.29 },
  "A-Mem": { overall: 48.38, single_hop: 39.79, multi_hop: 18.85, temporal: 49.91, open_domain: 54.05 }
}

文档同时给出一条重要边界说明:Letta 报告的 74.0% "accuracy" 使用了不同的评分方法(非 LLM-as-a-Judge),不可直接比较,只作为脚注出现。这是基线对齐时的典型陷阱——同名指标未必同口径。

4.4 对比表格生成

  • formatF1ComparisonTable(evalResults, f1Baselines):生成 claude-mem F1 对全部 F1 基线的 markdown 表,按类别 + 总体;
  • formatJudgeComparisonTable(evalResults, jBaselines):J 分对比表,按类别 + 总体,附 ±标准差;
  • formatLatencyComparisonTable(latencyStats):延迟/Token 对比表,Mem0 公布的参照数字为 search p50 = 0.148s、search p95 = 0.200s、total p50 = 0.708s、total p95 = 1.440s、1,764 tokens/query;
  • formatFullReport(evalResults):汇总完整 markdown 报告——三张对比表 + 按类别拆解 + top-5/bottom-5 题目错误分析 + 延迟与 Token 汇总。

5. 评分模块的测试策略

Phase 04 要求为两套评分各写一份测试文件,并给出了可验证的具体断言值,这是保证评测工具本身可信的关键一环。

5.1 F1 测试(evals/locomo/tests/f1-scoring.test.ts

覆盖用例与设计意图:

  • 精确匹配:"the cat sat" vs "the cat sat" → F1 = 1.0;
  • 部分匹配:"the big cat sat" vs "the cat sat" → 验证词元重叠被正确反映;
  • 完全不匹配:"dog" vs "cat" → F1 = 0.0;
  • 归一化:"The Cat!" vs "the cat" → F1 = 1.0(大小写 + 标点被消解);
  • 冠词移除:"a big dog" vs "big dog" → F1 = 1.0;
  • 词干提取:"running quickly" vs "runs quick" → 词干化后 F1 应非零;
  • 边界:预测为空、标准答案非空 → 0.0;双方均空 → 1.0;
  • 多集合行为:"the the the" vs "the" → 验证按出现次数计数的 precision/recall;
  • 聚合正确性:mock 3 条 single-hop(F1 = 0.8/0.6/1.0)+ 2 条 multi-hop(0.5/0.7)数据,验证类别均值;computeOverallF1 与期望的加权平均一致。

5.2 Judge 测试(evals/locomo/tests/judge-scoring.test.ts

  • buildJudgePrompt 输出必须包含问题、标准答案、预测答案、类别四项;
  • mock Anthropic SDK 验证 judgeAnswer 调用参数:model 为 claude-sonnet-4-6、temperature 0.5、max_tokens 256——参数级断言防止配置漂移;
  • JSON 解析失败路径:mock 一个格式错误的响应,验证返回 score = -1;
  • judgeAnswerMultipleRuns 聚合:mock 10 次返回 [70, 72, 68, 71, 73, 69, 70, 72, 71, 70] → 验证 mean ≈ 70.6、std ≈ 1.5;
  • 失败运行过滤:mock 10 次中有 2 次返回 -1 → 聚合只使用 8 次成功运行并触发警告。

阶段文档记录的验收结果为:F1 评分测试 25/25 通过,Judge 评分测试 14/14 通过,全量测试套件 119/119 通过(0 失败),运行命令为 bun test evals/locomo/tests/f1-scoring.test.tsbun test evals/locomo/tests/judge-scoring.test.ts

6. 在原型结果上的双指标打分(score-prototype.ts

Phase 04 的落地环节是在 Phase 03 的 QA 原型输出上跑双评分,脚本为 evals/locomo/scripts/score-prototype.ts

  1. evals/locomo/results/qa-prototype-results.json 加载原型结果(据 LOCOMO-EVAL-03.md 记载:conv-26 对话的 20 题——10 条 temporal、8 条 single-hop、2 条 multi-hop;平均搜索延迟 1165ms、平均回答延迟 2032ms、约 455 tokens/题);
  2. F1 打分:对每题应用 scoreResultsF1,打印按类别 F1 拆解与总体 F1——纯本地计算,无 API 调用;
  3. J 打分:每题调用 judgeAnswerMultipleRuns(10 次)。对约 20 题的原型即约 200 次判分 API 调用,批间加 200ms 延迟以规避速率限制;
  4. 输出三张对比表:F1 对基线、J 分对基线、原型运行的延迟汇总;
  5. 输出中注明口径:"Prototype only(~20 题,来自 1 个对话)。完整评测在 Phase 05 执行。"

运行命令:bun evals/locomo/scripts/score-prototype.ts

这一"原型先行、全量后置"的节奏是整个评测工程的重要模式:Phase 04 只在 20 题上验证双评分链路端到端可用,把昂贵的大规模判分推迟到 Phase 05 的带检查点运行器(LOCOMO-EVAL-05.md)——那里 J 打分被拆成独立 pass,与 QA pass 分开 checkpoint,使得"先落盘 QA 结果、再单独重跑判分"成为可能。

7. 小结:这套双评分引擎的方法学价值

Phase 04 的双评分引擎有三个可迁移的设计决策值得注意:

  1. 指标双轨、口径对齐:F1 服务历史可比性,J-score 服务现代可比性;每个指标都锁定一个"参考实现的口径"(LoCoMo 原论文 / Mem0 论文),并在基线表中显式标注不可比项(Letta);
  2. 判分器与被评系统解耦:不同模型判分(Sonnet 判 Opus 的产出)+ 多次采样(10 次)+ 失败哨兵值(-1)过滤 + 低置信度警告(成功运行 < 5 次),共同构成对 LLM 判分噪声的防御;
  3. 评测工具本身被测试:归一化、词干化、多集合计数、参数传递、解析降级、聚合数学全部有可断言的单元用例(验收 39 个评分用例、全量 119 个用例通过),避免"评测器 bug 污染基准结论"。

对于要在仓库内复现这套流程的读者,建议的查阅路径是:以 .maestro/playbooks/Wizard-2026-02-22/2026-02-22-LoCoMo-Eval/LOCOMO-EVAL-04.md 为评分实现规格书,用 LOCOMO-EVAL-03 对照回答管道、用 LOCOMO-EVAL-05/06 对照运行器与最终报告结构。需要说明的限制:完整评测运行依赖 ANTHROPIC_API_KEY(QA 用 Opus 4.6、判分用 Sonnet 4.6),且评测工作目录 evals/locomo/ 及数据集 data/locomo-repo/ 不属于本仓库主源码树,本文所述实现细节均以阶段文档的规格与验收记录为依据。

登录后查看全文
热门项目推荐
相关项目推荐