LangChain系列之Agent 的记忆(上):上下文塞不下了怎么办

2026-09-14 15:580 条评论85 次阅读

Agent 的记忆(上):上下文塞不下了怎么办

一、聊到第 50 轮,账单先撑不住

客服 Agent 上线两周,代码一行没改,账单先改了。token 花费翻了 6 倍,响应从 1.2 秒拖到 4 秒。

我把日志翻出来看。第 50 轮的输入里,躺着第 1 轮那句"你好"。

模型没换,提示词没动,变的只是它每一轮要看的东西。会话越长,越像背着一麻袋旧账本去开会。

三个问题浮上来:窗口装不下的时候怎么办?窗口装得下就没问题了吗?真出问题了,我该动哪一层?

这三个问题问下去,最先卡住的地方有点意外,卡在"记忆"这个词本身。

我们张口就说 Agent 没记忆,可产品经理嘴里的记忆、提示词里写的记忆、框架文档里的 memory,经常指的并不是同一件事。

词没对齐,后面挑工具、划边界、定容量,每一步都会拧。动手之前,先把这件事说清楚。

二、先把「记忆」这个词对齐

记忆是把这次交互里的信息,带到下一次决策里用的能力。

重音落在"带到下次"。存下来只是手段,用得上才算数。

有些项目一上来就搭向量库,召回率做得挺高,最后却没人用。问题往往出在这里:存了一堆,但没有一处影响决策。

拿人脑做个对照,工程上的映射会清楚很多。

人脑大致分三层。感觉记忆管毫秒级的瞬时感知,工作记忆装当下这几分钟的事,长期记忆存几天前甚至几年前的事。

工作记忆
当下几分钟的事

当前上下文
模型这一轮能看到的全部内容

长期记忆
几天前甚至几年前

外部存储
系统留着,需要时取回

感觉记忆
毫秒级瞬时感知

工程里没有对应物
模型每轮都从零开始

映射到 Agent 上剩两块。工作记忆对应当前上下文,也就是模型这一轮能看到的全部内容。

长期记忆对应外部存储,也就是系统自己留着、需要时再取回来的那部分。

第一层有个有意思的地方。感觉记忆在工程里找不到对应物,因为模型每一轮都从零开始,上一轮的"感觉"根本不存在。

接着是三个容易混的地方。这三个混点,几乎每个做 Agent 的人都会踩一次。

容易混的地方 各自指什么 一句话分清
上下文 / 记忆 上下文是模型这一轮能看到的,记忆是系统留着的 塞不进上下文的东西,才需要记忆
短期 / 长期 短期活在一个会话里,长期跨会话 区别在时间尺度,不在存在哪
记忆 / 检索 RAG 找外部知识,记忆找自己的历史 一个答"产品怎么退款",一个答"这位用户踩过什么坑"

上下文和记忆这两个词,日常几乎当同义词用,工程上必须分开。前者是这一轮的可见范围,后者是系统手上的底牌。

短期和长期的区别只看时间尺度。两套机制、两个存储位置,甚至两套 API。

记忆和检索的边界也常被抹掉。RAG 检索的是外部知识,同一个问题谁来问,答案都一样。

记忆检索的是自己的历史。同一个问题,换个用户问,答案就该不一样。

最后给张全局图。一个完整的记忆系统要做四件事:记什么、存在哪、怎么取、何时忘。

动作 要定下来的事 放在哪儿讲
记什么 抽取哪些信息,怎么脱敏 下一篇
存在哪 存储后端、隔离粒度 下一篇
怎么取 召回与排序策略 下一篇
何时忘 裁剪、删除、摘要 本篇

这一篇先把"何时忘"做扎实。

理由很实在,它是四件事里唯一不做就立刻出事的那个。前面三个缺失,系统只是不够聪明;忘的不做,系统会越来越贵,直到某天撑爆窗口。

三、模型的记忆是搭出来的

概念对齐了,回来看代码。先把一个事实钉死,模型自己不带记忆。

不信跑两行。

graph = create_agent(model)          # 注意:没有 checkpointer

