给 Agent 做记忆系统:别让 history 无限长下去

摘要:上一篇我们用 history 让模型拥有上下文,但这个办法有天然上限:对话越长、工具输出越多,history 就越重。真正能长期工作的 Agent,需要把原始记录、情景记忆和长期记忆分开,并在上下文接近上限前自动压缩。本文拆解一个文件化的三层记忆系统:history.jsonlYYYY-MM-DD.mdMEMORY.md

标签:Agent 记忆、Context Engineering、自动压缩、Python、LLM

在这里插入图片描述

history 带来了记忆,也带来了膨胀

在最小 Agent 里,history 是让模型“记得前文”的关键。每次用户输入,我们把 user 消息 append 进去;每次模型回答,再把 assistant 消息 append 进去;下一轮调用时,把整个列表重新发给模型。

这很有效,也很诚实。模型本身没有跨请求状态,所谓上下文就是调用方传进去的消息列表。

问题也同样明显:history 会一直增长。

一开始只是几句对话,成本很低。后来模型开始调用工具,history 里会出现命令输出、文件内容、网页正文、错误日志。再后来你和 Agent 在一个项目里聊上几十轮,它每次调用都要背着越来越多历史。速度会变慢,成本会上升,最终还会撞上上下文窗口。

所以记忆系统要解决的不是“让模型神奇地记住一切”,而是把不同类型的信息放到不同位置:完整记录保留下来,稳定事实常驻上下文,中间过程在合适的时候压缩。

在这里插入图片描述
在这里插入图片描述

三层记忆:不要让所有东西挤在 history 里

项目里的记忆模块在 agent/memory.py。它采用了一个非常朴素但好维护的三层结构:

Raw 原始层       memory/history.jsonl
Episodic 情景层  memory/YYYY-MM-DD.md
Core 长期层      memory/MEMORY.md

在这里插入图片描述

这三层的职责完全不同。

Raw 原始层保存完整对话档案。每轮 user、assistant 以及必要的非字符串内容,都会追加到 history.jsonl。它不进入上下文,主要用于审计和回查。以后你想知道某个结论是怎么来的,可以翻这份底账。

Episodic 情景层保存按天归档的对话摘要。文件名类似 2026-04-23.md。它不是每轮都写,而是在压缩触发时,把一段旧对话整理成“今天发生了什么、做过什么、留下什么线索”。

Core 长期层就是 MEMORY.md。它会被注入 system prompt,让 Agent 每轮都能看到关键事实、长期目标和当前项目背景。因为它常驻上下文,所以必须克制,不能写成流水账。

一个简单判断是:

必须完整保留,但不必每轮看见 → history.jsonl
需要按时间沉淀,未来可能检索 → YYYY-MM-DD.md
每轮都必须知道的稳定信息 → MEMORY.md

MemoryStore:先把落盘路径做稳

记忆系统的第一步不是复杂算法,而是稳定读写。

MemoryStore.append_history() 负责追加原始记录:

def append_history(self, role: str, content: Any) -> None:
    row = {
        "ts": datetime.now(_UTC8).isoformat(timespec="seconds"),
        "role": role,
        "content": content if isinstance(content, str) else _json_safe(content),
    }
    with self.history_file.open("a", encoding="utf-8") as f:
        f.write(json.dumps(row, ensure_ascii=False) + "\n")

这里有两个小细节很实用。

第一,时间使用 UTC+8。个人 Agent 面向人的日常工作,“今天做了什么”最好和本地日期一致。

第二,content 不一定是字符串。工具调用结果、模型 content block、对象结构都可能进入记录。_json_safe() 会尽量把它们转成可序列化内容,避免因为一条特殊消息导致记忆写入失败。

情景记忆按日期写:

def today_episode_path(self) -> Path:
    date = datetime.now(_UTC8).strftime("%Y-%m-%d")
    return self.memory_dir / f"{date}.md"

长期记忆则整体覆写:

def write_memory(self, content: str) -> None:
    self.memory_file.write_text(content.strip() + "\n", encoding="utf-8")

为什么不是 append?因为长期记忆不是日记。它应该像工作台上的便签,只保留下一轮推理真正需要的稳定信息。一直追加,只会把 MEMORY.md 变成另一个膨胀的 history。

什么时候该压缩

压缩不能只靠感觉。项目里用 TokenTracker 记录每次模型调用的 token 用量:

