AI Agent 记忆架构实战:从上下文窗口到分层记忆
问题的起点:Agent 为什么会"失忆"
几乎所有人在把 AI Agent 从 Demo 推向真实工作流时,都会撞上同一堵墙:它记不住。
一个典型场景是这样的:你让 Agent 帮你重构一个跨 20 个文件的功能,前 5 轮对话里它还记得"我们决定保留旧的接口签名以兼容下游服务",等到第 15 轮,它兴高采烈地给出了一个把接口签名全改掉的方案。你没有换模型,没有换提示词,只是对话变长了。
这不是模型变笨了,而是上下文窗口的物理边界在起作用。
主流模型的上下文窗口在 128K 到 200K token 之间,看似很大,但真实的长任务消耗远超想象:
- 一次
ripgrep全仓库搜索结果:几万 token - 一个中等规模源文件的完整内容:几千 token
- 加上系统提示、工具定义、历史对话、工具返回结果
几十轮之后,最早的决策就被挤出了窗口。 更隐蔽的问题是 "Lost in the Middle"——即使内容还在窗口里,位于中部的信息被模型有效利用的概率也显著低于首尾。换句话说,把记忆全部塞进上下文,等于把记忆交给了一个会随时间衰减的缓存。
记忆架构要解决的,就是让 Agent 在上下文窗口之外,拥有一个可检索、可写回、可维护的持久化层。
三层记忆模型:短期、工作、长期
借鉴认知科学的分层思路,工程上可以拆成三层。
第一层:短期记忆(Short-term / Context)
就是当前对话的上下文窗口本身。它的职责是保持当前任务链的连贯性——上一轮说什么、正在改哪个文件、工具返回了什么。
关键工程点:
- 不要让它无限增长。给上下文设一个软上限(比如 60% 窗口大小),超过就触发压缩。
- 压缩要保留决策而非过程。"我们试过 A 方案失败了因为 B 原因" 比 "执行了命令 X 返回了报错 Y" 更值得留在上下文里。
- 工具输出要截断。一次搜索返回 200 个结果,进上下文前先摘要成前 10 个 + 总数。
第二层:工作记忆(Working / Task State)
这是任务级的结构化状态,通常在上下文之外维护,但每次调用时注入进去。典型内容包括:
{
"task": "重构 user service 的鉴权逻辑",
"decisions": [
{ "id": "d1", "text": "保留 /api/v1 接口签名", "reason": "下游 billing 服务依赖" }
],
"files_touched": ["src/user/auth.ts", "src/middleware/jwt.ts"],
"pending": ["补充 refresh token 的测试用例"],
"constraints": ["不要引入新的运行时依赖"]
}
工作记忆的价值在于它是显式的、可编辑的。当 Agent 做出一个关键决策,把它写进这个结构;下一轮开始时不检索、不猜测,直接注入。这比指望模型从 50 轮历史里"回忆"可靠得多。
它同时也是人在回路(human-in-the-loop)的最佳介入点:你把 JSON 给用户看,用户改一行,Agent 的行为立刻纠正。
第三层:长期记忆(Long-term / Persistent)
跨会话、跨任务的知识沉淀。这一层最像传统意义上的"数据库",通常包含两到三种存储:
- 向量库(语义检索):适合"我之前是不是遇到过类似的问题"这类模糊召回。存项目约定、踩坑经验、用户偏好。
- 键值/结构化存储(精确检索):适合"这个项目的构建命令是什么"这类确定查询。比向量检索更快更准。
- 事件日志(时序检索):适合"我们三周前为什么放弃了这个方案"这类叙事查询。
长期记忆的核心难点不在存储,而在写入策略:什么该记、什么时候记、记多细。
记忆的生命周期:写入 → 检索 → 注入 → 衰减 → 遗忘
理解记忆架构,关键是把它当成一个循环而不是一堆存储。一条记忆从产生到消亡要走过五个阶段,每个阶段的策略都会反过来影响其他阶段。
写入:用重要性过滤,而非全量落库
很多团队的第一版实现是"把每轮对话都存进向量库",然后发现检索质量一塌糊涂——因为大部分内容是无价值的噪音。
一个实用的写入触发条件:
- 显式决策:Agent 或用户做出了"选择 A 而非 B"的判断 → 必写
- 纠错信号:用户说"不对,应该是……" → 必写,且高优先级
- 稳定的项目事实:构建方式、目录约定、技术栈版本 → 必写,进结构化存储
- 任务完成总结:一次任务收尾时生成一段 3-5 句的摘要 → 必写,这是跨会话最有效的记忆单元
反过来,工具调用的原始输出、失败的重试过程、闲聊内容——默认不写。
检索:混合策略优于纯向量
实际生产里,纯语义检索的问题很明显:精确的标识符(函数名、文件路径、错误码)在 embedding 空间里区分度很低。
更可靠的方案是混合检索:
候选集 = 向量相似度 Top-K ∪ 关键词/BM25 匹配 Top-K
∪ 最近 N 条工作记忆
∪ 用户显式 pin 的条目
最终排序后取有限条注入上下文
注入量要克制。经验值是长期记忆注入控制在 2000-4000 token 以内。记忆越多 ≠ 效果越好,噪音同样会稀释注意力。
衰减与遗忘:没有回收的记忆库必然腐化
记忆是有保质期的。给每条长期记忆打上时间戳和引用计数:
- 长期未被检索到的记忆 → 降权,检索时排在后面
- 被用户明确推翻的记忆 → 标记失效,不再参与召回
- 超过有效期且零引用的记忆 → 归档或删除
没有遗忘机制的长期记忆,最终会变成噪音库。 这一点常被忽略,但它是系统能长期运行的前提。
动手实践:在 Claude Code 风格的 Agent 上落地
上面讲了原理,这一节给可以直接抄的步骤。下面以一个本地 Agent(如 Claude Code / Cursor Agent / 自研 CLI Agent)为例,从零搭一套最小可用记忆。
步骤 0:准备目录结构
所有记忆落地成文件,方便人工检查和手改:
.agent/
├── working.json # 工作记忆(任务级,每轮覆盖)
├── memories.jsonl # 长期记忆(追加写,一行一条)
└── config.json # 配置(阈值、检索参数)
为什么用文件而不是数据库? 起步阶段可观测性比性能重要得多。你能 cat 出来、能手动改、能进 git。等链路跑通、数据量上来(通常几千条以上)再换 SQLite 或向量库。
步骤 1:定义工作记忆并每轮注入
working.json 的初始结构:
{
"task": "",
"decisions": [],
"files_touched": [],
"pending": [],
"constraints": [],
"updated_at": ""
}
会话启动时,把它序列化后拼进 system prompt:
import json
from pathlib import Path
WORKING = Path(".agent/working.json")
def load_working() -> dict:
return json.loads(WORKING.read_text(encoding="utf-8"))
def render_working(w: dict) -> str:
if not w.get("task"):
return ""
lines = [f"## 当前任务\n{w['task']}"]
if w["decisions"]:
lines.append("## 已定决策(不要推翻)")
for d in w["decisions"]:
lines.append(f"- {d['text']}(原因:{d['reason']})")
if w["constraints"]:
lines.append("## 硬约束")
lines += [f"- {c}" for c in w["constraints"]]
if w["pending"]:
lines.append("## 待办")
lines += [f"- {p}" for p in w["pending"]]
return "\n".join(lines)
把 render_working(load_working()) 注入到每轮请求的 system 部分。注意措辞:已定决策(不要推翻) 比 历史决策 的约束力强得多,这是提示词层面的小技巧但实测有效。
步骤 2:让 Agent 主动更新工作记忆
给 Agent 暴露一个工具,让它自己在关键节点调用:
TOOLS = [{
"name": "update_working_memory",
"description": "更新任务状态。做出关键决策、发现新约束、完成待办时调用。",
"input_schema": {
"type": "object",
"properties": {
"task": {"type": "string", "description": "任务目标,仅在任务变更时填写"},
"add_decision": {
"type": "object",
"properties": {
"text": {"type": "string"},
"reason": {"type": "string", "description": "为什么这样选,必填"}
},
"required": ["text", "reason"]
},
"add_constraint": {"type": "string"},
"resolve_pending": {"type": "string"},
"add_pending": {"type": "string"},
"touch_file": {"type": "string"}
}
}
}]
def handle_update(args: dict):
w = load_working()
if args.get("task"):
w["task"] = args["task"]
if d := args.get():
w[].append({: , **d})
c := args.get():
w[].append(c)
p := args.get():
w[].append(p)
p := args.get():
w[] = [x x w[] x != p]
f := args.get():
f w[]:
w[].append(f)
w[] = now_iso()
WORKING.write_text(json.dumps(w, ensure_ascii=, indent=))
关键设计:reason 是必填字段。 只记"选了什么"没用,记"为什么选"才能在未来判断这条记忆是否仍然成立。
在 system prompt 里加一句触发指令:
当你要做出架构/接口/依赖层面的选择时,先调用 update_working_memory 记录决策和原因,
再继续执行。当用户纠正你时,同样记录一条决策,原因写「用户纠正」。
步骤 3:任务收尾时生成长期记忆
任务完成时,让模型对着工作记忆生成一段摘要,追加到 memories.jsonl:
SUMMARY_PROMPT = """基于以下任务状态,生成一条记忆摘要。要求:
1. 3-5 句话
2. 必须包含:任务目标、最终方案、被否决的方案及原因
3. 不要包含过程性细节(执行了什么命令、试错了几次)
4. 输出 JSON:{"summary": "...", "decisions": [...], "tags": [...]}
任务状态:
{working}
"""
def save_memory(summary_obj: dict, base: dict):
record = {
"id": stable_hash(summary_obj["summary"]),
"summary": summary_obj["summary"],
"tags": summary_obj.get("tags", []),
"files": base.get("files_touched", []),
"created_at": now_iso(),
"last_hit_at": None, # 引用计数用
"hit_count": 0,
"status": "active" # active | deprecated
}
with open(".agent/memories.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
status 和 hit_count 这两个字段现在看起来多余,但它们是后面遗忘机制的基础。一开始就埋好,比以后回溯补要省事得多。
步骤 4:加混合检索
新任务开始时(或上下文超过阈值时),用当前任务描述去召回相关历史:
def recall(task_desc: str, top_k: int = 3) -> list[dict]:
records = [json.loads(l) for l in
Path(".agent/memories.jsonl").read_text(encoding="utf-8").splitlines()
if l.strip()]
records = [r for r in records if r["status"] == "active"]
vec_hits = vector_search(task_desc, records, k=top_k)
kw_hits = bm25_search(task_desc, records, k=top_k)
pinned = [r for r in records if r.get("pinned")]
merged, seen = [], set()
for r in vec_hits + kw_hits + pinned:
if r["id"] not in seen:
merged.append(r); seen.add(r["id"])
# 时效与引用加权
merged.sort(key=lambda r: score(r, task_desc), reverse=True)
result = merged[:top_k]
bump_hits(result) # 命中就更新 hit_count / last_hit_at
return result
起步阶段可以简化:先只做关键词匹配(files 字段 + tags 字段过滤),甚至只取最近 3 条。向量检索等 SQLite + sqlite-vec 或本地 embedding 服务就绪后再接。
bump_hits 是很多人会漏的一步——没有命中统计,遗忘机制就没有依据:
def bump_hits(records):
ids = {r["id"] for r in records}
lines = Path(".agent/memories.jsonl").read_text(encoding="utf-8").splitlines()
out = []
for l in lines:
if not l.strip():
continue
r = json.loads(l)
if r["id"] in ids:
r["hit_count"] += 1
r["last_hit_at"] = now_iso()
out.append(json.dumps(r, ensure_ascii=False))
Path(".agent/memories.jsonl").write_text("\n".join(out) + "\n", encoding="utf-8")
步骤 5:注入召回结果
把召回的摘要拼进 system prompt,注意明确标注这是历史经验而非当前指令:
def render_recall(records: list[dict]) -> str:
if not records:
return ""
lines = ["## 相关历史经验(供参考,可能已过时)"]
for r in records:
lines.append(f"- {r['summary']} _({r['created_at'][:10]})_")
return "\n".join(lines)
(供参考,可能已过时) 这句很重要:它能防止模型把三周前的旧决策当成当前任务指令照搬。
步骤 6:加遗忘与降权
定期(比如每天或每 100 个任务)跑一次维护任务:
def decay_and_gc():
records = load_all()
now = datetime.now(timezone.utc)
for r in records:
age_days = (now - parse(r["created_at"])).days
idle_days = (now - parse(r["last_hit_at"] or r["created_at"])).days
# 长期零引用 → 归档
if r["hit_count"] == 0 and age_days > 90:
r["status"] = "archived"
# 长期未被命中 → 降权(在检索打分时体现)
r["weight"] = 1.0 if idle_days < 14 else 0.5
# 被推翻的记忆由人工或 Agent 显式标记
save_all(records)
检索打分函数里带上权重和时效:
def score(r, task_desc):
base = similarity(r, task_desc)
freshness = 1.0 / (1 + days_since(r["created_at"]) / 30)
return base * r.get("weight", 1.0) * freshness
步骤 7:打日志,让记忆可观测
最后一步,也是最容易被跳过的一步:每轮把注入的记忆 ID 和来源打进日志。
log.info("injected_memory ids=%s source=%s tokens=%d",
[r["id"][:8] for r in recall_result],
"recall" if recall_result else "none",
est_tokens(render_recall(recall_result)))
Agent 行为异常时(比如突然推翻了之前的决策),你第一件事就是去看这一轮注入了什么。没有这个日志,调试记忆架构就是盲人摸象。
四步上线的推荐节奏
上面七个步骤听起来多,但不需要一次全做完。按投入产出比排序:
第一步(步骤 0-2):只做工作记忆。 一个 JSON 文件 + 一个工具,一天能做完。解决大部分"决策漂移"问题,投入产出比最高。建议先单独上线跑一周,观察 Agent 是否还会推翻之前的决策。
第二步(步骤 3):加任务级总结。 每完成一个任务追加一条记忆。这时还没有检索,纯粹是积累数据,顺便验证摘要质量。
第三步(步骤 4-5):接入检索注入。 从关键词匹配起步就够,等召回质量问题暴露出来再上向量。先跑通链路,不要一上来就纠结重排序模型选哪个。
第四步(步骤 6-7):加遗忘和日志。 这是系统能长期运行的必要条件,不是可选项。数据量小时感觉不到,跑三个月后不做的后果会非常明显。
几个容易踩的坑
把记忆当成上下文扩容。 如果你的方案本质是"检索一大堆东西塞进 prompt",那只是把失忆点往后推了。记忆架构的目标是减少注入量同时提高信息密度。
忽略记忆的冲突处理。 三周前记着"用 REST",今天决定"改用 GraphQL",两条记忆都在库里,检索回来哪条?必须有显式的时效标记和优先级规则,否则 Agent 会在矛盾信息间随机摇摆。
摘要丢掉了"为什么"。 只记"改用了方案 B"没有价值,记"改用方案 B,因为方案 A 在并发场景下会死锁"才有价值。决策的原因比决策本身重要得多。
工作记忆只增不改。 待办列表越积越长,已解决的条目从不清理,最后注入进去全是噪音。每次更新都要做删除——resolve_pending 这个动作和 add_pending 一样重要。
没有可观测性。 Agent 行为异常时,你得能回答:"这一轮注入了哪些记忆?"没有这个能力,调试记忆架构等于盲人摸象。至少在日志里打印注入的记忆 ID 和来源。
结语
Agent 的记忆问题,本质上不是"存储容量"问题,而是信息生命周期管理问题:什么信息在什么阶段以什么形式被保留、被检索、被丢弃。
上下文窗口是内存,分层记忆是磁盘加缓存,写入策略是持久化逻辑,检索策略是索引设计,遗忘机制是垃圾回收。用工程系统的方式去对待它,而不是指望把更多东西塞进 prompt——这是让 Agent 从"能演示"走到"能交付"的关键一步。
