上周有个朋友跑过来跟我吐槽,说他搭的ClaudeAgent聊到第40轮的时候,把用户早上刚说过的一句话忘得干干净净。用户问“我之前是不是明确说过不用邮件通知”,Agent特别诚恳地回答“没有哦”。这个场景我太熟了——所有做LLM Agent的人,迟早都会撞上同一堵墙:上下文窗口是有限的,但对话是无限的。这篇就聊我在从0到1构建ClaudeAgent时,怎么搞定“内存管理”里最核心的一环:上下文压缩。
当时我刚开始做Agent内存管理的时候,搜了一圈资料,发现大家都在说“要压缩上下文”,但真正讲清楚“什么时候压、压掉什么、压完怎么接回去”的文章少之又少。这篇我尽量把整个决策过程和实现细节摊开讲,适合正在用Claude API接Agent、被上下文窗口和token成本折磨的开发者,也适合想从原理上理解LLM“记忆”机制的人。
1. 构建Agent之前,先想清楚“记忆”到底是怎么运作的
1.1 大模型本身没有记忆,上下文窗口就是唯一的内存
很多第一次做Agent的人都会有个错觉:既然Claude能记住我之前说的话,那它是不是天生就有“记忆功能”?不是的。大模型本质上是个无状态函数,你每次调用API,它看到的只有你这次请求里塞进去的那一串文本。它之所以看起来“记得”之前的事,完全是因为你每次都在请求里附带了全部历史消息。
所以Agent的“内存”,说白了就是请求里的上下文窗口。窗口有多大,它就能记住多少;窗口塞满了,最早的对话就会被挤掉。这和传统编程里的内存管理完全是两码事。写C的时候你关心malloc和free,写Java你关心GC和引用泄漏,但搞LLM Agent,你真正要管的是另一件事:哪些话值得继续留在上下文里,哪些话该被“换一种更省地方的方式”存着。
我一开始也犯过傻,想着“上下文窗口不是有200K吗,随便聊呗”。结果做了两轮真实场景测试就被打脸了。
1.2 不压缩的代价,比你想的严重得多
先说最直接的成本账。假设一个Agent平均每轮要带3000 token的上下文去做工具调用和思考,聊天聊到50轮,单次请求就接近15万token。按照官方API价格粗算,一次请求的成本就已经高得让人肉疼,如果Agent是多用户并发跑,后台账单基本就是在燃烧。注意,这个费用是“每次请求都要付”的,不是一次性。
再说质量。上下文越长,模型对早期信息的注意力就越容易被稀释。我做过实验:给一个Agent塞了2万token的历史记录之后,它在第一轮就收到的系统指令,到后面偶尔会执行走样,因为它“看不过来”了。这就像你开一个超长的会议纪要,重点被淹没了,真正关键的决策反而没人记得。
还有个经常被忽略的问题是延迟。token越多,API响应越慢,用户等的时间越长。如果你做的是交互型Agent,多等两三秒的感觉完全是两回事。
最后就是最要命的:一旦历史超出窗口上限,API直接报错,对话当场断掉。所以上下文压缩不是一个“优化项”,而是一个Agent能长期稳定跑下去的必要前提。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 上下文压缩的方案选型:别一上来就写“总结代码”
2.1 三种主流方案:截断、摘要、结构化提取
真的要动手做压缩的时候,你会发现市面上能走的路大概有三条。
第一条是滑动窗口截断,超过窗口就删最早的对话,简单粗暴,实现成本最低,但信息丢失完全不可控。用户最早说过的关键偏好可能就这么没了。
第二条是用LLM做摘要压缩,把旧对话丢给模型,让它总结成一段浓缩文本拼回去。这个方案信息密度高,能保留大致脉络,缺点是细节容易丢,而且模型还可能在摘要里“脑补”出原文没有的内容。
第三条是结构化提取,也就是从对话里把用户偏好、任务状态、约束条件这类高价值信息抽出来,存成JSON字段。这东西机器可读、定位精准,但需要你提前设计好数据格式,而且只靠它的话,对话的连贯性会差一些。
说实话,我一开始在一个内部项目里只用截断方案,毕竟五分钟就能实现。跑了一周,用户反馈最多的问题就是“Agent怎么连这个都忘了”。后来换成纯摘要方案,好了一些,但模型偶尔会在摘要里梦游,把A客户的需求总结成B客户的。真正让我稳定下来的是混合方案。
| 方案 | 实现成本 | 信息保留能力 | 风险点 | 适用场景 |
|---|---|---|---|---|
| 滑动窗口截断 | 极低 | 差,不可控 | 关键信息丢失 | 低要求短会话 |
| LLM摘要压缩 | 中等 | 中高,有幻觉风险 | 细节丢失、脑补 | 长对话、通用场景 |
| 结构化提取 | 较高 | 高,按需保留 | 需要设计schema | 强约束、多偏好场景 |
2.2 我的最终选择:保留窗口 + 分层摘要 + 关键信息快照
折腾了一轮之后,我把方案定成了三层结构。
第一层是“最近窗口”。最近N轮对话的原文完整保留,这保证了Agent短期内对话的连贯性,比如“刚才你说要查的订单号是多少”“上一步我算出的结果是什么”,这些细节必须原样在。
第二层是“分层摘要”。超出窗口的旧对话,我会让模型压成一段摘要。但这个摘要不是“越短越好”,而是要有层次。我在压缩时会把摘要分成几个维度:对话背景、讨论过的话题、达成的结论、遗留问题。每类信息各占一个小节,后续拼接上下文时,Agent能在摘要里快速定位它需要的部分。
第三层是“关键信息快照”。这是我最想强调的一层。摘要再怎么优化,本质上是“整体概括”,它天然不适合保存“用户明确说不要发邮件”“这个客户预算上限5万”这类离散的硬约束。所以我会从每次用户消息里单独抽取这些信息,存成结构化的JSON快照。每轮对话都做一次增量抽取,快照只增不减,永远不会被窗口挤掉。
这三层合在一起,相当于同时模拟了人的短期记忆和长期记忆。短期记忆用最近窗口承担,长期记忆用摘要加快照承担。这样做成本上可控,信息保留也靠谱得多。
2.3 压缩触发阈值怎么定才合理
方案定了,下一个问题是什么时候触发压缩。我试过两种方式,一种是按轮数触发,比如每10轮压一次;另一种是按token量触发。强烈建议用token量,因为对话轮数完全不可靠——用户可能一句话就写了2000字,也可能只回了个“好的”。
实际操作中,我会给整个上下文设置一个安全水位。比如以Claude模型200K窗口为例,我设的触发线是150K。也就是当历史消息累计预估超过15万token时,就触发一次压缩。压缩的时候保留最近30K token的原文,再往前的全部走摘要化处理。这么做有两个原因:一是窗口不能等到100%满了再动手,API还需要留给max_tokens,也就是模型回复的输出空间;二是留出buffer,避免一次压缩刚完,下一轮对话又立刻触发第二次压缩的抖动。
token估算我推荐两种方式。如果能拿到API返回的usage字段,直接用官方count就行,最准。如果需要在请求前做判断,就用本地的tokenizer离线估算。英文大概是1个token对应4个字符,中文的话1个token大约能覆盖0.6到1个汉字。虽然估算值有误差,但拿来做触发判断完全够用了,因为阈值本身就不需要非常精确。
3. 从0到1落地实现:一个可运行的上下文压缩模块
3.1 模块结构设计:把记忆管理和Agent主流程解耦
写代码之前我想先强调一个设计原则:记忆管理一定要从Agent主流程里独立出来,做成单独模块。我当时看到很多项目的做法是直接在Agent循环里面写死一坨if-else流程,每次凑上下文都去翻历史列表,后面想调参数、想加缓存,都是一场噩梦。
比较好的做法是抽出一个MemoryManager类,它对外只暴露几个简单方法:接收新消息、判断是否压缩、执行压缩、组装请求上下文。Agent主流程根本不需要关心底层到底怎么压缩、什么时候压缩,它只需要在每次请求前拿组装好的messages就行了。这样一拆,后面无论是换压缩策略还是加数据库持久化,改动影响面都很小。
python复制import json
from typing import Dict, List, Optional
class MemoryManager:
def __init__(
self,
system_prompt: str,
max_trigger_tokens: int = 150_000,
keep_recent_tokens: int = 30_000,
):
self.system_prompt = system_prompt
self.history: List[Dict[str, str]] = []
self.summary: str = ""
self.snapshot: Dict[str, List[str]] = {
"preferences": [],
"constraints": [],
"tasks_todo": [],
"facts": [],
}
self.max_trigger_tokens = max_trigger_tokens
self.keep_recent_tokens = keep_recent_tokens
def add_message(self, role: str, content: str) -> None:
if role == "user":
self._extract_snapshot(content)
self.history.append({"role": role, "content": content})
if self._estimate_tokens(self.history) > self.max_trigger_tokens:
self._compress()
def build_context(self) -> List[Dict[str, str]]:
messages = [{"role": "system", "content": self.system_prompt}]
if self.summary:
messages.append({
"role": "system",
"content": f"以下是之前对话的摘要记忆:\n{self.summary}",
})
if self._snapshot_to_text():
messages.append({
"role": "system",
"content": f"以下是用户的关键信息快照:\n{self._snapshot_to_text()}",
})
messages.extend(self.history)
return messages
这个类一开始只需要维护h历史列表,等历史token数超过阈值后自动触发压缩。我把摘要和快照都放在system消息里,这样模型会把这些信息当作背景指令来遵循,而不是当成普通的对话记录,遵循度会高很多。
3.2 压缩器实现:把旧对话变成“有层次的摘要”
压缩这步是最核心的,也是最容易写歪的。很多人的压缩prompt就一句话“总结一下之前的对话”,结果模型一顿发挥,该留的没留,不该留的写了一堆。我后来把压缩prompt分成了几个区块,明确告诉模型“你是在为未来的自己写记忆,不是在写聊天纪要”。
我用的压缩prompt模板大致长这样:
text复制你负责压缩一段Agent与用户的对话记录。压缩结果将作为Agent后续对话的长期记忆,所以请务必做到:
1. 分维度输出摘要,包含以下区块:
- 对话背景与话题:这段对话围绕什么展开,涉及哪些领域
- 已达成结论:用户和Agent确认过什么、最终选了什么方案
- 遗留问题与待办:还有哪些没做完的事、需要后续跟进的点
- 用户明确表达过的偏好或禁止事项
2. 保留所有可核实的硬信息:人名、日期、数字、金额、订单号、邮箱、地址等。
3. 不要添加原文中不存在的推测或结论。
4. 输出简洁的中文摘要,不要客套话,不要自我介绍。
实际调用的时候,我会把旧对话的JSON数组直接拼进prompt,然后让Claude生成摘要。这里有个关键点:压缩用的模型可以不用Agent主模型。我当时为了控制成本,用的是同一个API下速度更快、价格更低的模型,因为压缩摘要这件事不需要超强推理,只要忠实度高就行。如果你手头有多家模型可选,用一个轻量级模型专门做压缩,把重活留给主模型,这是很划算的买卖。
每次压缩完,新摘要还要和旧摘要合并。因为整个生命周期里可能压了好几次,如果每次只留最新摘要,最早的信息会被层层“传话”传没。所以我会把新生成的摘要和之前的summary重新拼在一起,再让模型合并成一份。这个步骤的prompt我会加上一句“如果新旧摘要中有信息冲突,以新对话为准”,避免模型在合并时自己编一套历史。
python复制def _summarize(self, messages: List[Dict[str, str]]) -> str:
raw_history = "\n".join(
f"{m['role']}: {m['content']}" for m in messages
)
comp_prompt = f"""...</s>
其实编译实现的时候,我会把这部分逻辑再拆开,如果调用量大了,摘要动作本身也要做并发控制,避免多个请求同时触发压缩导致重复扣费。这块我后面会详细说。
3.3 关键信息快照:让Agent真正“记住”用户说过的话
摘要只能保留“大概的脉络”,但对话里有些信息是“一个都不能丢”的。比如用户说“我只用企业微信,不要用邮件给我发任何东西”,这句如果被摘要概括成“用户对通知方式有偏好”,Agent再往后基本就忽略这条了。所以快照要做的,就是把这类信息单独拎出来归置好。
我抽取快照的实现思路也很直接,每次用户发来新消息,我就把消息内容和一个固定的抽取prompt一起发给模型,要求它返回严格JSON。返回的JSON里分成preferences(偏好)、constraints(禁止事项/约束)、tasks_todo(待办任务)、facts(事实信息)四类。返回后直接合并进snapshot字典。对于偏好和约束,我做了个简单的去重,如果新抽取的内容已经出现过,就不重复添加。
python复制def _extract_snapshot(self, content: str) -> None:
extract_prompt = f"""从用户消息中抽取结构化记忆,只输出JSON格式:
{{
"preferences": ["用户偏好,如'不用邮件通知'"],
"constraints": ["用户约束,如'所有方案必须在预算内'"],
"tasks_todo": ["未完成任务,如'下周给出两个备选方案'"],
"facts": ["客观事实,如'月预算5万'"]
}}
如果没有某类信息,就返回空数组。
用户消息:{content}
"""
这一层其实是在模拟人的“印象深刻的事”。你回想一段对话,不可能记住每句话,但用户重复强调过的事、你自己答应过的事,会变成长久记忆。快照就是Agent的“长久记忆”。因为是结构化存储,后续想把快照存进数据库、跨对话复用,也非常方便。我当时做到后面甚至给快照加了一层优先级,偏好和禁止项优先级最高,普通事实优先级低,这样如果上下文真的紧张到只能放一部分快照,也知道先保留哪些。
3.4 把压缩模块接入Agent的主调用链
模块写完就要接进主流程了。这里我给一个完整的示例,你就能看到整个链路是怎么走的。主循环的每一步:接收用户输入、更新历史、自动压缩、组装上下文、调用API、拿结果、再写回历史。对Agent主流程来说,只会接触add_message和build_context两个方法,逻辑非常清爽。
python复制from anthropic import Anthropic
client = Anthropic(api_key="your-api-key")
mm = MemoryManager(system_prompt="你是一个业务助理Agent,请根据上下文回答用户问题。")
while True:
user_input = input("你: ")
if user_input.strip().lower() in {"exit", "quit"}:
break
mm.add_message("user", user_input)
messages = mm.build_context()
resp = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
messages=messages,
)
assistant_reply = resp.content[0].text
print(f"Agent: {assistant_reply}")
mm.add_message("assistant", assistant_reply)
跑起来之后你会发现,当对话比较短的时候,和普通Agent没什么区别;等聊到几十轮,历史开始逼近阈值,MemoryManager会自动做压缩。压缩后我拿到的usage对比非常直观:不压缩时一波请求峰值可能在十几万token,压缩之后稳定在一个相对低的水位,而且响应速度快了不少。这层“无感知自动压缩”一旦跑通,Agent的长期稳定运行才算是有了保障。
4. 实测中踩过的坑、排查技巧与避坑清单
4.1 摘要压缩后Agent“失忆”的高发场景
我实测过一段时间,总结了三个最容易出问题的场景,都是血泪教训。
第一个是用户明确的约束被摘要“柔化”。比如用户说“绝对不要在晚上九点后发任何消息”,摘要出来可能变成“用户对发送时间有一定要求”。这两句话对Agent后续行为的指导力度完全不是一个量级。解决办法就是把这类内容单独塞进关键信息快照,而不是依赖摘要。
第二个是工具调用中间结果的丢失。我的Agent经常需要先查数据、再算结果、最后汇报,中间会有一堆工具结果。如果这些中间结果被压缩进摘要,后面Agent要么得重新调工具,要么就拿摘要里残缺的数据硬算,产出自然不靠谱。后来我把近几轮的工具输出也纳入“保留原文”的范围,保证计算链条不断裂。
第三个是“未完成事项”被摘要漏了。用户说“你先去看看方案A,我下午再问你”,下午他真来问了,结果Agent已经把这事忘了。所以我在压缩prompt里专门加了“遗留问题与待办”这个区块,而且快照里也维护tasks_todo。两边都守着,这个问题才算基本解决。
4.2 压缩本身带来的新成本和新问题
压缩不是银弹,它自己也会产生成本和问题。最典型的就是压缩动作本身也是一次API调用,如果你用一个中大型模型做压缩,每次压缩可能消耗几千token的输入和几百token的输出。对话越长,压缩触发次数越多,这笔钱会悄悄累积起来。我的做法是压缩专用模型和主模型分开,压缩用更便宜更快的模型,能省下不少。
另一个问题是“压缩风暴”。如果你触发阈值设得太高,或者压缩后保留的原文太多,可能会导致连续几次请求都触发压缩,成本瞬间翻倍。我当时设定的策略是压缩后保留30K token,这样一次压缩至少能给后面的对话留出很大空间,不会出现压完一轮马上又压的情况。如果对话实在太长,中间层摘要已经膨胀到一定程度,我还会对摘要做一次“递归压缩”,也就是对旧摘要再压一轮,只保留最高层级的结论。这招偶尔用可以,别频繁用,因为层级太多之后信息损耗会很严重。
4.3 排查技巧:如何验证压缩后的上下文质量
上下文压缩最大的难点在于“你怎么知道哪些信息丢了”。我的习惯是维护一个“关键问题回归集”。所谓回归集,就是我从真实对话里摘出来的几个必须答对的硬问题,比如“用户的月预算是多少”“用户禁止用什么方式联系”“目前待办任务是哪几项”。每次调整压缩策略后,我都会拿压缩后的上下文去问这些硬问题,看Agent能不能答对。
这其实和小型QA评估差不多,不需要什么复杂框架,就是构造几次API调用跑一遍,准确率一目了然。如果某个问题答错了,你再回去看压缩文本,基本立刻就能定位是摘要丢失还是快照没有抽取到,然后针对性修prompt。我还建议把每次压缩前的原文日志留一份,出了线上问题可以回溯整个“压缩链”,看信息是在哪一步变形的。
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 压缩后忘记用户偏好 | 摘要概括过度,快照未命中 | 检查摘要prompt,检查抽取逻辑 |
| 工具调用结果对不上 | 中间结果被压缩成摘要 | 将工具输出纳入保留窗口 |
| 待办任务反复丢 | 摘要未强调遗留问题 | 压缩prompt增加待办区块 |
| 压缩后回复质量下降 | 摘要幻觉、合并冲突 | 对比原文日志,检查合并prompt |
| 连续触发多次压缩 | 触发阈值/保留窗口过小 | 调高触发线,加大保留量 |
这里面最花时间的其实是摘要prompt的迭代,因为丢失语义不像报错那样明显,只能靠回归集一遍遍观察。我当时前后改了至少五版压缩prompt,才让那些硬问题的通过率稳定在一个满意的水平。一旦你的回归集跑顺了,后面换模型、调窗口参数都有了客观参照,心里会踏实很多。
5. 后续还能怎么扩展:从单次会话走向长期记忆
上下文压缩解决的是“单次会话流水太长”的问题,但Agent的长期记忆远不止这么简单。我现在的做法是把整个MemoryManager分层落地后,又把摘要和快照持久化到了本地数据库,这样Agent重新启动、或者跨天继续聊,都能把上一次的摘要和快照加载回来,相当于记忆没有中断。这对做客服Agent、私人助理这类需要长期跟随用户的场景特别有用。
再往后走,摘要这种整体概括的方式也有它的天花板。当记忆跨了非常久、跨越了不同主题,你可能需要把摘要拆成多条条目,配合向量检索的方式,按相关性召回对应的记忆片段。这个思路其实已经超出了“上下文压缩”的范畴,进入“记忆检索”的领域了。不过它和压缩并不冲突,压缩照样负责短期会话内的信息收敛,检索负责长期记忆的命中。先能在上下文压缩这一层把Agent做稳,后面叠加长期记忆时才会顺手。
我做这个Agent项目时还有一个体会:不要一上来就追求“完美的记忆”。用户的需求会变,对话的形态会变,压缩策略也要跟着调。最怕的是写了特别复杂的一套记忆逻辑,结果跑了两天发现方案基于的错误假设。先把简单的方案跑起来,用真实对话去暴露问题,再迭代。这也是我为什么一开始坚持把MemoryManager拆成独立模块——改动多久都敢,因为流量是封闭的,坏了只影响记忆这部分,不会拖着整个Agent下水。
