从源码拆解 Claude Code 的上下文工程

0 阅读15分钟

Claude Code 上下文工程首图

从源码拆解 Claude Code 的上下文工程

引言

Claude Code 的源码泄露给了我们一个罕见的窗口——看看业界最活跃的 AI 编程助手,到底怎么管理上下文的。

我用 GitNexus 知识图谱对这套代码做了完整索引:1914 个文件、15037 个符号、44359 条依赖关系、1163 个功能聚类。这不是一篇"看了篇博客就来聊概念"的文章——下面每个结论都指向具体的源码文件和行号。

一、上下文工程到底在解决什么问题

先说清楚一件事:Context Engineering ≠ Prompt Engineering

Prompt Engineering 关注的是"怎么写指令让模型表现更好"——few-shot、CoT、ReAct 这些。这些技巧有用,但只解决了模型能力问题。

当你要构建一个需要长时间运行、处理多源输入、维护跨会话状态的 Agent 系统时,一个写得再好的 prompt 也不够。你需要解决的是:

  • 同一时刻可能有用户键盘输入、后台任务通知、权限请求同时涌进来,怎么排队处理?
  • 对话超过20轮,上下文窗口快满了,怎么在不丢关键信息的前提下压缩?
  • 当前会话学到的东西,下次会话怎么记得?
  • 模型输出了幻觉或格式错误,工程系统怎么兜住?

这些问题的答案不在 prompt 里——在代码里。

二、分层记忆:六层优先级的指令加载

Claude Code 的记忆系统是整个上下文工程最精巧的部分。绝大多数 AI 应用的做法是把一个 system prompt 塞进去就完了,Claude Code 不是。

src/utils/claudemd.ts 的头部注释(L1-L26),能看到完整的加载层级:

优先级是反序的——后加载的优先级更高。这意味着你在 CLAUDE.local.md 里写的指令会覆盖 CLAUDE.md 里的同类指令。设计意图很明确:越贴近当前工作上下文的指令,权重越大

getMemoryFiles()(L790-L1075)的实际加载流程:

// 1. 加载 Managed(全局策略)
const managedClaudeMd = getMemoryPath('Managed')
result.push(...(await processMemoryFile(managedClaudeMd, 'Managed', ...)))

// 2. 加载 User(个人偏好),注意:User 级始终允许引用外部文件
result.push(...(await processMemoryFile(userClaudeMd, 'User', processedPaths, true)))

// 3. 从根目录向下遍历到 CWD,逐层加载 Project 和 Local
const dirs: string[] = []
let currentDir = originalCwd
while (currentDir !== parse(currentDir).root) {
  dirs.push(currentDir)
  currentDir = dirname(currentDir)
}
for (const dir of dirs.reverse()) {
  // CLAUDE.md, .claude/CLAUDE.md, .claude/rules/*.md
  // CLAUDE.local.md
}

// 4. AutoMem 和 TeamMem
if (isAutoMemoryEnabled()) { ... }
if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { ... }

有几个细节值得注意:

目录遍历是从根到 CWDdirs.reverse() 让根目录的规则先加载(低优先级),当前目录的规则后加载(高优先级)。在 monorepo 场景下,根目录放通用规则,子项目目录放特定规则,后者自然覆盖前者。

@include 指令。Claude Code 的 memory 文件支持 @path 语法引用其他文件(L18-L25),递归深度限制为 5 层(MAX_INCLUDE_DEPTH)。这让你可以把大量文档拆成模块化结构,CLAUDE.md 只放索引。

条件规则(Conditional Rules).claude/rules/ 下的文件可以带 frontmatter 指定 paths 模式,只在操作匹配路径的文件时才注入。比如只针对 *.py 文件的 Python 风格指南,不会污染 TypeScript 的上下文。

