Appearance
第11章 短期记忆:上下文窗口管理
"An agent is only as smart as the context it can see."
本章要点
- 上下文窗口就是 Agent 的"工作记忆"——窗口外的信息对模型不存在
- Token 预算是零和博弈:系统提示词、工具定义、对话历史、工具结果互相竞争
- 自动压缩是长对话的关键——Claude Code 在接近限制时自动摘要历史消息
- 工具结果是最大的 token 消耗源,必须有截断和摘要策略
- "Lost in the Middle"效应:模型对超长上下文中部内容的注意力会下降
11.1 上下文窗口即工作记忆
人类的工作记忆容量约为 7±2 个信息块(Miller, 1956)。LLM 的"工作记忆"就是上下文窗口——一个固定大小的 token 缓冲区。模型在生成每个 token 时,只能"看到"这个缓冲区中的信息。
关键认知:窗口外的一切,对模型来说不存在。 上一轮读过但被压缩掉的文件内容、三天前的对话记录、没有显式注入的项目文档——模型一概不知。
这意味着上下文管理不是一个可选的优化——它是 Agent 能否工作的基础。
11.2 Token 预算分配
上下文窗口是零和博弈:一个组件占用越多,其他组件可用的就越少。
11.2.1 预算分配的动态性
会话早期,对话历史很短,可以给工具结果更多空间。会话后期,对话历史膨胀到 120K,工具结果和响应空间都受到挤压。
| 组件 | 典型占比 | 特点 | 优化手段 |
|---|---|---|---|
| System Prompt | 3-5% | 相对固定 | Prompt Caching |
| Tool Definitions | 4-10% | 工具越多越大 | 延迟加载(Deferred Tools) |
| 动态上下文 | 1-3% | 每会话不同 | 精简注入 |
| 对话历史 | 30-60% | 持续增长 | 自动压缩 |
| 工具结果 | 10-30% | 波动最大 | 截断 / 摘要 |
| 响应空间 | 15-30% | 必须预留 | 不可压缩 |
11.2.2 最危险的场景
用户对话了 30 轮后,让 Agent 读一个 5000 行的文件,然后期望模型给出详细的修改建议。此时:
对话历史: 120K tokens (已接近上限)
+ 文件内容: 20K tokens (一个大文件)
+ 系统提示: 5K tokens
+ 工具定义: 8K tokens
= 153K tokens
预留响应: 50K tokens
───────────────────
总计需要: 203K tokens > 200K 上限!解决方案:在读文件之前先触发压缩,或者只读文件的关键部分(使用 offset + limit)。
11.3 对话历史管理:三种策略
11.3.1 完整历史(最简单,不可持续)
保留所有消息。适合短对话(< 10 轮),但长对话必然溢出。
typescript
// 最简单的实现——直到 token 爆炸
const messages: Message[] = []
messages.push({ role: 'user', content: userInput })
// ... 永远不删除旧消息11.3.2 滑动窗口(简单,有信息损失)
只保留最近 N 轮对话,丢弃更早的消息:
typescript
function slidingWindow(messages: Message[], maxTokens: number): Message[] {
let totalTokens = 0
const result: Message[] = []
// 从最新消息开始倒序计算
for (let i = messages.length - 1; i >= 0; i--) {
const tokens = countTokens(messages[i])
if (totalTokens + tokens > maxTokens) break
totalTokens += tokens
result.unshift(messages[i])
}
return result
}致命问题: 用户在第 3 轮提到的关键需求,到第 20 轮时已经被滑出窗口。模型会"忘记"最初的任务目标。
11.3.3 摘要压缩(推荐,Claude Code 的做法)
Claude Code 的压缩策略:
typescript
async function compactConversation(
messages: Message[],
targetTokens: number
): Promise<Message[]> {
const KEEP_RECENT = 6 // 保留最近 6 条消息
const toCompress = messages.slice(0, -KEEP_RECENT)
const recent = messages.slice(-KEEP_RECENT)
// 用一次独立的 LLM 调用来生成摘要
const summary = await llm.complete({
system: 'You are a conversation summarizer.',
messages: [{
role: 'user',
content: `Summarize this conversation, preserving:
- Key decisions and their rationale
- File paths that were read or modified
- Any user preferences expressed
- Pending/incomplete tasks
- Error messages and their resolutions
Conversation to summarize:
${formatMessages(toCompress)}`
}]
})
return [
{
role: 'assistant',
content: `[Conversation compacted]\n\nSummary of earlier discussion:\n${summary}`
},
...recent
]
}摘要保留什么、丢弃什么是关键设计决策:
| 保留 | 丢弃 |
|---|---|
| 用户的原始需求 | 中间的调试试错过程 |
| 已做出的关键决策 | 被否决的方案细节 |
| 已修改的文件列表 | 完整的文件内容 |
| 未完成的任务 | 已完成任务的执行细节 |
| 用户表达的偏好 | 格式化的工具输出 |
11.4 工具结果管理
工具结果是上下文中波动最大的组件。一次 Read 可能返回 2000 行代码(~8K tokens),一次 Bash 可能输出几十 KB 的日志。
11.4.1 截断策略
typescript
function truncateToolResult(result: string, maxTokens: number): string {
const tokens = countTokens(result)
if (tokens <= maxTokens) return result
// 保留头部和尾部,中间省略
const headTokens = Math.floor(maxTokens * 0.6) // 头部 60%
const tailTokens = Math.floor(maxTokens * 0.3) // 尾部 30%
// 剩余 10% 给省略提示
const head = takeFirstNTokens(result, headTokens)
const tail = takeLastNTokens(result, tailTokens)
const omitted = tokens - headTokens - tailTokens
return `${head}\n\n... [${omitted} tokens truncated] ...\n\n${tail}`
}Claude Code 的 Read 工具天然支持按需读取——不需要一次读整个文件:
typescript
// 读取指定范围,而非整个文件
const result = await readTool.execute({
file_path: '/src/main.ts',
offset: 100, // 从第 100 行开始
limit: 50, // 只读 50 行
})11.4.2 结构化摘要
比截断更智能——不是砍掉内容,而是提取关键信息:
typescript
function summarizeCommandOutput(
output: string,
exitCode: number,
command: string
): string {
// 失败时:保留错误行
if (exitCode !== 0) {
const errorLines = output.split('\n')
.filter(l => /error|Error|FAIL|panic|exception/i.test(l))
return `Command failed (exit ${exitCode}):\n${errorLines.slice(0, 20).join('\n')}`
}
// 成功但输出很长时:保留首尾
const lines = output.split('\n')
if (lines.length > 100) {
return [
`Command succeeded (${lines.length} lines output).`,
`First 5 lines:`,
...lines.slice(0, 5),
`...`,
`Last 5 lines:`,
...lines.slice(-5),
].join('\n')
}
return output
}11.4.3 历史工具结果的渐进式衰减
随着对话进行,早期工具结果的价值递减:
第 1 轮的工具结果: 完整保留(刚读的文件,可能还需要引用)
第 5 轮的工具结果: 压缩到摘要("读了 src/main.ts,312 行,定义了 App 组件")
第 15 轮的工具结果: 只保留元信息("读了一个文件")
第 25 轮的工具结果: 在整体压缩中被摘要11.5 工作记忆模式
显式维护一个"工作记忆"块,比让模型从完整对话历史中自行提取状态更可靠、更省 token:
typescript
interface WorkingMemory {
currentTask: string // 当前在做什么
keyDecisions: string[] // 重要决策记录
modifiedFiles: string[] // 已修改的文件列表
pendingActions: string[] // 待完成的操作
userPreferences: string[] // 本次对话中表达的偏好
}
// 每次调用模型前注入
function injectWorkingMemory(memory: WorkingMemory): string {
return `## Current Working State
Task: ${memory.currentTask}
Files modified: ${memory.modifiedFiles.join(', ')}
Pending: ${memory.pendingActions.join('; ')}
User preferences: ${memory.userPreferences.join('; ')}`
}Claude Code 的 TaskCreate/TaskUpdate 工具本质上就是这种模式——它让模型显式地管理任务列表,而非依赖对话历史中的隐式状态。
11.6 上下文窗口经济学
更大的上下文 ≠ 更好的 Agent。原因:
11.6.1 成本线性增长
200K token 的请求比 20K 贵 10 倍。对于多轮调用的 Agent,成本差异更大——每轮都在累积。
11.6.2 延迟增加
更多的 input token 意味着更长的首 token 延迟(TTFT)。用户感知到的"Agent 思考时间"直接受影响。
11.6.3 "Lost in the Middle"效应
学术研究(Liu et al., 2023, "Lost in the Middle")发现:当上下文很长时,模型对中间部分内容的注意力会显著下降。开头和结尾的信息被更好地利用。
这意味着:
- 重要信息应该放在上下文的开头(系统提示)或末尾(最近的消息)
- 中间的对话历史越长,信息利用率越低
- 这是压缩历史消息的又一个理由
11.6.4 实践原则
不要问"能放多少进去"
要问"最少需要放什么进去"一个高效的 Agent 应该像好的 SQL 查询——只获取需要的数据(SELECT column1, column2 WHERE condition),而不是 SELECT *。
11.7 完整实现:带摘要的上下文管理器
typescript
class ContextManager {
private maxTokens: number
private reserveForResponse: number
private systemTokens: number
constructor(config: {
maxContext: number // 如 200000
reserveResponse: number // 如 16000
systemTokens: number // 系统提示词的 token 数
}) {
this.maxTokens = config.maxContext
this.reserveForResponse = config.reserveResponse
this.systemTokens = config.systemTokens
}
get availableForHistory(): number {
return this.maxTokens - this.systemTokens - this.reserveForResponse
}
needsCompaction(messages: Message[]): boolean {
const used = messages.reduce((sum, m) => sum + countTokens(m), 0)
return used > this.availableForHistory * 0.85 // 85% 阈值
}
async compact(messages: Message[]): Promise<Message[]> {
const keepRecent = 6
if (messages.length <= keepRecent) return messages
const old = messages.slice(0, -keepRecent)
const recent = messages.slice(-keepRecent)
const summary = await this.summarize(old)
return [
{ role: 'assistant', content: `[Compacted] ${summary}` },
...recent,
]
}
private async summarize(messages: Message[]): Promise<string> {
return await llm.complete(
`Summarize preserving: decisions, file paths, preferences, pending tasks.`,
messages
)
}
}11.8 本章小结
上下文窗口管理是 Agent 系统的基石,直接决定了 Agent 的实际智能水平:
- 窗口就是记忆 — 窗口外的信息对模型不存在,上下文管理决定了 Agent 能"看到"什么
- Token 零和博弈 — 精心分配给系统提示词、工具、历史和响应,动态调整比例
- 自动压缩是核心 — 用 LLM 摘要旧消息,保留要点丢弃细节,是长对话的唯一出路
- 工具结果是最大消耗源 — 按需读取(offset/limit)、截断、摘要、渐进式衰减
- 工作记忆模式 — 显式维护关键状态,比从历史推断更可靠且更省 token
- "Lost in the Middle" — 重要信息放开头和结尾,避免淹没在冗长的中间历史中
- 少即是多 — 不要塞满上下文,只放模型决策所需的最少信息
下一章跨越上下文窗口的边界——通过长期记忆系统让 Agent 在会话之间保持连续性。