技术5/31/2026·9 min

用事件溯源做 Agent 的联想记忆

用类型化图谱 + append-only 事件日志替代向量库做 agent 记忆,让召回跨会话强化。实测 MRR +7.4%、R@10 +16.1%。

起点

一个跨多次会话运行的 agent 会逐渐沉淀关于项目的知识:约定、过往决策、反复踩的坑。常见的第一反应是把每条笔记 embedding 进向量库,按 cosine 相似度召回。我们走了另一条路 —— #188 之后,整条召回路径里没有任何 embedding。

把我们推离向量方案的是两个问题。其一,原来的 memory_recall 无状态:每次会话都重跑关键词检索和图遍历,没有信号记录某个节点之前是否有用 —— 被召回五十次的笔记和从没打开过的排名完全一样。其二,没有表达「意图」的写入路径:用户和模型都无法说「这条很重要」,尽管打分代码早就读了一个 importance 字段,却没有任何地方往里写。

召回到底跑在什么之上

记忆是 .loopal/memory/ 下的纯 Markdown,一主题一文件,带 frontmatter 和 [[wikilink]] 引用。会话启动时扫描器把文件 fold 进 SQLite:memory_nodes 表、memory_edges 表,以及由触发器同步的 FTS5 虚拟表 memory_fts。没有 embedding 列。

检索是三路词法 + 结构信号,在同一次 memory_recall 里合并:FTS5 MATCH(或显式 anchor slug)出直接命中;边图上双向 BFS(默认深度 2)出邻居;TF-IDF 聚类合成的边出共现(上限 5)。边是有类型的,类型决定权重:

pub fn recall_edge_weight(kind: EdgeKind) -> f32 {
    match kind {
        EdgeKind::References   => 1.0,
        EdgeKind::ContainedIn  => 0.9,
        EdgeKind::DerivedFrom  => 0.8,
        EdgeKind::SupersededBy => 0.7,
        EdgeKind::Contradicts  => 0.6,
        EdgeKind::CoOccursSlug  => 0.55,
        EdgeKind::CoOccursToken => 0.45,
    }
}

一条手写的 references 边,权重高于推断出来的 co_occurs_token 边。这正是扁平向量索引会丢掉的信息:它分不清「作者的编辑意图」和「文本碰巧重叠」。这里 BFS 的 trail 保留了 provenance(frontmatterinline-linksynthesized),推断链接永远排不到手写链接前面。

把强化做成事件日志

#188 让召回行为跨会话累积。每次召回、每次重要性标记,都向 .loopal/memory-events/ 下的按会话文件追加一行 JSON:

pub enum EventKind {
    QueryEvent   { qid, query, anchor, result_count, latency_ms, caller },
    RecallHit    { qid, node, rank, score, source },
    ImportanceTag { node, importance, tags, note },
}

日志是 append-only 且按会话隔离的 —— 两个会话写不同文件,并发 agent 之间不争抢同一行,文件也像源码一样能在 git 里 merge。下次会话启动时,事件 fold 成一张内存映射:

pub struct RecallStats {
    pub recall_count: u32,
    pub last_recalled_at: i64,
    pub importance: i8,
    pub importance_ts: i64,
}

RecallHit 累加 recall_countImportanceTagimportance_ts 后写胜出。fold 就是对一条不可变事件流做 left fold —— 同样的输入永远得到同样的状态,损坏的行跳过而非让整个文件失败。状态是「派生」出来的,从不原地修改;这正是「在 SQLite 里放一个可变 recall_count 列」无法白送的性质。

随后两个派生项进入邻居打分:

pub fn recall_reinforcement_bonus(stats: Option<&RecallStats>) -> f32 {
    stats.map_or(0.0, |s| (1.0 + s.recall_count as f32).ln() * 0.15)
}

pub fn importance_bonus(stats: Option<&RecallStats>) -> f32 {
    stats.map_or(0.0, |s| s.importance as f32 * 0.20)
}

强化项取对数是刻意的 —— 高频召回的节点会被抬一下,但抬升会饱和,热门笔记压不过一条直接相关的笔记。它和 BFS 衰减、指数化的 recency 项(90 天半衰期)、类型权重、过期 TTL 惩罚并列叠加,这些都不是调用点里的魔法常量,统一放在一个 policy.rs

