第 7 章:上下文窗口管理
上下文窗口是 Agent 的"工作台":太小放不下材料,太大成本爆炸。管理好它,Agent 才能在复杂任务中保持清醒。 但这不是魔法——压缩会丢信息,预算会限制能力。没有免费的午餐。
你让 Agent 帮你调试一个生产问题。
对话进行了 50 轮,它终于定位到是数据库连接池配置不对。
然后你问:"刚才你说的连接池配置是什么来着?"
它回答:"抱歉,我不记得了。"
你愣住了。
50 轮对话,烧了几万 token,结果它把关键信息忘了?
这不是 Agent 的问题,是上下文管理没做好。
7.1 问题在哪?
LLM 有一个叫"上下文窗口"的东西——你可以把它理解成 Agent 的"工作台"。
工作台上能放多少东西,取决于窗口大小。
⚠️ 时效性提示 (2026-01): 模型上下文窗口和定价频繁变化,以下为示意。请查阅官方文档获取最新信息。
| 模型 | 上下文窗口 | 换算成字数(粗估) |
|---|---|---|
| GPT-4o | 128K tokens | ~50万字 |
| Claude 3.5 Sonnet | 200K tokens | ~80万字 |
| Gemini 1.5 Pro | 2M tokens | ~800万字 |
| 常见开源模型 | 4K - 32K | ~1.6-13万字 |
看起来挺大?800 万字,一本书都放得下。
但你想想实际场景:
- 50 轮对话,每轮平均 500 tokens
- 系统提示词(System Prompt)占 2000 tokens
- 工具定义(每个工具约 200 tokens,10 个工具就是 2000)
- 历史记录还在持续增长...
很快就撑满了。
更要命的是,每个 token 都是钱:
每轮对话 500 tokens × 50 轮 = 25,000 tokens(输入)
GPT-4o 输入价格 $2.5/百万 tokens
50 轮对话的输入成本 ≈ $0.0625
听起来不多?但这只是一个会话。
如果每天 10 万个会话,每个 50 轮...
每天成本 = $6,250 = 每月 $187,500
而且这还没算输出 token(更贵)。
上下文管理要解决四个核心问题:
| 问题 | 后果 | 解决方向 |
|---|---|---|
| 超限 | 请求直接失败,用户体验崩溃 | 压缩、裁剪 |
| 成本 | 历史越长,每次请求越贵 | 预算控制 |
| 信息丢失 | 关键上下文被截掉,Agent 犯傻 | 智能保留 |
| 噪音干扰 | 太多无关信息,降低回答质量 | 相关性过滤 |
这四个问题互相矛盾:压缩了会丢信息,不压缩会超限和超预算。
没有完美的方案,只有取舍。
7.2 Token:Agent 的计量单位
在讲解决方案之前,先搞清楚什么是 token。
什么是 Token?
⚠️ 时效性提示 (2026-01): Token 计数依赖具体 tokenizer(GPT-4 用 cl100k_base,Claude 用自有分词器)。以下换算为约数,实际使用请调用
tiktoken或对应 SDK 的 token 计数 API。
Token 不是字符,也不是单词。它是 LLM 用来"切分"文本的最小单位。
英文: "Hello world" → ["Hello", " world"] → 2 tokens
中文: "你好世界" → ["你", "好", "世", "界"] → 4 tokens(大概)
代码: "function foo() {}" → ["function", " foo", "()", " {}"] → 4 tokens
不同语言的 token 效率不同:
| 语言 | 平均 token/字符 | 说明 |
|---|---|---|
| 英文 | ~4 字符/token | 按词根切分 |
| 中文 | ~1.5 字符/token | 每个汉字约 1-2 token |
| 代码 | ~3 字符/token | 符号多,token 密度高 |
这意味着:同样的语义内容,中文可能比英文消耗更多 token。
为什么不用精确计数?
精确计算 token 需要调用 tokenizer,那样太慢了(每次都要调 API 或加载模型)。
Shannon 用了一个实测足够准的估算方法:
// 简化版 token 估算
func EstimateTokens(messages []Message) int {
total := 0
for _, msg := range messages {
// 每 4 个字符约 1 个 token
total += len([]rune(msg.Content)) / 4
// 每条消息有格式开销(role, content 结构)
total += 5
}
// 加 10% 安全边际
return int(float64(total) * 1.1)
}
| 组成部分 | 估算方式 | 说明 |
|---|---|---|
| 普通文本 | 字符数 / 4 | 标准 GPT 估算 |
| 消息格式 | 每条 +5 | role/content 结构开销 |
| 代码 | 字符数 / 3 | 代码 token 密度更高 |
| 安全边际 | +10% | 防止估算偏小 |
我测试过,这个估算误差在 10-15% 以内,对于预算控制来说够用了。
7.3 滑动窗口压缩
这是 Shannon 的核心策略:保留重要的,压缩中间的。
核心思路
原始对话历史 (500条消息,约 100K tokens)
↓
[前3条] + [中间压缩成摘要] + [后20条]
↓
压缩后 (23条消息 + 摘要,约 15K tokens)
为什么这样设计?
- 前 3 条(Primers):保留上下文建立。用户最开始说了什么需求,系统做了什么设定。
- 中间部分(Summary):压缩成语义摘要。关键决策、发现、待解决问题。
- 后 20 条(Recents):保留最近对话。保持连贯性,用户说的"刚才那个"能找到。
什么时候触发压缩?
不是每次都压缩——那样太浪费计算资源。Shannon 的策略是:
预算使用率 >= 75% → 触发压缩
压缩目标 → 预算的 37.5%
比如预算是 50K tokens,用到 37.5K 就开始压缩,压到 18.75K 左右。
为什么是 75% 和 37.5%?
- 75%:留 25% 余量给当前轮的输入输出
- 37.5%:压到一半以下,留更多空间给后续对话
这些数字不是死的,可以根据场景调。调试场景可以把触发阈值调高(比如 85%),让 Agent 看到更多历史。
压缩的代价
压缩会丢信息。这是必须承认的。
一段 10000 字的对话,压缩成 500 字的摘要,不可能保留所有细节。
摘要能保留的是:
- 关键决策点
- 重要发现
- 待解决问题
- 实体和关系
摘要会丢失的是:
- 具体的代码细节
- 中间的试错过程
- 用户的情绪表达
- 上下文暗示
所以压缩不是万能药。有些场景(比如代码调试),你需要更大的上下文窗口,而不是更激进的压缩。
7.4 三段式保留策略
Primers:为什么保留开头?
对话的"上下文"往往在开头建立。
用户第一句话可能是:"我在调试一个 Kubernetes 集群的网络问题,Pod 之间无法互相访问。"
这个背景信息对整个对话都很重要。如果丢了,Agent 后续可能给出完全不相关的建议。
默认保留开头 3 条:
primers_count: 3 # 可配置
什么场景需要调整?
- 多轮确认场景:用户可能前 5 轮都在确认需求,可以调到 5
- 简单问答场景:第一句话就是完整问题,可以调到 1
Summary:中间的压缩
中间那些对话,用 LLM 压缩成一段摘要。
Shannon 调用 llm-service 的 /context/compress 端点:
# llm-service 侧的压缩实现(概念示例)
async def compress_context(messages: list, target_tokens: int = 400):
prompt = f"""Compress this conversation into a factual summary.
Focus on:
- Key decisions made
- Important discoveries
- Unresolved questions
- Named entities and their relationships
Keep the summary under {target_tokens} tokens.
Use the SAME LANGUAGE as the conversation.
Conversation:
{format_messages(messages)}
"""
result = await providers.generate_completion(
messages=[{"role": "user", "content": prompt}],
tier=ModelTier.SMALL, # 用小模型,省钱
max_tokens=target_tokens,
temperature=0.2, # 低温度,保证准确
)
return result["output_text"]
摘要长这样:
Previous context summary:
用户正在调试一个 Kubernetes 网络问题。关键发现:
- Pod 无法访问外部服务
- CoreDNS 配置正常
- NetworkPolicy 存在限制
待解决:确认 NetworkPolicy 规则的具体配置
Recents:保持对话连贯
保留最近的 20 条消息。
这是"热"上下文——用户最近说了什么、Agent 最近回答了什么,都在这里。
用户说"刚才那个方案",Agent 能在 Recents 里找到。
recents_count: 20 # 可配置
调整建议:
- 调试场景:调到 30-50,保留更多细节
- 简单问答:调到 10,省 token
7.5 Token 预算管理
光有压缩还不够。还需要预算控制——设定硬上限,超了就停。
多层预算架构
Shannon 实现了三层预算控制:
- Session 预算:整个会话的总预算
- Task 预算:单次任务的预算
- Agent 预算:单个 Agent 的预算(多 Agent 场景)
为什么要分层?
一个复杂任务可能调用多个 Agent(研究、分析、写作)。如果只有一个总预算,第一个 Agent 可能把预算烧光,后面的 Agent 没得用。
分层预算让每个 Agent 都有自己的额度,不会相互挤占。
预算检查逻辑
每次请求前检查预算:
func (bm *BudgetManager) CheckBudget(sessionID string, estimatedTokens int) *BudgetCheckResult {
budget := bm.sessionBudgets[sessionID]
result := &BudgetCheckResult{CanProceed: true}
// 检查是否超限
if budget.TaskTokensUsed + estimatedTokens > budget.TaskBudget {
if budget.HardLimit {
result.CanProceed = false
result.Reason = "Task budget exceeded"
} else {
result.RequireApproval = budget.RequireApproval
result.Warnings = append(result.Warnings, "Will exceed budget")
}
}
// 检查警告阈值(比如 80% 时发警告)
usagePercent := float64(budget.TaskTokensUsed) / float64(budget.TaskBudget)
if usagePercent > budget.WarningThreshold {
bm.emitWarningEvent(sessionID, usagePercent)
}
return result
}
硬限制 vs 软限制 vs 审批模式
| 模式 | 行为 | 适用场景 |
|---|---|---|
| 硬限制 | 超预算直接拒绝 | 成本敏感、API 服务 |
| 软限制 | 超预算发警告,继续执行 | 任务优先、内部工具 |
| 审批模式 | 超预算暂停,等人工确认 | 需要人工把关的场景 |
选哪个?看你的场景:
- 对外 API:硬限制,防止单个用户烧光资源
- 内部调试:软限制,任务能完成更重要
- 关键任务:审批模式,超预算了让人确认是否继续
7.6 背压机制
当预算压力增大时,不是突然停止,而是渐进式限流。
func calculateBackpressureDelay(usagePercent float64) time.Duration {
switch {
case usagePercent >= 0.95:
return 1500 * time.Millisecond // 重度限流
case usagePercent >= 0.9:
return 750 * time.Millisecond
case usagePercent >= 0.85:
return 300 * time.Millisecond
case usagePercent >= 0.8:
return 50 * time.Millisecond // 轻微限流
default:
return 0 // 正常执行
}
}
这样做的好处:
- 用户感知:响应变慢了,知道该省着点用
- 平滑降级:不是突然断掉,有缓冲时间
- 自动恢复:用量下来后自动恢复正常
背压不是万能的。如果用户不理会变慢的信号继续猛烧,最终还是会触发硬限制。
7.7 配置最佳实践
场景化配置
不同场景需要不同的配置:
普通问答场景
session:
context_window_default: 30
token_budget_per_agent: 25000
primers_count: 2
recents_count: 10
特点:预算小,窗口小,省钱。适合简单问答、客服场景。
调试/开发场景
session:
context_window_debugging: 100
token_budget_per_agent: 75000
primers_count: 5
recents_count: 30
特点:预算大,窗口大,保留更多上下文。适合代码调试、复杂分析。
长时任务场景
session:
context_window_default: 75
token_budget_per_task: 300000
max_history: 1000
特点:任务预算大,历史条数多。适合研究任务、文档生成。
请求级覆盖
有时候需要在请求级别覆盖配置:
{
"query": "Fix the race condition in worker pool",
"context": {
"use_case_preset": "debugging",
"history_window_size": 100,
"token_budget_per_agent": 60000
}
}
优先级顺序:
- 请求级覆盖(最高)
- 用例预设(use_case_preset)
- 环境变量
- 配置文件默认值
7.8 压缩效果
实测数据:
| 场景 | 原始 Token | 压缩后 | 压缩率 | 说明 |
|---|---|---|---|---|
| 50 条消息 | ~10k | 无压缩 | 0% | 未触发阈值 |
| 100 条消息 | ~25k | ~12k | 52% | 轻度压缩 |
| 500 条消息 | ~125k | ~15k | 88% | 重度压缩 |
| 1000 条消息 | ~250k | ~15k | 94% | 极限压缩 |
压缩开销:
| 操作 | 耗时 | 说明 |
|---|---|---|
| Token 估算 | <5ms | 本地计算 |
| 历史整形 | <1ms | 切片操作 |
| 摘要生成(LLM) | 200-500ms | 主要开销 |
| 总压缩开销 | ~500ms | 只在触发时执行 |
摘要生成是最慢的,但只在触发压缩时才跑,不是每次请求都跑。
7.9 常见的坑
坑 1:压缩阈值设太高
# 太晚触发,没留余量给当前轮
COMPRESSION_TRIGGER_RATIO=0.95
# 留够余量(推荐)
COMPRESSION_TRIGGER_RATIO=0.75
后果:触发压缩时已经接近限额,压缩完还是不够用,请求失败。
坑 2:Primers/Recents 配置不当
# Primers 太少,丢失初始上下文
primers_count: 1
recents_count: 50
# 平衡配置(推荐)
primers_count: 3
recents_count: 20
后果:Primers 太少,Agent 忘记最初的需求;Recents 太多,摘要太短,中间信息全丢。
坑 3:硬限制 + 小预算
# 预算太小,硬限制导致任务经常失败
token_budget_per_agent: 10000
hard_limit: true
# 合理预算 + 软限制(推荐)
token_budget_per_agent: 50000
hard_limit: false
require_approval: true
后果:稍微复杂点的任务就超预算,用户体验极差。
坑 4:会话 ID 不一致
# 每次请求用新会话,历史不连续
SESSION_ID="session-$(date +%s)"
# 相关工作用同一个会话 ID(推荐)
SESSION_ID="feature-xyz-debug"
后果:每次请求都是新会话,上下文窗口管理形同虚设,历史完全不连续。
坑 5:忽略压缩质量
压缩摘要的质量取决于:
- 摘要模型的能力
- 压缩 prompt 的设计
- 目标 token 数的合理性
后果:摘要太短,关键信息丢失;摘要太笼统,没有可操作的细节。
建议:定期抽检压缩摘要的质量,确保关键信息被保留。
Shannon Lab(10 分钟上手)
本节帮你在 10 分钟内把本章概念对应到 Shannon 源码。
必读(1 个文件)
docs/context-window-management.md:重点看 "Sliding Window Compression"、"Token Budget Management"、"Configuration" 部分,理解压缩触发条件和多层预算
选读深挖(2 个,按兴趣挑)
activities/context_compress.go:看CompressAndStoreContext函数,理解压缩流程、PII 脱敏、向量存储budget/manager.go:看CheckBudget、calculateBackpressureDelay,理解预算检查逻辑和背压机制
练习
练习 1:配置优化
你有一个代码调试场景,用户经常抱怨"Agent 忘记了之前说的东西"。
写出你的配置调整方案:
- 要调整哪些参数?
- 为什么这样调整?
- 有什么代价?
练习 2:源码阅读
读 context_compress.go,回答:
redactPII函数处理了哪些类型的敏感信息?- 为什么用
[]rune而不是直接用string截断? - 如果 embedding 服务不可用,会发生什么?
练习 3(进阶):设计新功能
设计一个"重要消息标记"功能:用户可以标记某些消息为"重要",压缩时这些消息不能被删除。
思考:
- 数据结构怎么设计?
- 压缩逻辑怎么修改?
- 如果标记的消息太多,超过预算怎么办?
划重点
核心就一句话:上下文窗口是 Agent 的工作台,管不好会遗忘、会超限、会烧钱。
要点:
- Token 估算:约 4 字符/token + 每消息 5 token 格式开销
- 滑动窗口压缩:Primers(前3) + Summary(中间压缩) + Recents(后20)
- 多层预算:Session - Task - Agent 三级控制
- 背压机制:随使用率渐进式增加延迟
- 压缩是有损的:关键信息可能丢失,不是万能药
上下文窗口管理解决了"短期记忆"问题——单次对话内的上下文。但 Agent 如何在跨会话、跨任务之间保持"长期记忆"?
下一章我们来聊记忆架构——如何通过向量数据库实现语义检索和知识积累。
准备好了?往下走。
延伸阅读
- OpenAI Tokenizer - 在线体验 token 切分
- Anthropic Context Window - Claude 的上下文窗口说明
- Shannon Context Window Management - Shannon 完整文档