function parseFrontmatterPaths(rawContent: string): {
  content: string
  paths?: string[]
} {
  const { frontmatter, content } = parseFrontmatter(rawContent)
  if (!frontmatter.paths) return { content }
  // 解析 glob 模式,过滤匹配路径
  const patterns = splitPathInFrontmatter(frontmatter.paths)
    .map(pattern => pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern)
    .filter(p => p.length > 0)
  return { content, paths: patterns }
}

这种设计把"上下文"从一个静态文本变成了分层、条件化、可组合的配置系统。

三、动态上下文组装:不是塞 prompt,是编译 prompt

Claude Code 的 system prompt 不是一个写死的字符串。它在每次对话开始时被组装出来。

src/context.ts 展示了这个过程。系统上下文分成两部分:

3.1 systemContext:环境状态快照

export const getSystemContext = memoize(async () => {
  // Git 状态:当前分支、主分支、最近5条提交、工作区状态
  const gitStatus = await getGitStatus()
  
  return {
    ...(gitStatus && { gitStatus }),
    // cache breaker 用于调试,内部功能
  }
})

getGitStatus() 通过 Promise.all 并行执行五个 git 命令(L61-L77),拿到分支、默认分支、status、log、用户名。注意 status 输出有 2000 字符截断限制(MAX_STATUS_CHARS),超了会提示模型用 BashTool 自己跑 git status

git 状态被明确标注为"会话开始时的快照,不会在对话过程中更新"。这是一个重要的设计决策——避免每轮都刷新导致的缓存失效和上下文波动。

3.2 userContext:指令集合并

export const getUserContext = memoize(async () => {
  // CLAUDE.md 层级合并:Managed → User → Project → Local → AutoMem → TeamMem
  const claudeMd = getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
  // 缓存给 yoloClassifier 用(决定是否自动执行)
  setCachedClaudeMdContent(claudeMd || null)
  return {
    ...(claudeMd && { claudeMd }),
    currentDate: `Today's date is ${getLocalISODate()}.`,
  }
})

两个上下文都用了 memoize——整个会话只算一次。这意味着 CLAUDE.md 的修改在当前会话内不会生效(除非触发 cache clear)。这是性能和一致性的权衡。

最终在 query loop 里,这些被组装进 API 调用:

const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext)
)
// userContext 被 prepend 到消息序列前
messages = prependUserContext(messagesForQuery, userContext)

系统提示分成静态前缀和动态后缀,中间有 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔。前缀跨用户缓存共享(prompt cache hit),后缀是用户特定的。

四、上下文压缩:三级递进的窗口管理

当对话历史增长到接近上下文窗口上限时,Claude Code 不是简单地截断,而是有一套多级压缩策略。看 src/query.ts 的 query loop(L307 开始),每一轮迭代都按顺序跑这些步骤:

4.1 Tool Result Budget(工具结果裁剪)

messagesForQuery = await applyToolResultBudget(
  messagesForQuery,
  toolUseContext.contentReplacementState,
  ...
)

每个工具的输出有字节上限。超大的文件读取结果会被替换为引用指针。这在源头控制了上下文增长速度。

4.2 Snip Compact(历史片段删除)

if (feature('HISTORY_SNIP')) {
  const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
  messagesForQuery = snipResult.messages
  snipTokensFreed = snipResult.tokensFreed
}

Snip 是最轻量的压缩——直接移除旧的、不重要的消息片段,不需要调用模型。

4.3 Microcompact(微压缩)

const microcompactResult = await deps.microcompact(
  messagesForQuery, toolUseContext, querySource
)
messagesForQuery = microcompactResult.messages

Microcompact 比 Snip 更智能,但仍然不涉及完整 summarization。它处理的是可以安全压缩的局部冗余。

4.4 Context Collapse(上下文折叠)

if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
  const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
    messagesForQuery, toolUseContext, querySource
  )
  messagesForQuery = collapseResult.messages
}

Context Collapse 是一个实验性功能,它在消息序列上做"折叠"操作——概念上类似代码编辑器的折叠功能,把一段交互折叠成摘要,但保留展开的能力。

4.5 Auto Compact(自动全量压缩)

