Agent 记忆系统难在取舍

Agent 记忆的难点不是存储,而是判断什么值得写、什么时候合并、召回多少,以及团队资产如何治理。

阅读时间​:约 7 分钟

很多 Agent Memory 方案看起来都差不多:分层记忆、向量召回、长期画像、少用 token。

真正拉开差距的地方,不在这些名词,而在边界条件。

拆这类系统,最好先问四个问题:

  • 同一条信息要不要写
  • 新旧事实冲突时保留谁
  • 召回超时是等,还是放弃
  • 团队管理员能不能看个人记忆

TencentDB Agent Memory 值得拆,是因为它把这些判断写进了代码和 prompt。


图:Agent 记忆工程取舍

记忆系统先要敢丢东西

这套系统最重要的原则可以概括成四个字:宁缺毋滥。

原始对话不会直接变成高层记忆。每轮对话先落到 L0,再由异步流水线逐层提炼。

L0 不是单一存储。它会写 SQLite 表、向量表和按天分片的 JSONL。

这看着冗余,但用途不同:

存储 作用
SQLite 表 结构化查询,保留 team_id/user_id/agent_id/task_id
向量表 语义召回
JSONL 数据库异常时可回捞原话

从最底层就埋下 team_iduser_idagent_idtask_id,说明它一开始就不是个人单机备忘录。后面的团队资产治理,靠这些字段才能成立。


L0 到 L3 是一条异步提炼链

L1 抽取不会每轮都跑。默认每 5 轮触发一次,或者会话空闲 600 秒后触发。

配置里还有一个冷启动优化:enableWarmup 会按 1、2、4、5 轮提前触发,让第一条记忆更早出现。

关键配置大致如下:

复制代码
pipeline: {
  everyNConversations: 5,
  enableWarmup: true,
  l1IdleTimeoutSeconds: 600,
  l2DelayAfterL1Seconds: 10,
  l2MinIntervalSeconds: 900,
  l2MaxIntervalSeconds: 3600,
  sessionActiveWindowHours: 24,
}

这个节奏说明一件事:记忆不能影响对话主链路。写入和聚合放到异步流程里,用户先拿到回答,系统随后整理记忆。

L1 的抽取 prompt 同时做两件事:

  • 判断情境是否切换
  • 提取结构化记忆

记忆只分三类:

类型 内容 例子
persona 用户稳定属性、偏好、技能、价值观 用户偏好简洁回答
episodic 客观发生过的事件、决定、计划 用户在某天决定推进某项目
instruction 长期行为规则、格式和语气要求 回复时先给结论

它刻意排除了"没有客观事件支撑的情绪表达"。这是个很实用的限制。用户随口抱怨一句,不应该被系统永久写成稳定偏好。

还有一个特殊值:priority = -1。它留给极严格的全局命令。正常优先级是 0~100,负数反而代表最高约束,说明后续排序或过滤会为这类规则走特殊分支。


去重和聚合交给 LLM 判决

很多记忆系统用向量相似度阈值去重。TencentDB Agent Memory 没这么做。

它先召回 Top-5 候选,再让 LLM 判断动作:

动作 含义
store 新信息,直接新增
skip 旧记忆更好,新记忆没有增量
update 同一事实,新记忆更具体、更新或纠错
merge 多条记忆互补,合成一条

这比纯阈值更接近人类整理笔记的方式。向量只能判断"像不像",LLM 可以判断"是不是同一事实""谁更新""能不能合并"。

代价也明确:每次抽取后要多一次 LLM 判决。结果也不是完全可复现。同样两条记忆,不同运行可能得到不同合并结果。

L2 聚合更大胆。它不给聚类算法,而是给 LLM 一个目录的读写权限,让它维护场景文档。

关键约束是 maxScenes = 15。场景文件接近上限时,LLM 必须先合并相似场景,再处理新记忆。

这个上限很关键。没有容量约束,记忆只会越堆越多;有了上限,系统被迫持续归纳。

删除也不是让 LLM 真删文件。LLM 只能写 [DELETED] 标记,由工程侧清理。这个设计把"语义判断"交给模型,把"危险操作"留给代码。


召回侧把预算写进代码

召回链路走混合检索:关键词一路,向量一路。

关键词路线使用 SQLite FTS5 的 BM25。中文先做分词,再拼成 OR 查询。

向量路线使用 sqlite-vec 做余弦相似度。两路并行,各取 maxResults * 3 个候选。

融合使用 RRF:

复制代码
export const RRF_K = 60;

export function rrfMerge<T>(
  lists: T[][],
  getId: (item: T) => string,
  k: number = RRF_K,
): Array<T & { rrfScore: number }> {
  const map = new Map<string, { item: T; rrfScore: number }>();

  for (const list of lists) {
    for (let rank = 0; rank < list.length; rank++) {
      const item = list[rank];
      const id = getId(item);
      const score = 1 / (k + rank + 1);
      const existing = map.get(id);
      if (existing) existing.rrfScore += score;
      else map.set(id, { item, rrfScore: score });
    }
  }

  return [...map.values()]
    .sort((a, b) => b.rrfScore - a.rrfScore)
    .map(({ item, rrfScore }) => ({ ...item, rrfScore }));
}

RRF 的好处是不用比较原始分数。BM25 分数和余弦相似度不是一个量纲,直接相加需要调权重。RRF 只看排名,天然适合合并异构检索结果。