graph.invoke({"messages": [("user", "我叫老程,做搜索的")]})
graph.invoke({"messages": [("user", "我叫什么?")]})

第 2 次调用,模型只收到 1 条消息,5 个字符。上一轮说的名字根本没进上下文,它当然答不上来。

这事不能怪模型。chat 接口是纯函数式的,你发什么它看什么,发完就忘。

所以 Agent 的"记忆"是搭出来的,由三样东西拼成。state 装消息,checkpointer 负责存档,thread_id 决定存哪条线。

这三样上次已经装好了,今天不重复。记住一句就够:checkpointer 存的是历史,它只负责不丢,不管大小。

LangChain 把上下文的来源分成三种,放在一起看更清楚。

数据来源 官方叫法 生命周期 典型内容
静态配置 Runtime Context 整个会话不变 用户 ID、权限、环境配置
短期记忆 State 一个会话 对话消息、工具返回、上传的文件
长期记忆 Store 跨会话 用户偏好、抽取出来的事实

表里最后一行是下一篇的主角。它才是跨会话的入口,也是大多数人真正想要的那个"记忆"。

现在历史稳稳存着,麻烦也跟着来了。

四、历史为什么会失控

三个原因,一个比一个隐蔽。

最直白的是窗口有上限。超了就是超了,报错或者截断,没有第三条路。

更隐蔽的是第二个,窗口够大不等于能用。上下文里塞满 3 天前的寒暄,模型回答当下问题的注意力就被稀释了。

长上下文里"中间那段容易被忽略"这件事,已经被反复验证过。窗口大,替代不了治理。

最容易被忽略的是第三个,成本随轮次近乎平方级增长。第 N 轮的输入,等于前面所有轮加起来。

单轮看着不多,累计起来很吓人。我拿一段 20 轮的客服会话量了一下,把这笔账摆出来。

第 1 轮:模型看到 1 条消息

第 5 轮:9 条

第 10 轮:19 条

第 20 轮:39 条

每一轮都把前面全部重发一遍
累计输入 14986 个字符

不挂任何治理,20 轮跑完,模型累计吃了 14986 个字符,单轮峰值 39 条消息。

看到这个数字,开头那句"第 50 轮还在读第 1 轮的你好"就不难理解了。

接下来三招逐个把这笔账按住。三招都挂在中间件上,先把各自的适用场景分清,再看 API 会顺很多。

五、第一招 裁剪:发给模型的,少一点

裁剪改的是发给模型之前的那一份。写法是在 before_model 里重写消息列表,保留首条加最近几条,其余丢掉。

from langchain.agents.middleware import before_model
from langchain_core.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

@before_model
def trim_history(state, runtime):
    messages = state["messages"]
    if len(messages) <= 6:
        return None
    kept = [messages[0], *messages[-4:]]
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *kept]}

返回值里第一个元素是 RemoveMessage(id=REMOVE_ALL_MESSAGES),后面才是要留下的消息。

这个 id 是个特殊标记,意思是先把列表清空,再用后面这些填回去。它的常量在 langgraph.graph.message 里,值就是字符串 __remove_all__。

从 langchain_core.messages 里 import 这个名字会直接报错。这个坑我踩过,报错信息还不太提示方向。

同一段 20 轮对话,把裁剪挂上去,账单从 14986 降到 3925 字符,单轮峰值从 39 条降到 5 条。

有件事得说清楚,免得理解偏。裁剪的结果会写进状态,被裁掉的消息在后续轮次里不会再出现。

它不会下一轮又冒出来。这是设计意图,每一轮重算一遍等于白裁。

但如果你不想动状态,官方留了另一条路。

from langchain.agents.middleware import wrap_model_call

@wrap_model_call
def trim_transient(request, handler):
    messages = request.messages
    if len(messages) > 6:
        request = request.override(messages=[messages[0], *messages[-4:]])
    return handler(request)

这一版改的是本次请求,状态一个字节都不动。我拿同一段 6 轮对话跑了两遍做对照。

