AI Agent 记忆系统实战:从上下文窗口到长期记忆
1. AI Agent 记忆系统实战:从上下文窗口到长期记忆
[!abstract] 速览
- 根因:上下文窗口是有限、易失、且每轮都要重新付钱的缓冲区。
- 记忆问题拆解为三大需求:上一轮话怎么找回、三个月前的偏好怎么找回、40 万字文档怎么用。
- 四层记忆模型:L0 提示缓存层、L1 工作记忆层(用文件实现 scratchpad)、L2 检索记忆层(分块 + 混合检索 + 再排序)、L3 长期记忆层(写入门槛和冲突处理)。
- 设计原则:稳定内容放前、易变内容放后。
- 五条验收口径:召回命中率、误召回率、上下文膨胀比、记忆库有效率、一致性;最小可行路径按 L1→L2→L3 的顺序构建。
1.1. 主要内容
| # | 核心结论 | 原文要点 |
|---|---|---|
| 1 | Agent 记忆问题本质是上下文窗口的有限性、扁平性和成本问题 | 上下文是一次性的、扁平的、贵的,这导致需要将记忆问题拆解为工作记忆、检索记忆和长期记忆三类需求 |
| 2 | 四层记忆模型从便宜到昂贵依次叠加,L1 工作记忆用文件实现 scratchpad | 通过文件工具读写中间状态,避免变量失控,summarize 函数剔除过程性信息,仅保留状态信息进入 prompt |
| 3 | L2 检索记忆采用分块+混合检索+再排序方案 | 按语义边界切块、重叠 10%-15%、保留元数据;向量+BM25 混合检索 alpha=0.6 为起点;召回后再排序提升准确率 |
| 4 | L3 长期记忆设写入门槛和冲突处理机制 | 只有满足用户长期偏好、纠正性信息或被引用多次且相关时才写入;冲突时标记旧记忆为 superseded;召回时带时间衰减因子 |
| 5 | 记忆系统有效验收需践行五个量化指标 | 召回命中率 >90%、误召回率 <10%、上下文膨胀比 <15%、记忆库有效率 >60%、一致性无矛盾,30 轮模拟对话人工标注验证 |
让 Agent 记住昨天的事,而不是每次都失忆。 本文从"为什么会忘"讲起,给出四层记忆的最小可用实现,附可直接运行的 Python 代码。全程不依赖任何云端记忆服务,本地跑得通。
1.2. 先说清楚:Agent 到底为什么"忘事"
很多人以为模型失忆是模型能力问题,其实不是。根因在 上下文窗口(context window)是一个有限、易失、且每轮都要重新付钱的缓冲区:
- 上下文是 一次性的:这一轮塞进去 100K token,下一轮如果没被重新塞回去,模型就真的不知道了;
- 上下文是 扁平的:没有天然的"目录",第 3 轮的第 5000 字和第 50 轮的第 20 字在模型眼里没有优先级差异;
- 上下文是 贵的:每轮全量重发,意味着 token 成本随对话长度线性上涨。
所以"给 Agent 装记忆"这件事,本质上解决三件事:
| 要解决的问题 | 记忆系统里的对应物 |
|---|---|
| 上一轮说过的话怎么找回来 | 工作记忆(短期/会话内) |
| 三个月前的偏好怎么找回来 | 长期记忆(跨会话) |
| 40 万字的文档怎么用 | 外部记忆(检索式) |
把这三件事分开,你的记忆系统就已经比"把整个对话 history 塞进 prompt"强一个量级了。
1.3. 四层记忆模型
我用的分层是这样的,从便宜到昂贵依次往上叠:
| 层级 | 存放内容 |
|---|---|
| L0 提示缓存层 | 系统提示 + 工具定义(几乎不变,靠 provider 侧缓存兜底) |
| L1 工作记忆层 | 当前任务的 scratchpad,随任务生命周期存在 |
| L2 检索记忆层 | 外部文档/知识库,按需召回片段 |
| L3 长期记忆层 | 跨会话的用户事实与偏好,结构化存储 |
关键设计原则:只有"跨会话仍然成立的事实"才配进 L3 。一句话的判断标准是——
如果这句话明天可能就不成立了,它属于 L1;如果这句话下个月还成立,它才配进 L3。
把"用户今天有点累"写进长期记忆,是很多 Agent 记忆系统变脏的主因。
1.4. L1 工作记忆:用文件当 scratchpad,别用变量
新手最常见的写法是把中间状态存在 Python 变量或 messages 列表里,一旦要做多步任务(读文件 → 提取 → 写报告)就会失控。
更稳的做法是 把中间状态落成文件,让 Agent 用文件工具读写。
# memory/scratchpad.py
from pathlib import Path
import json, time
PAD = Path("./memory")
PAD.mkdir(exist_ok=True)
def task_id() -> str:
return time.strftime("%Y%m%d-%H%M%S")
def write_state(tid: str, state: dict) -> str:
p = PAD / f"task-{tid}.json"
p.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
return str(p)
def read_state(tid: str) -> dict:
p = PAD / f"task-{tid}.json"
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else {}
def summarize(state: dict) -> str:
"""把长状态压成给模型看的摘要,控制在几百 token 内。"""
keep = {k: v for k, v in state.items() if k not in ("raw_docs", "tool_trace")}
return json.dumps(keep, ensure_ascii=False)[:2000]
注意 summarize() 里刻意剔除了 raw_docs 和 tool_trace —— 这两样东西是 过程,不是 状态 。过程留在文件里,状态才进 prompt。这是工作记忆最容易踩的坑。
调用方式:
tid = task_id()
state = read_state(tid)
state.setdefault("docs", [])
state["docs"].append("docs/report.md")
write_state(tid, state)
# 组装 prompt 时只给摘要,不给全文
prompt = f"当前进度摘要:\n{summarize(state)}\n请继续下一步。"
1.5. L2 检索记忆:分块 + 混合检索,三条经验
外部知识库这一层,技术选型已经很收敛了,关键在工程细节。
1.5.1. 分块策略
不要按固定字符数硬切。实践中效果最好的组合是:
[!tip] 分块三条经验
- 按语义边界切:Markdown 标题、代码函数、PDF 段落;
- 重叠 10%~15%:防止答案恰好落在切缝上被劈开;
- 每个 chunk 保留元数据:来源文件、章节路径、更新时间。
def chunk_markdown(text: str, max_chars: int = 1200, overlap: int = 150):
import re
sections = re.split(r"\n(?=#{1,4}\s)", text)
chunks, buf = [], ""
for sec in sections:
if len(buf) + len(sec) > max_chars and buf:
chunks.append(buf.strip())
buf = buf[-overlap:] + "\n" + sec
else:
buf = (buf + "\n" + sec) if buf else sec
if buf.strip():
chunks.append(buf.strip())
return chunks
1.5.2. 混合检索:向量 + 关键词
纯向量检索在"专有名词、错误码、版本号"这类查询上会翻车;纯 BM25 在"意思相近但用词不同"上会翻车。 两路并行再融合,是当前性价比最高的方案。
def hybrid_search(query: str, chunks: list[dict], top_k: int = 5, alpha: float = 0.6):
vec_scores = vector_search(query, chunks) # 余弦相似度,已归一化到 0~1
bm25_scores = bm25_search(query, chunks) # BM25,已归一化到 0~1
merged = []
for i, c in enumerate(chunks):
s = alpha * vec_scores[i] + (1 - alpha) * bm25_scores[i]
merged.append((s, c))
merged.sort(key=lambda x: x[0], reverse=True)
return merged[:top_k]
alpha=0.6 是常用起点:语义为主、关键词为辅。 但要按你的语料调 ——如果语料以文档、报错码为主,应该降到 0.4 甚至 0.3。
1.5.3. 召回后的"再排序"别省
向量召回出来的 top-20 里,真正有用的常常只有 3~5 条。加一层 rerank(哪怕是个小 cross-encoder 或规则打分),准确率提升往往比换更大的 embedding 模型更明显。
1.6. L3 长期记忆:让模型自己决定"什么值得记"
这一层是整套系统里最容易做错的地方。常见错误是让模型 无条件 抽取记忆,结果记忆库三天就变成垃圾场。
正确的做法是给模型一个 明确的写入门槛 + 结构化 schema 。
1.6.1. 写入门槛
[!important] 满足任意一条才允许写入
- 用户明确表达了长期偏好(“我以后都用 TypeScript”);
- 该事实是纠正性信息(“我的部署环境是 Ubuntu 22.04,不是 Debian”);
- 该事实被引用超过 2 次,且在未来 30 天内仍然相关。
1.6.2. 结构化 schema
MEMORY_FIELDS = ["subject", "predicate", "object", "confidence", "source_turn", "expires_at"]
def should_write(fact: dict, existing: list[dict]) -> bool:
"""三条门槛全过才写。"""
if fact.get("confidence", 0) < 0.8:
return False
if fact.get("expires_at") and fact["expires_at"] < today():
return False
# 与已有记忆冲突时,不新增,交给 update 流程
for m in existing:
if m["subject"] == fact["subject"] and m["predicate"] == fact["predicate"]:
return False
return True
1.6.3. 冲突处理:后写覆盖,但保留历史
记忆之间会打架(“用 Python” vs “用 TypeScript”)。 不要静默丢弃,要标记旧记忆为 superseded,这样在召回时你可以选择用哪个。
def upsert_memory(new_fact: dict, store: list[dict]) -> list[dict]:
for m in store:
if m["subject"] == new_fact["subject"] and m["predicate"] == new_fact["predicate"]:
m["status"] = "superseded"
m["superseded_at"] = now()
new_fact["status"] = "active"
store.append(new_fact)
return store
1.6.4. 召回时必须带时间衰减
很久以前的偏好,权重应该低于最近的。加一个简单的时间衰减因子:
import math
def recency_weight(created_at_ts: float, half_life_days: float = 90.0) -> float:
age_days = (now_ts() - created_at_ts) / 86400
return 0.5 ** (age_days / half_life_days)
最终得分 = 语义相似度 × recency_weight × confidence。
1.7. 把这些串起来:一个最小可跑的 Agent 记忆循环
class MemoryAgent:
def __init__(self, model, store_path="./memory/long_term.json"):
self.model = model
self.store_path = store_path
self.long_term = json.loads(Path(store_path).read_text(encoding="utf-8")) \
if Path(store_path).exists() else []
def turn(self, user_input: str, task_id: str):
# 1) 召回
recalled = self._recall(user_input, top_k=5)
# 2) 组装上下文:稳定部分放前面(利于缓存),易变部分放后面
context = ("## 长期记忆\n" + json.dumps(recalled, ensure_ascii=False)
+ "\n\n## 当前任务状态\n" + summarize(read_state(task_id))
+ "\n\n## 用户输入\n" + user_input
)
# 3) 生成
reply = self.model.generate(context)
# 4) 回写状态
state = read_state(task_id)
state["last_reply"] = reply[:500]
write_state(task_id, state)
# 5) 抽取候选记忆 -> 门槛过滤 -> upsert
candidates = extract_facts(user_input + "\n" + reply)
for c in candidates:
if should_write(c, self.long_term):
self.long_term = upsert_memory(c, self.long_term)
self._save()
return reply
四个容易被忽略但非常关键的细节:
- 稳定内容放前、易变内容放后 ——这直接影响 prompt cache 命中率,能省下真金白银的推理成本;
- 召回到的长期记忆要标来源 (
source_turn),出错时能追溯; - 抽取记忆用小模型,别用主模型,省成本;
- 记忆写入是异步的,不要阻塞主流程响应。
1.8. 五条验收口径:怎么知道你的记忆系统真的有效
不要凭感觉说"感觉它记得了"。用这五个指标验收:
| 指标 | 含义 | 目标 |
|---|---|---|
| 召回命中率 | 该被召回的记忆,被召回了的比例 | > 90% |
| 误召回率 | 不该召回的,被召回的比例 | < 10% |
| 上下文膨胀比 | 记忆占 prompt 的 token 比例 | < 15% |
| 记忆库有效率 | 活跃记忆 / 全部记忆 | > 60% |
| 一致性 | 同一事实在多轮对话中表述是否稳定 | 无矛盾 |
[!warning] 只增不减,系统一定会慢慢烂掉
跑一个 30 轮的模拟对话,人工标注"每一轮应该召回什么",然后算这五个数。 没有这套验收,记忆系统一定会慢慢烂掉 ——因为它只增不减。
1.9. 常见坑清单
| 常见坑 | 后果 |
|---|---|
| 把全部对话 history 当记忆 | 上下文爆炸,成本失控 |
| 记忆不做过期处理 | 半年后你的 Agent 还在用你三年前的项目路径 |
| 没有冲突处理 | Agent 一会儿用 Python 一会儿用 TypeScript |
| 抽取记忆用大模型 | 成本是必要成本的三到五倍 |
| 召回不截断 | 召回 20 条全塞进去,反而干扰生成 |
| 不做 recency 衰减 | 三个月前的偏好压过昨天刚说的 |
1.10. 结语
记忆系统不是"给模型加个向量库",而是三件工程事:分层存放、门槛写入、可量化验收 。
最小可行路径建议:
[!success] 最小可行路径
- 先做 L1(文件 scratchpad),几乎零成本,立刻解决多步任务失控;
- 再做 L2(混合检索 + rerank),把外部文档接进来;
- 最后做 L3(带门槛和衰减的长期记忆),这一步最脏也最需要验收。
按这个顺序做,每一层都能单独产生价值,不会陷入"整套记忆系统写完才发现不好用"的坑。
觉得有用的话,点个「在看」。下一期我会拆解真实项目里怎么把这套东西接进 Claude Code / OpenClaw 这类 Agent 框架,明早见。