数据截至 (上游 commit be263ee23085)
量化记忆好不好 — 评测harness 与公开基准
30 秒导读: 你给 agent 装了个"记忆"(Zep 的时序知识图谱),怎么证明它真的比别人强? 这一章讲 Zep 仓库里两套"打分机器":一套是自研的 zep-eval-harness(把摄取→检索→答题→评分拆成 可复现的四阶段流水线),一套是跑 LoCoMo / LongMemEval 两个公开基准的适配器。核心难点只有一句: 记忆评测天生难——因为"记得对不对"要靠另一个 LLM 来判,而摄取是异步的、图谱要几十秒才建好。
本章只讲评测 / 基准这个子系统。记忆环本身怎么摄取、怎么检索,见 01-memory-model.md; 把记忆环塞进各框架见 02-integration-patterns.md。这里假设你已经知道 "Zep 把对话变成图里的节点(实体)和边(事实)"。
1. 这是什么(零基础也能懂)
1.1 一句话定义
评测 harness = 一台"考记忆的机器":它把一堆对话/文档喂进 Zep,然后拿一份"考卷"(问题 + 标准答案) 去问,最后自动判分——Zep 到底记住了没有、答得对不对。
1.2 它要解决谁的什么问题
假设你是 Zep 的工程师,今天改了图谱抽取的 prompt,或者把检索的 reranker 从 rrf 换成 cross_encoder。
你怎么知道这一改到底是变好还是变坏?
- 靠肉眼看几个 case → 不可信、不可复现、样本太小。
- 靠"感觉" → 更糟。
你需要一个能重复跑、能给出数字、能定位是哪一环出问题的东西。这就是 harness。
1.3 两个层次的"考卷"
仓库里其实有两套评测系统,面向不同目的:
| 系统 | 位置 | 用什么数据 | 目的 |
|---|---|---|---|
| 自研 harness | zep-eval-harness/ | 自带的合成数据(一个房产 agent 场景) | 日常回归:改了配置,快速看指标涨没涨 |
| 公开基准 | benchmarks/locomo/、benchmarks/longmemeval/ | 学术界公开数据集 | 对外证明:和别人在同一张卷子上比分数 |
本章两者都讲,但重点在自研 harness——因为它把"可复现工程"做得最完整(manifest + config 快照 + checkpoint), 公开基准更多是"把同一套评分逻辑套到别人的数据格式上"。
1.4 用起来什么样
自研 harness 就是四条命令,依次跑(来自 zep-eval-harness/README.md):
# ① 把用户对话+遥测摄取进各自的 user graph,并轮询等到图建好
uv run zep_ingest_users.py
# ② 把参考文档切块 + 让 LLM 生成摘要/上下文(最贵的一步,产物可复用)
uv run zep_chunk_documents.py
# ③ 把切好的块灌进一个共享 document graph
uv run zep_ingest_documents.py --chunk-set 1
# ④ 拿考卷去问,自动判分,落 results.json
uv run zep_evaluate.py --user-run 1 --doc-run 1
每一步都在 runs/ 下留一个带编号+时间戳的目录,里面有 manifest.json(这次跑了啥)和一份
config 快照(当时用的配置长啥样)。这两样是"可复现"的命根子,后面 §4 细讲。
1.5 一句话直觉
把它想成一条工厂流水线,每个工位下班时都拍一张"现场照片"存档。 照片(manifest + 快照)保证 哪怕明天改了配方,你也能翻出昨天那批货是怎么造的;传送带中途卡了(API 限流、进程被杀),下次能从 断点续上(checkpoint),不用整条重来。
2. 顶层全景(它大概怎么转)
2.1 自研 harness 的四阶段流水线
怎么读这张图:从上到下是数据流; 每个方框是一个独立脚本,跑完在 runs/ 落一个目录。
关键点:用户图和文档图两条支线互相独立,最后在评测阶段才汇合。
data/users.json ┐
conversations/ ├─►[① zep_ingest_users.py]──► 各 user graph ──► runs/users/{N}/manifest.json
telemetry/ ┘ (含 config 快照)
data/documents/ ──►[② zep_chunk_documents.py]──► runs/chunk_sets/{N}/chunks.jsonl
│ (切块+LLM 摘要,最贵、可复用)
▼
[③ zep_ingest_documents.py]──► 1 个 document graph ──► runs/documents/{N}/manifest.json
data/test_cases/ ──►[④ zep_evaluate.py --user-run N --doc-run M]
│
├─ 搜图(user graph + document graph, 并行)
├─ 判"检索够不够"(主指标) ┐ 两者并行
├─ 用检索结果生成答案 ┘
└─ 判"答得对不对"(次指标, LLM judge)
│
▼
runs/evaluations/{N}/results.json (含 config 快照 + 父 run 引用)
2.2 部件一句话职责
| 部件 | 干什么 | 文件 |
|---|---|---|
| 用户摄取 | 建用户、灌对话/遥测、轮询等图建好 | zep-eval-harness/zep_ingest_users.py |
| 文档切块 | 切块 + LLM 生成摘要与逐块上下文 | zep-eval-harness/zep_chunk_documents.py |
| 文档摄取 | 读切块集,灌进独立 document graph | zep-eval-harness/zep_ingest_documents.py |
| 评测 | 搜图→评完整性→生成→判分,落结果 | zep-eval-harness/zep_evaluate.py |
| 重试 | 指数退避 + 抖动,抗限流 | zep-eval-harness/retry.py |
| 断点 | 原子写/读/删 checkpoint | zep-eval-harness/checkpoint.py |
| 公共常量 | 轮询间隔/超时、Gemini base URL | zep-eval-harness/config/constants.py |
2.3 为什么拆成四步而不是一个大脚本
三条设计动机(都是"避免重复烧钱"):
- 摄取慢、只需一次;评测快、想多跑几遍。 分开后,一次摄取可以配多次不同参数的评测。
- 用户图和文档图解耦。 你可以造 2 种文档图 × 8 种用户图,评测时任意配对
(
--user-run 3 --doc-run 2),而不用为每种配对重灌一遍(zep-eval-harness/README.md:131-140)。 - 切块和灌库再拆一层。 切块要调 LLM(贵),灌库只调 Zep API(便宜)。一份切块集
(chunk set)能复用到多次不同 ontology 的灌库(
zep-eval-harness/README.md:142-159)。
3. 核心原理(逐个机制,由浅入深)
这一节挑五个真正体现工程含量的机制。前两个是任务点名的重点:异步摄取轮询、config 快照 + checkpoint。
3.1 两把尺子:主指标"检索够不够" vs 次指标"答得对不对"
它要解决的小问题
"Zep 答错了"——到底是图里根本没这条信息(检索的锅),还是信息在、但答题 LLM 没用好(生成的锅)? 一个笼统的"准确率"分不清这两者。
思路
拆成两把独立的尺子量(zep-eval-harness/README.md:451-465):
- 主指标 · 上下文完整性(Context Completeness): 只看检索出来的 context 里,有没有答这道题需要的信息,
完全不管最终答案。三档:
COMPLETE / PARTIAL / INSUFFICIENT。这把尺子直接量 Zep 的检索质量。 - 次指标 · 答案准确率(Answer Accuracy): 让答题 LLM 用 context 生成答案,再让另一个 LLM 判它和标准答案 是否语义等价。二值:对 / 错。
真实实现
主指标由 evaluate_context_completeness() 实现,注意它的 prompt 里反复强调"你不是在评答案,是在评 context
本身有没有料"(zep_evaluate.py:309,prompt 见 601-654 行)。次指标由 grade_ai_response() 用一个
LLM judge + Pydantic 结构化输出打分(zep_evaluate.py:223):
# 示意,非源码:两把尺子各问一次 LLM,结构化返回
completeness = judge_llm(question, golden, context) # → COMPLETE/PARTIAL/INSUFFICIENT
answer = response_llm(context, question) # 先生成答案
is_correct = judge_llm(question, golden, answer) # → True/False
关键细节 / 坑
- 两把尺子还能交叉分析。 结果里算了个"context 完整时答案的正确率"
(
accuracy_when_complete,zep_evaluate.py:834-838)——如果它很低,说明"检索没 问题,是生成拉胯"; 如果 context 常常INSUFFICIENT,那才是检索要改。这就是归因。 - judge 的 prompt 里埋了'时间陷阱'防御:反复叮嘱 LLM "带过去日期的历史事实仍然是有效 context,别因为
日期是过去就当它过期"(
zep_evaluate.py:369-375)。这直接呼应 01 里 Zep 的 双时间(bi-temporal)模型——事实有valid_at / invalid_at,评测端必须理解"过去发生 ≠ 无效"。
3.2 异步摄取轮询:图不是灌完就好了
它要解决的小问题
你调 thread.add_messages_batch(...) 把消息发给 Zep,API 立刻返回——但这时候图还没建好。
Zep 的图谱抽取是异步的:每条 episode(一条消息/一份 JSON)要 5–20 秒才被抽成实体和边
(zep-eval-harness/README.md:550)。如果你马上去评测,图是空的,分数全是 0。
这正是 01 讲的最终一致性在评测端的直接后果:写入是异步的,你必须等它收敛。
思路
摄取脚本默认轮询等到所有 episode 处理完才收工。机制是:每次 add 会拿到一个 task_id,轮询这些 task
的状态直到 succeeded;--no-poll 则跳过等待、立刻返回(适合"先灌一大批,过会儿再评")。
真实实现
poll_task_ids() 是核心(zep_ingest_users.py:365,文档侧同名函数在 zep_ingest_documents.py:304)。
两个巧妙点:
① 超时按 episode 数量成比例给,而不是一刀切:
# zep_ingest_users.py:389 —— episode 越多,给的等待预算越长
task_timeout = num_episodes * POLL_TIMEOUT_PER_EPISODE # 120s / episode
② 顺序轮询,每个 task 的计时"等前一个完成才开始"(注释见 zep_ingest_users.py:370-373)。
POLL_INTERVAL = 2 秒查一次,POLL_TIMEOUT_PER_EPISODE = 120 秒每 episode 的上限
(config/constants.py:5-6)。
摄取阶段是并发发起的:所有用户用 asyncio.gather 并行摄取(zep_ingest_users.py:803-813),
每个用户图的轮询也并行跑(poll_user_graph,zep_ingest_users.py:860)。轮询完还把
"平均每 episode 耗时"写回 manifest(ingestion_timing,zep_ingest_users.py:929-940)——
这本身就是一个可观测指标:图建得快不快。
关键细节 / 坑
- 评测脚本在搜每个用户图前,会先
user.warm(...)预热缓存(zep_evaluate.py:587);文档图没有 warm 方法, 就用一次query="."的轻量搜索"骗"它预热(zep_evaluate.py:562-567)。这是为了测检索延迟时不被冷启动污染。 - 超时后不是崩,而是优雅停下并报告已完成多少(
zep_ingest_users.py:409-423)。
3.3 config 快照 + checkpoint:可复现与可续跑的两根支柱
这是 harness 工程含量的最高点,拆成两件事讲。
(a) config 快照 = "把当时的配方拍照存档"
问题: 三个月后你想复现今天这次评测,但 config/ 里的 prompt、reranker、模型早被改了 N 遍。manifest 里
只记了"用了 custom ontology"这种元数据,不够——你要的是当时那份配置文件的原样。
做法: 每个阶段落 run 目录时,用 shutil.copytree 把整个 config 子目录复制进 run 目录当快照:
# zep_ingest_users.py:571-575 —— 把 config/user_ingestion_config/ 整个拷进 run 目录
snapshot_dir = os.path.join(run_dir, "user_ingestion_config_snapshot")
shutil.copytree("config/user_ingestion_config", snapshot_dir,
ignore=shutil.ignore_patterns("__pycache__"))
四个阶段各拍各的快照(zep-eval-harness/README.md:31-90):
| 阶段 | 快照目录 | 快照的是 |
|---|---|---|
| 用户摄取 | user_ingestion_config_snapshot/ | ontology / 指令 / 用户摘要指令 |
| 文档切块 | document_chunking_config_snapshot/ | chunk size、上下文化模型 |
| 文档摄取 | document_ingestion_config_snapshot/ | 文档 ontology / 指令 |
| 评测 | evaluation_config_snapshot/ | 搜索 limit、response/judge 模型、response prompt |
评测的 results.json 还额外记了父 run 引用(parent_runs,zep_evaluate.py:900-917):这次评测用的是
哪个 user run、哪个 doc run。于是从一份评测结果能反查出完整的血缘:结果 → 摄取 run → 各自的 config 快照。
这就是"可复现"闭环。
这与 01 的最终一致性呼应:因为摄取是异步且有随机成分(用户 ID 加随机后缀保证幂等,
zep_ingest_users.py:163-165),两次摄取不可能字节级相同;快照锁的是"输入配方",而不是妄图锁住输出。
(b) checkpoint = "传送带卡了能从断点续"
问题: 摄取要几分钟、要调几百次 API,中途一旦限流耗尽重试、或进程被 Ctrl-C,全部重来太浪费。
做法: 每完成一个单位就写一次 checkpoint,记下"已完成哪些";--resume 时跳过已完成的部分。
用户摄取里,每个用户摄取完就更新 checkpoint(ingest_user_with_checkpoint,zep_ingest_users.py:778):
# zep_ingest_users.py:787-797 —— 每完成一个用户,原子写一次进度
async with checkpoint_lock:
completed_users.append(result)
save_checkpoint(cp_path, {
"run_number": run_number,
"config": {...}, # 连配置一起存,resume 时恢复
"completed_users": completed_users,
})
checkpoint 的写是原子的——先写 .tmp 再 os.replace,保证半路挂掉不会留下损坏文件
(checkpoint.py:5-11)。全部成功后 checkpoint 会被删掉(delete_checkpoint,checkpoint.py:20)。
关键细节 / 坑
- 切块和文档摄取有各自更细的续跑粒度。 切块以"(文件名, 块序号)"为单位记已完成,resume 时读回
chunks.jsonl跳过已切的块,还复用上次的文档摘要保持一致(read_completed_chunks,zep_chunk_documents.py:224)。文档摄取以"已灌块数 + 文件偏移"续跑(下一节)。 - retry 与 checkpoint 是两层防线: retry 抗瞬时错误(限流),checkpoint 抗致命中断(进程死)。
3.4 切块集的 follow 模式:切一半就能边切边灌
它要解决的小问题
切块(调 LLM)慢,灌库(调 Zep)也要时间。串行等切块全切完再灌,浪费。
思路
切块脚本把每切好一块就追加一行写进 chunks.jsonl(append_chunk_line,zep_chunk_documents.py:218),
meta.json 的 status 先是 "in_progress",全切完才改 "complete"(zep_chunk_documents.py:311 / 414)。
灌库脚本则像 tail -f 一样盯着这个 JSONL:有新行就灌,没新行就等,直到 meta 变 complete 且行数追平。
真实实现
follow_and_ingest()(zep_ingest_documents.py:109)用文件偏移量避免每轮重读整个文件:
# zep_ingest_documents.py:149-151 —— 从上次读到的字节位置续读
with open(jsonl_path, "r") as f:
f.seek(file_offset)
for line in f: ...
退出条件是"切块集完成 且 我已灌完所有块"(zep_ingest_documents.py:235);还没好就
await asyncio.sleep(FOLLOW_POLL_INTERVAL) 每 3 秒探一次(zep_ingest_documents.py:64),最长等 1 小时。
于是你可以开两个终端并发跑:一个切块、一个灌库,灌库自动追着切块屁股跑(zep-eval-harness/README.md:161)。
--chunk-size N 的"inline 模式"则是图省事:先切完再灌,一条命令搞定,但不产生可复用的切块集
(zep-eval-harness/README.md:163)。
3.5 抗限流:指数退避 + 抖动
它要解决的小问题
所有阶段都在高并发打 LLM / Zep API,必然撞限流。撞了不能直接死,得退一步再试。