裁剪写法 模型每轮看到 最终状态留下
before_model 返回状态更新 5 条 6 条
wrap_model_call 改写请求 5 条 12 条

省钱效果一模一样,区别在状态留不留。要审计、要回溯、后面还想拿完整历史去抽长期记忆,就用瞬态那一版。

手写裁剪最容易翻车的地方在边界上。裁剪如果刚好把 AI 的工具调用和对应的工具返回拆开,模型会收到一段没有前因的返回。

结果轻则报错,重则乱答。官方给了个工具专门处理这件事。

from langchain_core.messages import trim_messages

trimmed = trim_messages(
    messages,
    max_tokens=4000,          # 预算
    token_counter=model,      # 用模型自己的分词器算,别用字符数
    strategy="last",          # 保留最近的
    include_system=True,      # 系统提示一定留下
    start_on="human",         # 从用户消息开头,工具调用配对不会被切断
)

start_on="human" 这一句是关键。实测下来它确实保住了工具调用的配对,把老的一整轮完整丢掉。

这个工具有个边界陷阱值得单独说。预算卡到刚好等于保留条数时,输出可能只剩一条系统提示,用户的问题整段消失。

我把预算从 4 条调到 8 条,结果立刻正常。预算别贴着下限设,要给系统提示和最近一轮留出余量。

六、第二招 删除:存档里也别留了

裁剪是少看,删除是忘掉。

典型场景有两个。用户贴了一段带密钥的日志,处理完要求删掉;或者一段无关的寒暄,留着纯属占地方。

写法在 after_model 里,返回一条 RemoveMessage,带上要删的消息 id。

from langchain.agents.middleware import after_model

SECRET = "sk-live-9f3a2b7c"

@after_model
def forget_on_request(state, runtime):
    messages = state["messages"]
    target = next((m for m in messages
                   if m.type == "human" and SECRET in str(m.content)), None)
    if target is None:
        return None
    asked = any(m.type == "human" and "忘掉" in str(m.content) for m in messages)
    if not asked:
        return None
    return {"messages": [RemoveMessage(id=target.id)]}

这段代码有两个容易写错的地方,我都踩过。

第一个是时机。after_model 跑的时候,state 里已经有模型刚产出的回复,所以不能拿 messages[-1] 当用户请求来判断。

那一刻它已经是 AI 的回复了。得遍历消息列表按类型去找。

第二个是重复触发。用户说"忘掉"那条消息本身还在列表里,不加存在性判断,后面每一轮 after_model 都会再删一次。

那删除到底删了什么?我实测了一遍,结论比想象中微妙。

删除之后,当前状态里找不到那段密钥了,模型后续也看不到它。这一步很干净。

但我翻了 12 个存档 checkpoint,其中 6 个仍然含这段密钥。删除动的是当前状态,历史存档原样保留。

用户要求忘掉某条消息

RemoveMessage 标记删除

当前状态:消息消失
后续模型看不到

历史存档:仍完整保留
能回溯,也需要单独清理

这个性质有两面,都值得记住。

好处是删错了能回溯,审计也留得住痕迹。出事故复盘的时候,这是救命的。

必须处理的一面是:用户要求删除敏感信息时,光靠 RemoveMessage 不够,存档清理得单独安排。留多久、怎么过期、谁来触发,都得在设计阶段定下来。

补一句场景判断。删除只适合"明确要忘"的场合。

如果你只是嫌历史太长,用裁剪或者摘要更合适。拿删除去凑长度,等于把要用的东西一起扔了。

七、第三招 摘要:保语义,不保原文

前两招都在丢信息。摘要不一样,它把老内容压成一段话,语义留着。

官方给了现成的中间件,挂上去就行。

from langchain.agents.middleware import SummarizationMiddleware

middleware = SummarizationMiddleware(
    model="deepseek:deepseek-chat",     # 摘要专用模型,挑便宜的
    trigger=("tokens", 600),            # 什么时候触发
    keep=("messages", 4),               # 最近几轮原文不动
    summary_prompt="把下面的对话压成要点:\n{messages}",
)
app = create_agent(model, middleware=[middleware], checkpointer=InMemorySaver())

