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] 满足任意一条才允许写入

  1. 用户明确表达了长期偏好(“我以后都用 TypeScript”);
  2. 该事实是纠正性信息(“我的部署环境是 Ubuntu 22.04,不是 Debian”);
  3. 该事实被引用超过 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

四个容易被忽略但非常关键的细节:

  1. 稳定内容放前、易变内容放后 ——这直接影响 prompt cache 命中率,能省下真金白银的推理成本;
  2. 召回到的长期记忆要标来源 (source_turn),出错时能追溯;
  3. 抽取记忆用小模型,别用主模型,省成本;
  4. 记忆写入是异步的,不要阻塞主流程响应。

1.8. 五条验收口径:怎么知道你的记忆系统真的有效

不要凭感觉说"感觉它记得了"。用这五个指标验收:

指标 含义 目标
召回命中率 该被召回的记忆,被召回了的比例 > 90%
误召回率 不该召回的,被召回的比例 < 10%
上下文膨胀比 记忆占 prompt 的 token 比例 < 15%
记忆库有效率 活跃记忆 / 全部记忆 > 60%
一致性 同一事实在多轮对话中表述是否稳定 无矛盾

[!warning] 只增不减,系统一定会慢慢烂掉
跑一个 30 轮的模拟对话,人工标注"每一轮应该召回什么",然后算这五个数。 没有这套验收,记忆系统一定会慢慢烂掉 ——因为它只增不减。


1.9. 常见坑清单

常见坑 后果
把全部对话 history 当记忆 上下文爆炸,成本失控
记忆不做过期处理 半年后你的 Agent 还在用你三年前的项目路径
没有冲突处理 Agent 一会儿用 Python 一会儿用 TypeScript
抽取记忆用大模型 成本是必要成本的三到五倍
召回不截断 召回 20 条全塞进去,反而干扰生成
不做 recency 衰减 三个月前的偏好压过昨天刚说的

1.10. 结语

记忆系统不是"给模型加个向量库",而是三件工程事:分层存放、门槛写入、可量化验收 。

最小可行路径建议:

[!success] 最小可行路径

  1. 先做 L1(文件 scratchpad),几乎零成本,立刻解决多步任务失控;
  2. 再做 L2(混合检索 + rerank),把外部文档接进来;
  3. 最后做 L3(带门槛和衰减的长期记忆),这一步最脏也最需要验收。

按这个顺序做,每一层都能单独产生价值,不会陷入"整套记忆系统写完才发现不好用"的坑。

觉得有用的话,点个「在看」。下一期我会拆解真实项目里怎么把这套东西接进 Claude Code / OpenClaw 这类 Agent 框架,明早见。