Skip to content

第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 Prompt3-5%相对固定Prompt Caching
Tool Definitions4-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 的实际智能水平:

  1. 窗口就是记忆 — 窗口外的信息对模型不存在,上下文管理决定了 Agent 能"看到"什么
  2. Token 零和博弈 — 精心分配给系统提示词、工具、历史和响应,动态调整比例
  3. 自动压缩是核心 — 用 LLM 摘要旧消息,保留要点丢弃细节,是长对话的唯一出路
  4. 工具结果是最大消耗源 — 按需读取(offset/limit)、截断、摘要、渐进式衰减
  5. 工作记忆模式 — 显式维护关键状态,比从历史推断更可靠且更省 token
  6. "Lost in the Middle" — 重要信息放开头和结尾,避免淹没在冗长的中间历史中
  7. 少即是多 — 不要塞满上下文,只放模型决策所需的最少信息

下一章跨越上下文窗口的边界——通过长期记忆系统让 Agent 在会话之间保持连续性。

基于 VitePress 构建