Learn Claude Code Agent 开发 | 6、三层压缩策略:支持无限长会话的内存管理 1. 长会话为什么会把 Agent 撑爆从一次读文件说起先还原一个真实场景。你写了一个 Claude Code Agent让它帮忙重构一个中型项目。第一轮它读了agents/目录下 8 个 Python 文件每个文件平均 300 行按 4 字符 1 token 估算光这一轮就吃掉大约 24000 token。接着它跑了 15 条 shell 命令看目录结构、查依赖、跑测试每条命令的输出平均 800 token又是 12000 token。再让它读两个配置文件、一个 README上下文直接冲到 50000 token 以上。这时候问题来了主流模型的上下文窗口虽然有 100k 甚至 200k但你的 Agent 每轮都要把完整历史重新塞进去。读几个大文件、跑几十条命令token 上限就被突破模型开始报context_length_exceeded或者更隐蔽地——它开始忘事前面读过的文件内容被截断回答质量断崖式下跌。我试过最粗暴的做法每轮只保留最近 10 条消息更早的直接删。结果 Agent 忘了自己刚才改过哪个文件重复劳动甚至把已经修好的 bug 又改回去。硬截断的问题在于它丢掉的不只是冗余内容还有关键决策和当前状态。所以长会话内存管理的核心命题不是怎么删而是怎么策略性遗忘——把不再需要的冗余内容移出活跃上下文但保留关键信息并且完整历史永远可回溯。这就是三层压缩策略要解决的问题。它适合所有需要长时间运行的 Agent 场景大型项目重构、多轮调试、持续集成里的自动修复、需要跑几小时的代码审查任务。三层压缩的激进程度逐步递增第一层每轮静默执行零成本第二层超过 token 阈值自动触发调用 LLM 做摘要第三层由模型主动调用工具触发。三层解耦各自独立配置。下面我把每一层的触发条件、协作逻辑和可复制的配置完整拆开。2. 接入前的准备TaoToken 配置与 Claude Code 环境在写压缩逻辑之前得先让 Agent 能稳定调用模型。我用的是 TaoToken 的 API 接入它的 Base URL 和 Key 配置方式和 Anthropic 官方 SDK 兼容改一行base_url就能切换。这一步不做后面的压缩验证根本跑不起来。先说清楚要准备什么。你需要一个 API Key一个可用的模型 ID以及 Claude Code 或你自己的 Agent 框架。TaoToken 的 API 地址是https://taotoken.net/api注意这个地址不带任何查询参数直接作为base_url使用。Key 在控制台的 API Keys 页面生成格式是一串以sk-开头的字符串。如果你用的是 Claude Code CLI配置方式是在项目根目录或用户目录下创建 settings 文件。我实测下来最稳妥的是放在项目级.claude/settings.json这样不同项目可以用不同的 Key 和模型。文件内容如下{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的Key粘贴在这里, ANTHROPIC_MODEL: claude-sonnet-4-20250514 } }三个字段缺一不可。ANTHROPIC_BASE_URL指向 TaoToken 的 API 入口ANTHROPIC_API_KEY是你的密钥ANTHROPIC_MODEL指定模型 ID。很多人只配了前两个结果 Claude Code 用默认模型跑行为和预期不一致。模型 ID 要和你账号里可用的模型对齐写错了会报 404 或 model not found。如果你不用 Claude Code CLI而是自己写 Python Agent那就用 Anthropic SDK 初始化客户端from anthropic import Anthropic client Anthropic( base_urlhttps://taotoken.net/api, api_keysk-你的Key粘贴在这里, ) MODEL claude-sonnet-4-20250514这样client.messages.create()就会走 TaoToken 的通道。我建议把base_url、api_key、MODEL三个值抽成常量放在文件顶部后面压缩逻辑里反复用到改起来方便。还有一点容易踩坑Claude Code 的 OAuth 登录和 API Key 是两套机制。如果你之前用 OAuth 登录过环境变量可能不生效。这时候要么在 settings 里显式覆盖要么清掉旧的凭据缓存。我遇到过OAuth token expired和local proxy failed同时出现的情况最后发现是环境变量没被读取显式写进 settings.json 就解决了。配置完成后先别急着写压缩。用一条最简单的请求验证通道是否通resp client.messages.create( modelMODEL, max_tokens100, messages[{role: user, content: 回复 OK 两个字母}], ) print(resp.content[0].text)能打印出内容说明 Base URL、Key、Model ID 三件套都对了。这一步过了再进入压缩策略的实现。如果你只是想先验证模型对话是否正常可以直接用模型对话页面发一条消息测试不用写代码。3. 三层压缩的可复制配置阈值、占位符与摘要规则这一节是全文的核心我把三层压缩的完整配置和代码拆开讲。所有代码都可以直接复制到你的 Agent 项目里改一下路径和模型 ID 就能跑。先定义常量。这三个值决定了压缩的触发时机和保留策略from pathlib import Path import json import time WORKDIR Path.cwd() THRESHOLD 50000 # 自动压缩的 token 阈值 TRANSCRIPT_DIR WORKDIR / .transcripts # 完整对话转录本目录 KEEP_RECENT 3 # micro_compact 保留最近 3 个工具结果THRESHOLD 50000这个值不是随便定的。主流模型上下文窗口 100k 起步留一半余量给系统提示、工具定义和模型输出50000 是个安全线。如果你用的是 200k 窗口的模型可以调到 80000 到 100000但别贴着上限设否则摘要本身还没生成完就超了。KEEP_RECENT 3意味着最近 3 个工具调用的完整结果保留更早的替换成占位符。这个数字可以按任务调整读文件密集的任务可以调到 5命令密集的任务 3 就够。Token 估算用一个极简函数不引入 tiktoken 这种重依赖def estimate_tokens(messages: list) - int: 粗略估算约 4 字符 1 token return len(str(messages)) // 4这个估算不精确但用来判断是否触发阈值足够了而且性能极高每轮调用开销可以忽略。如果你追求精确可以换成 tiktoken但会拖慢每轮循环得不偿失。第一层 micro_compact 是最轻量的每轮静默执行用户无感知。它的逻辑是收集所有工具结果的位置保留最近 KEEP_RECENT 个更早的如果内容超过 100 字符就替换成[Previous: used {tool_name}]占位符。def micro_compact(messages: list) - list: tool_results [] for msg_idx, msg in enumerate(messages): if msg[role] user and isinstance(msg.get(content), list): for part_idx, part in enumerate(msg[content]): if isinstance(part, dict) and part.get(type) tool_result: tool_results.append((msg_idx, part_idx, part)) if len(tool_results) KEEP_RECENT: return messages # 建立 tool_use_id 到工具名的映射 tool_name_map {} for msg in messages: if msg[role] assistant: content msg.get(content, []) if isinstance(content, list): for block in content: if hasattr(block, type) and block.type tool_use: tool_name_map[block.id] block.name to_clear tool_results[:-KEEP_RECENT] for _, _, result in to_clear: if isinstance(result.get(content), str) and len(result[content]) 100: tool_id result.get(tool_use_id, ) tool_name tool_name_map.get(tool_id, unknown) result[content] f[Previous: used {tool_name}] return messages关键点在于占位符保留了工具名。模型看到[Previous: used read_file]就知道之前读过文件虽然不知道具体内容但知道这件事做过不会重复调用。这一层一轮就能减少几千到几万 token是性价比最高的压缩。第二层 auto_compact 在 token 超过阈值时触发。它做三件事先把完整对话存到磁盘再让 LLM 生成摘要最后把整个对话替换成两条消息。def auto_compact(messages: list) - list: TRANSCRIPT_DIR.mkdir(exist_okTrue) transcript_path TRANSCRIPT_DIR / ftranscript_{int(time.time())}.jsonl with open(transcript_path, w) as f: for msg in messages: f.write(json.dumps(msg, defaultstr) \n) print(f[transcript saved: {transcript_path}]) conversation_text json.dumps(messages, defaultstr)[:80000] response client.messages.create( modelMODEL, messages[{ role: user, content: Summarize this conversation for continuity. Include: 1) What was accomplished, 2) Current state, 3) Key decisions made. Be concise but preserve critical details.\n\n conversation_text }], max_tokens2000, ) summary response.content[0].text return [ {role: user, content: f[Conversation compressed. Transcript: {transcript_path}]\n\n{summary}}, {role: assistant, content: Understood. I have the context from the summary. Continuing.}, ]摘要提示词里明确要求包含三样东西已完成的工作、当前状态、关键决策。这三样是上下文连续性的命脉。少了任何一样Agent 恢复后都会迷失方向。conversation_text截断到 80000 字符防止摘要请求本身超限。压缩比通常能到 10:1 甚至更高几万 token 的对话变成几千 token 的摘要。第三层是 compact 工具让模型主动触发压缩。注册方式和普通工具一样TOOL_HANDLERS { compact: lambda **kw: Manual compression requested., } TOOLS [ { name: compact, description: Trigger manual conversation compression., input_schema: { type: object, properties: { focus: { type: string, description: What to preserve in the summary } } } }, ]模型可以在它觉得上下文快满的时候主动调用 compact还可以通过focus参数指定摘要要重点保留什么。比如它可以说保留所有关于数据库 schema 的决策摘要就会侧重那部分。三层整合到主循环执行顺序很关键def agent_loop(messages: list): while True: micro_compact(messages) # Layer 1 if estimate_tokens(messages) THRESHOLD: # Layer 2 print([auto_compact triggered]) messages[:] auto_compact(messages) response client.messages.create( modelMODEL, systemSYSTEM, messagesmessages, toolsTOOLS, max_tokens8000, ) messages.append({role: assistant, content: response.content}) if response.stop_reason ! tool_use: return results [] manual_compact False for block in response.content: if block.type tool_use: if block.name compact: manual_compact True output Compressing... else: output TOOL_HANDLERS[block.name](**block.input) results.append({type: tool_result, tool_use_id: block.id, content: output}) messages.append({role: user, content: results}) if manual_compact: # Layer 3 print([manual compact]) messages[:] auto_compact(messages)注意messages[:] auto_compact(messages)是原地替换整个列表修改直接生效不会因为引用问题丢失。三层完全解耦每层独立配置主循环逻辑和之前版本保持一致只新增了压缩相关代码。4. 验证压缩效果观察会话长度与内存占用的实际变化配置写完了怎么确认它真的在工作我设计了一套验证动作你可以照着跑一遍观察 token 数和内存占用的变化。第一步准备一个会触发压缩的任务。让 Agent 逐个读取agents/目录下的所有 Python 文件python agents/s06_context_compact.py s06 Read every Python file in the agents/ directory one by one read_file: [文件内容1] read_file: [文件内容2] read_file: [文件内容3] read_file: [文件内容4]读到第 4 个文件时micro_compact 会把第一个 read_file 的结果替换成[Previous: used read_file]。你可以在micro_compact函数里加一行打印观察替换前后的 token 估算值before estimate_tokens(messages) micro_compact(messages) after estimate_tokens(messages) print(f[micro_compact] {before} - {after} tokens)实测下来读 8 个中等文件后micro_compact 一轮能省下 15000 到 25000 token。这个数字取决于文件大小和 KEEP_RECENT 的设置。第二步继续让 Agent 读文件直到触发 auto_compacts06 Keep reading files until compression triggers automatically [auto_compact triggered] [transcript saved: .transcripts/transcript_1234567890.jsonl]看到[auto_compact triggered]就说明 token 超过 50000 了。这时候去.transcripts/目录看会有一个 jsonl 文件里面是压缩前的完整对话。用wc -l数一下行数再用du -h看文件大小这就是被移出活跃上下文的历史。第三步验证摘要质量。压缩后让 Agent 回答一个需要历史信息的问题s06 我们刚才读过哪些文件当前改到哪一步了如果摘要做得好Agent 能准确说出读过的文件和当前状态。如果它答不上来说明摘要提示词需要调整把关键决策和当前状态的要求写得更具体。第四步手动触发第三层s06 Use the compact tool to manually compress the conversation compact: Compressing... [manual compact] [transcript saved: .transcripts/transcript_1234567891.jsonl]手动压缩和自动压缩走的是同一套摘要逻辑区别只是触发时机。手动压缩适合在任务切换前主动清理上下文比如从读代码阶段进入改代码阶段。验证内存占用可以用tracemalloc或psutil监控进程内存。我实测下来没有压缩时跑 50 轮对话内存会涨到 800MB 以上开启三层压缩后稳定在 200MB 到 300MB 之间因为活跃上下文被控制在 50000 token 以内历史都在磁盘上。指标无压缩三层压缩活跃上下文 token持续增长50 轮后超 150k稳定在 50k 以内进程内存800MB200-300MB会话可持续轮数受窗口限制约 30 轮理论上无限历史可回溯截断后丢失完整保存在 .transcripts/这张表是我在同一个项目上跑出来的对比数据任务类型是代码审查加重构。你的数字会有差异但趋势一致。5. 常见报错排查401、local proxy failed 与 reading choices压缩逻辑跑起来后最容易出问题的不是压缩本身而是模型调用通道。我把踩过的坑按报错类型整理出来对照着排查。401 Unauthorized。这个最常见原因是 Key 没配好或没生效。先检查ANTHROPIC_API_KEY是否写对有没有多余空格。如果用的是 Claude Code CLI确认 settings.json 里的env字段被正确读取。我遇到过环境变量和 settings 同时存在但值不一样的情况最后以 settings 为准。还有一种情况是 Key 被禁用或额度用完去控制台的 API Keys 页面确认状态。local proxy failed。这个报错通常出现在 Claude Code 启动时说明它尝试走本地代理但失败了。检查ANTHROPIC_BASE_URL是否写成了https://taotoken.net/api注意结尾不要多加斜杠也不要带任何查询参数。如果之前配过其他代理地址清掉环境变量里的残留。我建议把 Base URL、Key、Model ID 三件套统一写在 settings.json 里不要混用环境变量减少变量来源。reading choices of undefined。这个报错说明返回结构不符合预期通常是模型 ID 写错了或者请求发到了不兼容的端点。确认ANTHROPIC_MODEL的值和你账号里可用的模型一致。如果你用的是 OpenAI 兼容格式的调用注意 Anthropic SDK 和 OpenAI SDK 的返回结构不同choices是 OpenAI 的字段Anthropic 用的是content。混用两套 SDK 会导致这个错误。OAuth token expired。Claude Code 的 OAuth 登录过期了。如果你用的是 API Key 模式这个报错不该出现。出现了说明它还在走 OAuth 通道。解决办法是在 settings.json 里显式配置ANTHROPIC_API_KEY覆盖 OAuth 凭据。必要时清掉~/.claude下的凭据缓存。context_length_exceeded。这个不是通道问题是压缩没生效。检查THRESHOLD是否设得太高或者estimate_tokens的估算偏差太大。如果单轮读入的文件特别大micro_compact 可能来不及压缩就超了。这时候要么调低 THRESHOLD要么在读取大文件前先手动触发 compact。摘要后 Agent 失忆。压缩成功了但 Agent 忘了关键信息。这是摘要提示词的问题。把提示词里的关键决策改成更具体的要求比如列出所有修改过的文件路径和修改原因。也可以在 compact 工具的focus参数里指定要保留的内容。排查顺序建议先确认三件套配置正确再用一条简单请求验证通道最后才怀疑压缩逻辑。大部分问题出在配置层不在代码层。如果你在接入文档里找不到对应报错可以直接在模型对话页面发一条测试消息确认账号和模型是否正常。6. 从压缩策略到长期编码把 Agent 跑成常驻工具三层压缩跑通之后你的 Agent 就具备了长时间工作的基础能力。但要让它在真实项目里持续跑几天还有几件事值得做。第一把.transcripts/目录纳入版本控制或定期归档。完整历史是调试 Agent 行为的金矿出问题时可以回溯它每一步的决策依据。我习惯按天归档文件名带时间戳方便定位。第二根据任务类型调整 KEEP_RECENT 和 THRESHOLD。代码审查任务读文件多KEEP_RECENT 调到 5 更稳命令执行任务输出短3 就够。THRESHOLD 跟着模型窗口走别贴着上限设。第三给 compact 工具加更细的 focus 策略。比如在进入重构阶段前让模型主动调用 compact 并指定保留所有接口签名和依赖关系。这样摘要会侧重那部分后续重构不会因为丢失接口信息而出错。第四监控压缩频率。如果 auto_compact 触发太频繁说明单轮 token 消耗太快可能是工具输出没做截断。给 shell 命令的输出加个长度上限超过就截断并提示输出过长已截断能显著降低压缩压力。如果你打算把 Agent 做成常驻的编码助手长期跑在项目里可以考虑用 Coding Plan 这类面向持续编码场景的方案配合三层压缩基本能做到开着不管需要时问它。模型对话页面适合快速验证单个请求API Keys 和接入文档适合把配置固化下来。这套三层压缩机制的核心思想就一句话策略性遗忘而非硬截断。信息不会真正丢失只是移出了活跃上下文完整历史永远在磁盘上。第一层零成本覆盖大部分场景第二层用摘要换空间第三层给模型自主权。三层解耦各自独立配置组合起来就能支撑无限长会话。