2024 年我做了一个 AI 助理 Agent:用户跟它聊需求、它帮用户记事、跨多轮对话办事。怎么让它"记得住"用户说过的话?这件事我压根没多想。第一版我做得很顺手:记忆嘛,不就是把聊过的话留着?我把每一轮的用户消息和模型回复,原封不动地全部追加进一个 messages 列表,下一次请求就把这个越来越长的列表整个发给大模型。就完事了。本地拿几轮对话一测——真不错:我上一句说"我叫老王",下一句问"我叫什么",它答得分毫不差。我心里很笃定:"Agent 的记忆不就是把对话历史留着吗?context window 这么大,全塞进去,它自然什么都记得,要什么记忆系统?"可等这个 Agent 真正上线、被用户连着用上几十轮、几百轮,一串问题冒了出来。第一种最先把我打懵:聊到几十轮之后,API 直接报错——context length exceeded,对话彻底进行不下去了。第二种最难缠:对话还没超限的时候,我发现它越聊越笨、响应越来越慢,而且每一轮的费用肉眼可见地往上涨。第三种最头疼:用户在第 3 轮说过的一个关键偏好,到第 50 轮它像是彻底忘了,明明那句话还在 messages 列表里。第四种最莫名其妙:用户上周说"我喜欢喝美式",这周改口说"我改喝拿铁了",可 Agent 有时记着美式、有时记着拿铁,两条互相矛盾的记忆都在,它不知道该信哪条。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"Agent 的记忆 = 把对话历史原样留着,context window 够大就行"。这句话把"记忆"这件事,粗暴地等同于"把所有原始信息堆在一起不丢"。可记忆根本不是这么回事。我脑子里,Agent 的记忆是一个无限大的盒子:我把每一句对话原样丢进去,要用的时候模型自己会从里面找。可这个想法,错得彻底。第一,context window 不是"无限大的盒子",它是模型每次推理时的"工作台",空间有限(有 token 上限)、而且越大越贵越慢——大模型的注意力计算量随 token 数大致按平方增长,你把工作台堆满,模型反而要在一堆无关的旧信息里费力地找那几条有用的,这就是它"越聊越笨"。第二,把信息原样堆着,根本不叫记忆。人的记忆不是录音机:你不会逐字记住三年前每一次谈话,你记住的是"提炼后的结论"——老王喜欢美式。记忆的本质是"提炼、组织、按需取用",不是"原样堆积"。第三,记忆必须分层。人脑有管当下的工作记忆、有管最近的短期记忆、有管长久的长期记忆,它们职责不同、容量不同、留存时长不同。Agent 也一样:对话窗口是它的短期工作记忆,容量小、只放当下最相关的;真正要长久记住的事实和偏好,得提炼出来、存进一个外部的长期记忆库,用的时候再按相关性把需要的那几条捞回工作台。我第一版做的,是把长期记忆这个层完全省掉了,指望一个有限的工作台兼任无限的仓库——它当然兼任不了。真正把 Agent 的记忆做扎实,核心不是"把对话原样全塞进 context",而是认清 context window 是有限且昂贵的工作记忆而非无限仓库,把记忆分成短期、长期、工作三层,长期记忆要靠提炼写入而非原样堆积,要靠相关性检索按需召回而非全量加载,还要有遗忘机制淘汰过期和低价值的记忆,并把 token 占用、检索命中这些信号接进监控。这篇文章就把 Agent 记忆系统这个坑梳理一遍:为什么把全部对话塞进 context 行不通、记忆为什么要分短期长期工作三层、长期记忆怎么提炼着写入、怎么按相关性检索召回、为什么必须有遗忘机制,以及一些把记忆系统做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为大模型 API 的设计太"无状态"了——你每次请求都得自己把全部上下文传进去,而最省事的做法就是"把历史原样攒着、一股脑全传"。它错得隐蔽,是因为短对话下这个做法毫无问题:聊个三五轮、十来轮,对话总长度离 context 上限还远,token 费用也不显眼,它"记性"看起来好得很。它只在对话真正变长、用户跨天跨周地反复使用之后才暴露——历史攒到撑爆 context,费用随长度飙升,关键信息被海量噪声淹没,新旧矛盾的信息同时堆在里面,而你还以为"我明明把所有话都留着了"。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:几十轮后报 context length exceeded;对话变长后越聊越笨、越来越慢越来越贵;早期说的关键信息后期像被忘了;新旧矛盾的记忆同时存在。
- 错误认知一:以为 context window 是个无限大的盒子。真相是它是有限且昂贵的工作台,塞满了既会超限,也会让模型在噪声里找不到重点。
- 错误认知二:以为把对话原样堆着就是记忆。真相是记忆的本质是提炼和组织,原样堆积只是囤积原始数据。
- 错误认知三:以为有一个存储层就够了。真相是记忆要分短期、长期、工作三层,职责、容量、留存时长各不相同。
- 真相:好的记忆系统是分层的——短期窗口放当下,长期记忆库提炼着存、按相关性取,再加遗忘机制淘汰过期信息。
一、为什么把全部对话塞进 context 行不通
先把第一版那个"记忆"摆出来。它就是字面意思——一个只增不减的 messages 列表,每次请求整个发出去:
# 第一版:把全部对话历史原样塞进 context(反面教材)
class NaiveAgent:
def __init__(self, client):
self.client = client
# 一个只会增长、永不收缩的列表 —— 这就是第一版的"记忆"
self.messages = [{"role": "system", "content": "你是一个助理"}]
def chat(self, user_input: str) -> str:
self.messages.append({"role": "user", "content": user_input})
# 每次请求,把越来越长的整个历史全发出去
resp = self.client.chat(messages=self.messages)
reply = resp.content
self.messages.append({"role": "assistant", "content": reply})
return reply
# 聊 5 轮:messages 很短,一切完美
# 聊 50 轮:messages 撑到几万 token,又慢又贵
# 聊 100 轮:messages 超过 context 上限,API 直接报错,对话中断
这段代码没有任何语法错误,短对话下它表现完美。它唯一的问题是把一个有限的资源,当成了无限的。context window 是大模型一次推理能"看见"的 token 总量,它有明确的上限。把全部历史原样塞进去,会同时撞上三堵墙:一是超限——对话一长,token 总数超过上限,API 直接拒绝;二是变贵变慢——API 按 token 计费,而 Transformer 的注意力计算量大致随 token 数的平方增长,历史越长,每一轮都更贵、更慢;三是信噪比下降——几百轮对话里真正有用的可能就那么几条,把它们埋进海量闲聊里,模型反而更难抓住重点。把这三堵墙画出来:
[mermaid]
flowchart TD
A[对话轮数不断增长] --> B[messages 列表原样累积]
B --> C{累积的 token 总量}
C -->|超过 context 上限| D[API 报错 context length exceeded]
C -->|逼近上限但未超| E[注意力计算量随 token 平方增长]
E --> F[每轮更慢更贵]
B --> G[有用信息被海量闲聊稀释]
G --> H[模型抓不住重点 越聊越笨]
这里要建立的第一个、也是最重要的认知是:任何一个看起来"大到不像是限制"的资源,只要你设计出一种"只进不出、无限累积"的使用方式,它就一定会从"够用"变成"瓶颈"——而且往往不是温和地变慢,是会撞上一堵硬墙。context window 有几万甚至上百万 token,这个数字大到让人潜意识里觉得"我随便塞,塞不满的"。可问题从来不在容量的绝对值,而在你的使用模式:一个随对话轮数"单调增长、永不释放"的列表,它的增长是没有上界的,无论 context 上限多大,它都只是决定了"第几轮撞墙",而不是"撞不撞墙"。这个认知能迁移到大量场景:一个永不清理的日志文件,磁盘再大也总有一天写满;一个只往里加、从不删的缓存,内存再多也会 OOM;一个不设上限的重试队列,总有一天会堆爆。它们的共性是:只要"流入"没有对应的"流出",这个系统就没有稳态,它只有一个"还没崩"和"已经崩了"的区别。所以面对任何一个会随时间或规模累积的东西,你要养成一个习惯——别问"这个容器够不够大",要问"它有没有一个让流入和流出平衡的机制"。一个有出有进、能达到稳态的小容器,远比一个只进不出的大容器可靠。给累积装上"流出"的阀门——淘汰、压缩、归档、过期——让它能在某个规模上稳定下来,这才是真正解决问题,而不是把撞墙的那一天往后推一推。
二、记忆要分层:短期、长期、工作记忆
认清了"原样堆积"行不通,就得换一套结构。一个好用的参照,是人脑的记忆分层。人不会把一生的经历都摊在眼前,而是分了层:管"此刻正在处理的事"的工作记忆,管"最近发生的事"的短期记忆,管"长久要记住的事"的长期记忆。Agent 的记忆系统照着这个分,就清楚多了:
Agent 记忆的三层结构:各管一段,职责不同
┌─────────────────────────────────────────────────────┐
│ 工作记忆 (Working Memory) │
│ 装什么:当前这个任务的状态、中间结果、待办步骤 │
│ 存在哪:一个结构化对象,随任务开始而生、结束而灭 │
│ 特点:容量极小、变化极快、只服务当下这一个任务 │
├─────────────────────────────────────────────────────┤
│ 短期记忆 (Short-term Memory) │
│ 装什么:最近 N 轮的对话原文 │
│ 存在哪:就是发给模型的 messages 窗口 │
│ 特点:容量有限(受 context 限制),滚动更新,旧的要挤出│
├─────────────────────────────────────────────────────┤
│ 长期记忆 (Long-term Memory) │
│ 装什么:从对话里提炼出的事实、偏好、结论 │
│ 存在哪:外部存储(向量库 / 数据库),不占 context │
│ 特点:容量大、长久留存,用时按相关性检索少量回来 │
└─────────────────────────────────────────────────────┘
关键:context window 只够装"工作记忆 + 短期记忆 + 检索回来的
少量长期记忆",绝不是用来装"全部历史"的
这三层里,短期记忆最容易做——它就是那个 messages 窗口,但要从"无限增长"改成"有限滚动":只保留最近 N 轮,更早的要么丢弃、要么压缩成摘要。下面是短期记忆的滚动窗口加摘要压缩:
# 短期记忆:有限滚动窗口 + 把挤出去的旧对话压缩成摘要
class ShortTermMemory:
def __init__(self, client, keep_rounds=10):
self.client = client
self.keep_rounds = keep_rounds # 窗口只保留最近 10 轮原文
self.recent = [] # 最近 N 轮的对话
self.summary = "" # 更早对话压缩成的摘要
def add(self, role: str, content: str):
self.recent.append({"role": role, "content": content})
# 超出窗口,就把最旧的一轮挤出去、压进摘要
if len(self.recent) > self.keep_rounds * 2:
overflow = self.recent[:2] # 挤出最旧的一问一答
self.recent = self.recent[2:]
self.summary = self._compress(self.summary, overflow)
def _compress(self, old_summary: str, overflow: list) -> str:
# 用模型把"旧摘要 + 刚挤出的对话"重新提炼成一段更新的摘要
text = old_summary + "\n" + str(overflow)
return self.client.summarize(f"把以下内容压缩成简洁摘要:\n{text}")
def render(self) -> list:
# 交给模型的短期记忆 = 一段摘要 + 最近 N 轮原文
msgs = []
if self.summary:
msgs.append({"role": "system", "content": f"早前对话摘要:{self.summary}"})
msgs.extend(self.recent)
return msgs
工作记忆则是另一回事。它不存对话,它存的是当前这个任务的状态——Agent 正在帮用户订机票,那么"出发地、目的地、日期、还差哪个信息没问到"就是工作记忆。它是一个结构化的小对象,任务一开始就建、任务一结束就清:
# 工作记忆:当前任务的结构化状态,随任务生灭
class WorkingMemory:
def __init__(self):
self.task = None # 当前任务类型
self.slots = {} # 任务需要的槽位:已填的 / 待填的
self.steps_done = [] # 已完成的步骤
def start(self, task: str, required_slots: list):
# 任务开始:建一个全新的工作记忆
self.task = task
self.slots = {s: None for s in required_slots}
self.steps_done = []
def fill(self, slot: str, value):
self.slots[slot] = value
def active(self) -> bool:
return self.task is not None
def render(self) -> str:
missing = [s for s, v in self.slots.items() if v is None]
return (f"当前任务:{self.task};已知:{self.slots};"
f"还缺:{missing};已完成步骤:{self.steps_done}")
def finish(self):
# 任务结束:工作记忆直接清空,绝不残留到下一个任务
self.task, self.slots, self.steps_done = None, {}, []
# 工作记忆让 Agent 在多轮里"不忘当前在办什么"——
# 但它只服务这一个任务,任务一完就销毁,绝不堆进长期记忆
这里要建立的认知是:分层,是人类对付"又要容量大、又要访问快、还要成本低"这类不可能三角时,最古老也最强大的一招——而它的核心机制,是承认"不存在一种存储能同时满足所有要求",于是用多种特性不同的存储,各自只服务它最擅长的那一档需求,再让它们协作。你不可能有一种存储,既像 context 那样能被模型瞬间、零延迟地访问,又像数据库那样能近乎无限地存。所以记忆系统不去追求那个不存在的"全能存储",而是分层:工作记忆快到极致但极小,短期记忆是模型直接可见但容量受限,长期记忆容量巨大但要额外一步检索才能取用。每一层都在"容量、速度、成本"上做了一个不同的、明确的取舍,合起来才覆盖了全部需求。这个分层的思想,你一旦认出来,会发现它无处不在:计算机的存储层级,寄存器、L1/L2/L3 缓存、内存、SSD、磁盘,就是一条从"极快极贵极小"到"极慢极便宜极大"的连续光谱;一个公司的信息也是分层的,你脑子里记着今天的待办,笔记本上记着这周的安排,文档库里存着所有归档资料。它们的共性是:面对一个有内在矛盾的需求,不要去幻想一个完美的单层方案,而要把需求按"冷热、快慢、远近"拆开,为每一档配一种恰当的存储,再设计好数据在层与层之间流动的规则。学会主动地"分层",你就掌握了驾驭一切"既要又要还要"型存储难题的总钥匙。
三、长期记忆怎么写入:提炼,而不是堆砌
三层里,长期记忆是真正的难点,也是第一版完全缺失的那一层。它的第一个关键问题是:往里写什么、怎么写?第一版的隐含答案是"把每句话原样写进去",这恰恰是错的。长期记忆不该是对话的录音,而该是从对话里提炼出来的事实。用户说了一长段话,真正值得长久记住的,可能就是其中一两条事实或偏好。所以写入长期记忆,中间要隔一道"提炼"的工序:
# 长期记忆写入:不存原文,先用模型从对话里提炼出"事实"
import time
EXTRACT_PROMPT = """从下面这轮对话里,提炼出值得长期记住的"事实"。
只提炼稳定的信息:用户的身份、偏好、长期目标、重要约定。
忽略闲聊、临时性的话、客套话。
每条事实输出一行;如果这轮对话没有值得记的,输出空。
对话:
{dialog}"""
class LongTermMemory:
def __init__(self, client, vector_db):
self.client = client
self.vector_db = vector_db
def write(self, user_input: str, assistant_reply: str):
dialog = f"用户:{user_input}\n助理:{assistant_reply}"
# 关键一步:让模型先提炼,而不是把对话原样存进去
facts_text = self.client.complete(EXTRACT_PROMPT.format(dialog=dialog))
for line in facts_text.strip().splitlines():
fact = line.strip()
if not fact:
continue
# 每条提炼出的事实,转成向量,连同元数据存进向量库
self.vector_db.insert(
text=fact,
embedding=self.client.embed(fact),
metadata={"created_at": time.time(), "hits": 0},
)
# 用户说了一大段:"我最近在学做菜,昨天搞砸了一锅红烧肉,
# 不过没关系。对了我不吃香菜,做啥都别放香菜。"
# 提炼后只存一条真正长期有效的事实:"用户不吃香菜"
# 那些临时的、闲聊的内容,不进长期记忆 —— 这就是提炼的价值
注意这里多花的那一次模型调用——用 EXTRACT_PROMPT 做提炼。它不是浪费,它是用一次"写入时的成本",换掉了无数次"读取时的成本":提炼过的记忆又短又干净,以后每次检索、每次注入 context,都因为它的精炼而更省、更准。
这里要建立的认知是:"提炼后写入"这个设计,体现的是一个在工程里反复出现的深刻权衡——读多写少的场景,要把成本尽量挪到"写"的那一侧。一条记忆,写入只发生一次,但它可能被检索、被读取成百上千次。在这种"一次写、多次读"的格局下,你在写入时多做的功——提炼、清洗、结构化、建索引——是一次性的投入;而它换来的,是之后每一次读取都更快、更便宜、更准。反过来,如果你图省事在写入时偷懒(把原始对话原样堆进去),那么每一次读取都要去面对那堆未经整理的原始数据,你把成本从"一次"摊成了"无数次"。这个"把功夫下在写入侧"的思想,在系统设计里到处都是:数据库的索引,是写入时多花力气维护一个有序结构,换来查询时的飞快;数据仓库的预聚合,是在数据进来时就把常用的统计算好,换来报表查询的秒出;搜索引擎的倒排索引,是在文档入库时就拆好词、建好表,换来检索时的高效。它们的共性是:都识别出了"读远多于写"这个格局,然后果断地把复杂度、把计算量,从高频的读路径,转移到了低频的写路径。所以当你设计任何一个存储或记忆系统时,先问一句:它是读多还是写多?如果是读多写少——而绝大多数记忆、缓存、知识库都是——那就别在写入时偷懒,把提炼、整理、索引这些功夫,扎扎实实地下在写入的那一刻。写入时多流的汗,会在之后千百次的读取里,连本带利地还给你。
四、长期记忆怎么检索:按相关性召回,而不是全量加载
长期记忆库里攒下成千上万条事实之后,第二个关键问题来了:该怎么取?第一版的错误,本质是把"全部记忆"都加载进 context。正确的做法是:每一轮对话,只把和"当前这句话"相关的那几条记忆,检索出来、注入 context。这正是向量检索的用武之地——把当前用户输入转成向量,去长期记忆库里找语义最接近的几条:
# 长期记忆检索:只按相关性召回少数几条,而不是全量加载
class LongTermMemory: # 接上一节,补上检索方法
def recall(self, query: str, top_k=5) -> list:
# 把当前用户输入转成向量,去记忆库找语义最接近的 top_k 条
q_vec = self.client.embed(query)
hits = self.vector_db.search(q_vec, top_k=top_k)
# 命中的记忆,顺手把 hits 计数加一(给遗忘机制用,见第五节)
for h in hits:
self.vector_db.update_meta(h.id, hits=h.metadata["hits"] + 1)
return [h.text for h in hits]
# 把三层记忆组装成最终发给模型的 prompt
def build_prompt(system_prompt, short_term, long_term, working, user_input):
msgs = [{"role": "system", "content": system_prompt}]
# 1. 检索回来的少量长期记忆 —— 只取和当前输入相关的几条
facts = long_term.recall(user_input, top_k=5)
if facts:
msgs.append({"role": "system",
"content": "关于这个用户你已知的事实:\n" + "\n".join(facts)})
# 2. 当前任务的工作记忆
if working.active():
msgs.append({"role": "system", "content": working.render()})
# 3. 短期记忆:摘要 + 最近 N 轮原文
msgs.extend(short_term.render())
# 4. 当前这句用户输入
msgs.append({"role": "user", "content": user_input})
return msgs
# 哪怕长期记忆库里存了一万条事实,真正进入 context 的永远只有
# 最相关的 5 条 —— context 占用是恒定的,不再随对话变长而膨胀
这就是整个记忆系统的转机:无论长期记忆库膨胀到多大,每一轮真正注入 context 的,永远只是固定的一小撮最相关记忆。context 的占用从此恒定,不再随对话轮数无限增长——第一版那道"撑爆 context"的死结,就此解开。
这里要建立的认知是:从"全量加载"到"按相关性检索召回",这中间的思维转变,是一个能力跃迁——它的本质,是把"我把所有东西都带在身上以备不时之需"的囤积思维,换成"我需要什么的时候,再去精准地取什么"的索引思维。第一版那种"把全部历史塞进 context"的做法,背后是一种朴素的不安全感:我怕漏掉有用的,所以我把一切都带着。可这种"全都带着"的策略,在数据量一大,就立刻破产——你背不动,而且东西一多,你反而找不到真正要的那件。索引思维则完全相反:它不追求"把东西带在身边",而追求"知道东西在哪、并能在需要时快速取到"。你不需要把整个图书馆背在身上,你只需要一张借书证和一套检索系统。这个转变威力极大,因为它把你的"即时负担"从"和数据总量成正比"变成了"和你当前真正需要的量成正比"——前者会随规模爆炸,后者是恒定的。你会在无数地方看到这个思维的胜利:数据库不会把整张表读进内存,它走索引只取匹配的行;操作系统不会把整个磁盘载入内存,它按需把用到的页换进来;一个好的 RAG 系统不会把整个知识库塞给模型,它检索出相关的几段。所以当你面对"数据多到带不动"的困境时,不要再想"怎么把它们都塞进来",要转向问"我能不能建一个索引,让我在需要的精确时刻,只取我精确需要的那一点"。从囤积到索引,是处理大规模信息时,一道真正的分水岭。
五、遗忘机制:记忆也需要被淘汰
到这里记忆系统已经能跑了,但还缺最后、也最反直觉的一环:遗忘。第一版那个"新旧矛盾的记忆同时存在"的怪现象——用户从美式改口拿铁,两条记忆都在——根子就是只写入、从不淘汰。一个只增不减的长期记忆库,会越来越大、检索越来越慢,更糟的是会塞满过期的、被推翻的、再没人用的垃圾记忆。所以记忆系统必须有遗忘机制,主动淘汰低价值的记忆:
# 遗忘机制:给每条记忆算一个"价值分",定期淘汰低分的
import time
class MemoryForgetting:
def __init__(self, vector_db, max_size=5000):
self.vector_db = vector_db
self.max_size = max_size # 长期记忆库的容量上限
def score(self, mem) -> float:
# 一条记忆的价值 = 新近度 + 被用到的频次
age_days = (time.time() - mem.metadata["created_at"]) / 86400
recency = 1.0 / (1.0 + age_days) # 越新,分越高
frequency = mem.metadata["hits"] # 被检索命中越多,分越高
return recency + 0.5 * frequency
def forget_if_needed(self):
all_mem = self.vector_db.all()
if len(all_mem) <= self.max_size:
return
# 超容量了:按价值分排序,淘汰掉分数最低的那一批
ranked = sorted(all_mem, key=self.score)
to_delete = ranked[: len(all_mem) - self.max_size]
for mem in to_delete:
self.vector_db.delete(mem.id)
def supersede(self, new_fact: str, old_fact_id):
# 处理"改口":新事实进来时,把被它推翻的旧事实直接删掉
# (例:用户说"改喝拿铁了",就删掉"用户喜欢美式"那条)
self.vector_db.delete(old_fact_id)
# 新近度 + 频次的组合很关键:一条很久没被用、又很老的记忆,
# 自然沉到底部被淘汰;而一条天天被检索命中的,哪怕很老也留得住
这里要建立的认知是:遗忘不是记忆系统的缺陷,而是它必备的、和记忆同等重要的功能——一个不会遗忘的系统,不是记性更好,而是会被它自己积累的垃圾活活拖垮。这件事极其反直觉:我们天然觉得"记得越多越好""忘记是一种损失"。可一旦你认真做一个会长期运行的记忆系统,就会发现:记忆是有保鲜期的,信息会过期、会被推翻、会失去价值;一条三年前的、早被新事实覆盖的旧记忆,留着它不是资产,是负债——它占空间、拖慢检索,还可能在某次检索里被错误地召回,污染当前的判断(用户明明改喝拿铁了,它却把美式翻出来)。遗忘机制做的事,就是主动地、有策略地清掉这些负债,让记忆库始终维持在一个"有用信息密度高"的健康状态。这个认知能迁移到极广的范围:一个缓存系统,它的精髓一半在"缓存什么",另一半就在"淘汰什么"(LRU、LFU、TTL,全是遗忘策略);一个监控系统,老数据要降采样、要归档、要过期,否则存储会爆;甚至一个人的知识体系,也需要主动遗忘——及时抛弃那些已被证伪的旧观念,新知识才进得来。它们的共性是:任何一个持续接收新信息的系统,如果只有"写入"没有"遗忘",它的长期归宿一定是被噪声淹没、被垃圾拖垮。所以,当你设计任何一个长期运行、持续积累的系统时,一定要在设计"怎么记住"的同时,就把"怎么忘记"想清楚——什么样的信息算过期了?谁来判断价值高低?多久清理一次?把遗忘当成一个一等公民的功能去认真设计,你的系统才能长久地保持健康,而不是在某一天被自己的记忆压垮。
六、工程里那些记忆系统的坑
记忆系统的主线理顺了,落地时还有几个工程坑反复咬人。第一个,提炼本身会出错。用模型做事实提炼,它可能漏提、错提、甚至幻觉出原文没有的"事实"。所以提炼出的记忆最好带上来源(它从哪轮对话来),关键事实可以让用户确认,别让一条幻觉记忆污染整个记忆库。第二个,记忆冲突要有解决规则。同一个属性的新旧记忆冲突时(美式 vs 拿铁),要明确"新的覆盖旧的",写入新事实时就主动作废被它推翻的旧事实,而不是两条都留着。第三个,多用户记忆必须隔离。长期记忆库要按 user_id 严格分区,检索时带上用户过滤,绝不能把 A 用户的记忆检索给 B 用户——这既是体验问题,更是隐私和安全问题。第四个,提炼和检索都要异步或缓存。写入时的提炼是一次额外的模型调用,别让它阻塞用户拿到回复,可以异步去做;检索的 embedding 也可以缓存。第五个,记忆是隐私数据。它存的是用户的真实偏好和身份信息,存储要加密、要支持用户查看和删除自己的记忆、要遵守数据合规要求。把这些信号都接进监控,你才有数据判断记忆系统健不健康:
Agent 记忆系统上线后必须盯死的几个指标:
context_token_usage 每轮注入 context 的 token 数,应稳定不应随对话增长
memory_recall_hit 检索回的记忆里,真正被模型用上的占比
extract_fact_count 每轮提炼出的事实数,长期为 0 说明提炼没生效
long_term_mem_size 长期记忆库总条数,持续暴涨说明遗忘机制没跟上
memory_conflict_rate 检测到的新旧记忆冲突频率
recall_latency 记忆检索的耗时,别让它拖垮整轮响应
per_user_isolation 抽样校验:有没有跨用户串记忆的情况
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"AI Agent 记忆系统"乃至所有"用大模型搭系统"的总体判断——你真正在搭的,不是一个调用大模型的程序,而是一套把大模型这个"能力强、但不可靠"的部件,妥善地装进一个工程框架里的系统。第一版的我,把"记忆"想成了一个纯粹的存储问题,以为存下来就行;可这一节的每一个坑都在说,记忆系统的难点根本不在存储,而在于它处处都要和大模型这个"不靠谱的合伙人"打交道:提炼要靠模型,而模型会幻觉;检索的相关性判断要靠 embedding,而 embedding 会有偏差;冲突的消解、价值的判断,背后都有模型参与决策的影子。大模型给了你一种过去没有的强大能力——它能理解、能提炼、能判断语义,但它给你的每一个输出,都是"大概率对、但不保证对"的。所以围绕它做工程,核心心法就是:充分利用它的能力,同时为它的不可靠性处处设防。带上来源以便追溯,让用户确认关键事实,给冲突设明确的裁决规则,给提炼结果做校验和兜底——这些都不是多余的麻烦,它们是把一个"概率性正确"的部件,封装成一个"系统级可靠"的产品所必需的工程。这里要建立的通用认知是:在 AI 时代做工程,你的价值越来越不在于"会调用模型",而在于你能不能围绕这个不确定的核心,搭起一圈确定的、可靠的工程骨架——校验、兜底、隔离、可追溯、人在回路。把大模型的"聪明"和工程的"严谨"这两件事结合好,让聪明的部分发挥到极致、让不靠谱的部分被牢牢兜住,这才是 AI 应用真正的护城河。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| context window | 模型一次推理能看见的 token 总量 | 有限且昂贵 是工作台不是无限仓库 |
| 短期记忆 | 最近 N 轮对话原文构成的滚动窗口 | 容量受 context 限制 旧的要挤出或压缩 |
| 长期记忆 | 从对话提炼的事实存进外部存储 | 不占 context 用时按相关性检索回来 |
| 工作记忆 | 当前任务的结构化状态与中间结果 | 极小 极快 随任务生灭 |
| 记忆分层 | 把记忆按冷热快慢分成三层协作 | 不存在全能存储 各层各做取舍 |
| 提炼写入 | 用模型从对话里抽取事实再存 | 把成本下在写入侧 换读取的省与准 |
| 相关性检索 | 按当前输入只召回少量相关记忆 | 从囤积思维转向索引思维 |
| 遗忘机制 | 按价值分主动淘汰低价值记忆 | 不遗忘的系统会被自身垃圾拖垮 |
| 记忆冲突 | 同一属性的新旧记忆互相矛盾 | 要有新覆盖旧的明确裁决规则 |
| 多用户隔离 | 记忆库按 user_id 严格分区 | 检索带用户过滤 严防跨用户串记忆 |
避坑清单
- 别把全部对话原样塞进 context,它有限又昂贵,对话一长必然超限、变慢、变贵。
- 记忆要分短期长期工作三层,各做容量速度成本的取舍,别指望一层兼任所有。
- 短期记忆窗口要有限滚动,只留最近 N 轮,挤出去的旧对话压缩成摘要。
- 长期记忆要提炼着写入,从对话抽取稳定的事实和偏好,别把闲聊原文堆进去。
- 长期记忆要按相关性检索召回,每轮只注入最相关的几条,context 占用才恒定。
- 必须有遗忘机制,按新近度和命中频次淘汰低价值记忆,否则记忆库会被垃圾撑爆。
- 新旧记忆冲突要有裁决规则,新事实覆盖旧事实,别让矛盾的记忆同时留着。
- 提炼会出错要设防,记忆带上来源,关键事实让用户确认,别让幻觉污染记忆库。
- 多用户记忆严格按 user_id 隔离,检索带用户过滤,绝不跨用户串记忆。
- 把 context token 占用和记忆库大小接进监控,持续暴涨就是分层或遗忘没生效。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为 Agent 的记忆就是把对话历史原样留着,context window 够大就行。可 context window 根本不是无限仓库,它是模型每次推理的工作台,空间有限、越大越贵越慢;而把信息原样堆着也根本不叫记忆,记忆的本质是提炼和组织。我把"长期记忆"这一整层省掉了,指望一个有限的工作台兼任无限的仓库——于是对话一长,它必然撞上超限、变慢变贵、关键信息被淹没、新旧矛盾共存这四堵墙。
真正把 Agent 的记忆做扎实,工作量不在"把对话塞进去",而在一次结构的转变:把记忆分成短期、长期、工作三层。一旦接受这一点,该做的事就都浮现出来了——短期记忆做成有限滚动窗口加摘要压缩,长期记忆靠模型提炼着写入、靠向量检索按相关性召回,再加一套按价值分淘汰的遗忘机制,把 token 占用和记忆库大小接进监控。每一步都不复杂,难的是先承认:你要的不是一个能无限堆东西的大盒子,而是一套有进有出、能提炼、会取舍的记忆系统。
我后来常拿一个人怎么记事来想这件事。第一版的我,像是要求一个人把这辈子听过的每一句话,逐字逐句、一字不漏地全堆在眼前的桌子上——桌子再大也会堆满,堆满之后他反而在那座纸山里找不到今天真正要用的那张便条。而一个记性好的人根本不是这么做的:他桌上只摊着此刻在办的那件事(工作记忆),手边记着最近这几天的安排(短期记忆),而过往岁月里真正重要的事——朋友的喜好、重要的约定——他早已提炼成了几句结论,收进了脑海深处那个庞大的长期记忆里,要用的时候,顺着当下的话题,自然就想起了相关的那几条。他也会遗忘:那些早已过时的、被推翻的旧事,他不再记挂。记性好,从来不是"什么都记得",而是"该记的提炼着记牢、该忘的果断忘掉、要用的时候精准想起"。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你聊个三五轮、十来轮一测,对话短得很,原样堆历史的写法又准又快,你压根看不出自己漏掉了一整层记忆。它只在真实用户跨天跨周地连续使用、对话攒到几十上百轮之后才暴露——历史撑爆 context、费用随长度飙升、早期的关键信息被淹没,而这些征兆没有一个会在短对话的功能测试里喊疼。所以别等用户开始抱怨"它怎么把我说过的话忘了""怎么越用越贵",才想起去补记忆系统:做 Agent 的第一天,就该想清楚它会被用上几百轮、记忆该怎么分层、长期记忆怎么提炼怎么检索怎么遗忘——记忆系统不该是"用户抱怨了再补"的补丁,而该是你设计 Agent 时,和它的对话能力一起摆上桌的另一半。把"记忆是要分层、要提炼、要取舍的系统"这件事在一开始就认下来,你才算真正跳出了那个把 context 当成无限仓库、出了事还在盲目加长历史的坑。
—— 别看了 · 2026