LangChain系列之Agent 的记忆(上):上下文塞不下了怎么办
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 轮的客服会话量了一下,把这笔账摆出来。
不挂任何治理,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 不够,存档清理得单独安排。留多久、怎么过期、谁来触发,都得在设计阶段定下来。
补一句场景判断。删除只适合"明确要忘"的场合。
如果你只是嫌历史太长,用裁剪或者摘要更合适。拿删除去凑长度,等于把要用的东西一起扔了。
七、第三招 摘要:保语义,不保原文
前两招都在丢信息。摘要不一样,它把老内容压成一段话,语义留着。
官方给了现成的中间件,挂上去就行。
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 领取。