从源码拆解 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()) { ... }
有几个细节值得注意:
目录遍历是从根到 CWD。dirs.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.ts 的 query() 函数是整个系统的心脏。看它的主循环(简化后):
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.ts | Git 状态快照、system/user context 构建 |
| 压缩系统 | src/services/compact/ | Auto/Micro/Snip/Reactive/Session Memory compact |
| 压缩 Prompt | src/services/compact/prompt.ts | 9 维摘要指令、analysis 草稿本 |
| 持久记忆 | src/memdir/memoryTypes.ts | 四类记忆体系、排除规则 |
| 记忆提取 | src/services/extractMemories/ | 后台 fork 提取、两轮并行策略 |
| Session Memory | src/services/SessionMemory/prompts.ts | 会话级笔记模板、token 预算 |
| 消息队列 | src/utils/messageQueueManager.ts | 三级优先级、模块级单例 |
| 附件系统 | src/utils/attachments.ts | 40+ 附件类型、delta 增量注入 |
| Agent Loop | src/query.ts | 主循环、压缩调度、错误恢复 |