· ARJUN · WRITING

Agent 记不住过去,通常不是模型的问题

你昨天刚交代过的事,Agent 到了新会话却像第一次听见。很多时候,不是模型忘了,而是应用没有把那条信息存下来,也没有在需要时找回来。

你对一个编程 Agent 说:「这个项目尽量使用 Go 标准库,不要随便引入框架。」它在当前对话里照做了。第二天你打开新会话,它又推荐了一套新框架。

这时很容易得出一个结论:模型的记忆不够好。

可模型可能根本没有机会“记住”。上一次对话结束后,那条偏好没有被写入持久化存储;新会话开始时,系统也没有把它取回来。问题不在模型忘了,而在应用没有完成记忆的写入与读取。

这也是 Agent Memory 最容易讲乱的地方。Context Window、聊天记录、会话状态、向量数据库和模型参数经常被统称为“记忆”,仿佛只要接上一个数据库,Agent 就拥有了长期记忆。

先把这些东西拆开,后面的设计才不会混在一起。

三个经常被混为一谈的概念

在工程系统里,可以先按作用范围区分三层:

层次 它是什么 能解决什么 不能保证什么
Context Window 某一次模型调用实际收到的输入 让模型处理眼前任务 不会自动跨调用保存信息
Thread State 某条会话或任务线程内持久化的状态 保存消息、工具结果、任务进度与中间产物 不会自动跨线程共享
Long-Term Memory 跨会话保存、按作用域读取的数据 记住用户偏好、事实、经历或规则 存进去不等于以后一定能正确取回

CoALA 论文给了一个重要提醒:语言模型本身在多次调用之间是无状态的,语言 Agent 才会通过外部数据结构维持工作记忆与长期记忆。它还特别区分了模型的 Context 和 Agent 的 Working Memory:后者可以跨模型调用保存,模型每次只接收其中的一部分。

换句话说,Context 是本次交给模型看的材料;State 是 Runtime 目前掌握的现场;Memory 是以后可能再次用到的记录。

它们有关联,但不是一回事。

Context Window、Thread State 与 Long-Term Memory 的关系
Context 是本次输入,State 保存线程现场,Long-Term Memory 则跨线程保存可复用信息;数据只通过明确的选择、检索与写入流动。

Context Window 不是一个会自动保存内容的仓库

Context Window 是输入预算。系统提示词、当前问题、历史消息、工具定义、工具返回结果和检索内容,都要争夺这段空间。

窗口变长能容纳更多内容,却不代表模型一定能同样好地使用每一段内容。Lost in the Middle 的实验发现,在它测试的多文档问答与键值检索任务中,相关信息位于长上下文中间时,部分模型的表现明显下降。这个结果来自特定模型与任务,不能扩展成“所有长上下文模型都看不到中间”;但它足以说明,能装下与能用好不是同一件事。

因此,把完整聊天记录不断塞回 Prompt 不是可靠的记忆策略。它会同时带来三个问题:

  1. 无关历史挤占当前任务的空间;
  2. 推理延迟和调用成本随输入增长;
  3. 关键约束埋在大量旧消息里,未必会被稳定使用。

更合理的做法是先保存完整状态,再由 Context Builder 为当前步骤选择必要信息。模型看到的是工作集,而不是整个仓库。

短期记忆更接近“线程内状态”

当前 LangChain 与 LangGraph 文档把 Short-Term Memory 定义为 Thread-Scoped State:它属于一条会话线程,可以通过 Checkpointer 持久化,在每次图调用或执行步骤之间恢复。

它通常包含消息历史,但不必只有消息。一个做代码修改的 Agent 还可能保存:

{
  "goal": "修复订单重复扣款",
  "constraints": ["不修改公开 API", "必须可回滚"],
  "current_step": "复现并定位幂等键失效路径",
  "evidence": ["trace-8f21", "test_payment_retry"],
  "artifacts": ["diagnosis.md", "patch.diff"]
}

这些字段比一长串自然语言更容易保存、检查和恢复。系统中断后,Runtime 可以知道任务做到哪一步,不需要让模型重新读完整聊天记录再猜。

当消息太长时,可以截断、删除或摘要早期内容。摘要能节省 Token,但它是有损压缩:一句被摘要器忽略的限制,之后可能再也回不来。像目标、权限边界、验收条件和工具执行结果这类信息,更适合放在结构化 State 中,而不是只依赖一段自动摘要。

长期记忆不是向量数据库的同义词

向量数据库解决的是一种检索问题:当用户换了一种说法,系统仍能按语义相似度找到相关文本。它不是长期记忆的唯一存储,也不负责判断什么值得记、旧信息是否失效、两条记忆冲突时相信哪一条。

从信息类型看,Agent 的长期记忆可以分成三类:

这个分类来自认知架构,不等于三种固定数据库。数据结构要跟查询方式匹配:

LangGraph 的 Long-Term Store 就展示了另一种思路:长期记忆可以是按 Namespace 和 Key 组织的 JSON 文档,再按需要增加过滤或语义搜索。先把数据的归属与更新规则设计清楚,才轮到选择检索索引。

真正难的第一步:决定什么时候写入

不是每句话都值得成为长期记忆。

