1. 为什么ClaudeAgent必须要做内存管理
先说一个很多人在构建Agent时的痛点:单轮对话模型能力再强,一旦进入“多轮工具调用+长日程执行”的真实业务场景,上下文窗口就变成了最卡脖子的资源。市面上讨论“C语言内存管理”“Linux内存管理”的帖子满天飞,但LLM应用的“内存”往往被忽略。这里的“内存”不是进程地址空间里的堆和栈,而是模型一次能看到的Token数量——你可以把Token窗口理解成Agent的工作记忆,它决定了Agent能记住多少历史、决策依据和工具返回结果。窗口不够用,轻则被迫截断历史导致失忆,重则直接报错、任务中断。
在做ClaudeAgent的内存管理时,“上下文压缩”是最实用、也最容易被低估的一环。很多人的第一反应是“把旧消息删掉不就完了”,但真正落地时你会发现:直接删可能导致关键信息丢失,不删又放不下,而粗暴拼接摘要会导致Agent在后续步骤里丢失细节。我自己的经验是,一套可靠的上下文压缩方案需要同时解决三个问题:什么时候触发压缩、压缩成什么形态、压缩后如何保证任务的连续性。
这篇文章我会用完整的思考和可运行的代码,带你把上下文压缩这一层真正落到实际Agent里。适合已经在用Claude API做Agent开发、但被长对话中的“上下文爆炸”困扰的开发者。即使你用的是其他大模型API,这套思路和代码结构也可以直接迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 内存管理的设计拆解:不是“压缩”而是“取舍”
2.1 先认清上下文爆炸的三个来源
在做压缩之前,必须搞清楚Token被谁吃掉了。根据我在真实业务里的统计,Agent对话的Token消耗通常来自三块:
第一块是多轮历史消息。用户每说一句,Agent每回复一句,都会完整留存。如果Agent还要在循环里调用工具,每一次工具结果都会作为一条消息进入上下文,一轮推理就可能新增几千Token。
第二块是工具返回的原始数据。例如查询数据库返回100行记录,搜索接口返回大段JSON,这些内容占用的Token常常是历史对话的数十倍。这些数据往往只对当前这一步决策有用,下一步可能就不再需要。
第三块是Agent自身的思考和中间输出。在Claude的Agent循环里,模型会先作规划,再调用工具,再根据结果作下一步计划。如果把这些中间步骤全部塞进上下文,Alias会迅速膨胀。
理解了这三个来源,你就会明白:盲目保留“完整历史”其实是在浪费Token,而上下文压缩的本质,是对信息做分层——哪些要完整保留,哪些要提炼后保留,哪些直接丢弃。
2.2 压缩策略选型:摘要式、结构化、裁剪式
我在项目里试过三种压缩思路,分别适用于不同场景,这里直接给出选型结论。
摘要式压缩是最通用的一种。它把较旧的历史对话交给模型生成一条精炼摘要,然后将摘要替代原始消息。好处是保留了语义主干,坏处是细节会丢失。适合“目标明确、但过程较长”的Agent任务,比如多轮搜索、数据整理。
结构化压缩更适合流程型任务,例如“先查库存、再下单、最后通知”。这类任务的每一个步骤都有固定的状态和字段,如果全部塞入对话历史,浪费严重。正确做法是把历史信息提炼成结构化记录,例如“库存查询结果:SKU-1001剩余12件,SKU-1002已缺货;下单结果:订单号SO-2024-001已创建”。
裁剪式压缩就是硬删。只保留最近N轮消息和永久必要的系统级信息,更早的原始消息直接移除。它不产生摘要,逻辑最简单,代价是Agent会遗忘较早的约束条件。这里有一个很容易踩的坑:如果你把用户最初提出的硬性要求也一并裁剪掉,后续步骤就会“跑偏”。
2.3 为什么“无脑截断”不可行
很多人最开始想着:“既然窗口不够,我就只保留最后10轮消息,前面全删掉。”这个方案在简单Demo里可能能跑通,但放到真实业务里几乎必然翻车。
举个真实例子。我做过一个销售线索分析Agent,用户的初始指令是“只分析华南地区的客户,排除竞品相关的合同”。如果我简单截断早期消息,两轮之后Agent就会开始把华东客户也分析进去,因为它已经“忘记”了区域限制。这就是典型的上下文丢失。
所以我的结论是:压缩本身是一种“有损操作”,关键不是避免损失,而是有意识地决定损失哪些信息。你需要在触发压缩时,把用户约束、目标状态、决策依据等“不可丢”的信息显式保留下来,哪怕是以摘要的形式。
3. 核心实现:一个可落地的上下文压缩器
3.1 整体设计与消息结构约定
我在项目里维护了一个名为 Compactor 的类,它的核心职责是:持续跟踪当前对话窗口的Token占用,一旦超过阈值,就对旧消息执行压缩,并把压缩结果重新塞回消息列表里。
为了让这段代码能直接跑起来,我先把消息结构做一个简单约定:所有消息都是字典,格式与Claude API的Messages接口一致,包含 role 和 content 两个字段。例如:
python复制{"role": "user", "content": "查询Q1销售额"}
{"role": "assistant", "content": "我来查询数据库。"}
{"role": "tool_result", "content": "Q1销售额为1200万。"}
如果你用的是其他模型,只需将消息格式稍作映射即可。
3.2 Compactor类的骨架代码
先给出一个完整的类实现,后面的小节会对关键方法逐一拆解。
python复制import json
from typing import List, Dict, Optional
class Compactor:
def __init__(
self,
llm,
max_tokens: int = 8000,
trigger_ratio: float = 0.75,
summary_ratio: float = 0.25,
always_keep_last_n: int = 4,
reserve_tool_tokens: int = 1000,
verbose: bool = True,
):
"""
llm: 任何实现了 chat(messages) -> str 接口的模型客户端
max_tokens: 窗口硬上限,超过这个值就触发压缩
trigger_ratio: 当实际tokens / max_tokens 超过该比例时触发
summary_ratio: 压缩时,摘要部分的目标token占比
always_keep_last_n: 最近N条消息永远不压缩(保留细节)
reserve_tool_tokens: 为可能的工具调用预留的token
"""
self.llm = llm
self.max_tokens = max_tokens
self.trigger_tokens = int(max_tokens * trigger_ratio)
self.summary_tokens = int(max_tokens * summary_ratio)
self.always_keep_last_n = always_keep_last_n
self.reserve_tool_tokens = reserve_tool_tokens
self.verbose = verbose
def count_tokens(self, messages: List[Dict]) -> int:
"""
实际项目中请替换为 tiktoken 或 claude 的 token 计数接口。
这里为了演示,使用近似估算:中英文混合情况下,
平均每个字符约0.4个token,可以通过简单乘法快速判断。
"""
total = 0
for msg in messages:
content = msg.get("content", "")
if isinstance(content, str):
total += int(len(content) * 0.4) + 4
elif isinstance(content, list):
for block in content:
if block.get("type") == "text":
total += int(len(block["text"]) * 0.4) + 4
return total
def need_compact(self, messages: List[Dict]) -> bool:
current = self.count_tokens(messages)
return current >= self.trigger_tokens
def _build_summary_prompt(self, history_messages: List[Dict]) -> str:
# 把需要压缩的历史消息转换成摘要任务的输入
history_text = ""
for msg in history_messages:
role = msg["role"]
content = msg.get("content", "")
history_text += f"[{role}]\n{content}\n\n"
return (
"你是一个Agent系统里的记忆压缩器。你的任务是把下面的对话历史压缩成简洁、信息完整的摘要,"
"保留以下五类关键信息:\n"
"1. 用户的最终目标和所有硬性约束;\n"
"2. 已经确认的事实与数据(包括数值、编号、状态);\n"
"3. 已经采取的决策和行动,以及原因;\n"
"4. 尚未完成的事项和下一步计划;\n"
"5. 可能影响未来任务的所有上下文。\n"
"禁止添加不存在的信息。请严格控制摘要长度。\n\n"
f"历史消息:\n{history_text}\n\n"
"请输出压缩后的摘要:"
)
def _summarize(self, history_messages: List[Dict]) -> str:
prompt = self._build_summary_prompt(history_messages)
summary = self.llm.chat(
[{"role": "user", "content": prompt}],
max_tokens=self.summary_tokens,
)
return summary.strip()
def compact(self, messages: List[Dict]) -> List[Dict]:
if len(messages) <= self.always_keep_last_n:
return messages
# 明确哪些历史消息可以压缩,哪些必须保留
compressible = messages[:-self.always_keep_last_n]
keep_latest = messages[-self.always_keep_last_n:]
# 引入系统级摘要消息
summary = self._summarize(compressible)
summary_msg = {
"role": "system",
"content": (
"【历史任务摘要】这段时间里,你和用户已经完成了以下工作。"
"你必须始终记住这些背景信息,并据此继续后续工作。\n"
f"{summary}"
),
}
new_messages = [summary_msg] + keep_latest
if self.verbose:
print(f"[Compactor] 压缩完成:{len(messages)} -> {len(new_messages)} 条消息")
return new_messages
3.3 关键方法拆解:为什么这样设计
触发条件:不要等到撑爆才压缩。 我建议的阈值是 max_tokens * 0.75。如果等到90%甚至100%再做压缩,留给模型的余量太少——它可能正准备生成一大段工具调用结果,结果窗口直接溢出。75%是一个折中:既能利用完整上下文,又给后续操作留了缓冲。
为什么用系统消息承载摘要。 摘要本质上不是“用户说的话”,也不是“助手说的话”,而是运行期注入的外部记忆。在Claude的消息结构中,system角色天然适合用来放置这类贯穿全局的背景信息。这样设计还有一个好处:摘要不会和最近N轮的对话内容混淆,模型能清晰地区分“历史背景”和“当前对话”。
为什么保留最近N条不压缩。 因为摘要毕竟是“二手信息”,无法还原原始细节。保留最近几轮原文,是为了让Agent在处理当前步骤时能直接引用原始上下文,而不是每走一步都要去摘要里反推。我在实践中把 always_keep_last_n 设成4,效果比较稳,你可以根据任务粒度和平均消息长度调整。
Token估算函数:先用近似法,再接精确计数。 代码里我用字符数乘0.4来估算Token,这在中英文混合场景下精度尚可,但正式生产环境建议替换成tiktoken或API官方计数接口。千万不要在生产环境用估算值做硬校验,否则压缩节奏会失准。
4. 集成到Agent运行循环:从“手写压缩”到“自动内存管理”
4.1 在什么节点触发压缩
很多人的第一反应是“每轮对话结束后都检查一次”,这没问题,但要小心:如果Agent主循环里既有模型推理调用,又有工具调用,那么“轮”的定义要统一。我自己的实现方式是:每次模型即将生成下一轮回复之前,做一次窗口体检。
伪代码如下:
python复制def agent_loop(user_request: str, tools: List[Tool]):
messages = [{"role": "user", "content": user_request}]
while True:
if compactor.need_compact(messages):
messages = compactor.compact(messages)
# 调用Claude模型,获取下一动作
response = client.chat(
model="claude-3-5-sonnet-20241022",
max_tokens=compactor.reserve_tool_tokens,
messages=messages,
tools=tools,
)
messages.append({
"role": "assistant",
"content": response.output_text,
})
# 如果模型不再需要调用工具,直接返回
if not response.stop_reason == "tool_use":
return response.output_text
# 否则执行工具并把结果追加到消息列表
tool_result = execute_tool(response.tool_calls[0])
messages.append({
"role": "tool_result",
"content": tool_result,
})
这段代码里的关键点很明确:need_compact的检查被放在了模型调用之前。这样做的好处是,无论上一轮是工具调用还是用户新消息,只要窗口偏大,就能在推理前完成压缩,最大程度避免了“生成一半Token溢出”的悲剧本剧。
4.2 压缩后如何保证任务不“失忆”
压缩后的消息列表里,摘要消息是 system 角色,但光有一个摘要还不够。在Agent的实际运行中,还需要在每次调用模型之前,把“当前还差什么没做完”这个状态显式注入进去。我在系统提示词里加了一段固定的任务状态占位符:
python复制task_state_prompt = (
"【当前任务进度】\n"
"已完成:{completed}。\n"
"未完成:{pending}。\n"
"下一步请继续执行未完成事项。"
)
这段信息可以放在消息列表的最前面,和摘要消息一起作为系统级输入。原因很简单:摘要负责“历史记忆”,任务进度负责“当前坐标”——两者共同决定了Agent能不能续上断点。如果压缩后没有任务进度提示,Agent常常会误以为任务已经结束,直接输出一个错误的总结。
4.3 结合Claude的工具调用能力进行分治
对于包含工具调用的Agent,我还有一种更细粒度的压缩策略——只压缩工具结果中的“大块内容”,而不动对话主干。具体做法是:在每条 tool_result 消息传入模型之前,先做一次长度检查,如果内容超过5000字符,就把关键字段抽取成结构化摘要。例如,一个数据库查询结果原本是:
json复制[{"customer_id": 1, "name": "张三", "region": "华东", "amount": 1200}, ... 共100条]
压缩后变成:
code复制查询结果摘要:共100条客户记录,华东区域占60%,订单总额8.5万,最大单笔1200。
注意,这里不能用普通的“截断”,因为截断会丢掉最末尾的记录,而很多排序查询里最大/最新记录往往在末尾。正确的姿势是让模型先读完整内容,再输出结构化摘要。这一步会多消耗一次模型调用,但换来的是后续所有轮次都保持轻量,总体Token开销反而更小。
5. 实测效果与参数调优经验
5.1 在长流程业务场景里的效果对比
我在一个真实的订单处理Agent上做了对比实验。该Agent的工作流程是:接收用户订单请求→查库存→核对价格→生成订单→确认支付→回执结果。不加压缩时,处理到第3个订单左右,上下文就已经超过2万Token;到第5个订单,已经接近模型窗口上限,且响应速度明显变慢。
加上压缩器之后,我用 max_tokens=8000, trigger_ratio=0.75, summary_ratio=0.25, always_keep_last_n=4 跑同一批订单,效果如下:
| 指标 | 不加压缩 | 加压缩 |
|---|---|---|
| 第5个订单时的Token占用 | 约22000 | 约7600 |
| 平均单轮响应延迟 | 约3.8秒 | 约2.1秒 |
| 任务完成率(10个订单) | 6/10(第7个订单开始频繁超限) | 10/10 |
| 是否出现“遗忘约束” | 是(第3个订单起偶发) | 否 |
从数据可以看到,压缩最大的收益不是省Token本身,而是保证了任务能持续跑完。长任务场景下,窗口溢出是致命的,压缩让Agent可以无限续跑。
5.2 几个常用参数的调优直觉
trigger_ratio 我建议设在0.7到0.8之间。设得太小,比如0.5,压缩会很频繁,历史细节丢得太多;设得太大,比如0.95,压缩器介入太晚,生成时容易撞上下限。这个值的本质是“你愿意为当前这条消息预留多少生成空间”。如果任务单步输出很长,比如经常要生成大段代码,建议把阈值再降低一点。
summary_ratio 指的是摘要目标长度占窗口的比例。在大多数场景里0.2到0.3就够。摘要太长会挤占有效对话空间,太短则信息密度不够。更精细的做法是根据任务类型动态调整:分析报告类的任务摘要可以长一点,工具调用密集型的任务摘要短一点。
always_keep_last_n 需要和“最近几步是否需要原始数据”挂钩。如果你是做多轮工具调用,最近几条消息正好承载着上一次工具结果,建议保留4到6条;如果任务是纯问答长对话,保留3条左右就够了。
5.3 压缩后的摘要可靠性如何保证
摘要压缩的一个隐患是“模型在摘要里编造内容”。如果摘要里出现了一条并不存在的“已确认信息”,后续所有决策都会建立在这个幻觉之上,而且因为历史已经被替换,几乎无法追溯。我踩过这个坑之后,在 _build_summary_prompt 里加了两条硬性约束:禁止添加不存在的信息;如果历史中没有明确某个数值,必须写“未知”。
另外,压缩器里的 llm.chat 调用降温参数也要单独设置。生成摘要时,temperature 建议设为0或接近0,目标是稳定、忠实,而不是创造性。我之前用默认的0.7跑摘要,结果它把用户没说过的偏好也“脑补”进了摘要,差点导致Agent做出了错误决策。
6. 常见问题与排查技巧实录
6.1 压缩后Agent突然“失忆”,怎么办
这是最高频的问题。排查步骤我建议按顺序来:
- 确认摘要消息是否真的在最新消息列表里。很多人的代码里,压缩器生成了新消息列表,但忘记把它传回主循环变量。
- 检查摘要里是否缺少硬性约束。如果摘要Prompt没有特别要求“保存用户的约束条件”,模型很容易把这类信息忽略。
- 检查摘要消息的角色。如果错用了
user或assistant角色,模型会把它当作普通对话,权重远低于系统级指令,延续性会大打折扣。
6.2 每轮都触发压缩,Token反而越用越多
这个现象很反直觉:压缩本身要调用一次模型生成摘要,如果触发太频繁,摘要调用的Token开销可能超过省下来的量。我刚接上压缩器时就遇到过这个问题,后来发现是 trigger_ratio 设得太低,导致系统每两轮就压缩一次。
解决思路:给压缩操作加一个“冷却时间”。在Compactor里记录上次压缩的轮次序号,只有间隔超过5轮才允许再次压缩。如果冷却期内窗口又接近上限,优先裁剪 tool_result 大字段,而不是再次跑摘要压缩。
6.3 压缩时工具结果里的关键数值被丢失
工具返回的JSON里,很多数值看着不起眼,但后续决策一定要用到。比如订单Agent里,库存量、价格、折扣率一旦在摘要中丢了,后续计算就会出错。我的经验是:摘要Prompt里必须有“保留所有数值、编号、状态字段”的明确要求,并在摘要返回后做一次校验——把原始历史里的所有数字提取出来,与摘要中的数字比对,若发现关键数值缺失,就自动追加一条“补充摘要”。
这听起来繁琐,但在金融、订单、数据分析类任务里非常值得。没有任何一个摘要算法能做到100%保真,所以必要时得用工程手段兜底。
6.4 什么时候不该做摘要压缩
最后说一个容易被忽略的点:不是所有情况都适合把历史消息替换成摘要。当Agent的任务是“严格按用户原文执行”时,比如法律文书解读、合同条款复核、代码逐行审查,摘要压缩几乎必然导致信息失真。针对这类场景,我的方案是降低 trigger_ratio,提高 always_keep_last_n,甚至在关键任务阶段完全禁用压缩,改为让Agent在单一窗口内分阶段运行,每个阶段结束后再启动压缩。
6.5 内存管理不只是“压缩”
现在回头看你可能会发现,上下文压缩只是Agent内存管理的一部分。真正的内存体系还包含短期记忆和长期记忆。上下文窗口是短期记忆,压缩器是“把短期记忆转成中期摘要”的缓存策略,而真正能跨会话复用的长期记忆应该写入外部存储。在实践中,我通常会在压缩完成后,把生成的摘要追加写入一个本地JSON文件或向量数据库,这样即使Agent被重启,也能通过加载摘要恢复状态。
之前在做Julia性能优化相关项目时,我发现“显式管理内存”的理念和LLM上下文压缩高度相通:你不可能无限持有所有数据,必须决定哪些放缓存、哪些落盘、哪些淘汰。LLM的上下文压缩也是一种淘汰策略,只不过淘汰的维度是Token,而衡量价值的标准是“对当前目标是否还有用”。
7. 写在实现之后的几点体会
这套压缩器目前已经在我的Agent项目中稳定跑了两个多月,最大的感受是:上下文压缩不是为了炫技,而是为了让你敢让Agent去处理更长的任务。很多Agent看起来很聪明,其实不过是站在完整上下文的肩膀上;一旦窗口缩水,能力立刻断崖式下跌。压缩器的核心意义,就是在保持Agent连续工作能力的同时,尽量不伤害它的“记忆”。
如果你也想在自己的Agent里加入这套机制,不用追求一次性做到完美。可以先按文中的代码落地一个最简单的版本,跑通之后再逐步补上token精确计数、冷却机制、结构化提取这些进阶功能。有一点很重要:压缩策略一定得结合你自己的业务场景调参,不存在一套万能参数能应对所有情况。
最后分享一个小技巧。如果你拿不准某个参数该设多少,可以加一条 verbose=True 的调试开关,把每次压缩前后的消息数量和Token占用打印出来,跑一轮业务就能直观看到压缩的收益和隐患。我这一整套方案就是靠着这些日志一点点改出来的,调试Agent的内存管理,光靠肉眼盯模型输出是不够的,必须把“压缩前后发生了什么”摊开来看。