const { compactionResult } = await deps.autocompact(
  messagesForQuery, toolUseContext, { systemPrompt, userContext, ... },
  querySource, tracking, snipTokensFreed
)

当 token 用量超过阈值(effectiveContextWindow - 13000),触发全量压缩。这会调用模型生成会话摘要,替换掉旧消息。

src/services/compact/autoCompact.ts 的阈值计算:

export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
}

关键细节:连续失败3次后触发熔断器MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3),停止重试。这防止了一个有趣的生产问题——当上下文已经不可恢复地超限时,反复尝试压缩只会浪费 API 调用。代码注释说得很直白:

BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally.

4.6 压缩 Prompt 的设计

Compact 的 prompt 设计本身也值得分析。src/services/compact/prompt.ts 定义了压缩指令:

const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.
`

这段前导指令非常强硬,反复强调不要用工具。原因在注释里:

on Sonnet 4.6+ adaptive-thinking models the model sometimes attempts a tool call despite the weaker trailer instruction. With maxTurns: 1, a denied tool call means no text output → falls through to the streaming fallback (2.79% on 4.6 vs 0.01% on 4.5).

压缩摘要要求保留9个维度的信息:主要请求意图、技术概念、文件和代码片段、错误和修复、问题解决过程、所有用户消息、待办任务、当前工作、下一步。特别重要的是第9项——"用原文引用最近的对话内容",防止任务理解漂移。

还有一个设计细节:<analysis> 标签作为"草稿本",让模型先整理思路再输出摘要。formatCompactSummary() 会在摘要进入上下文前把 <analysis> 部分剥掉——它只是提升生成质量的手段,不占用宝贵的上下文空间。

五、持久记忆:四种类型的自动提取

Claude Code 不只是在会话中管理上下文,它还有一套跨会话的持久记忆系统。

5.1 Memory 类型体系

src/memdir/memoryTypes.ts,记忆被分为四类:

类型含义示例
user关于用户的信息"用户是数据科学家,目前关注可观测性"
feedback用户给出的行为指导"不要在测试中 mock 数据库,因为上次生产出过问题"
project项目动态信息"下周四开始 merge freeze,移动端要切 release 分支"
reference外部资源指针"pipeline bug 跟踪在 Linear 的 INGEST 项目里"

这个分类体系有一个明确的排除原则

export const WHAT_NOT_TO_SAVE_SECTION: readonly string[] = [
  '## What NOT to save in memory',
  '- Code patterns, conventions, architecture, file paths, or project structure — '
  + 'these can be derived by reading the current project state.',
  '- Git history, recent changes, or who-changed-what — '
  + '`git log` / `git blame` are authoritative.',
  '- Debugging solutions or fix recipes — '
  + 'the fix is in the code; the commit message has the context.',
  ...
]

原则很清楚:能从代码和 git 历史中推导出来的信息,不存。只存那些"不可推导"的知识——用户身份、行为偏好、项目动态、外部引用。

5.2 自动提取机制

src/services/extractMemories/ 实现了后台记忆提取。它作为主对话的一个 fork 运行——共享相同的系统提示和消息前缀,但只有有限的工具集(读文件、Grep、Glob、只读 Bash、编辑/写入工具仅限 memory 目录)。

提取 prompt 有一段关于效率的指导:

`You have a limited turn budget. FileEditTool requires a prior FileReadTool 
of the same file, so the efficient strategy is: turn 1 — issue all 
FileReadTool calls in parallel for every file you might update; turn 2 — 
issue all FileWriteTool/FileEditTool calls in parallel.`

两轮完成:第一轮并行读所有可能要更新的文件,第二轮并行写。不浪费 turn。

5.3 记忆漂移防护

记忆有个固有问题:存的时候是对的,读的时候可能已经过时了。Claude Code 专门为此设计了验证机制(TRUSTING_RECALL_SECTION):

"A memory that names a specific function, file, or flag is a claim 
that it existed *when the memory was written*. It may have been 
renamed, removed, or never merged. Before recommending it:
- If the memory names a file path: check the file exists.
- If the memory names a function or flag: grep for it."

翻译:记忆不是事实,是历史快照。用之前先验证。

5.4 Session Memory:会话级笔记

除了跨会话的持久记忆,还有会话内的结构化笔记系统(src/services/SessionMemory/)。Session Memory 用一个固定模板维护当前会话的状态:

export const DEFAULT_SESSION_MEMORY_TEMPLATE = `
# Session Title
# Current State — 当前正在做什么
# Task specification — 用户要求构建什么
# Files and Functions — 重要文件列表
# Workflow — 通常运行哪些命令
# Errors & Corrections — 遇到的错误和修复方法
# Codebase and System Documentation
# Learnings — 什么有效什么无效
# Key results — 关键输出结果
# Worklog — 步骤级工作日志
`

每个 section 有 token 上限(2000),总量上限 12000 token。当接近限制时会提示模型压缩旧内容。这个机制让 Session Memory 在长会话中保持可用,不会无限膨胀。

六、消息队列:统一入口的设计

所有输入——用户键盘输入、后台任务通知、权限请求——进入同一个队列。

src/utils/messageQueueManager.ts 的核心是三级优先级:

const PRIORITY_ORDER: Record<QueuePriority, number> = {
  now: 0,    // 立即处理
  next: 1,   // 用户输入
  later: 2,  // 系统通知
}

两个入队函数区分了优先级:enqueue() 默认 next(用户输入),enqueuePendingNotification() 默认 later(系统消息)。这确保用户交互永远优先出队

状态管理绕过了 React Context,用模块级单例 + useSyncExternalStore

const commandQueue: QueuedCommand[] = []
let snapshot: readonly QueuedCommand[] = Object.freeze([])
const queueChanged = createSignal()

function notifySubscribers(): void {
  snapshot = Object.freeze([...commandQueue])
  queueChanged.emit()
}

每次队列变化都创建新的冻结快照——引用变化触发重渲染,不可变保证并发安全。这比 React Context 的逐层传播可靠得多,尤其在终端 UI 这种对更新延迟敏感的场景。

出队时有智能批处理(src/utils/queueProcessor.ts):斜杠命令和 Bash 模式单独处理(需要完整执行链路和错误隔离),普通消息按 mode 批量消费。

七、附件系统:运行时上下文注入

Claude Code 的附件系统(src/utils/attachments.ts,3998 行)是上下文工程中最复杂的一环。它负责在每轮对话中动态注入和上下文相关的额外信息。

附件类型非常多样——用 GitNexus 统计至少 40+ 种,包括但不限于:

  • 文件附件:用户 @mention 的文件内容
  • 嵌套记忆:根据当前操作路径动态注入的条件规则
  • 相关记忆:基于语义相关性从 memdir 中检索的持久记忆,每轮最多5个,每个最大 4KB
  • TODO/任务提醒:每隔 10 轮注入待办状态
  • Plan 模式提醒:每隔 5 轮注入计划状态,全量/精简交替
  • 技能发现:根据用户意图自动发现并注入相关技能描述
  • 诊断信息:LSP 诊断结果、lint 错误
  • 工具增减 delta:当可用工具集变化时增量通知
  • Agent 列表 delta:子 Agent 可用性变化的增量通知
  • 日期变更:跨日对话时注入新日期
export const RELEVANT_MEMORIES_CONFIG = {
  MAX_SESSION_BYTES: 60 * 1024, // 单会话累计注入上限 60KB
} as const

相关记忆的注入有累计预算控制——整个会话最多注入 60KB 的记忆内容。超过后停止预取。这防止了长会话中反复注入记忆导致上下文爆炸。

八、Query Loop:把一切串起来

src/query.tsquery() 函数是整个系统的心脏。看它的主循环(简化后):

while (true) {
  1. Tool Result Budget → 裁剪工具输出
  2. Snip Compact → 轻量删除
  3. Microcompact → 局部压缩
  4. Context Collapse → 折叠(实验性)
  5. Auto Compact → 全量压缩(如果需要)
  6. 组装 system prompt + user context
  7. 调用模型 API,流式接收响应
  8. 执行工具调用(支持流式并行执行)
  9. 生成 Tool Use Summary(异步)
  10. 收集附件(记忆、诊断、提醒等)
  11. 判断:还有工具调用未处理?→ continue
  12. 判断:需要错误恢复?→ continue
  13. 完成 → return
}

每一轮迭代都维护了大量状态:

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  turnCount: number
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  // ...
}

错误恢复特别值得看。当模型输出超长触发 max_output_tokens 时,最多重试 3 次。当上下文太大触发 prompt_too_long 时,会依次尝试 Context Collapse drain → Reactive Compact → 回退模型。这些恢复路径之间有明确的优先级和互斥关系——代码注释里反复强调某个 feature flag 的 withhold 和 recover 必须"一致",否则会"吞掉消息"。

九、可复用的设计模式

从这套源码里可以提炼出几个通用的 Agent 上下文管理模式:

模式一:分层指令 + 就近覆盖

不要用一个大 system prompt。把指令分成多层(全局→用户→项目→会话),后加载的覆盖先加载的。这让同一套系统可以适应不同团队、不同项目的需要,同时保持默认行为的一致性。

模式二:渐进压缩

不要等上下文满了才压缩。设置多级阈值(Snip → Microcompact → Collapse → Full Compact),从轻量到重量逐级触发。轻量操作频繁执行、几乎无成本;重量操作只在必要时触发、有完备的失败熔断。

模式三:不可推导原则

只持久化那些无法从当前代码和 git 历史中推导出来的信息。代码模式、架构、文件结构——这些可以随时重新读取。用户偏好、项目决策动机、外部资源位置——这些不存就丢了。

模式四:附件 Delta

不要每轮都全量注入所有上下文。跟踪什么变了,只注入增量(deferred_tools_delta、agent_listing_delta、mcp_instructions_delta)。既节省 token,又避免重复信息干扰模型注意力。

模式五:用确定性包裹不确定性

模型输出是不确定的,但围绕它的一切——消息队列、状态管理、压缩调度、记忆持久化——都是确定性的工程系统。用 Object.freeze() 防止意外修改,用引用变化触发更新,用熔断器控制重试,用 memoize 保证一致性。在不确定的模型输出外面套一层确定性的壳。

结语

Claude Code 的上下文工程不是某个单一的技巧,而是一整套相互配合的系统设计。分层记忆解决"什么知识该在什么时候出现",动态组装解决"system prompt 如何因时而变",多级压缩解决"20万 token 窗口怎么用到极致",持久记忆解决"跨会话如何不失忆",附件系统解决"运行时怎么按需补充上下文"。

对构建 Agent 系统的工程师来说,这里真正值得学的不是某一段代码写得多精巧,而是这种把上下文当一等公民来管理的架构意识——它需要的不是更好的 prompt,而是更好的工程。


源码参考

模块文件路径核心职责
分层记忆src/utils/claudemd.ts六层 CLAUDE.md 加载、@include、条件规则
上下文组装src/context.tsGit 状态快照、system/user context 构建
压缩系统src/services/compact/Auto/Micro/Snip/Reactive/Session Memory compact
压缩 Promptsrc/services/compact/prompt.ts9 维摘要指令、analysis 草稿本
持久记忆src/memdir/memoryTypes.ts四类记忆体系、排除规则
记忆提取src/services/extractMemories/后台 fork 提取、两轮并行策略
Session Memorysrc/services/SessionMemory/prompts.ts会话级笔记模板、token 预算
消息队列src/utils/messageQueueManager.ts三级优先级、模块级单例
附件系统src/utils/attachments.ts40+ 附件类型、delta 增量注入
Agent Loopsrc/query.ts主循环、压缩调度、错误恢复

参考资料