记忆与状态
LLM 自己什么都不记得——记忆全是 Harness 演的戏。真正难的不是把对话存下来,而是下一次对话时,该想起什么、又该忘掉什么。
一个反直觉的事实
先泼盆冷水:LLM 本身没有记忆。每次调用模型都是一次无状态的推理——它不知道你上一句话说了什么,不知道昨天聊过什么,甚至不知道自己是第几次被调用。你感觉 ChatGPT「记得你」,全是模型外面的那层程序(Harness)在演戏:每次请求都把历史对话拼进 prompt,模型只是读了一遍「会议纪要」然后接着演。
想通这一点,Agent 的记忆问题就从玄学变成了工程:记忆 = 你要在每次调用前,往上下文里塞什么。存下来不难,硬盘很便宜;难的是判断什么值得存、什么时候该想起、什么时候该忘掉。这一章我们用人脑的记忆系统作类比,把这个工程问题一层层拆开。
记忆分类学:不只三种
认知科学对记忆的分类,映射到 Agent 上意外地严丝合缝:
- 工作记忆(Working Memory):你脑子里此刻正在处理的信息,容量极小(经典的 7±2 个组块)。对应 Agent 的短期记忆——当前会话的上下文窗口。
- 情景记忆(Episodic Memory):「上周三我在咖啡馆遇到一个老同学」——带时间地点的具体经历。对应 Agent 存储的历史交互记录:某次对话、某次任务的过程与结果。
- 语义记忆(Semantic Memory):「巴黎是法国首都」——从经历中抽象出来、与具体场景脱钩的知识。对应 Agent 沉淀的事实与偏好:用户是后端工程师、团队用 pnpm 不用 npm。
- 程序性记忆(Procedural Memory):「怎么骑自行车」——关于「怎么做」的技能。对应 Agent 的 Skill / 工具使用经验:调试某类 bug 的标准流程、发布系统的操作步骤。
这个分类的价值在于,它直接告诉你不同记忆该用不同机制存:工作记忆靠上下文窗口,情景记忆靠日志和向量检索,语义记忆靠提炼后的结构化条目,程序性记忆靠 Skill 文档和少样本示例(few-shot examples)。很多团队用一个向量库统存所有东西,结果就是技能检索不出、事实对不上号——不是存储不行,是分类错了。
另一个推论:人脑是「小容量工作记忆 + 海量长期存储 + 按需提取」的架构。你不可能把所有经历同时装进脑子里,Agent 也一样——上下文窗口永远不够用,所以必须有长期记忆,所以必须有「检索」这个动作。后文的所有机制,本质上都是在回答同一个问题:有限的工作台,怎么摆出此刻最需要的那些东西。
短期记忆:会话内的上下文管理
短期记忆看起来最简单——不就是把消息列表存起来吗?但真实的坑在上下文窗口是有限的。聊得久了,消息总长度超过窗口,怎么办?三种常见策略,按实现成本排序:
- 截断(Truncation):只保留最近 N 轮。简单,但「N 轮之前你说过你海鲜过敏」就丢了。
- 摘要(Summarization):用一个 LLM 调用把旧对话压缩成一段摘要放在上下文开头,详细消息只保留最近几轮。这是主流做法。
- 分层摘要:摘要的摘要——对话很长时先按话题分段摘要,再对段摘要做总摘要。
// 一个最小可用的滑动窗口 + 摘要策略
async function buildContext(history: Message[]): Promise<Message[]> {
const recent = history.slice(-10); // 最近 10 轮原样保留
const old = history.slice(0, -10); // 更早的部分压缩
if (old.length === 0) return recent;
const summary = await llm.summarize(old); // 一次额外的模型调用
return [{ role: "system", content: `前情提要:${summary}` }, ...recent];
}
注意这个设计的本质:摘要就是工作记忆的「压缩算法」,和人脑把一天的经历浓缩成「今天开了三个会」是同一件事。代价是信息有损——压缩必然丢细节。工程上的补救是「摘要 + 可回溯」:摘要里带上原始消息的索引,模型发现摘要不够用时,可以通过工具把原文捞回来(MemGPT 的 recall memory 就是这个思路,后面细讲)。
还有一个常被忽视的点:摘要要增量更新,不要每次全量重算。每轮都把全部历史重新摘要一遍,token 成本是平方级增长。正确姿势是维护一个滚动摘要,每轮只把新溢出的消息「折叠」进去。
会话状态:记忆不只是聊天记录
很多人把「记忆」和「消息历史」划等号,其实会话里还有更多状态要管:
- 任务进度:这个任务进行到哪一步了,完成了什么,还剩什么
- 中间产物:已经生成的文件、查到的资料、工具的执行结果
- 环境状态:当前在哪个目录、分支是什么、测试上次跑没跑过
工程上常见的做法是把状态显式建模成一个对象,随会话持久化:
interface SessionState {
sessionId: string;
messages: Message[]; // 对话历史(会被摘要压缩)
task?: { goal: string; doneSteps: string[]; pendingSteps: string[] };
artifacts: Record<string, string>; // 产物名 -> 路径
updatedAt: number;
}
这样「恢复一个三天前的会话」就不是翻聊天记录,而是加载一个状态快照(checkpoint)。Claude Code 的 --resume、LangGraph 的 checkpointer,干的都是这件事。checkpoint 和长期记忆的区别在于:checkpoint 是「这个会话的存档」,只对恢复当前任务有意义;长期记忆是「跨会话的沉淀」,面向未来的所有会话。一个是游戏存档,一个是玩家的经验值。
长期记忆:写入、过滤与遗忘
长期记忆解决「下次对话还能记得」。架构就三个环节:写入、存储、读取。存储后面单独讲,这里先说最容易被做砸的写入侧。
什么值得记:写入过滤标准
把原始对话整段存起来是最懒也最差的方案——噪音太多,检索时什么都搜不准。好的做法是写入前先用 LLM 提炼,并给「值得记」一个明确标准。一条信息值得进入长期记忆,通常要满足至少一条:
- 稳定性:三个月后大概率仍然成立(用户职业、技术栈、长期偏好)
- 复用性:未来会话里会被用到(项目约定、部署约束、协作习惯)
- 不可推导:从公开信息或当前上下文推不出来(用户私下说的决定)
反面清单同样重要:寒暄、一次性的上下文(「这个报错我刚重启解决了」)、会话内临时状态,一律不写。
// 会话结束后,提炼值得长期记住的东西
const memories = await llm.extract(`
从以下对话中提取值得长期记住的事实(用户偏好、项目约定、重要决定),
忽略寒暄和一次性细节,每条一句话:
${transcript}
`);
// -> ["用户偏好简洁的回答", "生产环境禁直连数据库"]
await memoryStore.save(userId, memories);
这就是语义记忆的形成过程:从具体经历中抽象出通用知识,丢弃经历本身。
遗忘与过期:记忆只进不出就是垃圾场
人脑会遗忘,这不是缺陷而是特性——遗忘清理了检索空间。Agent 的记忆同样需要出口机制:
- TTL(过期时间):时效性强的记忆(「本周在用 feature-x 分支」)写入时就带上有效期
- 冲突更新:新记忆与旧记忆矛盾时(「团队从 Vue 迁到 React」),应该更新而不是并存,否则检索会同时捞出两条相反的事实。实现上可以在写入时先按相似度查一遍旧记忆,命中高度相似的条目就让 LLM 判断是「补充」「修正」还是「推翻」,再决定插入还是覆盖
- 价值衰减淘汰:长期没被检索命中过的记忆,重要性随时间衰减,低于阈值就归档或删除。注意是「没被命中」而不是「时间久」——一条每年都会用到的记忆不该因为老而被清掉
读取侧:检索时机、query 与预算
写入做对只完成一半,读取侧同样有三个工程决策:
- 什么时候检索:不要每轮无条件检索。常见做法是轻量判断先行——看当前用户输入是否涉及「人、项目、历史约定」这类长期主题,或者干脆让模型自己决定(MemGPT 的思路)。闲聊句检索出的记忆纯属噪音
- 拿什么检索:直接拿用户最后一句话当 query 通常不准,因为代词和省略太多(「那这个怎么部署?」)。更稳的做法是让 LLM 结合最近几轮对话把 query 重写成一句自包含的问题,再走向量库
- 注入多少:给记忆设 token 预算(比如不超过上下文的 10%),检索结果去重后按分数截断。记忆是上下文的「佐料」,不是主菜——注入太多会稀释当前任务,模型反而抓不住重点
这三条都做错的话,会出现一个典型的诡异现象:记忆库越大,Agent 表现越差。不是记忆的错,是读取策略把信噪比打崩了。
Embedding 与向量检索:记忆的「联想机制」
长期记忆存好了,怎么在需要的时候找到?关键词匹配太笨——用户说「数据库连不上」,记忆里存的是「生产环境禁直连数据库」,两句话几乎没有一个共同词。Embedding 解决的就是「意思相近但字面不同」的匹配。
原理与距离度量
一个 Embedding 模型把任意文本映射成一个高维向量(常见 768~3072 维),语义相近的文本在向量空间里距离也近。检索 = 把查询也变成向量,找距离最近的 K 条。距离怎么算,三个主流选择:
- 余弦相似度:只看向量夹角,不看长度。文本检索的默认选择,因为句子长短不该影响语义判断
- 点积(内积):向量归一化后与余弦等价;不归一化时,模长会参与打分——有些模型(如部分推荐场景)刻意用模长编码「热度」,文本语义检索一般不用
- 欧氏距离:对向量各维的绝对差敏感,适合向量本身有明确几何意义的场景,文本检索里较少用
import numpy as np
def cosine(a, b):
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
store = VectorStore(dim=1024)
store.add(embed("用户是后端工程师,主要写 Go")) # 写入:文本 -> 向量 -> 入库
query = embed("这个用户的职业背景是什么?")
top_k = store.search(query, k=3) # 返回最相关的 3 条,注入 prompt
模型选型:开源还是商用
选 Embedding 模型比选向量库影响更大,几个实际考量:
- 开源自托管:BGE 系列(智源 FlagEmbedding,bge-large-zh 中文表现好)、m3e 是中文社区常用的选择。优点是数据不出内网、无调用成本、维度低(768/1024)存储省;缺点是要自己运维推理服务,多语言混合场景弱一些
- 商用 API:OpenAI text-embedding-3 系列、Cohere embed 系列。开箱即用、多语言强,缺点按 token 计费、数据要出域
- 维度不是越高越好:1536 维比 768 维的存储和检索成本翻倍,但很多任务上收益很小。先拿自己的真实语料做小规模评测再定
一个硬建议:不管选哪个,把「换模型」当一次性事件设计——Embedding 模型一换,历史向量全部作废要重算,所以向量库里一定要存模型版本号。
向量库选型:真实差异对比
存向量、查最近邻,理论上几十行代码能写。但数据量大了、要并发、要过滤,就得用专门的向量库。三个主流选项的真实差异:
| 维度 | pgvector | Qdrant | Milvus |
|---|---|---|---|
| 索引算法 | HNSW、IVFFlat | HNSW | HNSW、IVF 系列、DiskANN 等十多种 |
| 过滤检索 | SQL WHERE 全能力 | payload 条件过滤 | 标量字段过滤表达式 |
| 部署形态 | PG 扩展,零新增组件 | 单二进制/Docker,可集群 | 分布式,依赖 etcd + 对象存储 |
| 舒适区规模 | 百万级 | 千万级 | 亿级以上 |
| 运维成本 | 复用现有 PG 运维 | 低 | 高 |
两个名词解释清楚:HNSW(分层小世界图)建一张多层图,查询时从顶层稀疏层跳到下层稠密层,召回率高、查询快,代价是索引占内存大;IVF(倒排文件)先把向量聚类成 n 个桶,查询只搜最近的几个桶,省内存快建索引,但召回率随桶数权衡。绝大多数场景两者都够用,不用纠结。
选型一句话:从 pgvector 开始,撞到墙再换。大多数 Agent 项目的记忆量级(每用户几十到几百条)远没到需要专用向量库的程度。但注意一个真实混淆点:pgvector 不是「传统数据库顺便支持向量」的玩具——它的 HNSW 实现在百万级上性能不输专用库;真正的差距在分布式能力和超大规模下的索引构建速度。
经典设计:Generative Agents 的三因素模型
「该想起什么」这个问题,2023 年斯坦福的 Generative Agents 论文给了至今仍被大量抄作业的回答。它给每条记忆打分,取 Top-K 注入上下文:
score = α × 相关性 + β × 新近性 + γ × 重要性
- 相关性(Relevance):记忆与当前情境的 Embedding 余弦相似度
- 新近性(Recency):指数衰减函数,论文用 0.995^(距上次被访问的小时数)——注意是「上次被访问」而不是「被创建」,被想起的记忆会「保鲜」,这很像人脑
- 重要性(Importance):写入时让 LLM 打 1-10 分,「早饭吃了什么」是 1 分,「被裁员了」是 10 分
def score(memory, query_emb, now):
relevance = cosine(memory.emb, query_emb)
recency = 0.995 ** hours_since(memory.last_accessed, now)
importance = memory.importance / 10 # 归一化到 0~1
return relevance + recency + importance # 论文里三个权重都是 1
top_k = sorted(memories, key=score, reverse=True)[:5]
三因素的妙处在于它们会互相纠偏:只靠相关性,陈旧的相似记忆会霸榜;加入新近性,近期发生的事自然优先;加入重要性,「用户随口一句」压不过「用户立下的规矩」。你实现自己的记忆系统时,这三个因子是最低配置,α/β/γ 按场景调。
经典设计:MemGPT 把记忆当操作系统管
MemGPT(2023)把上下文窗口类比为内存、外部存储类比为硬盘,让 LLM 自己通过函数调用管理记忆,像操作系统做分页调度一样。它的记忆分三层:
- Core Memory(核心记忆):直接放在系统提示词里,模型每轮都能看见。存最关键的人设和用户档案,容量小。模型可以用
core_memory_append/core_memory_replace自己改写 - Archival Memory(归档记忆):无限容量的外部存储(向量库),模型用
archival_memory_insert写入、archival_memory_search检索——检索是模型主动发起的动作,不是框架自动注入 - Recall Memory(回溯记忆):完整会话历史,模型用
conversation_search按关键词/时间翻旧账——这就是前文说的「摘要 + 可回溯」的落地
这个设计的精髓是把记忆管理从「框架的固定逻辑」变成「模型可学习的技能」:什么时候该记、什么时候该查,由模型根据情境判断,而不是开发者写死的规则。代价也明显——每轮都要带一堆记忆管理函数的定义,prompt 膨胀,且模型需要足够强才玩得转。
争议:显式记忆模块 vs 超长上下文直通
上下文窗口涨到百万 token 之后,一个认真的质疑出现了:既然能把全部历史塞进去,还要记忆系统干嘛?
正方(超长上下文派)的理由:没有压缩就没有信息损失;省掉 Embedding、向量库、读写策略一整层复杂度;「大海捞针」测试表明模型在长上下文里找细节的召回已经很高。
反方(显式记忆派)的反击:
- 成本与延迟:每轮请求都带上百万 token,费用和首 token 延迟都是数量级差距。记忆系统本质是缓存——你不会因为内存贵了就把 CPU 缓存砍掉
- ** Lost in the middle **:研究表明模型对长上下文中部信息的利用明显弱于头尾,塞得多不等于看得全
- 跨会话的结构化沉淀:用户偏好这种「从一百次对话里抽象出来的一条结论」,靠塞原始历史是让模型每轮重新做一遍归纳,浪费且不稳定
- 遗忘是功能:有些记忆业务上就该过期、该删除(合规、隐私),全量塞历史没有这个开关
务实的共识是:超长上下文让「短期记忆」的容量焦虑消失了,但长期记忆的写入提炼、检索排序、遗忘机制一个都没被替代。两者是协作关系不是替代关系——上下文装当前任务,记忆系统管跨任务沉淀。
常见误区
- 把聊天记录全量塞进上下文:窗口爆炸、成本爆炸、模型还会被无关信息带偏。上下文是稀缺资源,要精打细算
- 认为向量检索 = 记忆:向量检索只是「联想」这一种提取方式。精确事实(API key、数据库地址)该用结构化 key-value 存,程序性知识该进 Skill 文档,别什么都向量化
- 记忆只进不出:没有遗忘机制的记忆库会变成垃圾场,检索信噪比随时间崩盘
- 每轮无条件检索记忆:注入一堆无关记忆会稀释上下文、白烧 token。该想的时候不想、不该想的时候乱想,比没有记忆更糟
- 拿用户最后一句话直接当检索 query:结合会话上下文重写 query 再搜,准头会好得多
- 为了记忆上重基建:上来就部署 Milvus 集群,结果全公司记忆数据不到十万条。先跑通读写策略,再谈规模
术语表
| 名词 | 定义 | 一句话直觉 | 常见混淆 |
|---|---|---|---|
| 上下文窗口 | 模型单次调用能看到的最大 token 数 | 模型的「工作台」大小 | 不等于记忆,只是记忆的展示位 |
| 工作记忆 | 当前会话内正在被处理的信息 | 脑子里此刻想的事 | 常被当成全部记忆,忽视长期存储 |
| 情景记忆 | 带时间地点的具体经历记录 | 「上周三那次对话」 | 与语义记忆混存导致检索噪音 |
| 语义记忆 | 从经历抽象出的通用知识与偏好 | 「用户是后端工程师」 | 不是原始对话的搬运,需要提炼 |
| 程序性记忆 | 「怎么做」的技能与流程 | 会骑自行车 | 该进 Skill 文档,不该进向量库 |
| Embedding | 把文本映射为高维向量的模型输出 | 给每句话一个「语义坐标」 | 换模型后旧向量全部作废 |
| 余弦相似度 | 两向量夹角的余弦值 | 只比方向不比长短 | 与点积混淆:归一化后才等价 |
| HNSW | 分层小世界图索引算法 | 从粗到细跳格子找人 | 与 IVF 混淆:一个建图一个分桶 |
| IVF | 倒排文件索引,先聚类分桶再搜桶 | 先锁定小区再挨家找 | 桶数设小了召回率会掉 |
| 过滤检索 | 向量相似度之外的标量条件筛选 | 「只在他的记忆里搜」 | 先过滤后检索 vs 先检索后过滤结果不同 |
| checkpoint | 会话状态的持久化快照 | 游戏存档 | 是单会话存档,不是跨会话长期记忆 |
| TTL | 记忆的过期时间 | 牛奶的保质期 | 没有 TTL 的记忆库会越翻越不准 |
参考材料
- Generative Agents: Interactive Simulacra of Human Behavior — 三因素记忆检索模型的出处
- MemGPT: Towards LLMs as Operating Systems — 分页式记忆管理与函数调用读写的设计原文
- LLM Powered Autonomous Agents — Lilian Weng 的综述,记忆分类讲得很清楚
- pgvector — PostgreSQL 向量扩展,HNSW/IVFFlat 索引文档在 README
- Qdrant 官方文档 — 过滤检索与 HNSW 参数调优细节
- FlagEmbedding (BGE) — 智源开源 Embedding 模型,含中文榜单
小结
记住一张图:上下文窗口是工作记忆,摘要和截断是压缩算法,向量库加 Embedding 是情景记忆的联想检索,结构化条目是语义记忆,Skill 是程序性记忆,checkpoint 是会话存档。存记忆不难,难的是读写策略——什么值得写、按什么标准想起、什么时候忘掉。先把三因素打分和遗忘机制跑对,存储选型反而是最不重要的事。下一章我们会把这些记忆机制放进完整的 Agent Loop 里,看它们和工具调用、规划如何配合。