「这次用 Python 写一个脚本」描述的是当前任务;「以后给我的示例默认使用 Python」才像一个跨会话偏好。如果系统把前一句也永久保存,下次处理 Go 项目时就可能拿出一条错误偏好。

一次写入至少应该回答:

可以在请求主链路中即时写入,也可以在后台整理。前者让新信息立刻可用,但增加延迟;后者不阻塞当前回复,却要处理写入时机和新旧会话之间的同步。两种方式都是真实存在的工程选择,不存在一套适合所有 Agent 的固定答案。

我更愿意把“明确陈述”和“系统推断”分开处理。用户说「以后默认简洁回答」可以直接成为候选偏好;系统从两次短对话中猜出「用户不喜欢解释」则不应该伪装成事实。记忆一旦带着错误的确定性写进去,之后每次召回都会放大这个错误。

Agent Memory 写入流程
写入从是否值得长期保存开始。Scope、Type、Source 与 Lifetime 没有定义清楚时,数据库只会把模糊信息保存得更久。

读取记忆,不是拿 Top K 塞进 Prompt

有了存储,Agent 仍然可能“记错”。很多时候不是数据不存在,而是取回了错误的数据,或者正确记录被无关内容淹没。

一次比较完整的读取过程通常包含:

  1. 从当前任务生成检索意图,而不只是复制用户最后一句话;
  2. 先按用户、项目、记忆类型和有效状态做硬过滤;
  3. 根据数据选择精确查询、关键词、向量或混合召回;
  4. 对候选结果重排、去重,并检查相互冲突的记录;
  5. 只把当前步骤真正需要的内容装入 Context,同时保留来源。

Generative Agents 的研究原型把 Recency、Importance 和 Relevance 组合成检索分数。这是一个有影响力的设计实例,但不是通用公式。客服偏好、代码故障记录和虚拟角色经历,对“重要”的定义完全不同;最终权重需要结合任务和评测来确定。

还有一个容易忽略的区别:知识库 RAG 与用户记忆可以使用相似的检索技术,但它们不是同一种数据。产品文档回答「系统支持什么」,用户记忆回答「这个用户或这次经历发生过什么」。两者的权限、更新频率和可信来源都不同。

Agent Memory 读取流程
读取是一条检索管线:先限定作用域,再召回、重排、去重和检查冲突,最后由 Context Builder 控制模型实际看到什么。

记忆必须能被纠正,也必须能被忘记

假设系统保存过一条信息:

部署方式:Docker Compose。

两个月后项目迁移到 Kubernetes。如果系统只是再追加一条新记忆,检索时就可能同时取回两个互相冲突的答案。

正确的处理不一定是删除所有历史。可以把旧记录标记为 Superseded,保留它曾经有效的时间和来源,再把新记录设为 Current。这样 Agent 回答当前部署方式时使用新值,调查历史故障时仍能知道旧环境曾经存在。

“遗忘”也不只是降低向量相似度。临时状态可以设置 TTL,过期事实可以失效,重复事件可以合并,用户要求删除的数据必须从持久化存储中真正移除。仅仅在组装 Prompt 时不再引用某条内容,并不等于它已从系统里删除。

记忆的纠正、版本更新与删除
更新事实不等于抹掉历史。Current 与 Superseded 可以并存,但用户要求删除的数据必须从持久层真正移除。

一套够用的最小架构

不必一开始就同时部署 Redis、向量数据库、图数据库和一套记忆框架。对大多数个人 Agent,一个清楚的最小实现已经能解决不少问题:

  1. Checkpointer 保存每条 Thread 的消息与任务状态;
  2. Profile Store 保存少量明确、可覆盖的用户或项目事实;
  3. Episode Store 保存带来源和结果的历史事件,需要时再添加全文或向量索引;
  4. Memory Policy 决定写入、更新、失效和删除;
  5. Context Builder 根据当前步骤选择 State 与 Memory,控制最终输入预算。

伪代码反而很短:

def build_context(user_id, thread_id, request):
    state = checkpoints.load(thread_id)
    profile = profiles.get(user_id)
    episodes = memories.search(
        query=request,
        filters={"user_id": user_id, "status": "current"},
    )
    return context_builder.pack(state, profile, episodes)

数据库负责保存,检索器负责找候选,Policy 负责判断,Context Builder 负责把材料交给模型。把职责分开之后,某一层出错时才知道应该查哪里。

什么时候不需要长期记忆

长期记忆不是 Agent 的入场券。

如果工具每次只处理一个独立请求,任务结束后没有状态需要复用,就不必为了“更像 Agent”而保存用户数据。固定业务流程需要的可能是可靠的数据库状态机,不是模型记忆。公开文档问答需要的可能是版本明确的知识检索,也不是用户画像。

先问一句:下一次任务如果拿不到这条信息,会具体损失什么?如果答案说不清楚,就先别存。

参考资料

Agent 看起来像在“回忆”,背后其实是一套很具体的数据流:哪些信息被保存,保存到哪里,什么时候取回,冲突时相信谁,以及什么时候彻底删除。

模型只处理眼前这次输入。让过去在下一次仍然有用,是系统的工作。