2024 年我做一个 AI 对话助手,要让它能"记住"用户:记住用户说过的偏好、记住之前聊到一半的事。第一版我做得很省事:既然要"记住",那就把所有历史对话,一股脑全拼进每一次请求的 prompt 里。本地我聊了十几轮测了测——真不错:它确实记得我前面说过的话,答得有来有回。我心里很踏实:"让 AI 有记忆嘛,就是把聊过的全塞进 prompt,它自然就记住了。"可等这个助手真正上线、被用户一聊就是几十上百轮,一串问题冒了出来。第一种最先把我打懵:对话一长,每一次请求都又贵又慢——因为我把越堆越厚的全部历史,反复地、完整地发了一遍又一遍。第二种:聊到某个长度,请求直接报错——历史的 token 总量,撑爆了模型的上下文窗口。第三种最隐蔽:就算没报错,模型也开始"丢三落四"——它在超长的上下文里,牢牢记得开头和结尾,却把中间聊过的关键信息忘了个干净。第四种最致命:进程一重启,那个存历史的内存列表清零,用户昨天告诉它的一切,今天全不认识了——它根本没有"记忆",只是临时把历史塞进了 prompt。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"让 AI 有记忆,就是把聊过的全塞进 prompt"。这句话把"把全部历史塞进上下文"和"拥有记忆"当成了一回事。可它不是。把全部历史塞进 prompt,既贵又慢、会撑爆窗口、模型还会在长上下文里遗忘中段;而且进程一重启它就荡然无存——这根本不是记忆,只是一次性的、临时的上下文堆砌。真正给 AI 做记忆,核心不是"把历史全塞进去",而是理解记忆要分层:最近的对话留原文、久远的对话压成摘要、关键的事实抽出来存进外部、用时再按需检索。这篇文章就把 AI Agent 的记忆系统梳理一遍:为什么"全塞进 prompt"是错的、短期记忆怎么用滑动窗口、久远对话怎么压成摘要、长期记忆怎么抽取存储再检索、三种记忆怎么组装成系统,以及记忆写入时机、检索噪声、记忆冲突这些把 Agent 记忆真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把全部历史对话都塞进每次请求的 prompt 之后,上线冒出一串问题:对话一长,每轮请求都又贵又慢(反复重发越堆越厚的历史);聊到一定长度,请求直接报错(token 撑爆上下文窗口);即使没报错,模型也记不住中段信息(超长上下文里的"中间遗忘");进程一重启,所有"记忆"全部清零。
我当时的错误认知:"让 AI 有记忆,就是把聊过的对话全塞进 prompt,它自然就记住了。"
真相:一个真正可用的 Agent 记忆系统,不是一个越堆越大的历史列表,而是一个分层的结构。人的记忆就是分层的:你记得刚刚那句话的每个字(短期),你记得上周开会大致谈了什么、但记不清原话(摘要),你记得一个老朋友的名字和喜好、哪怕几年没联系(长期)。AI 的记忆也该这样分:短期记忆保留最近几轮对话的原文;摘要记忆把滑出窗口的久远对话,压缩成精炼的摘要;长期记忆把真正重要的事实抽取出来,存进外部存储(如向量库),需要时再检索回来。把这三层组装好,Agent 才能既不撑爆窗口、又不丢失信息、还能跨会话地真正"记住"。
要把 Agent 的记忆做对,需要几块认知:
- 为什么"全塞进 prompt"是错的——又贵又慢、撑爆窗口、中段遗忘、重启即失忆;
- 短期记忆——用滑动窗口,只保留最近几轮的原文;
- 摘要记忆——把滑出窗口的久远对话,压缩成摘要;
- 长期记忆——抽取关键事实,存进外部,按需检索;
- 三层记忆怎么组装、写入时机、记忆冲突这些工程坑怎么处理。
一、为什么"把全部历史塞进 prompt"是错的
先把这件最根本的事钉死:大模型本身是"无状态"的——它处理完这一次请求,就把这次请求的一切忘得干干净净,下一次请求对它来说是一张白纸。所谓"让模型记住",从来不是模型那一头记住了什么,而是你这一头,在每次请求时,主动把"它需要知道的过去"重新喂给它。问题就出在这个"喂"字上:如果你喂的方式是"把全部历史原封不动地喂",那么对话越长,你每一次要喂的东西就越多——这既会让成本和延迟线性飙升,又迟早会超过模型一次能接收的上限(上下文窗口)。
下面这段,就是我那个"对话一长就崩"的第一版:
# 反面教材:把全部历史对话,一股脑塞进每一次请求的 prompt
history = [] # 一个永远只增不减的列表
def chat(user_input):
history.append({"role": "user", "content": user_input})
resp = client.chat.completions.create(
model="gpt-4o",
messages=history, # 破绽:history 会无限膨胀
)
reply = resp.choices[0].message.content
history.append({"role": "assistant", "content": reply})
return reply
# 破绽一:对话一长,history 里的 token 无限增长,每轮请求又贵又慢。
# 破绽二:token 总量一旦超过模型上下文窗口,请求直接报错。
# 破绽三:进程一重启,history 这个内存列表清零 —— 所谓"记忆"荡然无存。
这段代码在本地聊十几轮时表现完美,因为十几轮的历史,token 还远没堆到窗口上限,成本和延迟也都微不足道。它的问题不在代码本身,而在一个被忽略的前提:它默认"历史是可以无限堆叠的"。可历史会一直长,而模型的窗口、你的预算、用户的耐心,都是有限的。于是那串问题就有了解释:又贵又慢,是因为每轮都在重发越来越厚的全部历史;报错,是因为历史撑爆了窗口;重启失忆,是因为历史只活在内存里,根本没被持久化。问题的根子清楚了:给 Agent 做记忆的工程量,全在"承认历史会无限增长、而你的容器是有限的"之后——你不去做分层、不去做取舍,就只能眼看着历史把一切撑爆。先从离当下最近的那一层说起。
二、短期记忆:滑动窗口保留最近几轮
记忆分层的第一层,是短期记忆。它的职责很明确:保留最近几轮对话的原文,一字不差。因为离当下最近的对话,信息最密、最可能被立刻追问,这部分必须用原文,压缩或丢弃都会立刻伤害体验。但"最近几轮"是个滑动的窗口:新的对话进来,最老的就要滑出去:
class ShortTermMemory:
"""短期记忆:只保留最近 N 轮对话的原文,更早的让它滑出去。"""
def __init__(self, max_turns=6):
self.max_turns = max_turns # 一轮 = 一问一答
self.messages = []
def add(self, role, content):
self.messages.append({"role": role, "content": content})
# 超出窗口,就把最老的对话从头部丢掉
limit = self.max_turns * 2
if len(self.messages) > limit:
# 被挤出去的这部分,不能直接扔 —— 要交给摘要记忆
overflow = self.messages[:-limit]
self.messages = self.messages[-limit:]
return overflow
return []
这里有个关键设计:被挤出窗口的旧消息,add 方法不是默默丢弃,而是 return 出来。因为"滑出短期窗口"不等于"这段对话不重要了",它只是不该再占用宝贵的原文配额——它要被转交给下一层处理。窗口该开多大,可以用 token 数来更精确地衡量:
import tiktoken
_enc = tiktoken.encoding_for_model("gpt-4o")
def count_tokens(messages):
"""估算一组消息占用的 token 数,用来精确判断窗口该不该滑动。"""
total = 0
for m in messages:
total += len(_enc.encode(m["content"])) + 4 # 每条消息有固定开销
return total
这里的认知要点是:短期记忆是一个有意保持"小而新鲜"的窗口——它只装最近、最该用原文的那几轮。它的核心动作不是"存",而是"滑":让旧的有序地退场。而退场不等于消失,旧对话要被交接给下一层。那么,被交接出来的旧对话,怎么处理?这就是摘要记忆。
三、摘要记忆:把久远对话压缩成摘要
从短期窗口滑出去的久远对话,如果直接丢掉,Agent 就失忆了;如果原样留着,又回到了"历史无限膨胀"的老路。摘要记忆是这两难之间的解法:把久远的对话,压缩成一段精炼的摘要——保留关键事实、做过的决定、没完成的事项,扔掉寒暄和细枝末节。这件事,正好可以交给模型自己做:
class SummaryMemory:
"""摘要记忆:把滑出短期窗口的久远对话,压缩成一段精炼的摘要。"""
def __init__(self):
self.summary = "" # 一段持续滚动更新的摘要文本
def absorb(self, overflow_messages):
"""把溢出的旧消息,融合进已有摘要里。"""
if not overflow_messages:
return
old_text = "\n".join(
f'{m["role"]}: {m["content"]}' for m in overflow_messages
)
prompt = (
"下面是一段已有摘要,和一批新的对话记录。"
"请把它们融合成一段更新后的摘要,"
"保留关键事实、决定和未完成的事项,去掉寒暄。\n\n"
f"已有摘要:\n{self.summary}\n\n"
f"新对话:\n{old_text}\n\n更新后的摘要:"
)
resp = client.chat.completions.create(
model="gpt-4o-mini", # 摘要是简单活,用便宜的小模型就够
messages=[{"role": "user", "content": prompt}],
)
self.summary = resp.choices[0].message.content.strip()
注意这里的摘要是"滚动更新"的:每次有新的旧对话溢出来,都把它和已有摘要一起,重新融合成一段新摘要。这样,无论对话进行了多少轮,摘要本身的长度始终是受控的——它不会随对话无限增长。这里的认知要点是:摘要记忆是一次主动的、有损的压缩——它用"丢掉细节"换"长度可控"。这个取舍是值得的:久远的对话,你需要的本就是它的要点,而不是逐字的原文。但摘要也有它的软肋:它是"对话流"的压缩,适合记"我们聊了什么",却不擅长精确地记住一条孤立的关键事实(比如"用户对花生过敏")。这种事实,要靠长期记忆。
四、长期记忆:抽取事实,存外部,按需检索
长期记忆解决的是"跨越很长时间、甚至跨越会话,精确记住一条关键事实"。它和摘要记忆的根本区别在于:摘要记忆压缩的是"对话流",而长期记忆存储的是"一条条独立的事实"。它的第一步,是从对话里把值得长期记住的事实抽取出来——不是存原文,而是提炼:
def extract_facts(user_input, reply):
"""从一轮对话里,抽取出值得长期记住的"事实",而不是整段原文。"""
prompt = (
"从下面这轮对话中,抽取关于用户的、值得长期记住的事实"
"(如偏好、身份、长期目标)。每条一行,没有就回空。\n\n"
f"用户:{user_input}\n助手:{reply}\n\n事实:"
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
lines = resp.choices[0].message.content.strip().split("\n")
return [ln.strip("- ").strip() for ln in lines if ln.strip()]
抽取出的事实,要存进一个外部的、持久的存储——这样它不怕进程重启。用向量库存,是因为之后要按"语义相关"来检索它:
import chromadb
_client = chromadb.Client()
_facts = _client.create_collection("user_facts")
def remember_fact(user_id, fact, turn_no):
"""把一条事实,连同它的来源信息,写进长期记忆(向量库)。"""
_facts.add(
documents=[fact],
metadatas=[{"user_id": user_id, "turn": turn_no}],
ids=[f"{user_id}-{turn_no}-{hash(fact) & 0xffffff}"],
)
长期记忆不会、也不该把所有事实都塞进 prompt——那又回到老问题了。它的用法是"按需检索":用户问到什么,就检索出和当前问题语义相关的几条事实,只把这几条带进上下文:
def recall_facts(user_id, query, top_k=3):
"""根据当前用户输入,从长期记忆里检索出最相关的几条事实。"""
result = _facts.query(
query_texts=[query],
n_results=top_k,
where={"user_id": user_id}, # 只检索这个用户自己的记忆
)
return result["documents"][0] if result["documents"] else []
这里的认知要点是:长期记忆的精髓,是"存得多、取得少"——你可以往外部存储里存下海量的事实,但每次对话只检索回当下真正相关的那几条。它把"记住"和"用到"分开了:记住是无限的、持久的,用到是有限的、即时的。三层记忆各司其职,接下来要把它们组装成一个系统。
五、把三种记忆组装成一个记忆系统
短期、摘要、长期三层备齐,就要用一个 MemoryManager 把它们编排起来。它要做两件事:对话开始前,从三层里各取所需,拼出这一轮的上下文;对话结束后,把这一轮的内容分别更新进三层。先看拼上下文:
class MemoryManager:
"""把短期、摘要、长期三种记忆,组装成一次完整的上下文。"""
def __init__(self, user_id):
self.user_id = user_id
self.short = ShortTermMemory(max_turns=6)
self.summary = SummaryMemory()
self.turn_no = 0
def build_context(self, user_input):
"""为这一轮对话,拼出要喂给模型的 messages。"""
msgs = []
# 第一层:长期记忆 —— 检索和当前问题相关的事实
facts = recall_facts(self.user_id, user_input)
if facts:
msgs.append({"role": "system",
"content": "已知用户信息:\n" + "\n".join(facts)})
# 第二层:摘要记忆 —— 久远对话的压缩
if self.summary.summary:
msgs.append({"role": "system",
"content": "早前对话摘要:" + self.summary.summary})
# 第三层:短期记忆 —— 最近几轮的原文
msgs.extend(self.short.messages)
msgs.append({"role": "user", "content": user_input})
return msgs
再看对话结束后,怎么把这一轮归档进三层:
def commit_turn(self, user_input, reply):
"""一轮对话结束后:更新三层记忆。"""
self.turn_no += 1
# 短期记忆:加入本轮,拿到被挤出窗口的旧消息
self.short.add("user", user_input)
overflow = self.short.add("assistant", reply)
# 溢出的旧消息,交给摘要记忆吸收 —— 不直接丢弃
self.summary.absorb(overflow)
# 长期记忆:抽取本轮里值得长期记住的事实
for fact in extract_facts(user_input, reply):
remember_fact(self.user_id, fact, self.turn_no)
下面这张图,把一轮对话里记忆系统的完整流转串起来:
这里的认知要点是:记忆系统的本质,是一套"分层的取舍"——每一层都在回答"什么该用原文、什么该压缩、什么该外置"。MemoryManager 不创造记忆,它只是那个忠实的编排者:每轮对话前按相关性把三层拼起来,每轮对话后把新内容分发回三层。
六、工程坑:写入时机、检索噪声与记忆冲突
三层设计之外,还有几个工程坑,不处理就会让记忆系统记错、记乱、或记了一堆没用的。坑 1:不是每句话都值得写进长期记忆。如果每一轮都无脑抽取、无脑写入,长期记忆很快会被"今天天气真好"这种废话塞满,检索时全是噪声。extract_facts 里那句"值得长期记住、没有就回空"的提示词,就是第一道闸——要让模型学会判断"这条信息值不值得记",宁可少记,不可滥记。坑 2:记忆会变,新记忆要能覆盖旧记忆。用户上个月说"我在北京",这个月说"我搬到上海了"——如果只是把新事实堆上去,长期记忆里就同时存着两条矛盾的事实,检索出来 Agent 就精神分裂。写入前要先查有没有冲突的旧记忆,有就更新而不是堆叠:
def resolve_conflict(user_id, new_fact):
"""写入新事实前,先看它是否和旧记忆冲突,冲突就更新而非堆叠。"""
similar = _facts.query(
query_texts=[new_fact],
n_results=1,
where={"user_id": user_id},
)
docs = similar["documents"][0]
dists = similar["distances"][0]
# 距离很近 = 讲的是同一件事,可能用户的信息变了
if docs and dists[0] < 0.15:
old_id = similar["ids"][0][0]
_facts.delete(ids=[old_id]) # 删掉过时的旧事实
remember_fact(user_id, new_fact, turn_no=0)
坑 3:检索回来的记忆有噪声,别全盘信任。向量检索是按"语义相似"找的,相似不等于相关。检索 top_k=3 条事实,可能有一两条其实和当前问题没关系。把它们塞进 prompt,反而可能误导模型。所以检索结果可以再加一道相关性过滤(用距离阈值、或让小模型再判一次),或者在提示词里明确告诉模型"以下信息仅供参考,不相关可忽略"。坑 4:记忆是隐私,要能被用户查看和删除。长期记忆里存的全是用户的个人信息。这意味着你必须提供让用户查看自己被记住了什么、并能要求删除的能力——这不只是产品功能,很多地方是法规的硬性要求。remember_fact 里带上 user_id,正是为了让记忆能按用户隔离、按用户清除。坑 5:摘要会逐层失真,关键信息要"加固"。摘要是有损压缩,一段对话被反复卷进摘要,细节会一轮轮流失。对那些绝不能丢的关键信息(如用户的硬性约束),不要只依赖摘要,而要明确抽成事实、进长期记忆——长期记忆是精确的,摘要是模糊的,越重要的信息越该交给精确的那一层。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 模型无状态 | 模型本身不记事,记忆全靠每次请求主动重新喂给它 |
| 全塞 prompt 的弊端 | 又贵又慢、撑爆上下文窗口、中段遗忘、重启即失忆 |
| 短期记忆 | 滑动窗口保留最近几轮原文,新进旧出 |
| 摘要记忆 | 把滑出窗口的久远对话,滚动压缩成长度受控的摘要 |
| 长期记忆 | 抽取关键事实存进外部向量库,跨会话持久、按需检索 |
| 事实抽取 | 从对话提炼值得长期记住的事实,而非存储整段原文 |
| 按需检索 | 存得多取得少,每轮只检索回与当前问题相关的几条 |
| MemoryManager | 编排三层:对话前拼上下文,对话后分发回三层 |
| 记忆冲突 | 新事实与旧记忆矛盾时要更新覆盖,而非堆叠并存 |
| 记忆与隐私 | 长期记忆是用户隐私,须可查看、可删除、按用户隔离 |
避坑清单
- 模型本身无状态,记忆是你每次请求主动喂进去的,不是它记的。
- 把全部历史塞进 prompt,会又贵又慢、撑爆窗口、重启即失忆。
- 记忆要分层:短期留原文、摘要压久远、长期存事实。
- 短期记忆用滑动窗口,被挤出的旧对话要交接而非丢弃。
- 摘要要滚动更新,让它的长度始终受控、不随对话增长。
- 长期记忆抽取的是事实而非原文,存进外部存储防重启失忆。
- 长期记忆存得多、取得少,每轮只检索回相关的几条。
- 不是每句话都值得记,要让模型判断信息值不值得长期保留。
- 新记忆和旧记忆冲突时要更新覆盖,否则检索出矛盾事实。
- 长期记忆是隐私,必须能按用户查看、删除、隔离。
总结
回头看那串"又贵又慢、撑爆窗口、中段遗忘、重启失忆"的问题,以及我后来在 Agent 记忆上接连踩的坑,最该记住的不是某一个数据结构,而是我动手前那个想当然的判断——"让 AI 有记忆,就是把聊过的全塞进 prompt"。这句话错在它把"记忆"和"堆叠历史"画上了等号。我以为记得越全,就是记忆越好。可我忽略了一件事:记忆的价值,从来不在"全",而在"在对的时候,调出对的那一点"。一个把所有事都事无巨细记着、且每次都全盘倒给你的人,不是记性好,而是没法用。把全部历史塞进 prompt,不是给了 AI 记忆,而是给了它一个越来越沉、迟早背不动、而且一摔就碎的包袱。
所以给 Agent 做记忆,真正的工程量不在"把历史存进一个列表"这一个动作上。那个动作,谁都会做。真正的工程量,在于你要承认"历史会无限增长、而上下文窗口、预算、模型的注意力都是有限的",并据此搭一套分层取舍的结构:离当下最近的,你就用短期记忆原文留住;滑出窗口的,你就用摘要记忆压成要点;真正关键的事实,你就抽出来、进长期记忆、存到外部;每一轮要用时,你就按相关性,从三层里只取回此刻该用的那一点。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"全塞 prompt"为什么错,再讲短期记忆怎么滑动、久远对话怎么压成摘要、关键事实怎么抽取存储再检索、三层怎么组装成系统,最后是写入时机、检索噪声、记忆冲突这几个把 Agent 记忆做扎实的工程细节。
你会发现,给 AI 做记忆,和现实里"一个人怎么管理自己的记忆"完全相通。一个不会管理记忆的人会怎么做?他试图把每一天发生的每一句话、每一个细节,都一字不差地、随时挂在脑子里(这就是把全部历史塞进 prompt)。结果呢?他的脑子很快就被塞爆了(这就是撑爆上下文窗口),而且真要用某个信息时,反而从一团乱麻里捞不出来(这就是中段遗忘)。而一个真会管理记忆的人怎么做?他对刚刚发生的事记得清清楚楚(这就是短期记忆);对上周、上个月的事,只记得一个大致的脉络和要点(这就是摘要记忆);而对那些真正重要的事——朋友的名字、家人的喜好、重要的约定——他会专门记牢,哪怕隔很久也调得出来(这就是长期记忆)。他还懂得新信息要覆盖旧信息:朋友搬了家,他记的就是新地址(这就是记忆冲突的处理)。
最后想说,Agent 的记忆做没做对,差距永远不会在"本地聊十几轮都记得"时暴露——本地你聊的轮次少到根本堆不满窗口、成本可以忽略、进程也一直没重启,你会觉得"把历史塞进一个列表"已经是全部。它只在真实的、用户一聊几十上百轮、还会隔几天回来接着聊的线上环境里才显形。那时候它会用最伤体验的方式给你结账:做不好,你的助手会越聊越慢越贵,聊到一定长度直接报错罢工,或者把用户中途说过的关键信息忘得一干二净,更糟的是用户隔天回来,它像从没见过这个人;而做对了,你的助手会记得恰到好处:最近说的话它字字记得,很久以前聊过的它记得要点,你的偏好和约定它跨越多次会话都牢牢记着,而这一切既没撑爆窗口、也没烧掉预算。所以别等"用户抱怨它什么都记不住"找上门,在你写下那行 history.append 的那一刻就该想清楚:我要的不是一个越堆越厚、迟早背不动的历史列表,而是一套会分层、会取舍、会遗忘也会牢记的记忆系统——短期、摘要、长期这三层,我是不是都替它搭好了?这些问题有了答案,你做出来的才不只是一个"短时间内显得挺聪明"的对话框,而是一个真正记得住用户、经得起长期相处的 AI 助手。
—— 别看了 · 2026