先定边界。上一篇的 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_timeparticipantslocation。条目按 AppName + UserID 隔离。 见 KindMemoryEntry

二、跟着一条事实走完写入生命周期

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-hop282一条记忆能否直接回答。
multi-hop321多条事实能否被一起找回并组合。
temporal96相对时间能否转成稳定时间并正确排序。
open-domain841开放问法下能否找到相关背景。
adversarial446证据不足时是否会拒答,而不是编造。

主实验固定 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 extractionpgvector 单次检索原始框架 Memory 能做到什么。
Optimized MemoryFact/Episode、绝对时间与更细原子事实Hybrid + RRF、top-30、2–3 次换查询同时改善抽取和检索后的完整收益与成本。
Session Recall保留原始 events,不抽知识搜索并加载原会话结构化 Memory 是否真的优于直接取证;详见上下文篇。

这个设计能比较完整系统,却不能把提升精确归因给某一个开关:Optimized 同时改了写入 schema、检索方式、top-k 和调用轮数。 因此主表回答“这条 pipeline 值不值得”,top-k 与检索轮数消融才回答局部参数。报告中的部分框架对比还依赖 benchmark adapter 或手动配置,不应解读成每个框架开箱即用默认值的排名。

4.3 结果:质量追平 Long Context,但成本不是免费的

方案F1BLEULLM ScoreTokens/QACalls/QALatency
Long Context0.4690.4260.52618,7761.02,607 ms
Original Memory0.3990.3710.4163,0562.06,659 ms
Optimized Memory0.4690.4310.53217,1823.08,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,而不是所有类别都赢

类别OriginalOptimized变化
single-hop0.3160.396更完整的原子事实改善直接召回。
multi-hop0.0960.453多轮检索带来最大提升。
temporal0.0880.247Episode、绝对时间和排序有效。
open-domain0.3580.441Hybrid search 扩大召回。
adversarial0.8140.626Original 更激进地拒答;不能把下降藏在总分里。

这张表比“提升 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 决定候选能不能进入未来运行。

参考源码与实验