【AI】AI Agent 记忆系统设计:从上下文窗口到长期记忆
1. 一个被反复混淆的概念
很多项目在介绍自己的 Agent 时会说:「它有记忆,因为它把历史对话都带上了。」
这不叫记忆,这叫上下文窗口。
两者的差别可以用人来类比:
- 上下文窗口相当于你此刻的工作台面。台面有限,放多了就乱,人一走开全部清空。
- 记忆相当于你的大脑和笔记。你不在工位的时候,事情依然记着;下次坐下来,相关的内容会被想起来。
一个每轮都把所有历史塞进 prompt 的系统,会同时遇到三个问题:
- 成本线性增长:第 100 轮对话的输入 token 是第 1 轮的几十上百倍
- 注意力稀释:上下文越长,模型对中段信息的关注越弱
- 跨会话失忆:新开一个会话,之前所有积累归零
要解决这三个问题,就必须把「记忆」从「上下文」里独立出来,做成一套真正的子系统。
2. 记忆的四种类型
认知科学对记忆的分类,对 Agent 设计有很强的借鉴意义。实践中通常映射成四类:
| 类型 | 认知科学含义 | Agent 中的对应物 | 典型生命周期 |
|---|---|---|---|
| 工作记忆 | 当前正在处理的信息 | 当前对话上下文、任务清单 | 单次任务 |
| 情景记忆 | 经历过的事件 | 历史交互记录、任务执行轨迹 | 天 ~ 月 |
| 语义记忆 | 抽象的事实与知识 | 用户偏好、项目事实、领域知识 | 月 ~ 永久 |
| 程序记忆 | 学会的技能 | 工具使用模式、成功的解决方案模板 | 长期 |
大部分 Agent 架子只实现了第一类(工作记忆),然后指望上下文窗口能兜住剩下三类——这就是它们在长任务上崩掉的根因。
一个成熟的记忆系统,至少要把后三类显式建模。
3. 架构:两条路径
记忆系统的核心就是两条路径:写入和检索。
┌───────────────────────────┐
│ Agent Loop │
└───────┬───────────▲───────┘
│ │
写入候选 注入上下文
│ │
┌───────▼───────────┴───────┐
│ Memory Manager │
│ 提取 / 去重 / 冲突消解 │
└───────┬───────────▲───────┘
│ │
┌───────▼───────────┴───────┐
│ Memory Store │
│ 向量库 + KV + 图 + 关系库 │
└───────────────────────────┘3.1 写入路径
每轮交互结束后,从对话中提取值得长期保留的信息:
- 事实:「用户的主力语言是 TypeScript」
- 偏好:「用户不喜欢过度注释的代码」
- 决定:「项目决定用 Postgres 而不是 MySQL」
- 约束:「生产环境不能重启数据库」
关键点是:不要什么都存。把所有对话原文塞进向量库,效果往往比不存还差——检索出来的全是噪音。
一个实用的提取策略是让模型按固定 schema 输出候选记忆:
{
"memories": [
{
"type": "preference",
"content": "用户偏好函数式风格,避免可变状态",
"confidence": 0.85,
"entities": ["用户", "代码风格"]
}
]
}然后用规则过滤掉低置信度、重复、以及明显是「当前任务临时信息」的条目。
3.2 检索路径
检索的核心问题是:当前这一步,需要想起什么?
最朴素的实现是「用当前 query 做向量检索,取 Top-K」。但纯向量检索在记忆场景下有几个明显缺陷:
- 时间盲区:三个月前的一条相似记忆和昨天的一条,向量相似度可能几乎一样
- 精确匹配弱:用户说「Postgres」,存的是「PostgreSQL」,纯向量可能匹配不好
- 关系断裂:查「项目 X 的技术栈」,无法自然地带出「项目 X 用的数据库」这条关联记忆
因此实践中常见的检索是混合检索 + 多重排序:
候选 = 向量检索(Top 20)
∪ 关键词/BM25 检索(Top 20)
∪ 实体关联检索(Top 10)
∪ 最近记忆(Top 5)
最终 = 重排(候选, 权重)
权重 = w1·语义相似度
+ w2·时间衰减
+ w3·重要性
+ w4·使用频次时间衰减通常用指数衰减:
import math
def time_decay(age_days: float, half_life_days: float = 30.0) -> float:
"""越久远的记忆权重越低,半衰期默认 30 天"""
return math.exp(-math.log(2) * age_days / half_life_days)注意「重要性」和「使用频次」这两个因子。一条被反复检索命中的记忆,说明它确实有用,应该获得更高的持久权重——这是让记忆系统自我优化的一个简单手段。
4. 存储选型
不存在「一个数据库搞定所有记忆」的方案。实际系统通常是组合:
| 存储 | 承担的记忆类型 | 理由 |
|---|---|---|
| KV / 关系库 | 用户画像、配置、结构化事实 | 需要精确读写和事务 |
| 向量库 | 语义记忆、情景记忆 | 需要模糊语义检索 |
| 图数据库 | 实体关系、知识图谱 | 需要多跳关联 |
| 对象存储 | 原始对话、长文档 | 冷数据,成本敏感 |
大多数团队的现实选择是:关系库 + 向量库起步,图数据库等真正需要多跳推理时再加。
小规模场景(几千到几万条记忆)甚至可以直接用 pgvector 或 sqlite-vec,没必要一上来就引入独立向量数据库——多一个组件就多一份运维成本和故障面。
5. 遗忘:被忽视但关键的一环
只增不减的记忆系统,最终一定会退化。原因很简单:噪音会淹没信号。
遗忘机制有几种常见实现:
5.1 TTL(生存时间)
临时信息设置明确过期时间。比如「用户现在在改 A 文件」这条,两小时后就没有意义了。
5.2 重要性衰减
重要性不是静态的。一条长期未被检索、未被引用的记忆,重要性应该缓慢下降,降到阈值以下就被归档或删除。
5.3 合并与压缩
多条高度相似的记忆应该合并成一条更抽象的。比如:
"用户喜欢用 pnpm"
"用户不喜欢 npm 的安装速度"
"用户的项目都用 pnpm workspace"
合并为 ↓
"用户偏好 pnpm,常用 workspace 管理多包项目"这本质上是对记忆做「摘要」,能显著降低检索噪音。
5.4 冲突消解
当新记忆与旧记忆矛盾时,不能简单追加,否则检索时两条矛盾信息会一起返回,模型就懵了。
处理策略:
- 同一实体的同一属性 → 用新值覆盖旧值,保留旧值的变更历史
- 偏好类 → 以最新为准,但保留时间戳
- 事实类 → 需要判断是「更正」还是「补充」
def resolve_conflict(old: Memory, new: Memory) -> Memory:
# 同实体同属性:新值覆盖,旧值进历史
if old.entity == new.entity and old.attribute == new.attribute:
new.history = old.history + [old.snapshot()]
return new
# 不同属性或不同实体:并存
return new6. 工程上的五个坑
坑一:把「用户说过的话」当成「事实」
用户说「我最近在学 Rust」,不代表「用户是 Rust 工程师」。提取时要区分陈述和事实,置信度要分开。
坑二:记忆污染
如果模型自己生成的内容也被写回记忆,错误会被反复强化。原则上:只把用户明确提供或有外部证据的信息写入长期记忆。
坑三:上下文膨胀
检索回来 20 条记忆,全塞进 prompt,等于把上下文窗口又浪费了。记忆的注入也需要预算控制——通常 2~5 条高相关的就够,宁可少而精。
坑四:隐私与合规
长期记忆本质上是在持续收集用户数据。需要考虑:用户能否查看、能否删除、数据保留多久、是否加密、跨用户是否隔离。这不是「以后再说」的事,而是架构期就要决定的。
坑五:没有评估
记忆系统最大的问题不是「存不下」,而是「记错了没人发现」。必须建立评估:
- 检索质量:给定 query,期望命中的记忆是否在 Top-K 内(Recall@K)
- 端到端:有记忆 vs 无记忆,任务成功率差多少
- 负面指标:注入的记忆里,有多少实际被模型使用、有多少是噪音
7. 一个最小可用实现
如果不想一上来就搞复杂,可以从这个最小结构开始:
class SimpleMemory:
def __init__(self, store):
self.store = store
def write(self, content: str, mem_type: str,
importance: float, entities: list[str]):
if importance < 0.4: # 低价值直接丢弃
return
if self._is_duplicate(content): # 去重
return
self.store.insert({
"content": content,
"type": mem_type,
"importance": importance,
"entities": entities,
"created_at": now(),
"last_used_at": now(),
"use_count": 0,
})
def retrieve(self, query: str, limit: int = 5) -> list[str]:
cands = self.store.search(query, top_k=20)
scored = []
for m in cands:
score = (
0.5 * m.similarity
+ 0.2 * time_decay(days_since(m.created_at))
+ 0.2 * m.importance
+ 0.1 * min(m.use_count / 10, 1.0)
)
scored.append((score, m))
scored.sort(reverse=True, key=lambda x: x[0])
result = scored[:limit]
self._touch(result) # 更新 last_used_at / use_count
return [m.content for _, m in result]这个实现不到 40 行,但覆盖了写入过滤、去重、混合打分、使用反馈四个关键点。先跑起来,再谈优化。
8. 小结
记忆系统的本质,是把「无限的历史」压缩成「当前需要的少量信息」。它和上下文工程解决的是同一个问题的两面:
- 上下文工程关心:这一轮该给模型看什么
- 记忆系统关心:什么东西值得被留到下一轮、下一周、下一次会话
按优先级排序,一个 Agent 的记忆能力大致应该这样演进:
- 先做工作记忆:任务状态、待办、中间结果的结构化管理
- 再做语义记忆:用户偏好和项目事实,这是收益最直接的一层
- 然后做情景记忆:历史交互检索,注意做好衰减和去重
- 最后才考虑图记忆:多跳关联、复杂推理,成本也最高
别倒着做。