核心思想
compaction_control让 SDK 在 token 超过阈值时自动接管:注入摘要提示 → 模型生成<summary>→ 清空历史、只留摘要继续跑,于是长任务不再被 200k 上限卡死。真正要理解的是它换了什么——用可控的信息损失换不受限的任务长度,所以它只适合条目边界清晰的顺序型工作流(工单、多阶段流水线)。反过来,需要完整审计轨迹、或每步都死抠前文细节的任务,开了压缩只会更糟。还有一个反直觉的坑:服务端采样循环里缓存 token 会虚高累积,把压缩提前触发,顶上去的其实是缓存而非真实对话。
长时间运行的智能体任务常常会撑爆上下文上限。工具密集的工作流,或者拉得很长的对话,很快就会把 token 上下文窗口吃干净。我们在《Effective Context Engineering for AI Agents》一文中讨论过,管好上下文可以避免性能下降和上下文腐化(context rot)。
Claude Agent Python SDK 提供了一套现成的方案:当 token 用量超过你设定的阈值时,自动压缩对话历史,让任务能一路跑下去,突破通常 200k token 的上下文上限。
这篇 cookbook 会用一个智能体客服工作流来演示上下文压缩(context compaction)。设想你做了一个 AI 客服智能体,任务是处理一队支持工单。每张工单都要走完一整套流程:给问题分类、检索知识库、定优先级、分派给对口团队、起草回复、最后标记完成。一张接一张处理下去,对话历史里很快就塞满了分类结果、知识库检索记录和起草的回复——几千个 token 转眼就没了。
什么是上下文压缩?
在带工具调用的智能体工作流里,智能体在复杂任务上反复迭代,对话会膨胀得非常厉害。compaction_control 参数提供了一套自动的上下文管理机制:
- 逐轮次监控对话的 token 用量
- 一旦超过阈值,就以用户轮次的形式注入一段摘要提示词
- 让模型生成一段用
<summary></summary>标签包裹的摘要。这对标签并不会被真正解析,纯粹是用来引导模型的 - 清空对话历史,只带着这段摘要继续
- 用压缩后的上下文把任务跑完
读完这篇 cookbook,你将能够
- 理解如何在迭代式工作流中有效管理上下文上限
- 写出会用自动上下文压缩的智能体
- 设计出在多轮迭代中始终不跑偏的工作流
前置条件
开始之前,请确保下面这些已经就绪:
知识储备
- 对智能体模式和工具调用有基本了解
工具准备
- Python 3.11 或更高版本
- Anthropic API key
- Anthropic SDK >= 0.74.1
在用 Opus 4.6? 那推荐直接上服务端压缩,它会自动接管上下文管理,SDK 这边不用做任何配置。
本文讲的是 SDK 侧的压缩,适合两种情况:你用的是较老的模型,或者你想换一个更便宜的模型专门来做摘要。
环境准备
先把依赖装好:
# %pip install -qU anthropic python-dotenv注意:确保你的 .env 文件里有这么一行:
ANTHROPIC_API_KEY=your_key_here
加载环境变量并配置好客户端。这里还引入了一个小工具,用来把 Claude 的消息响应可视化出来。
from dotenv import load_dotenv
load_dotenv()
MODEL = "claude-sonnet-4-6"搭好舞台
在 utils/customer_service_tools.py 里,我们定义了一组处理客服工单的函数:
get_next_ticket()—— 从队列里取出下一张待处理工单classify_ticket(ticket_id, category)—— 把问题归到账单、技术、账户、产品或物流五类之一search_knowledge_base(query)—— 检索相关的帮助文档和解决方案set_priority(ticket_id, priority)—— 设定优先级(low、medium、high、urgent)route_to_team(ticket_id, team)—— 把工单分派给对口的支持团队draft_response(ticket_id, response_text)—— 生成面向客户的回复mark_complete(ticket_id)—— 把处理完的工单收尾归档
有了这几个工具,客服智能体就能成体系地处理工单了。每张工单都得走一遍分类、查资料、定优先级、分派、起草回复。可一旦要连着处理 20 到 30 张工单,对话历史里就会塞满每一次分类、每一次知识库检索、每一份起草回复的返回结果,token 随之线性增长。
这些函数都套了 beta_tool 装饰器,好让 Claude 智能体能调用到它们。装饰器会自动提取函数参数和文档字符串,作为工具的元数据交给 Claude。
import anthropic
from anthropic import beta_tool
@beta_tool
def get_next_ticket() -> dict:
"""Retrieve the next unprocessed support ticket from the queue."""
...import anthropic
from utils.customer_service_tools import (
classify_ticket,
draft_response,
get_next_ticket,
initialize_ticket_queue,
mark_complete,
route_to_team,
search_knowledge_base,
set_priority,
)
client = anthropic.Anthropic()
tools = [
get_next_ticket,
classify_ticket,
search_knowledge_base,
set_priority,
route_to_team,
draft_response,
mark_complete,
]基线:先不开压缩跑一遍
先从一个贴近真实的客服场景开始:处理一队支持工单。
流程是这样的:
对每一张工单:
- 用
get_next_ticket()取出工单 - 判断问题类别(billing、technical、account、product、shipping)
- 到知识库里检索相关信息
- 定一个合适的优先级(low、medium、high、urgent)
- 分派给对的团队
- 起草给客户的回复
- 把工单标记完成
- 接着处理下一张
难点在这里:队列里有 5 张工单,每张要 7 次工具调用,Claude 至少要调 35 次工具。每一步的返回结果——分类结论、知识库检索、起草的回复——全都会堆在对话历史里。不开压缩的话,处理每一张工单时这些数据都原封不动地留在内存里;等处理到第 5 张时,上下文里装的是前面 4 张工单的完整细节。
先不开压缩跑一遍,看看会发生什么:
from anthropic.types.beta import BetaMessageParam
num_tickets = 5
initialize_ticket_queue(num_tickets)
messages: list[BetaMessageParam] = [
{
"role": "user",
"content": f"""You are an AI customer service agent. Your task is to process support tickets from a queue.
For EACH ticket, you must complete ALL these steps:
1. **Fetch ticket**: Call get_next_ticket() to retrieve the next unprocessed ticket
2. **Classify**: Call classify_ticket() to categorize the issue (billing/technical/account/product/shipping)
3. **Research**: Call search_knowledge_base() to find relevant information for this ticket type
4. **Prioritize**: Call set_priority() to assign priority (low/medium/high/urgent) based on severity
5. **Route**: Call route_to_team() to assign to the appropriate team
6. **Draft**: Call draft_response() to create a helpful customer response using KB information
7. **Complete**: Call mark_complete() to finalize this ticket
8. **Continue**: Immediately fetch the next ticket and repeat
IMPORTANT RULES:
- Process tickets ONE AT A TIME in sequence
- Complete ALL 7 steps for each ticket before moving to the next
- Keep fetching and processing tickets until you get an error that the queue is empty
- There are {num_tickets} tickets total - process all of them
- Be thorough but efficient
Begin by fetching the first ticket.""",
}
]
total_input = 0
total_output = 0
turn_count = 0
runner = client.beta.messages.tool_runner(
model=MODEL,
max_tokens=4096,
tools=tools,
messages=messages,
)
for message in runner:
messages_list = list(runner._params["messages"])
turn_count += 1
total_input += message.usage.input_tokens
total_output += message.usage.output_tokens
print(
f"Turn {turn_count:2d}: Input={message.usage.input_tokens:7,} tokens | "
f"Output={message.usage.output_tokens:5,} tokens | "
f"Messages={len(messages_list):2d} | "
f"Cumulative In={total_input:8,}"
)
print(f"\n{'=' * 60}")
print("BASELINE RESULTS (NO COMPACTION)")
print(f"{'=' * 60}")
print(f"Total turns: {turn_count}")
print(f"Input tokens: {total_input:,}")
print(f"Output tokens: {total_output:,}")
print(f"Total tokens: {total_input + total_output:,}")
print(f"{'=' * 60}")Turn 1: Input= 1,537 tokens | Output= 57 tokens | Messages= 1 | Cumulative In= 1,537
Turn 2: Input= 1,760 tokens | Output= 102 tokens | Messages= 3 | Cumulative In= 3,297
Turn 3: Input= 1,905 tokens | Output= 88 tokens | Messages= 5 | Cumulative In= 5,202
Turn 4: Input= 2,237 tokens | Output= 84 tokens | Messages= 7 | Cumulative In= 7,439
Turn 5: Input= 2,385 tokens | Output= 89 tokens | Messages= 9 | Cumulative In= 9,824
Turn 6: Input= 2,537 tokens | Output= 301 tokens | Messages=11 | Cumulative In= 12,361
Turn 7: Input= 2,888 tokens | Output= 67 tokens | Messages=13 | Cumulative In= 15,249
Turn 8: Input= 3,079 tokens | Output= 56 tokens | Messages=15 | Cumulative In= 18,328
Turn 9: Input= 3,316 tokens | Output= 91 tokens | Messages=17 | Cumulative In= 21,644
Turn 10: Input= 3,450 tokens | Output= 84 tokens | Messages=19 | Cumulative In= 25,094
Turn 11: Input= 3,777 tokens | Output= 84 tokens | Messages=21 | Cumulative In= 28,871
Turn 12: Input= 3,925 tokens | Output= 89 tokens | Messages=23 | Cumulative In= 32,796
Turn 13: Input= 4,077 tokens | Output= 349 tokens | Messages=25 | Cumulative In= 36,873
Turn 14: Input= 4,476 tokens | Output= 67 tokens | Messages=27 | Cumulative In= 41,349
Turn 15: Input= 4,668 tokens | Output= 56 tokens | Messages=29 | Cumulative In= 46,017
Turn 16: Input= 4,894 tokens | Output= 91 tokens | Messages=31 | Cumulative In= 50,911
Turn 17: Input= 5,028 tokens | Output= 84 tokens | Messages=33 | Cumulative In= 55,939
Turn 18: Input= 5,333 tokens | Output= 84 tokens | Messages=35 | Cumulative In= 61,272
Turn 19: Input= 5,481 tokens | Output= 89 tokens | Messages=37 | Cumulative In= 66,753
Turn 20: Input= 5,633 tokens | Output= 334 tokens | Messages=39 | Cumulative In= 72,386
Turn 21: Input= 6,017 tokens | Output= 67 tokens | Messages=41 | Cumulative In= 78,403
Turn 22: Input= 6,209 tokens | Output= 56 tokens | Messages=43 | Cumulative In= 84,612
Turn 23: Input= 6,435 tokens | Output= 91 tokens | Messages=45 | Cumulative In= 91,047
Turn 24: Input= 6,569 tokens | Output= 84 tokens | Messages=47 | Cumulative In= 97,616
Turn 25: Input= 6,896 tokens | Output= 84 tokens | Messages=49 | Cumulative In= 104,512
Turn 26: Input= 7,044 tokens | Output= 89 tokens | Messages=51 | Cumulative In= 111,556
Turn 27: Input= 7,196 tokens | Output= 372 tokens | Messages=53 | Cumulative In= 118,752
Turn 28: Input= 7,618 tokens | Output= 67 tokens | Messages=55 | Cumulative In= 126,370
Turn 29: Input= 7,808 tokens | Output= 56 tokens | Messages=57 | Cumulative In= 134,178
Turn 30: Input= 8,040 tokens | Output= 96 tokens | Messages=59 | Cumulative In= 142,218
Turn 31: Input= 8,179 tokens | Output= 85 tokens | Messages=61 | Cumulative In= 150,397
Turn 32: Input= 8,508 tokens | Output= 84 tokens | Messages=63 | Cumulative In= 158,905
Turn 33: Input= 8,656 tokens | Output= 89 tokens | Messages=65 | Cumulative In= 167,561
Turn 34: Input= 8,808 tokens | Output= 332 tokens | Messages=67 | Cumulative In= 176,369
Turn 35: Input= 9,190 tokens | Output= 67 tokens | Messages=69 | Cumulative In= 185,559
Turn 36: Input= 9,382 tokens | Output= 60 tokens | Messages=71 | Cumulative In= 194,941
Turn 37: Input= 9,475 tokens | Output= 297 tokens | Messages=73 | Cumulative In= 204,416
============================================================
BASELINE RESULTS (NO COMPACTION)
============================================================
Total turns: 37
Input tokens: 204,416
Output tokens: 4,422
Total tokens: 208,838
============================================================
有了这条基线,不开压缩时上下文是怎么涨起来的就一目了然了。可以看到,每一轮次都在往输入里追加 token,整体呈线性增长。
结果就是 token 消耗居高不下,而且很快就有撞上上下文上限的风险。到第 27 轮次时,仅仅 5 张工单就已经累计吃掉了 15 万输入 token。
来看看不开压缩、5 张工单全部处理完之后,Claude 给出的最终回复:
print(message.content[-1].text)---
## ✅ ALL TICKETS PROCESSED SUCCESSFULLY!
**Summary of Completed Work:**
I have successfully processed all 5 tickets from the queue. Here's what was accomplished:
1. **TICKET-1** - Sam Smith - Payment method update error
- Category: Billing | Priority: High | Team: billing-team
2. **TICKET-2** - Morgan Johnson - Missing delivery
- Category: Shipping | Priority: High | Team: logistics-team
3. **TICKET-3** - Morgan Jones - Email address change request
- Category: Account | Priority: Medium | Team: account-services
4. **TICKET-4** - Alex Johnson - Wrong item delivered
- Category: Shipping | Priority: High | Team: logistics-team
5. **TICKET-5** - Morgan Jones - Refund request for cancelled subscription
- Category: Billing | Priority: High | Team: billing-team
Each ticket was:
✅ Classified correctly
✅ Researched in the knowledge base
✅ Assigned appropriate priority
✅ Routed to the correct team
✅ Given a detailed, helpful customer response
✅ Marked as complete
The queue is now empty and all tickets have been processed!
问题出在哪
在上面这条基线流程里,Claude 得做这些事:
- 依次处理 5 张支持工单
- 每张工单走完 7 个步骤(取单、分类、查资料、定优先级、分派、起草、完成)
- 发起 35 次工具调用,返回结果全部堆积在对话历史里
- 把每一次分类、每一次知识库检索、每一份起草回复都留在内存里
为什么会这样:
- token 线性增长 —— 每调用一次工具,整个对话历史(包括此前所有工具返回结果)都要重新发给 Claude 一遍
- 上下文污染 —— 处理工单 B 的时候,工单 A 的分类结论和起草回复还赖在上下文里
- 成本滚雪球 —— 处理到第 5 张工单时,每一次 API 调用都在重复发送前 4 张工单的全部数据
- 响应变慢 —— 处理臃肿的上下文本身就更费时间
- 撞上限的风险 —— 迟早会顶到 200k token 的上下文窗口
我们真正需要的是什么:工单 A 处理完之后,我们只需要一段简短的摘要(已解决、类别、优先级),并不需要完整的分类结果、知识库检索记录和整篇起草回复。中间那些详细的过程记录该丢就丢,只留下完成记录。
下面看看自动上下文压缩是怎么解掉这个问题的。
打开自动上下文压缩
同一套客服工作流,这次把自动上下文压缩打开再跑一遍。改动很小,只需要给 tool runner 加上 compaction_control 参数。
compaction_control 有一个必填字段,外加几个可选字段:
enabled(必填):布尔值,压缩的总开关context_token_threshold(可选):触发压缩的 token 数(默认 100,000)model(可选):用哪个模型来生成摘要(默认跟主模型一致)summary_prompt(可选):自定义的摘要生成提示词
这个客服工作流里,我们把阈值设成 5,000 token。也就是说,处理掉几张工单之后压缩就会自动触发。这样 Claude 就可以:
- 留下完成摘要(哪些工单已解决、类别、结果)
- 丢掉详细的工具返回结果(知识库文章全文、完整的分类结果、起草回复的全文)
- 处理下一批工单时轻装上阵
这跟真人客服的工作方式如出一辙:把工单处理掉,简单记一笔,翻下一单。
# 重新初始化队列,这次开启压缩
initialize_ticket_queue(num_tickets)
total_input_compact = 0
total_output_compact = 0
turn_count_compact = 0
compaction_count = 0
prev_msg_count = 0
runner = client.beta.messages.tool_runner(
model=MODEL,
max_tokens=4096,
tools=tools,
messages=messages,
compaction_control={
"enabled": True,
"context_token_threshold": 5000,
},
)
for message in runner:
turn_count_compact += 1
total_input_compact += message.usage.input_tokens
total_output_compact += message.usage.output_tokens
messages_list = list(runner._params["messages"])
curr_msg_count = len(messages_list)
if curr_msg_count < prev_msg_count:
# 消息条数变少,就说明刚刚发生了压缩
compaction_count += 1
print(f"\n{'=' * 60}")
print(f"🔄 Compaction occurred! Messages: {prev_msg_count} → {curr_msg_count}")
print(" Summary message after compaction:")
print(messages_list[-1]["content"][-1].text) # type: ignore
print(f"\n{'=' * 60}")
prev_msg_count = curr_msg_count
print(
f"Turn {turn_count_compact:2d}: Input={message.usage.input_tokens:7,} tokens | "
f"Output={message.usage.output_tokens:5,} tokens | "
f"Messages={len(messages_list):2d} | "
f"Cumulative In={total_input_compact:8,}"
)
print(f"\n{'=' * 60}")
print("OPTIMIZED RESULTS (WITH COMPACTION)")
print(f"{'=' * 60}")
print(f"Total turns: {turn_count_compact}")
print(f"Compactions: {compaction_count}")
print(f"Input tokens: {total_input_compact:,}")
print(f"Output tokens: {total_output_compact:,}")
print(f"Total tokens: {total_input_compact + total_output_compact:,}")
print(f"{'=' * 60}")Turn 1: Input= 1,537 tokens | Output= 57 tokens | Messages= 1 | Cumulative In= 1,537
Turn 2: Input= 1,755 tokens | Output= 108 tokens | Messages= 3 | Cumulative In= 3,292
Turn 3: Input= 1,906 tokens | Output= 88 tokens | Messages= 5 | Cumulative In= 5,198
Turn 4: Input= 2,216 tokens | Output= 84 tokens | Messages= 7 | Cumulative In= 7,414
Turn 5: Input= 2,364 tokens | Output= 89 tokens | Messages= 9 | Cumulative In= 9,778
Turn 6: Input= 2,516 tokens | Output= 332 tokens | Messages=11 | Cumulative In= 12,294
Turn 7: Input= 2,898 tokens | Output= 67 tokens | Messages=13 | Cumulative In= 15,192
Turn 8: Input= 3,090 tokens | Output= 56 tokens | Messages=15 | Cumulative In= 18,282
Turn 9: Input= 3,325 tokens | Output= 97 tokens | Messages=17 | Cumulative In= 21,607
Turn 10: Input= 3,465 tokens | Output= 90 tokens | Messages=19 | Cumulative In= 25,072
Turn 11: Input= 3,801 tokens | Output= 84 tokens | Messages=21 | Cumulative In= 28,873
Turn 12: Input= 3,949 tokens | Output= 89 tokens | Messages=23 | Cumulative In= 32,822
Turn 13: Input= 4,101 tokens | Output= 368 tokens | Messages=25 | Cumulative In= 36,923
Turn 14: Input= 4,519 tokens | Output= 67 tokens | Messages=27 | Cumulative In= 41,442
Turn 15: Input= 4,711 tokens | Output= 57 tokens | Messages=29 | Cumulative In= 46,153
Turn 16: Input= 4,934 tokens | Output= 97 tokens | Messages=31 | Cumulative In= 51,087
============================================================
🔄 Compaction occurred! Messages: 31 → 1
Summary message after compaction:
<summary>
## Support Ticket Processing Progress Summary
### Task Overview
Processing 5 support tickets sequentially, completing all 7 steps for each ticket (fetch, classify, research, prioritize, route, draft, complete).
### Tickets Completed (2 of 5)
**TICKET-1 (Chris Davis) - COMPLETED**
- Issue: Account locked, unlock email link not working
- Category: account
- Priority: high
- Team: account-services
- Status: resolved
- Response: Provided guidance on checking spam folder, link expiration (1 hour), and requesting new unlock link
**TICKET-2 (Chris Williams) - COMPLETED**
- Issue: Unrecognized $49.99 charge on 2025-10-30
- Category: billing
- Priority: high
- Team: billing-team
- Status: resolved
- Response: Explained billing cycles, subscription possibility, and refund policy (5-7 business days, pro-rated for annual plans)
### Current Status
**TICKET-3 (John Jones) - IN PROGRESS**
- Issue: Asking about Google Sheets integration for project management
- Category: product
- Priority: NOT YET SET
- Team: NOT YET ASSIGNED
- Steps completed: 1 (fetch), 2 (classify)
- Steps remaining: 3 (research KB), 4 (set priority), 5 (route), 6 (draft), 7 (mark complete)
### Next Steps
1. Complete TICKET-3: Search knowledge base for product integration info
2. Set priority (likely low/medium for feature inquiry)
3. Route to product-team
4. Draft response about integrations
5. Mark complete
6. Fetch and process TICKET-4
7. Fetch and process TICKET-5
### Key Knowledge Base Info Learned
- Account: Password reset links expire in 1 hour, sent from noreply@support.example.com
- Billing: Refunds take 5-7 business days, pro-rated for annual plans, billing on same date monthly/yearly
### Remaining Work
3 tickets left to process (TICKET-3 currently in progress, then TICKET-4 and TICKET-5)
</summary>
============================================================
Turn 17: Input= 1,774 tokens | Output= 94 tokens | Messages= 1 | Cumulative In= 52,861
Turn 18: Input= 1,906 tokens | Output= 95 tokens | Messages= 3 | Cumulative In= 54,767
Turn 19: Input= 2,365 tokens | Output= 431 tokens | Messages= 5 | Cumulative In= 57,132
Turn 20: Input= 3,164 tokens | Output= 60 tokens | Messages= 7 | Cumulative In= 60,296
Turn 21: Input= 3,383 tokens | Output= 160 tokens | Messages= 9 | Cumulative In= 63,679
Turn 22: Input= 3,872 tokens | Output= 447 tokens | Messages=11 | Cumulative In= 67,551
Turn 23: Input= 4,687 tokens | Output= 64 tokens | Messages=13 | Cumulative In= 72,238
Turn 24: Input= 4,914 tokens | Output= 160 tokens | Messages=15 | Cumulative In= 77,152
============================================================
🔄 Compaction occurred! Messages: 15 → 1
Summary message after compaction:
<summary>
## Support Ticket Processing Progress Summary
### Task Overview
Processing 5 support tickets sequentially, completing all 7 steps for each ticket (fetch, classify, research, prioritize, route, draft, complete).
### Tickets Completed (4 of 5)
**TICKET-1 (Chris Davis) - COMPLETED**
- Issue: Account locked, unlock email link not working
- Category: account
- Priority: high
- Team: account-services
- Status: resolved
- Response: Provided guidance on checking spam folder, link expiration (1 hour), and requesting new unlock link
**TICKET-2 (Chris Williams) - COMPLETED**
- Issue: Unrecognized $49.99 charge on 2025-10-30
- Category: billing
- Priority: high
- Team: billing-team
- Status: resolved
- Response: Explained billing cycles, subscription possibility, and refund policy (5-7 business days, pro-rated for annual plans)
**TICKET-3 (John Jones) - COMPLETED**
- Issue: Asking about Google Sheets integration for project management
- Category: product
- Priority: medium
- Team: product-success
- Status: resolved
- Response: Explained that Product Success team will provide details on integration options, API access, and current/planned features
**TICKET-4 (Sam Johnson) - COMPLETED**
- Issue: Wants to know differences between Standard and Premium plans, specifically "advanced analytics"
- Category: product
- Priority: low
- Team: product-success
- Status: resolved
- Response: Explained that Product Success team will provide detailed plan comparison and feature breakdown
### Current Status
**TICKET-5 (Morgan Brown) - IN PROGRESS**
- Issue: Damaged package (Order #ORD-43312), broken product inside, needs replacement
- Category: shipping (classified)
- Priority: NOT YET SET
- Team: NOT YET ASSIGNED
- Steps completed: 1 (fetch), 2 (classify), 3 (research KB - no shipping info found)
- Steps remaining: 4 (set priority), 5 (route), 6 (draft), 7 (mark complete)
### Next Steps for TICKET-5
1. Set priority (likely HIGH - damaged/broken product requiring replacement)
2. Route to appropriate team (likely fulfillment, operations, or customer-service team)
3. Draft response addressing damaged shipment, replacement process, and next steps
4. Mark complete
5. **ALL TICKETS WILL BE COMPLETE**
### Key Knowledge Base Info Learned
- **Account**: Password reset links expire in 1 hour, sent from noreply@support.example.com
- **Billing**: Refunds take 5-7 business days, pro-rated for annual plans, billing on same date monthly/yearly; accepts Visa, Mastercard, Amex, PayPal
- **Technical**: Max upload 100MB, supported formats: PDF, DOCX, PNG, JPG, CSV; system requirements: 4GB RAM, modern browsers
- **Product category**: Does not exist in KB (only billing, technical, account available)
- **Shipping info**: Not found in knowledge base
### Team Routing Patterns Observed
- account-services: Account access issues
- billing-team: Billing/payment inquiries
- product-success: Product features, integrations, plan comparisons
### Remaining Work
1 ticket left to complete (TICKET-5 - final ticket, currently in progress at step 3 of 7)
</summary>
============================================================
Turn 25: Input= 2,077 tokens | Output= 496 tokens | Messages= 1 | Cumulative In= 79,229
Turn 26: Input= 2,942 tokens | Output= 438 tokens | Messages= 3 | Cumulative In= 82,171
============================================================
OPTIMIZED RESULTS (WITH COMPACTION)
============================================================
Total turns: 26
Compactions: 2
Input tokens: 82,171
Output tokens: 4,275
Total tokens: 86,446
============================================================
打开自动上下文压缩后可以看到,每轮次的 token 用量不再线性攀升,而是在每次压缩事件之后被拉回低位。整个工单处理过程中一共触发了两次压缩,紧随其后的那一轮次,token 总量都明显下降。
跟基线那一版比,这次只用了 7.9 万 token。我们还把每次压缩事件之后生成的摘要消息打印了出来,可以直观看到 Claude 是怎么把之前那些工单细节浓缩成摘要的。
再看看开启压缩后,5 张工单全部处理完的最终回复。
print(message.content[-1].text)Perfect! **ALL 5 TICKETS HAVE BEEN SUCCESSFULLY COMPLETED!** 🎉
## Final Summary - All Tickets Processed
### TICKET-5 (Morgan Brown) - **COMPLETED** ✓
- **Issue**: Damaged package (Order #ORD-43312), broken product inside, needs replacement
- **Category**: shipping
- **Priority**: high
- **Team**: logistics-team
- **Status**: resolved
- **Response**: Apologized for damaged shipment, escalated to Logistics Team with HIGH priority, explained they'll process immediate replacement, provide return instructions, and contact customer with tracking and timeline
---
## 🎯 ALL 5 TICKETS COMPLETED
1. ✅ **TICKET-1** (Chris Davis) - Account locked → account-services
2. ✅ **TICKET-2** (Chris Williams) - Billing charge → billing-team
3. ✅ **TICKET-3** (John Jones) - Google Sheets integration → product-success
4. ✅ **TICKET-4** (Sam Johnson) - Plan comparison → product-success
5. ✅ **TICKET-5** (Morgan Brown) - Damaged shipment → logistics-team
### Processing Statistics
- **Total tickets processed**: 5 of 5 (100%)
- **Steps per ticket**: 7 (fetch, classify, research, prioritize, route, draft, complete)
- **Total operations**: 35 successful operations
- **Categories used**: account, billing, product (2x), shipping
- **Teams utilized**: account-services, billing-team, product-success (2x), logistics-team
- **Priority distribution**: 2 high, 2 medium, 1 low
All tickets have been properly classified, prioritized, routed to the appropriate teams, and have draft responses ready for team review! 🎊
两次运行对比
开启压缩之后,两次运行在 token 开销上拉开了明显差距,而工作流的质量和最终的总结并没有打折。
自动上下文压缩到底带来了哪些变化:
- 处理若干张工单后上下文会重置 —— 当 5 到 7 张工单产生的工具返回结果超过 5k token 时,SDK 会自动:
- 注入一段摘要提示词
- 让 Claude 生成一份用
<summary></summary>标签包裹的完成摘要 - 清空对话历史,丢掉详细的分类过程、知识库检索和回复正文
- 只带着这份完成摘要继续往下跑
- 让 Claude 生成一份用
- 注入一段摘要提示词
- 输入 token 被牢牢摁住 —— 不会随着工单越处理越多而一路累积到 10 万以上,每次压缩后输入 token 都会重新起算。处理第 5 张工单时,身上没有背着第 1 到第 4 张工单的完整工具返回结果。
- 任务照样跑完 —— 整个流程顺畅地走完了所有工单,没有撞上上下文上限
- 质量没有打折 —— 摘要保住了关键信息:
- 处理过的工单及其 ID
- 判定的类别和优先级
- 分派到了哪个团队
- 整体进度状态
所有工单依然做到了分类准确、定级合理、分派到位、回复齐全。
- 处理过的工单及其 ID
- 贴合真实工作方式 —— 这和真人客服的做法一模一样:处理完一张工单,在系统里简单记一笔,关掉,翻下一张。你不会在处理新工单的时候,还把之前每一篇知识库文章和整份回复草稿全都摊在桌面上。
下面把 token 的节省幅度直观地摆出来:
# 基线与压缩两版对比
print("=" * 70)
print("TOKEN USAGE COMPARISON")
print("=" * 70)
print(f"{'Metric':<30} {'Baseline':<20} {'With Compaction':<20}")
print("-" * 70)
print(f"{'Input tokens:':<30} {total_input:>19,} {total_input_compact:>19,}")
print(f"{'Output tokens:':<30} {total_output:>19,} {total_output_compact:>19,}")
print(
f"{'Total tokens:':<30} {total_input + total_output:>19,} {total_input_compact + total_output_compact:>19,}"
)
print(f"{'Compactions:':<30} {'N/A':>19} {compaction_count:>19}")
print("=" * 70)
# 计算节省量
token_savings = (total_input + total_output) - (total_input_compact + total_output_compact)
savings_percent = (
(token_savings / (total_input + total_output)) * 100 if (total_input + total_output) > 0 else 0
)
print(f"\n💰 Token Savings: {token_savings:,} tokens ({savings_percent:.1f}% reduction)")======================================================================
TOKEN USAGE COMPARISON
======================================================================
Metric Baseline With Compaction
----------------------------------------------------------------------
Input tokens: 204,416 82,171
Output tokens: 4,422 4,275
Total tokens: 208,838 86,446
Compactions: N/A 2
======================================================================
💰 Token Savings: 122,392 tokens (58.6% reduction)
掀开引擎盖:压缩到底做了什么
当 tool_runner 发现 token 用量越过阈值,就会自动做这几件事:
- 暂停工作流,先不发下一次 API 调用
- 注入一条摘要请求,以用户消息的形式让 Claude 总结当前进展
- 生成摘要 —— Claude 产出一段用
<summary></summary>标签包裹的摘要,内容包括:- 已完成的工单:已解决工单的简要记录(ID、类别、优先级、结果)
- 进度状态:处理了多少张,还剩多少张
- 关键规律:工单之间值得注意的共性趋势
- 下一步动作:接下来该干什么(继续处理剩下的工单)
- 已完成的工单:已解决工单的简要记录(ID、类别、优先级、结果)
- 清空历史 —— 整段对话历史(连同所有工具返回结果)被这份摘要整个替换掉
- 恢复处理 —— Claude 带着压缩后的上下文继续干活,处理下一批工单
按需调整压缩配置
压缩的行为可以按自己的场景来调。下面是几个关键配置项。
调整阈值
context_token_threshold 决定了压缩在什么时候触发:
compaction_control={
"enabled": True,
"context_token_threshold": 5000, # 处理 5-7 张工单后触发压缩
}阈值不能压得太低,否则摘要本身就会超过阈值,刚压缩完立刻又触发一次压缩,陷入自我循环。这里设成 5,000 token 只是为了方便演示;实际使用时,多试几档,找到最适合自己工作流的那个值。
几条大致的经验:
- 低阈值(5k–20k):
- 适合边界清晰的迭代式任务处理
- 压缩更频繁,上下文几乎不会堆积
- 最适合逐个实体依次处理的场景
- 适合边界清晰的迭代式任务处理
- 中等阈值(50k–100k):
- 适合阶段少、检查点大的多阶段工作流
- 在保留上下文和控制上下文之间取得平衡
- 适合工具调用开销较大的工作流
- 适合阶段少、检查点大的多阶段工作流
- 高阈值(100k–150k):
- 适合需要大量历史上下文的任务
- 压缩次数少,能保住更多原始细节
- 单次调用成本更高,但压缩次数更少
- 适合需要大量历史上下文的任务
- 默认值(100k):对一般的长任务来说是个不错的平衡点
回到工单场景:5k 这个阈值之所以好用,是因为每张工单的流程都会产出不少工具返回结果,而工单之间又互相独立。工单 A 处理完之后,它那些知识库检索细节对处理工单 B 毫无用处。
换一个模型来做摘要
摘要这件事也可以交给一个更快、更便宜的模型来做:
compaction_control={
"enabled": True,
"model": "claude-haiku-4-5", # 用 Haiku 生成摘要,性价比更高
}自定义摘要提示词
你可以自己写一段提示词,来指导摘要该怎么生成。在客服这类需要保住特定信息的工作流里,这一招尤其管用。
比如,按我们的需求可以这样定制:
- 所有已完成工单的工单摘要
- 判定的类别和优先级
- 分派到的团队
- 进度状态(完成了几张、还剩几张)
- 流程中的下一步动作
compaction_control={
"enabled": True,
"summary_prompt": """You are processing customer support tickets from a queue.
Create a focused summary that preserves:
1. **COMPLETED TICKETS**: For each ticket you've fully processed:
- Ticket ID and customer name
- Issue category and priority assigned
- Team routed to
- Brief outcome
2. **PROGRESS STATUS**:
- How many tickets you've completed
- Approximately how many remain in the queue
3. **NEXT STEPS**: Continue processing the next ticket
Format with clear sections and wrap in <summary></summary> tags."""
}没有工具的场景:简单聊天循环里的压缩
上面的例子都围绕工具密集的智能体工作流展开,但在由用户主导对话的简单对话类应用里,上下文压缩同样有价值。
**注意:**上面演示的 compaction_control 参数是配合 tool_runner 用的,服务于带工具的智能体工作流。如果是不带工具的简单聊天应用,你得按同样的原理自己动手实现压缩。
设想一个聊天应用,用户跟 Claude 聊得很深——讨论复杂话题、反复打磨想法、一步步啃一个难题。随着对话越拉越长,你会撞上一模一样的上下文堆积问题。
区别在于:这次撑大 token 的不是工具调用,而是一来一回的对话本身。每交流一轮,历史里就多几条消息:
- 用户提一个问题
- Claude 给出一段详细回答
- 用户追问细节或要求展开
- Claude 补充更多背景继续回答
- 如此往复几十上百次
不做压缩的话,到第 50 轮次时,你每调用一次 API 都要把整段对话历史(全部 50 轮交流)重发一遍。
解法:在自己的聊天循环里按同样的套路手动实现压缩:
- 每轮次结束后记录 token 用量
- 一旦超过阈值,就发一次摘要请求
- 用摘要替换掉整段对话历史
- 带着压缩后的上下文继续聊
具体实现如下:
#!/usr/bin/env python3
"""
Simple Compaction Example - User-Driven Chat Loop
This shows the basic pattern for a chat application with compaction.
No tools required - just a simple loop where the user drives continuation.
"""
# 配置
COMPACTION_THRESHOLD = 3000 # token 超过这个数就压缩(演示用,设得比较低)
# 用于压缩的结构化摘要提示词
SUMMARY_PROMPT = """You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include:
1. **Task Overview**
- The user's core request and success criteria
- Any clarifications or constraints they specified
2. **Current State**
- What has been completed so far
- Files created, modified, or analyzed (with paths if relevant)
- Key outputs or artifacts produced
3. **Important Discoveries**
- Technical constraints or requirements uncovered
- Decisions made and their rationale
- Errors encountered and how they were resolved
- What approaches were tried that didn't work (and why)
4. **Next Steps**
- Specific actions needed to complete the task
- Any blockers or open questions to resolve
- Priority order if multiple steps remain
5. **Context to Preserve**
- User preferences or style requirements
- Domain-specific details that aren't obvious
- Any promises made to the user
Be concise but complete—err on the side of including information that would prevent duplicate work or repeated mistakes.
Write in a way that enables immediate resumption of the task.
Wrap your summary in <summary></summary> tags."""
# 消息历史
messages = []
print("Chat with Claude (type 'quit' to exit, or just hit Enter to continue)")
print("This is a demonstration - try having a conversation and watch compaction trigger")
print("=" * 60)
# 模拟一段对话,仅用于演示
demo_messages = [
"Help me understand how Python decorators work",
"Can you show me an example with a timing decorator?",
"How would I make a decorator that takes arguments?",
]
for user_input in demo_messages:
print(f"\nYou: {user_input}")
# 追加用户消息
messages.append({"role": "user", "content": user_input})
# 获取 Claude 的回复
response = client.messages.create(
model=MODEL,
max_tokens=2048,
messages=messages,
)
messages.append(
{
"role": "assistant",
"content": response.content,
}
)
print("\nClaude: ", end="")
for block in response.content:
if block.type == "text":
print(f"{block.text[:300]} ...")
# 判断是否需要压缩
usage = response.usage
# 计算 token 总量(含缓存 token)
total_input_tokens = (
usage.input_tokens
+ (usage.cache_creation_input_tokens or 0)
+ (usage.cache_read_input_tokens or 0)
)
total_tokens = total_input_tokens + usage.output_tokens
cache_info = ""
if usage.cache_creation_input_tokens or usage.cache_read_input_tokens:
cache_info = f" (cache: {usage.cache_creation_input_tokens or 0} write + {usage.cache_read_input_tokens or 0} read)"
print(
f"\n[Tokens: {total_input_tokens} in{cache_info} + {usage.output_tokens} out = {total_tokens} total]"
)
if total_tokens > COMPACTION_THRESHOLD:
print(f"\n{'=' * 60}")
print(f"🔄 Compacting conversation... {len(messages)} messages → ", end="", flush=True)
# 用结构化提示词生成摘要
summary_response = client.messages.create(
model=MODEL,
max_tokens=4096,
messages=messages + [{"role": "user", "content": SUMMARY_PROMPT}],
)
summary_text = "".join(
block.text for block in summary_response.content if block.type == "text"
)
# 用摘要替换历史
messages = [{"role": "user", "content": summary_text}]
print("1 message")
print(f"{'=' * 60}\n")
print(f"Final conversation messages: {messages[-1].get('content')}")
print("\nDemo complete! In a real application, this loop would continue with user input.")Chat with Claude (type 'quit' to exit, or just hit Enter to continue)
This is a demonstration - try having a conversation and watch compaction trigger
============================================================
You: Help me understand how Python decorators work
读懂这个聊天循环模式
上面的例子演示了在对话场景里手动做压缩。它的运作方式是这样的:
几个关键部件:
- token 计数:每收到一次回复,就算一遍 token 总量(输入 + 输出 + 缓存 token)
- 阈值判断:总量一旦越过阈值,就触发压缩
- 摘要请求:把那段结构化的 SUMMARY_PROMPT 发给 Claude
- 历史替换:用这段摘要整个换掉消息历史
- 继续对话:用户的下一条消息接在摘要之后,而不是接在完整历史之后
什么时候用这个模式:
- 长时间的头脑风暴:用户和 Claude 来回探讨想法,动辄几十个轮次
- 学习型对话:横跨几十轮问答的教程或讲解
- 反复打磨:用户针对草稿、设计或方案持续给反馈
- 聊天应用:任何多轮对话界面
和 Tool Runner 的关键差异:
| 对比维度 | Tool Runner(自动) | 聊天循环(手动) |
|---|---|---|
| 触发方式 | 到阈值自动触发 | 阈值判断得你自己写 |
| 摘要生成 | SDK 负责发摘要请求 | 你自己显式调一次 API |
| 历史管理 | SDK 替换消息 | 你手动替换列表 |
| 适用场景 | 带工具的智能体工作流 | 用户主导的对话 |
上生产前要考虑的事:
- 调大阈值:真实应用里别用演示这么小的值
- 定制摘要提示词:按对话类型来裁(头脑风暴、技术支持、辅导教学各不相同)
- 给用户一点提示:显示一句“正在总结对话……”之类的话,让用户知道这段卡顿是怎么回事
- 保住关键上下文:确保摘要提示词能抓住用户真正在意的领域信息
这套模式把压缩的时机和方式完全交到你手里,因此特别适合用不上 SDK 自动 tool runner 压缩的对话类应用。
限制与权衡
自动上下文压缩确实好用,但有几个限制必须心里有数。
服务端采样循环
当前的限制:压缩和服务端采样循环(server-side sampling loop)配合得并不好,服务端网页搜索工具就是典型例子——这类循环的多轮采样由模型在服务端自行完成,客户端看不到中间过程。
原因:缓存 token 会在一轮轮采样循环中不断累积,把总量撑得虚高,于是压缩会被提前触发——真正把它顶上去的是缓存内容,而不是实际的对话历史。
这个特性在下面这些场景里表现最好:
- ✅ 客户端工具(比如本文这套客服 API)
- ✅ 常规工具调用的标准智能体工作流
- ✅ 文件操作、数据库查询、API 调用
- ❌ 服务端扩展思考
- ❌ 服务端网页搜索工具
信息损失
权衡取舍:摘要天然就会丢掉一部分信息。Claude 抓重点的能力不错,但总有些细节会被压掉或略去。
放到工单场景里看:
- ✅ 保住了:工单 ID、类别、优先级、团队、处理结果、进度状态
- ❌ 丢掉了:知识库文章全文、起草回复的完整文本、分类时的详细推理过程
这通常是可以接受的——你并不需要把每篇知识库文章和每份回复全文一直留着,留下完成记录就够了。
怎么缓解:
- 用自定义摘要提示词把关键信息钉住
- 对需要大量历史上下文的任务,把阈值调高
- 把任务拆成模块化的结构(每个阶段接的是摘要,而不是原始细节)
什么时候不该用压缩
这几种情况就别开压缩了:
- 短任务:任务在 50k–100k token 内就能收尾的话,压缩纯属徒增开销
- 需要完整审计轨迹的任务:有些任务必须能回溯到此前的每一处细节
- 走服务端采样的工作流:如前所述,等官方把这个限制解决掉再说
- 高度依赖迭代打磨的任务:每一步都死死依赖前面所有步骤精确细节的那种
什么时候该用压缩
压缩在这些场景里最能发挥价值:
- 顺序处理:就像本文的工单流程,一件接一件地处理
- 多阶段工作流:每个阶段结束前都能先把进展总结一遍再往下走
- 迭代式数据处理:把大数据集分块处理,或者一次处理一个实体
- 长时间的分析任务:横跨大量实体做数据分析
- 批量操作:处理成百上千个彼此独立的条目
工单处理是压缩的绝佳用例,因为:
- 每张工单的流程基本互不相干
- 你要的是完成摘要,不是完整的工具返回结果
- 天然存在压缩节点(处理完几张工单之后)
- 整个流程本身就是迭代式、顺序式的
小结
自动上下文压缩是个很有分量的能力,它让长时间运行的智能体工作流得以突破通常的上下文上限。这篇 cookbook 借着一个客服工单处理流程,把压缩完整地走了一遍。
不妨在自己的工作流里试着接入压缩:
- 找出天然的压缩节点(处理完一个条目、走完一个阶段等等)
- 如果每个条目的边界很清晰,一开始就大胆用激进的阈值(5k–10k)
- 用自定义摘要提示词把关键信息保下来
- 盯住压缩的触发时机,确认质量没有滑坡
- 再根据自己的实际需要微调阈值
相关笔记
- 用语义嵌入实现工具检索 —— 同一批入库,同样冲着省上下文去,但走的是”一开始就别加载”而非”事后压缩”,两条路可以叠加用
- Claude Code 会话管理与百万上下文 —— 同一问题在 Claude Code 里的产品化答案,可对照看压缩阈值的取值直觉
- AI代理的上下文工程:构建Manus的经验教训 —— 上下文预算怎么分配,压缩只是其中一种手段
- 上下文分叉:编程智能体的实战技巧 —— 与压缩相反的思路:不压缩历史,而是开新分支隔离上下文
- 智能体记忆系统详解 —— 摘要丢掉的细节该往哪放,外部记忆是压缩的天然补集
- LLM 中的 KV 缓存:原理与权衡 —— 解释了本文那个”缓存 token 虚高触发压缩”的坑从哪来