4 个参数值得逐个说。

参数 作用 建议
model 专门用来做摘要的模型 压缩不需要强推理,用便宜模型甚至本地小模型
trigger 什么条件下触发 按 token 数或窗口占比,别按感觉
keep 保留最近多少条原文 留够一轮完整往返,保证当前对话连贯
summary_prompt 摘要模板 换成业务字段,别用默认的通用模板

model 单独指定,是这一篇最实用的一条经验。摘要做的是信息压缩,推理含量很低,用便宜模型效果够用,成本差一个数量级。

trigger 支持按 token 数、按消息条数、按窗口占比三种写法,按占比最省心,换模型不用改数字。

keep 决定当前对话的连贯。summary_prompt 可以换成自己的模板,让摘要按业务需要的字段输出。

同一段 20 轮对话,trigger 设成 600,账单从 14986 降到 7474 字符,峰值从 39 条降到 15 条。

阈值是笔取舍账,我扫了几档。

trigger 设置 累计输入字符 单轮峰值 摘要调用次数
不设(无治理) 14986 39 条 0
600 7474 15 条 2
400 5590 9 条 5

阈值调紧省得更多,但摘要本身也要调模型。次数一多,省下来的成本会还回去一部分。

经验值放在上下文窗口的六到七成,给工具调用和系统提示留出余量。剩下的余量是安全垫,别省到小数点后面。

最后是摘要的代价。它有信息损失,而且损失会累积。

摘要再被摘要,细节一层层磨掉。长会话里想保住关键事实,光靠摘要不够,得配一套真正的长期记忆。

八、三招怎么选

三招的脾气不一样,放在一张表里看。

招式 改哪一层 信息损失 省 token 适合场景
裁剪 发给模型的列表 直接丢 最大 硬约束,永远别让窗口爆
删除 状态里的消息 整条没了 中 明确要忘的内容,或者敏感信息
摘要 老内容换成摘要 语义还在 中到大 长会话,要保住上下文

一条实用建议:摘要为主,裁剪兜底。

摘要保住语义,但触发是滞后的,中间那几轮可能已经超了。裁剪挂在前面做硬约束,永远不让窗口爆。

删除只留给明确要忘的场景。三招搭起来的顺序,是先裁剪、再摘要,删除按需触发。

九、中间件 hook 体系速查

三招都挂在中间件上,而中间件的 hook 不止这两个。

hook 触发时机 能改什么 典型用途
before_agent 整个 Agent 开始前 初始状态 注入用户档案
before_model 每次调模型前 消息列表、提示词 裁剪、注入长期记忆
after_model 模型返回后 消息列表 删除、写记忆
wrap_model_call 包裹模型调用 请求与响应 瞬态裁剪、重试、模型降级
wrap_tool_call 包裹工具调用 工具入参与结果 清理结果、脱敏
after_agent 整个 Agent 结束后 最终状态 归档、打点

这张表是后面所有高级玩法的入口。长期记忆的写入、工具结果的清理、敏感信息脱敏,都从这里进。

顺带推一个现成的中间件。ContextEditingMiddleware 配 ClearToolUsesEdit,专门清理旧的工具输出。

工具返回往往才是 token 大户,一条接口响应几千 token 很常见。工具调用密集的 Agent,这个中间件可能比前面三招都管用。

十、收尾

三条治理清单,带走就能用。

窗口有上限,裁剪做硬约束,永远别让它爆。老内容压成语义,摘要挂在中间件上,用便宜模型跑。明确要忘的用删除,记得存档清理要单独安排。

这套办法解决的是"这一轮聊得下去"。

它解决不了"下次还认识你"。thread_id 一换,前面全归零,用户明天再来,Agent 还是陌生人。

下一篇解决这件事。把记忆从会话里搬出来,让它跨会话活着,顺便把开源的记忆方案摸一遍,看看什么时候该用现成的,什么时候该自己造。


本篇完整 demo 代码(含三招治理实测、两种裁剪对照、账单对比)已打包。在公众号回复 context 领取。