先定边界。上一篇的 Summary 与 Session Recall 管“当前会话怎样压短、怎样找回原始证据”;这一篇的 Memory 管“哪些知识值得跨会话留下”。它们都能帮助模型想起过去,但读的是不同账本,写入条件也不同。
证据边界。实现链接固定到公开快照 0c7774187da9330144df2a038ef18ee89ef2ae1c,实验链接固定到 benchmark 快照 c9aabe56c0eb1cb80e927d2a34ecc72658173cbe。Memory 的初始服务由 Rememorio 在 #70 对应提交引入;本文数字是特定模型、prompt、存储后端和检索预算下的实验结果,不外推成所有部署的保证。
阅读契约。请一直跟踪“用户已经从 Go 1.23 升级到 1.24”这条信息。读完后,你应该能说清:Runner 何时交出哪些新消息,extractor 为什么提议 update 而不是 add,确定性 reconcile 怎样兜住重复写入,最终条目存在哪里,以及新 session 如何在有限预算内把它找回来。
一、为什么 Summary 不能顺便充当 Memory
假设一轮证书排障产生了三类信息:“正在读取日志”只是过程;“用户生产环境用 Go 1.24”是稳定事实; “2026-07-20 在上海机房轮换过证书”是带时间的经历。Summary 的目标是让当前工单继续,它会为了压缩舍弃细节; Memory 的目标是让未来工单检索,因此必须先判断什么值得长期保存。
| 内容 | 更合适的 owner | 原因 |
|---|---|---|
| 昨天完整的工具输出 | Session events / Session Recall | 它是原始证据,不应先被抽成“用户事实”。 |
| “已排除网络问题” | Session Summary | 它帮助当前任务续跑,但未必对未来任务长期有效。 |
| “用户生产环境使用 Go 1.24” | Memory Fact | 稳定、可复用,未来新 session 仍可能需要。 |
| “7 月 20 日轮换过证书” | Memory Episode | 意义依赖时间、参与者和地点。 |
源码把这个区别做进数据结构:KindFact 表示偏好、身份和背景,KindEpisode 表示发生在某个时间的事件,
还可以带 event_time、participants 与 location。条目按 AppName + UserID 隔离。
见 Kind、Memory 与 Entry。
二、跟着一条事实走完写入生命周期
2.1 Runner 收尾后,worker 只看这次新增的对话
Auto memory 不是每收到一句话就写库。run 完成后,服务把 Session 交给 worker;worker 读取上次抽取时间,扫描之后新增的事件,
只保留有正文的 user / assistant 消息,跳过 tool message、tool call 和空内容。若 checker 判断这批增量还不值得抽取,就到此为止。
入口见 EnqueueJob,
增量过滤见 scanDeltaSince。
完整 Session
└─ 上次抽取点之后的 delta
├─ 用户:生产环境是 Go 1.24
├─ 助手:已生成证书轮换方案
└─ 工具输出:12 KB 日志 // 不直接交给 memory extractor
MemoryJob = {UserKey, delta messages, latest timestamp}
这里的 checker 不是另一个“判断要不要记忆”的模型,而是一个纯 Go 函数 func(*ExtractionContext) bool。
它能看到用户键、过滤后的增量消息和上次抽取时间;没有配置 checker 时默认通过。内置条件包括
“消息数严格大于 n”与“距离上次抽取超过一段时间”,还可以用 ChecksAll / ChecksAny
组合成 AND / OR。返回 false 是正常 no-op:不调用 extractor、不写 Memory,也不推进 cursor;这个接口本身没有 error 通道。
定义见 Checker 与内置条件。
// memory/extractor/checker.go 与 memory.go(节选)
type Checker func(ctx *ExtractionContext) bool
func CheckMessageThreshold(n int) Checker {
return func(ctx *ExtractionContext) bool {
return len(ctx.Messages) > n
}
}
func (e *memoryExtractor) ShouldExtract(ctx *ExtractionContext) bool {
if len(e.checkers) == 0 {
return true
}
for _, check := range e.checkers {
if !check(ctx) {
return false
}
}
return true
}
代码里的 len(ctx.Messages) > n 是严格大于,不是达到 n 就触发;checkers 为空则直接 true,保持默认行为。
多个 checker 在 ShouldExtract 里天然是 AND;需要 OR 时,调用方先用 ChecksAny 组合成一个 checker。
因为返回值只有 bool,false 只能表示“这次不抽取”,不能携带一个需要重试的错误。
2.2 后台队列为什么按用户分流,队列满了又怎样
Memory extraction 需要额外的模型调用和存储写入,若放在前台,用户已经拿到的答案会被“记忆整理失败”再次拖慢或判错。
worker 因此使用 context.WithoutCancel:HTTP 请求结束不会顺带取消已排队的整理任务;每个 job 再套独立超时,
默认是一名 worker、容量 10、最长 30 秒。相同 AppName + UserID 经过稳定 hash 进入同一队列,避免同一个用户的
两批更新乱序,不同用户则可以落到不同 worker。
队列未启动或已满时,运行时不会静默丢掉这批 delta:只要原调用尚未取消,就退回同步处理;原调用已经取消才跳过。 extractor 不存在、Session 为空、用户键缺失、没有新 user/assistant 正文、checker 判定不值得抽取时,则是正常 no-op。 这些分支说明 Auto Memory 是“符合条件才整理”的后台维护任务,不是每轮必写一条数据库记录。
2.3 抽取前先查旧记忆,因为“新增”可能其实是“更新”
如果库里已经有“用户使用 Go 1.23”,新对话说“已经升级到 1.24”,直接追加会制造冲突。worker 先用这批用户消息构造查询,
找回相关旧条目,再把“旧记忆 + 新对话”一起交给 extractor。这样模型输出的不是一段自由文本,而是 add、update、delete 或 clear 操作。
路径在 createAutoMemory;
extractor 把主 choice 的 tool calls 解析为操作,见 Extract。
查旧条目本身也有降级:相关性搜索报错时,worker 读取少量最近 Memory 作为 dedup 上下文;只有搜索与 fallback read 都失败, 整个 job 才停止且不推进抽取时间点。它不会在完全不知道旧状态时悄悄裸写一批 add。
extractor 的 prompt 把四种操作做成工具:add 保存真正的新知识,update 修改已有条目,
delete 处理明确过时或用户要求遗忘的单条信息,clear 只用于“忘掉全部”这类显式请求。
它还能给条目标注 Fact / Episode、topics、绝对时间、参与者与地点。模型选择的是“这句话在语义上意味着什么操作”,
但它没有直接拿到存储事务,也不能仅凭生成一个 tool call 就改库。
旧条目 memory-17
Fact: "用户的生产环境使用 Go 1.23"
topics: [go, production]
新 delta
user: "我们已经把生产环境升到 Go 1.24 了"
extractor 提议
update(memory_id="memory-17",
memory="用户的生产环境使用 Go 1.24")
2.4 Reconcile 怎样把“换一种说法”挡在写库之前
模型有时仍会把升级事实提议成 add。worker 会对每个 add 额外检索最多 3 条候选,并用两个独立信号比较:后端返回的相关分数, 以及候选文本与旧条目的 token Jaccard 重合度。两个信号用“或”连接,是因为向量后端擅长语义改写,关键词后端的 BM25 分数通常更低,而 Jaccard 能抓住版本号、实体名等共同词。
| 判定层级 | 满足任一条件 | 运行时动作 |
|---|---|---|
| 近乎同一条 | score ≥ 0.90 或 Jaccard ≥ 0.70 | 没有新 topic 就丢弃 add;只有 topic 新增则改成 update。 |
| 同一事实的新版本 | score ≥ 0.60 或 Jaccard ≥ 0.40 | 把 add 改写成对最佳旧条目的 update,并合并 topics。 |
| 确实是新知识 | 都未达到 | 保留原 add。 |
// memory/internal/memory/auto.go(节选)
func reconcileDecisionTier(score, jaccard float64) int {
switch {
case score >= reconcileSkipScore ||
jaccard >= reconcileJaccardHigh:
return reconcileTierSkip
case score >= reconcileUpdateScore ||
jaccard >= reconcileJaccardMid:
return reconcileTierUpdate
default:
return reconcileTierNone
}
}
先比较层级,再比较 score 和 Jaccard,确保“明确重复”不会被另一条分数略高但只是相关的记忆抢走。 reconcile 自身检索失败时保留 extractor 的原操作,而不是把不确定性当成重复并丢数据;若 add 被改成 update, 但部署禁用了 update,则退回原 add。这里的设计原则才是:LLM 提议语义操作,确定性代码负责幂等与能力边界。 阈值与决策见 reconcile tiers。
这段 switch 解释了表格里的“满足任一条件”:搜索分数与 Jaccard 不是相加后再过一条神秘总分线,而是两个独立证据源。 高阈值分支写在前面,所以同一候选同时满足 skip 与 update 时一定进入 skip;两个信号都不够才保留原 add。 模型生成的是候选操作,最终 tier 完全由这段普通 Go 控制流决定。
2.5 写入成功点决定下一轮会不会重复抽取
reconcile 后,worker 才逐条执行 add、update、delete 或 clear。update 指向的 ID 已不存在时,如果 add 能力开启,运行时可以降级为 add;
单条操作失败会记录日志并继续后面的操作,不让一条坏数据阻断整批。需要注意一个边界:extract 与 reconcile 整体成功后,
last_extract_at 才推进;但单条存储写失败只记日志,不会让整批返回错误,因此时间点仍可能前进。
这避免无限重放同一 delta,却意味着部署必须监控操作级失败,而不能只看 job 是否完成。
如果查旧记忆、调用 extractor 或整个 job 超时,时间点不会推进,后续任务仍可重试这批 delta。成功写入的条目按
AppName + UserID 隔离,下一次新 Session 只要使用同一用户键,就能检索到;当前前台回复不会因为后台写入而回头改变。
这补齐了可见性边界:Memory 影响的是后续模型调用,不是已经结束的 run。
这个增量 cursor 的所有者是 Session,而不是 Memory store:框架把最后一个已纳入抽取的 Event 时间写进
Session.State["memory:last_extract_at"]。它与 add/update/delete 并不处在同一个存储事务里;因此它只能表示
“worker 已经处理到哪里”,不能证明这一批每条 Memory 都写入成功。真正的运维信号必须同时包含操作级错误日志与该 cursor,
读写实现见 readLastExtractAt / writeLastExtractAt。
把前面的 memory-17 继续走到底,就能看见“模型提议”和“跨会话记住”之间还隔着哪些状态:
1. extractor 产出候选操作
update(memory_id="memory-17", memory="用户的生产环境使用 Go 1.24")
2. worker 执行存储接口
UpdateMemory(
Key{AppName: "support", UserID: "user-42", MemoryID: "memory-17"},
"用户的生产环境使用 Go 1.24",
topics=["go", "production"],
kind=Fact,
)
3. Memory store 中的 durable Entry(形状级)
ID: <backend 返回的有效 ID>
AppName: "support"
UserID: "user-42"
Memory.Memory: "用户的生产环境使用 Go 1.24"
Memory.Topics: ["go", "production"]
Memory.Kind: "fact"
UpdatedAt: <写入时间>
4. 新 Session 使用同一 UserKey
UserKey{AppName: "support", UserID: "user-42"}
-> preload / search
-> 本轮 model request 看见 Go 1.24
更新请求用旧 ID 定位目标,但某些内容寻址后端会根据新正文与元数据生成新的 canonical ID,另一些后端可以原位更新;
因此跨 Session 的稳定隔离键是 AppName + UserID,不是让业务代码长期记住 memory-17。
只有第 3 步成功,才产生 durable Memory;只有第 4 步的 preload 或主动搜索命中,它才被后续模型实际采用。
2.6 存储结构要为检索服务,不是为了字段好看
Fact / Episode、绝对时间、topic、参与者和地点,真正价值都在查询阶段。例如“我去年先去了哪里,再去了哪里”需要按 event time 排序;
“Alice 去过哪座山”需要核对 participants;专有名词又常常需要关键词命中。对应搜索配置支持 kind、时间窗口、排序、去重、
hybrid search 与 RRF,见 SearchOptions。
三、从新 Session 读回记忆,再谈怎样搜得准
3.1 先区分框架预载与 Agent 主动搜索
Memory 已经写入,不代表模型会自动看见它。tRPC-Agent-Go 提供两条读路径。第一条是框架预载:应用显式配置
WithPreloadMemory(N) 后,ContentRequestProcessor 在组装模型请求时,从 Invocation 取得
MemoryReader,再用当前 Session 的 AppName + UserID 读取同一用户的旧记忆。默认值是 0,也就是关闭;
-1 表示全部加载,正数 N 表示本轮允许预载的条数预算。
新 Session 的第一轮
user: “按我现在的生产环境给升级建议”
└─ ContentRequestProcessor
├─ probe 读取 N+1 条,判断记忆集合是否很小
├─ 小集合:直接装入全部结果
└─ 大集合:用当前用户消息构造 query
└─ hybrid search + deduplicate,最多 N 条
└─ 注入本轮模型请求
// internal/flow/processor/content.go(节选)
probeEntries, err := reader.ReadMemories(ctx, userKey, budget+1)
if err != nil || len(probeEntries) == 0 {
return nil
}
if len(probeEntries) <= budget {
return newPreloadMemoryMessage(probeEntries, p.PreloadMemoryPlaybook)
}
query := buildPreloadSearchQuery(inv.Message)
if query == "" {
return p.loadPreloadMemoryMessage(ctx, inv, reader, userKey, budget)
}
searchOpts := memory.SearchOptions{
Query: query, MaxResults: budget,
Deduplicate: true, HybridSearch: true,
}
memories, err := reader.SearchMemories(
ctx, userKey, query, memory.WithSearchOptions(searchOpts),
)
if err != nil || len(memories) == 0 {
return p.loadPreloadMemoryMessage(ctx, inv, reader, userKey, budget)
}
return newPreloadMemoryMessage(memories, p.PreloadMemoryPlaybook)
小集合不必花一次相关性搜索;超过预算后,processor 才用当前 user message 做 query。搜索报错或返回空集时,会降级读取最近 N 条;
probe 或 fallback read 也失败时,本轮不注入 Memory,但请求仍继续。预载内容默认作为 system message 放进 prompt,也可以切到
user/history 附近的注入模式。整条分支见
getPreloadMemoryMessage。
budget+1 是一个很小但关键的实现技巧:processor 不需要先做昂贵的 count,只多读一条就能判断“是否超过预算”。
超过后才构造 query;query 为空、搜索失败或零命中都回到有界的 recent read。只有最前面的 probe 失败时直接返回 nil,
因为此时连存储是否可读都无法确认,继续发模型请求比把 Memory 故障升级成整轮失败更稳妥。
第二条是Agent 主动调用工具。应用把 memory_search / memory_load 暴露给 Agent 后,模型可在同一次 run
中根据问题决定搜什么、是否拆成多次查询。空 query 会返回空结果,后端错误则作为 tool error 回到 Agent,由它决定改写查询、降级回答或说明失败。
所以二者不是重复开关:预载保证常用事实在第一次模型调用前就可见,工具搜索把长尾、多跳、时间过滤交给 Agent 按需完成。
3.2 向量与关键词各自补盲区
向量搜索擅长语义近似,却可能把书名、地名和精确版本号排得不够靠前;关键词搜索擅长精确词,又不理解改写。
pgvector 后端先做向量检索,再做全文检索,最后用 Reciprocal Rank Fusion 合并两个排名;kind 结果太少时还会去掉 kind 过滤重试,
最后做内容去重。完整顺序见 SearchMemories。
vector rank: [Mt. Fuji trip, hiking preference, Alice profile]
keyword rank: [Alice + Mt. Fuji, Mt. Fuji trip]
RRF merge: [Mt. Fuji trip, Alice + Mt. Fuji, hiking preference]
dedupe: remove near-identical restatements
RRF 不直接比较“余弦 0.82”和“关键词 7.4”这两个不可比的原始分数,而只比较名次。默认 k=60,
某条结果在一个列表排第 1、另一个排第 3,它会得到 1/(60+1) + 1/(60+3);只在一个列表出现的结果只有一项。
因此同时被语义和精确词命中的条目自然前移,又不会要求两个后端共享同一分数标尺。
3.3 Kind fallback 与去重是在修正抽取的不确定性
用户问“什么时候升级到 1.24”时,Agent 可以偏好 Episode;但 extractor 也可能把这句话存成 Fact。若带 kind 的结果少于 3 条,
KindFallback 会再做一次不带 kind 的搜索并合并,优先保留原 kind 命中。随后内容去重以 0.80 的词集合 Jaccard
为阈值,留下分数更高的代表条目,避免两个近义版本同时占用 prompt。Memory 工具默认开启 hybrid 与 deduplicate,
只有调用者明确给 kind 时才开启 kind fallback。
这些能力是 service contract,但具体执行取决于后端。pgvector、SQLiteVec、MySQLVec 等向量后端可以把向量与关键词排名做 RRF; 纯关键词后端使用 BM25 风格相关度。文章说“Hybrid”时,指支持该能力的后端路径,不意味着所有存储都会暗中生成 embedding。
3.4 多跳问题需要多次换查询角度
“Alice 去日本前买了什么,回来后又做了什么?”通常不是一次 top-k 能覆盖。benchmark 里的 optimized agent 会把问题拆成 2–3 个短查询,
从实体、时间或子问题角度分别搜索,再汇总答案。工具说明也明确建议多部分问题分别搜索,并要求用 participants 检查人名归属;
见 memory_search。
代价也在这里:多次搜索意味着模型会反复读取已有上下文,token 和时延一起上涨。Memory benchmark 因而不能只报 F1, 还必须同时报 Tokens/QA、Calls/QA 和 latency。
四、Benchmark 怎样把“抽得好”和“找得好”拆开
4.1 为什么选 LoCoMo-10
LoCoMo-10 包含 10 段长期对话和 1,986 个问答。它不是只考“记住一句话”,而是把问题分成 single-hop、multi-hop、temporal、 open-domain 与 adversarial 五类,因此能同时暴露事实遗漏、跨条目组合、时间顺序和不应回答的问题。 完整实验协议见 Memory benchmark report。
| 类别 | 数量 | 它在检验什么 |
|---|---|---|
| single-hop | 282 | 一条记忆能否直接回答。 |
| multi-hop | 321 | 多条事实能否被一起找回并组合。 |
| temporal | 96 | 相对时间能否转成稳定时间并正确排序。 |
| open-domain | 841 | 开放问法下能否找到相关背景。 |
| adversarial | 446 | 证据不足时是否会拒答,而不是编造。 |
主实验固定 GPT-4o-mini 作为回答模型与 judge,embedding 使用 text-embedding-3-small。三条主臂分别是:完整 transcript 的
Long Context;基础 auto extraction + pgvector 的 Original;加入结构化抽取、hybrid search 与 multi-pass retrieval 的 Optimized。
Session Recall 也跑在同一套数据上,但它检索的是原始历史事件,已放在上一篇上下文篇讨论,不在这里冒充 Memory 优化。
4.2 四条实验臂分别改变了什么
| 实验臂 | 写入 | 读取 | 它回答的问题 |
|---|---|---|---|
| Long Context | 不抽 Memory | 把完整对话放进 prompt | 不做检索时的质量与 token 上界。 |
| Original Memory | 基础 auto extraction | pgvector 单次检索 | 原始框架 Memory 能做到什么。 |
| Optimized Memory | Fact/Episode、绝对时间与更细原子事实 | Hybrid + RRF、top-30、2–3 次换查询 | 同时改善抽取和检索后的完整收益与成本。 |
| Session Recall | 保留原始 events,不抽知识 | 搜索并加载原会话 | 结构化 Memory 是否真的优于直接取证;详见上下文篇。 |
这个设计能比较完整系统,却不能把提升精确归因给某一个开关:Optimized 同时改了写入 schema、检索方式、top-k 和调用轮数。 因此主表回答“这条 pipeline 值不值得”,top-k 与检索轮数消融才回答局部参数。报告中的部分框架对比还依赖 benchmark adapter 或手动配置,不应解读成每个框架开箱即用默认值的排名。
4.3 结果:质量追平 Long Context,但成本不是免费的
| 方案 | F1 | BLEU | LLM Score | Tokens/QA | Calls/QA | Latency |
|---|---|---|---|---|---|---|
| Long Context | 0.469 | 0.426 | 0.526 | 18,776 | 1.0 | 2,607 ms |
| Original Memory | 0.399 | 0.371 | 0.416 | 3,056 | 2.0 | 6,659 ms |
| Optimized Memory | 0.469 | 0.431 | 0.532 | 17,182 | 3.0 | 8,585 ms |
从 Original 到 Optimized,F1 从 0.399 升到 0.469,提升 17.5%,并追平 Long Context;但 nominal token 从 3,056 涨到 17,182, 时延也更高。报告进一步统计 43.9% prompt tokens 命中 provider cache,按“新输入 token”估算约 9,663/QA;这能解释账单, 却不能抹掉三次模型调用带来的端到端时延。
4.4 提升来自哪里:multi-hop 与 temporal,而不是所有类别都赢
| 类别 | Original | Optimized | 变化 |
|---|---|---|---|
| single-hop | 0.316 | 0.396 | 更完整的原子事实改善直接召回。 |
| multi-hop | 0.096 | 0.453 | 多轮检索带来最大提升。 |
| temporal | 0.088 | 0.247 | Episode、绝对时间和排序有效。 |
| open-domain | 0.358 | 0.441 | Hybrid search 扩大召回。 |
| adversarial | 0.814 | 0.626 | Original 更激进地拒答;不能把下降藏在总分里。 |
这张表比“提升 17.5%”更重要:optimized pipeline 真正修复的是 multi-hop 与 temporal;adversarial 反而下降。 因此部署时不能只调 retrieval,还要单独治理“没有足够证据时怎样拒答”。
4.5 Top-k 消融:拿得更多,不等于想得更准
在 locomo10_1 的 199 个问题上,SQLiteVec top-k 从 5 增加到 10,F1 从 0.320 升到 0.343;继续升到 20 与 40,
F1 反而回落到 0.329 与 0.327,prompt tokens 却从 346,253 涨到 621,790 与 965,423。第二次搜索也把 prompt 提到 659,981,
总体 F1 仍是 0.342。检索噪声会占用注意力,这就是 Memory 不能只追 recall 的原因。
Benchmark 给出的不是“Memory 已经解决”。它证明结构化抽取、时间信息、Hybrid + RRF 与多轮检索能显著修复特定问题;同时也证明每多取一条、每多搜一轮都可能增加噪声、token 与时延。最优点是质量与预算共同决定的,不是 top-k 越大越好。
实验还有两个不能藏在脚注里的边界。第一,LoCoMo-10 只有 10 段长对话,同一套 GPT-4o-mini 同时参与回答与 judge, 结果适合比较本实验中的方案,不足以推出模型无关结论。第二,Memory 质量由“是否写对、是否找回、模型是否正确使用”三段相乘; F1 下降时,必须保留 extractor 操作、检索排名和最终回答三层 trace,才能知道应该修 schema、后端还是回答策略。
五、把实验结论翻译成工程选择
| 你面对的情况 | 优先选择 | 不要先做什么 |
|---|---|---|
| 同一长会话快装不下 | Summary + Context Compaction;细节用 Session Recall。 | 不要把整段会话抽成长期用户事实。 |
| 稳定偏好、身份、环境配置 | 原子 Fact,写前检索并 reconcile。 | 不要保存带大量过程噪声的整段 transcript。 |
| 带时间的人生/任务经历 | Episode + 绝对时间 + 参与者。 | 不要只存“上个月”“后来”等相对词。 |
| 多实体或多跳问题 | 拆查询,多路检索,再限制总预算。 | 不要一次性把 top-k 提到很大。 |
| 证据不足的高风险问题 | 独立拒答策略与 adversarial 评估。 | 不要用总体 F1 掩盖错误肯定回答。 |
所以 Memory 的完整定义不是“有一个向量库”。它是一条有写入治理的知识管线:选择增量、抽取原子事实与事件、参考旧条目做 reconcile、 按用户隔离持久化,再用受预算约束的混合检索把少量证据送回模型。下一篇会把 owner 再换一次:Evolution 不记用户事实, 而是把任务经验变成可复用 Skill,并用 benchmark 决定候选能不能进入未来运行。