AI Agent 记忆系统完全指南:从一次"聊到几十轮 context 直接爆掉,早说过的话它全忘了"看懂记忆分层

2024 年我做了一个 AI 助理 Agent 用户跟它聊需求它帮用户记事跨多轮对话办事怎么让它记得住用户说过的话这件事我压根没多想第一版我做得很顺手记忆嘛不就是把聊过的话留着我把每一轮的用户消息和模型回复原封不动地全部追加进一个 messages 列表下一次请求就把这个越来越长的列表整个发给大模型就完事了本地拿几轮对话一测真不错我心里很笃定 Agent 的记忆不就是把对话历史留着吗 context window 这么大全塞进去它自然什么都记得可等这个 Agent 真正上线被用户连着用上几十轮几百轮一串问题冒了出来第一种最先把我打懵聊到几十轮之后 API 直接报错 context length exceeded 对话彻底进行不下去了第二种最难缠对话还没超限的时候我发现它越聊越笨响应越来越慢每一轮的费用肉眼可见地往上涨第三种最头疼用户在第 3 轮说过的一个关键偏好到第 50 轮它像是彻底忘了第四种最莫名其妙用户上周说喜欢喝美式这周改口说改喝拿铁了可 Agent 有时记着美式有时记着拿铁两条互相矛盾的记忆都在我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 Agent 的记忆等于把对话历史原样留着 context window 够大就行可 context window 不是无限大的盒子它是模型每次推理时的工作台空间有限越大越贵越慢把信息原样堆着根本不叫记忆记忆的本质是提炼组织按需取用而且记忆必须分层对话窗口是短期工作记忆真正要长久记住的事实和偏好得提炼出来存进外部的长期记忆库用的时候再按相关性把需要的捞回工作台真正把 Agent 的记忆做扎实核心不是把对话原样全塞进 context 而是认清 context window 是有限且昂贵的工作记忆把记忆分成短期长期工作三层长期记忆要靠提炼写入而非原样堆积要靠相关性检索按需召回而非全量加载还要有遗忘机制淘汰过期和低价值的记忆本文从头梳理为什么把全部对话塞进 context 行不通记忆为什么要分短期长期工作三层长期记忆怎么提炼着写入怎么按相关性检索召回为什么必须有遗忘机制以及一些把记忆系统做扎实要避开的工程坑

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 严格分区 检索带用户过滤 严防跨用户串记忆

避坑清单

  1. 别把全部对话原样塞进 context,它有限又昂贵,对话一长必然超限、变慢、变贵。
  2. 记忆要分短期长期工作三层,各做容量速度成本的取舍,别指望一层兼任所有。
  3. 短期记忆窗口要有限滚动,只留最近 N 轮,挤出去的旧对话压缩成摘要。
  4. 长期记忆要提炼着写入,从对话抽取稳定的事实和偏好,别把闲聊原文堆进去。
  5. 长期记忆要按相关性检索召回,每轮只注入最相关的几条,context 占用才恒定。
  6. 必须有遗忘机制,按新近度和命中频次淘汰低价值记忆,否则记忆库会被垃圾撑爆。
  7. 新旧记忆冲突要有裁决规则,新事实覆盖旧事实,别让矛盾的记忆同时留着。
  8. 提炼会出错要设防,记忆带上来源,关键事实让用户确认,别让幻觉污染记忆库。
  9. 多用户记忆严格按 user_id 隔离,检索带用户过滤,绝不跨用户串记忆。
  10. 把 context token 占用和记忆库大小接进监控,持续暴涨就是分层或遗忘没生效。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为 Agent 的记忆就是把对话历史原样留着,context window 够大就行。可 context window 根本不是无限仓库,它是模型每次推理的工作台,空间有限、越大越贵越慢;而把信息原样堆着也根本不叫记忆,记忆的本质是提炼和组织。我把"长期记忆"这一整层省掉了,指望一个有限的工作台兼任无限的仓库——于是对话一长,它必然撞上超限、变慢变贵、关键信息被淹没、新旧矛盾共存这四堵墙。

真正把 Agent 的记忆做扎实,工作量不在"把对话塞进去",而在一次结构的转变:把记忆分成短期、长期、工作三层。一旦接受这一点,该做的事就都浮现出来了——短期记忆做成有限滚动窗口加摘要压缩,长期记忆靠模型提炼着写入、靠向量检索按相关性召回,再加一套按价值分淘汰的遗忘机制,把 token 占用和记忆库大小接进监控。每一步都不复杂,难的是先承认:你要的不是一个能无限堆东西的大盒子,而是一套有进有出、能提炼、会取舍的记忆系统。

我后来常拿一个人怎么记事来想这件事。第一版的我,像是要求一个人把这辈子听过的每一句话,逐字逐句、一字不漏地全堆在眼前的桌子上——桌子再大也会堆满,堆满之后他反而在那座纸山里找不到今天真正要用的那张便条。而一个记性好的人根本不是这么做的:他桌上只摊着此刻在办的那件事(工作记忆),手边记着最近这几天的安排(短期记忆),而过往岁月里真正重要的事——朋友的喜好、重要的约定——他早已提炼成了几句结论,收进了脑海深处那个庞大的长期记忆里,要用的时候,顺着当下的话题,自然就想起了相关的那几条。他也会遗忘:那些早已过时的、被推翻的旧事,他不再记挂。记性好,从来不是"什么都记得",而是"该记的提炼着记牢、该忘的果断忘掉、要用的时候精准想起"。

这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你聊个三五轮、十来轮一测,对话短得很,原样堆历史的写法又准又快,你压根看不出自己漏掉了一整层记忆。它只在真实用户跨天跨周地连续使用、对话攒到几十上百轮之后才暴露——历史撑爆 context、费用随长度飙升、早期的关键信息被淹没,而这些征兆没有一个会在短对话的功能测试里喊疼。所以别等用户开始抱怨"它怎么把我说过的话忘了""怎么越用越贵",才想起去补记忆系统:做 Agent 的第一天,就该想清楚它会被用上几百轮、记忆该怎么分层、长期记忆怎么提炼怎么检索怎么遗忘——记忆系统不该是"用户抱怨了再补"的补丁,而该是你设计 Agent 时,和它的对话能力一起摆上桌的另一半。把"记忆是要分层、要提炼、要取舍的系统"这件事在一开始就认下来,你才算真正跳出了那个把 context 当成无限仓库、出了事还在盲目加长历史的坑。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Redis 大 key 与热 key 完全指南:从一次"一条 DEL 卡死整个实例,集群扩容也救不了"看懂单线程阻塞

2026-5-22 19:09:20

技术教程

消息队列幂等完全指南:从一次"积分被加了三次,库存被多扣"看懂 at-least-once 与重复消费

2026-5-22 19:22:55

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索