重要性工具

memory_set_importance 就是之前缺失的写入路径。它接受一个 slug 和 1–10 的整数,唯一的副作用是追加一条 ImportanceTag 事件:

self.graph.record_event(EventKind::ImportanceTag {
    node: params.node.clone(),
    importance: params.importance,
    tags: params.tags,
    note: params.note,
});

IMPORTANCE_SCALE = 0.20 下,标到 10 给节点加 2.0 分 —— 刻意做到足以压过弱 BFS 排名。工具描述告诉模型何时该用它:强烈的用户偏好、反复出现的关注点、必须记住的事故。因为效果是一条事件,它和强化一样跨会话存活;那条「有读者没写者」的打分路径,现在读写都齐了。

怎么量

评测把 58 条 ground-truth 查询(30 条关键词、14 条 anchor、14 条 mixed)跑在固定 fixture 语料上,报告 Recall@K、MRR、nDCG,并在同一批查询上对比冷启动与各种预热态(5x/20x 强化、importance +5/+10)。

冷启动基线:R@5 = 0.579,MRR = 0.753。按 per-query 强化后,提升为 MRR +7.4%、R@10 +16.1%。集成测试钉的是机制而非总指标:一个测试召回某节点五次、fold、新会话重开,断言 recall_count >= 5 已持久化、被强化的邻居排到等距兄弟节点前面;另一个把节点标 importance=5,断言其分数严格高于冷启动分数。

评测还暴露一个坑:全局给「每一个」相关节点预热,对总指标几乎没动 —— cross-query 污染把噪声和信号一起抬了。诚实的度量是 per-query:重置统计、只预热当前查询的目标。全局数字会美化改动,per-query 数字才是我们对着发版的那个。

事件日志带来的 bug

append-only 日志用「行争抢」换来了「文件生命周期」的风险面,#188 的多角度排查找出六个:

  • 压缩时在 renameremove 之间崩溃,.jsonl.jsonl.gz 并存,两者都 fold 会让 recall_count 翻倍。修复是把 .gz 当权威副本,下次 GC 删掉孤儿源文件。
  • 两个会话同时压缩争抢固定 .tmp 文件名;后缀改成 pid + nanos
  • 文件中途 IO 错误丢掉整个文件已解析的事件;现在 fold 会把已读部分 batch 应用上。
  • 压缩曾把多 GB 会话文件整个读进内存;现在用 io::copy 流式处理。
  • 非 UTF-8 的既有 gitignore 导致每次启动重复追加规则。

这些都不冷僻,是任何「先追加再压缩」存储的标准失效面,也佐证 GC 路径要小、要显式测 —— 现在有八个恢复测试守着。压缩在 90 天触发,归档 365 天,都可配。

索引的意义

只有当语料本身信息密度高,检索机制才划得来 —— 这是「策展」问题,不是「排序」问题。写记忆的侧边栏 agent 是 Knowledge Manager(#98),不是 note-taker,遵循两条命名公理(#142):

  • 每条目的信噪比 —— 凡是未来 agent 能在约 30 秒内从代码、git log 或项目文档里重建出来的,一律拒收。这条优先级高于用户显式的保存请求。
  • 跨条目的潜结构 —— 索引编码的是「原因」而非「症状」;同一模式的三次观测应坍缩成一条命名该模式的条目。索引应像一组正交因子分解,而非流水账。

观测会在 2 秒窗口内 debounce,让连发写入合并成一次 agent spawn,把 spawn 次数砍掉 50–80%。

结论

对这个规模的单项目 agent 记忆,类型化图谱加 append-only 事件日志,在两个真正重要的维度上胜过向量库:召回能跨会话强化,因为「使用」被记成可派生的事件;类型化的边让手写链接始终压在推断链接之上。代价是日志这层的 GC 纪律。语料大、查询确实语义化时再上 embedding;对一个策展过、链接丰富的知识库,「在显式图谱上做词法检索 + 从事件 fold 出强化」是更高杠杆的设计 —— MRR +7.4% / R@10 +16.1% 的提升,全程没有一个 embedding 模型参与。