row = {
    "model": model,
    "input": getattr(usage, "input_tokens", 0) or 0,
    "output": getattr(usage, "output_tokens", 0) or 0,
    "cache_read": getattr(usage, "cache_read_input_tokens", 0) or 0,
    "cache_create": getattr(usage, "cache_creation_input_tokens", 0) or 0,
}

是否触发压缩,由最近一次输入 token 决定:

def should_compact(self, max_context: int, threshold: float = 0.7) -> bool:
    return self._last_input_tokens > max_context * threshold

默认策略是:如果最大上下文按 200K 算,输入超过 70% 左右就触发压缩。这样做的好处是,压缩由运行时资源状态触发,而不是让模型自己猜“我是不是快满了”。

Compactor:把旧对话变成可用记忆

压缩逻辑在 agent/compactor.py

当压缩触发时,系统不会把全部 history 一刀切掉,而是保留最近 K=10 轮,把更早的消息交给 compactor:

old = history[: -self.K]
recent = history[-self.K :]

随后,compactor 把旧对话、现有 MEMORY.md、用户偏好文件和今日情景记忆一起放进提示词,让模型输出三段结构化内容:

<episode>...</episode>
<updated_memory>...</updated_memory>
<updated_user>...</updated_user>

解析后分别写入:

  • episode 追加到当天 YYYY-MM-DD.md
  • updated_memory 覆写 MEMORY.md
  • updated_user 覆写用户偏好文件;
  • 最后写入 compact_event 标记。

这样旧对话没有消失,而是从“逐字历史”变成“情景摘要 + 长期事实”。内存里的 history 只保留最近几轮,下一轮对话仍然连贯,但上下文负担被降下来。

在这里插入图片描述

为什么不一开始就上向量库

一提到 Agent 记忆,很多人会马上想到向量数据库。向量检索当然有价值,尤其适合在海量文档或历史归档中按语义找线索。

但在这个阶段,最先要解决的不是“如何检索所有旧信息”,而是“当前上下文怎么不爆”。如果所有原始消息都一直堆在工作记忆里,旁边接一个向量库也救不了主上下文膨胀。

所以这里先做三层文件记忆。它解决的是生命周期:

原始消息完整保存
旧对话按天沉淀
核心事实常驻上下文

等这个边界稳定后,再把旧 episode 或项目资料放进向量检索,会更自然。向量库应该服务于“按需召回”,而不是替代工作记忆、情景记忆和长期记忆的分工。

如何验证记忆系统真的在工作

不要只靠“模型好像记得”来判断。更可靠的验证方式是看文件和触发点。

第一,看 memory/history.jsonl 是否每轮追加。用户输入和助手输出都应该能找到。

第二,看 memory/tokens.jsonl 是否记录每次调用的输入输出 token。压缩是否触发,依赖这里的最近输入 token。

第三,调低阈值或模拟长对话,确认 compactor 会写入当天 YYYY-MM-DD.md,并更新 MEMORY.md

第四,重启程序,确认最后一次 compact 之后的未归档历史能被启动归档处理。长期 Agent 不只是当前进程里记得,还要跨会话接住上下文。

如果这四点都成立,记忆系统就不再是 prompt 里一句“请记住”,而是一套有文件、有阈值、有归档结果的运行机制。

记忆内容应该写多细

做记忆系统时,最难的不是写文件,而是决定什么值得留下。

原始层可以完整,因为它不进入上下文,主要用于审计。工具输出很长也没关系,最多占磁盘空间。情景层就要开始筛选:它应该记录今天发生的重要事件、做出的关键决策、遇到的阻塞、后续可能要回查的线索,而不是复述每一轮对话。

长期层更要克制。MEMORY.md 应该只放稳定事实,例如:

当前项目目标是什么
用户长期偏好是什么
已经确认的重要架构约束是什么
当前任务的稳定背景是什么

不适合放进长期记忆的内容包括:一次性命令输出、临时错误日志、已经解决的中间状态、模型当时的详细推理过程。这些内容要么留在 raw history,要么压进当天 episode。

如果长期记忆写得太细,它会变成另一个上下文垃圾桶。每轮都注入,就每轮都消耗注意力。长期记忆越短、越稳定,价值越高。

Compact 不是简单摘要

很多人把 compact 理解成“把旧对话总结一下”,这还不够。真正有用的 compact 至少要同时完成三件事。