召回注入也做了预算控制。

稳定内容放在 system prompt 末尾,例如 persona、场景导航和工具指南。每轮变化的 L1 相关记忆放在 user prompt 前面。

这个位置选择会影响账单。如果每轮都把变化内容塞进 system prompt,prompt cache 会不断失效。把稳定内容和动态内容拆开,能保住缓存命中率。

召回还有 5 秒超时保护。超时后返回空注入,让主流程继续。

这条判断很重要:记忆是增强功能,不该拖垮对话主链路。宁可无记忆回答,也不能让用户等一个不确定的后台依赖。


上下文压缩才是省 token 的主战场

很多文章会把节省 token 归功于长期记忆召回。真正每天发生的压力,是上下文窗口快满。

系统里有三档阈值:

复制代码
mildOffloadRatio: 0.5,
aggressiveCompressRatio: 0.85,
emergencyCompressRatio: 0.95,
mmdMaxTokenRatio: 0.2,

含义如下:

档位 触发比例 处理动作
Mild 50% 替换非当前任务的工具结果
Aggressive 85% 删除更早消息,用状态图回填
Emergency 95% 紧急压缩到目标比例

Mild 档只扫描最近 70% 的消息,并优先处理最可替换的前 40%。这说明系统不做简单 FIFO,而是先移走对当前决策价值较低的内容。

Aggressive 档更像手术:从最老消息开始删除,每轮削掉约 40% 的消息 token,同时通过 L1.5 判断任务边界,避免把同一任务切断。

被删掉的内容不会彻底消失。系统会用一张 Mermaid 状态图回填,告诉模型"任务走到哪一步"。

可以把这个机制理解成:

复制代码
删除几十条工具调用原文
  -> 保留关键状态和证据
  -> 用一张状态图回填当前进展

mmdMaxTokenRatio = 0.2 是必要的刹车。压缩产物最多占 20% token,避免"用压缩摘要把上下文再次塞满"。


团队记忆的核心是治理

个人记忆只要能搜回来就行。团队记忆需要回答更多问题:

  • 这份记忆属于谁
  • 哪个 Agent 可以用
  • 团队成员能不能看
  • 历史版本能不能回滚
  • 同一份知识如何挂给不同任务

这就是治理。

系统把记忆资产登记成有 Owner、版本、可见性和装配关系的资源,再按权限决定 Agent 能不能读取。

private 语义最值得看:只有 owner 可以访问,团队 admin 也不能直接读。

这个选择很产品化。Chat Memory 里会有用户偏好、工作习惯和私下表达。如果管理员也能看,很多人不会愿意打开这个功能。

可见性可以粗略分成三类:

可见性 语义
private 个人隐私资产,只有 owner 可见
team 团队共享,成员可读,owner/admin 可管理
restricted 严格白名单,按 ACL 授权

权限判定顺序也经过优化:先判断资源和 owner,再看成员、visibility、角色默认权限和 ACL。高频路径尽量不查 ACL,减少每次装配记忆的额外成本。


接入层比算法层更重

这个项目有一个很现实的比例:接入层代码量远大于知识引擎。

原因不难理解。做 Agent Memory,算法只是其中一部分;真正麻烦的是怎么接入不同客户端。

早期插件模式要适配每个 Agent 框架的生命周期。换一个闭源客户端,就可能接不进去。

v2.x 换成代理模式:拦截 Anthropic、OpenAI 或其他兼容协议,把请求转发给上游,同时在旁路解析消息、工具调用和 usage,流结束后异步写入 L0。

流式响应的关键是 SSE 边界处理。网络 chunk 不保证刚好按 \n\n 切开,所以代理必须保留未完成的缓冲区,等下一个 chunk 拼完整再解析。

会话识别也靠一组 header 兜底,例如:

复制代码
x-conversation-id
x-session-id
x-claude-code-session-id
x-thread-id

这就是"零侵入接入"的真实代价。用户只改 base URL,看起来很轻;兼容性压力转移到了代理层。

不同客户端会塞不同的系统提示、补全请求、压缩请求和工具调用格式。代理必须识别哪些要写记忆,哪些只是辅助请求。

所以,记忆系统从个人玩具走向团队工具时,接入和治理常常比算法更费工程量。


收束

TencentDB Agent Memory 给出的几个判断可以迁移到很多 Agent 产品里:

  1. 记忆系统先设计"丢弃规则",再设计存储
  2. LLM 适合做归纳、去重和合并,但危险动作要由代码执行
  3. 容量上限能逼系统持续整理,而不是无限堆积
  4. 召回失败要可降级,不能阻塞主流程
  5. 团队记忆必须先解决权限和可见性,算法效果排在后面

它用到的技术并不神秘:SQLite、FTS5、向量检索、RRF、异步队列、prompt cache、SSE 解析。

真正值得学的是这些技术被放在了正确的边界上。该让模型判断的地方让模型判断,该让代码兜底的地方让代码兜底,该让权限拒绝的地方就直接拒绝。

记忆系统的核心能力,最后会落到一句很朴素的话上:存得少一点,取准一点,错了能改回来。

推荐阅读

数字分身 Agent 的工程内核

"你是专家"这句 Prompt 该删了吗

Claude Code 为什么用随机动词缓解等待焦虑

RAG Hybrid 到底混合了什么

LLM 强化学习为什么能落地