第一,把旧对话变成情景记录。它应该回答“那段时间发生了什么”,方便未来回查。

第二,更新长期记忆。它要判断哪些信息已经稳定到值得每轮注入,哪些旧信息已经过期,应该删除。

第三,更新用户偏好。比如用户喜欢什么输出风格、项目里有哪些长期习惯、哪些信息不应该重复问。

这就是为什么 compactor 的输出用标签拆成 episodeupdated_memoryupdated_user。它不是一段随意摘要,而是一次记忆整理。

失败时应该怎么处理

记忆系统也会失败。比如模型输出没有包含预期标签,写文件失败,压缩调用超时,或者生成的长期记忆质量很差。

教学项目里先保持简单:能提取到哪个标签就写哪个。更严谨的实现可以继续加几层保护:

压缩前备份 MEMORY.md
提取不到标签时不覆写长期记忆
长期记忆超过字数上限时拒绝写入
压缩失败时保留原 history
记录 compact 错误供人工检查

这些不是概念重点,但决定了系统能不能长期运行。记忆系统越接近基础设施,越不能只看成功路径。

什么时候应该触发 compact

最直接的触发条件是 token 数。项目里通过 tokens.jsonl 记录最近调用消耗,再判断是否超过阈值。这个办法简单、可观察,也容易调试。

但真实项目里,compact 还可以结合任务边界。比如一个小任务完成后、一次工具探索结束后、用户明确切换主题时,都适合整理一次。因为这些时刻天然有边界,模型更容易把“刚才发生了什么”压成一个清楚 episode。

相反,在任务中间频繁 compact 会有风险。模型可能还需要前面某个细节继续推理,如果过早摘要,就会丢失原始上下文。所以 compact 的节奏不是越勤越好,而是要在“上下文快撑不住”和“任务语义刚好收束”之间找平衡。

记忆要允许被修正

长期记忆听起来像真理库,但它应该允许被更新。用户偏好会变,项目目标会变,前面记录的事实也可能后来被证明不准确。

所以 MEMORY.md 不应该只追加。更合理的方式是每次 compact 都产出一份新的稳定记忆:保留仍然成立的,删除已经过期的,改写描述不准确的。

这也是为什么长期记忆最好短一点。越短,越容易维护;越长,越像没人敢碰的旧文档。Agent 需要的不是一本百科,而是一份能在每轮推理里真正起作用的高密度背景。

记忆和隐私边界

只要系统开始长期保存信息,就要考虑隐私边界。并不是用户说过的每句话都应该长期保存,也不是每个工具输出都适合进入 MEMORY.md

一个保守策略是:raw history 用于本地审计,长期记忆只保存完成任务确实需要的稳定信息。涉及密钥、隐私、临时口令、一次性路径、敏感日志的内容,即使出现在对话或工具输出里,也不应该被压进常驻记忆。

记忆系统的目标不是“什么都记住”,而是“记住应该被带到下一次推理里的东西”。

小结

history 让模型看见前文,但 history 不能无限增长。一个能长期工作的 Agent,需要把记忆拆开:原始记录完整保存,情景记忆按天沉淀,长期记忆高密度常驻。

这一篇的核心不是炫技,而是工程卫生。记忆系统首先让上下文成本、历史审计和长期状态各有位置,其次才改善“模型好像更懂我”的体验。

有了记忆之后,Agent 能在更长时间里工作。但当任务步骤变多时,另一个问题会出现:模型可能跳步、重复、半途自称完成。下一步,我们需要把“计划”也从模型脑内拿出来,变成可观察、可校验的外部状态。


视频与源码

如果你想看完整演示,可以在主页的《从零手搓 Agent》合集里按顺序观看视频版:

  • 抖音:https://www.douyin.com/user/MS4wLjABAAAAk5lgbm96yoPPEoGXoY3MIp4S8voya0dzcnG0Lom5-SI?from_tab_name=main
  • B站:https://space.bilibili.com/1452412374

文章里的示例代码和完整项目也放在这里:

  • 📦 教学仓库:https://github.com/TheSyart/claude-agent-examples
  • ⚔️ 实战项目:https://github.com/TheSyart/emperor-agent

我会持续更新 Agent 教学与实战内容。觉得有用的话,欢迎给项目点个 Star ⭐,也谢谢你一路看到这里。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