Claude Code 101|05|Context Engineering

Claude Code 的上下文不是一次字符串拼接,而是 systemPrompt、userContext、systemContext、CLAUDE.md、git status、attachments 和 hooks 的组合。

Claude Code 101|05|Context Engineering

到了第五篇,我们终于可以讨论一个 Coding Agent 最容易被低估、也最容易被误解的问题:模型到底“看见”了什么。

很多 Agent demo 的上下文构造只有一行:把 system prompt、历史消息、当前用户输入拼成一个数组,然后发给模型。这个做法在玩具环境里成立,因为任务短、工具少、状态少、用户预期也少。但一旦 Agent 进入真实工程项目,问题会迅速变复杂:项目里有约定,用户有偏好,工作区有未提交修改,git 分支有语义,工具结果可能非常长,之前的会话可能已经 compact,某些文件被读取过但后来又变了,hook 可能注入额外上下文,系统 prompt 还可能因为运行模式不同而改变。

所以,Context Engineering 的核心不是“把更多内容塞进 prompt”,而是把 session state 投影成 model-visible context。这里有三个关键词:state、projection、visible。Agent Runtime 内部保存的状态可以很多,但每一次模型调用只能看到其中一部分;这部分不是随意截断出来的,而是按控制意图、事实来源、预算约束和安全边界重新编译出来的视图。

Claude Code 很适合作为 Context Engineering 案例:它把上下文拆成 user context 和 system context,区分 system prompt、项目事实、环境快照、工具结果、compact 边界和预算。重要的是,这些机制共同指向一个工程结论:成熟 Agent 的上下文层,是 runtime 的投影层,不是字符串拼接层。

工程问题:上下文不是历史,而是当前决策视图

Coding Agent 面临的上下文问题可以拆成四类。

第一,控制上下文。模型必须知道自己是什么角色、可以做什么、不能做什么、如何使用工具、如何与用户交互。这部分通常进入 system prompt。它不是事实资料,而是行为边界。

第二,项目上下文。模型需要理解项目规则、构建命令、代码风格、团队约定、用户偏好。这类信息可能来自 CLAUDE.md、memory files、显式附加目录,或者用户在会话中长期积累的偏好。

第三,环境上下文。模型需要知道当前工作区状态,例如是否是 git 仓库、当前分支、主分支、未提交变更、最近提交、git user 等。用户说“帮我改一下这个功能”时,如果 Agent 不知道工作区已有未提交修改,就可能覆盖用户工作;如果不知道当前分支,就可能给出错误的 PR 建议。

第四,对话上下文。模型需要知道本轮任务之前发生了什么,包括用户目标、工具调用结果、错误恢复、已读文件、已编辑文件、compact 后的摘要等。但这部分不能无限增长,也不能把所有 tool output 原样塞回模型。

这四类上下文的优先级和生命周期不同。如果全部混成一个长 prompt,就会出现几个典型故障:控制规则被事实材料淹没,过期 git 状态被模型误认为实时事实,长期记忆覆盖当前任务,巨大的工具输出挤掉真正重要的用户意图,或者 compact 后的摘要和原始历史互相冲突。

因此,Claude Code 的 Context Engineering 要解决的问题不是“如何获得上下文”,而是“如何在每个 turn 之前构造一个足够准确、足够稳定、足够省预算、足够可治理的上下文视图”。

概念边界:它位于 Agent Loop 和 Tool Runtime 之间

上一篇讨论 Agent Loop 时,重点是模型与工具如何在 continuation/recovery 状态机中推进。Agent Loop 关心的是“下一步是否继续调用模型”“工具结果如何回填”“出错后如何恢复”。Context Engineering 则在每次模型调用之前工作:它决定这次调用所看到的 system、messages、tool schemas 和附加环境事实。

下一篇会进入 Tool Runtime。Tool Runtime 关心工具如何定义 schema、权限、执行、UI 和结果序列化。Context Engineering 与 Tool Runtime 的关系是双向的:一方面,工具定义会消耗上下文窗口,工具数量越多,schema 越需要延迟加载和搜索;另一方面,工具结果又会返回对话历史,成为下一次上下文投影的原材料。也就是说,Context Engineering 并不只处理“用户说过的话”,它还处理工具系统制造出来的观察结果。

可以把这一层理解成 runtime 的“编译器前端”:session state 是输入材料,model request 是目标产物。编译器不会把项目目录里的所有文件都塞进目标程序,它会解析、过滤、内联、删除死代码、保留符号信息、生成当前执行所需的结构。上下文投影也是同样的道理。

机制一:把控制面与事实面分离

Claude Code 的第一个关键设计,是把 system prompt、user context、system context 分开。src/context.ts 中的 getUserContext() 读取 CLAUDE.md / memory files,并注入 current date;getSystemContext() 则收集 git status 这类运行环境事实。system prompt 的构造则在 src/utils/systemPrompt.ts 一侧完成。

这个分层看起来朴素,但非常重要。system prompt 是控制面,负责定义 Agent 的行为协议;user context 是项目与用户事实,负责提供偏好和约定;system context 是运行时快照,负责提供环境状态。三者都可能变成模型可见文本,但它们不应该拥有同一种治理策略。

例如,CLAUDE.md 里的内容通常来自项目或用户,可能很长,也可能夹杂过时说明。它适合被作为项目上下文注入,但不应该覆盖工具安全规则。git status 是一个时间点快照,适合提醒模型“开始时工作区长什么样”,但不能被理解成实时状态。system prompt 的权限、安全、交互规范则应该具有更高优先级,不能被项目文档轻易改写。

src/context.ts 里对 git status 的文案也体现了这一点:它明确说明这是 conversation start 时的快照,并非会话期间自动更新。这个细节避免了模型把旧状态当成实时状态。真实工程里,很多上下文 bug 都不是因为没有信息,而是因为信息的时间语义不清楚。

可迁移的抽象如下:

type ContextLayer =
  | { kind: 'control'; priority: 'system'; text: string }
  | { kind: 'project'; priority: 'user'; text: string; source: 'memory' | 'project-doc' }
  | { kind: 'environment'; priority: 'system-fact'; text: string; snapshotAt: Date }
  | { kind: 'conversation'; priority: 'turn'; messages: Message[] }

function projectContext(layers: ContextLayer[]): ModelRequest {
  const control = mergeControlLayers(layers)
  const facts = rankFactLayers(layers)
  const history = compactConversation(layers)

  return {
    system: renderSystem(control, facts.environment),
    messages: prependProjectFacts(history, facts.project),
  }
}

这段伪代码的重点不是函数名,而是边界:控制面和事实面可以同时进入模型请求,但它们必须在 runtime 中保持不同身份。否则你无法解释“哪段文本应该支配行为,哪段文本只是参考资料”。

机制二:项目记忆要缓存,也要可关闭

src/context.ts 对 user context 做了 memoize。注释说这部分会被 prepend 到每次 conversation,并在会话期间缓存。它读取 memory files,再组合成 claudeMd,同时加入 current date。这里的缓存不是性能优化的小技巧,而是 prompt-cache 稳定性和上下文一致性的一部分。

如果每一轮都重新扫描项目文档,模型看到的前缀可能在不经意间变化,导致 prompt cache 失效,也导致同一会话内上下文语义漂移。对于 Agent 来说,“今天日期变化”“项目文档刚被用户编辑”“运行模式从普通变成 bare”都可能改变上下文。如果这些变化没有明确边界,模型行为就会变得难以解释。

Claude Code 也不是无条件读取项目文档。src/context.ts 中可以看到环境变量和 bare mode 对 CLAUDE.md 自动发现的影响:禁用开关可以 hard off;bare mode 会跳过自动发现,但仍保留显式 add-dir。这个设计说明 user context 是一项能力,不是不可撤销的默认污染。

这对自己的 Agent Harness 有直接启发:项目记忆应该具备三个属性。

第一,可发现。Agent 应该能自动找到项目约定,否则每次都依赖用户手工提示,体验会退化。

第二,可缓存。一次会话内的项目约定应该稳定,否则缓存和推理都会被破坏。

第三,可关闭。某些模式下,用户希望一个干净的 prompt,不希望 runtime 自动注入本地文档。尤其在评测、批处理、远程执行和安全敏感场景中,自动注入必须是可控的。

伪代码可以写成:

type ProjectMemoryPolicy = {
  autoDiscover: boolean
  explicitDirectories: string[]
  cacheScope: 'session' | 'turn'
}

async function loadProjectMemory(policy: ProjectMemoryPolicy): Promise<ProjectMemory> {
  if (!policy.autoDiscover && policy.explicitDirectories.length === 0) {
    return { blocks: [], disabled: true }
  }

  const files = await discoverMemoryFiles(policy)
  const filtered = filterInjectedOrUnsafeFiles(files)
  const text = await concatenateMemory(filtered)

  return { blocks: [{ source: 'project-memory', text }], disabled: false }
}

注意这里的 filterInjectedOrUnsafeFiles。真实 Agent 不能只问“有没有记忆文件”,还要问“这份记忆从哪里来、是否被注入、是否应该在当前模式中出现”。上下文层天然是 prompt injection 的接触面,因此它必须承担来源治理。

机制三:环境上下文是快照,不是事实数据库

src/context.ts 中的 git status 构造很有代表性。它先判断当前目录是否为 git 仓库,然后并行获取 branch、main branch、status、recent commits 和 git user。status 长度超过限制时会截断,并提示如果需要更多信息,应通过 BashTool 主动运行 git status。

这里有几个工程点值得拆开。

第一,环境上下文是 bounded 的。git status 在大型仓库里可能非常长,不能无限塞进 system context。Claude Code 用字符上限保护上下文窗口,并把“需要更多信息”转移给工具调用。这是一种懒加载策略:默认给模型足够的工作区信号,而不是完整状态。

第二,环境上下文是 snapshot。文案明确说明它是会话开始时的状态,不会在会话期间自动更新。这样模型在后续回合看到它时不会误解。若模型需要实时状态,应该调用工具重新查询。

第三,环境上下文可以按运行环境跳过。代码中远程环境或禁用 git instructions 的情况下会跳过 git status。这说明上下文注入不是“越多越好”,而是要考虑运行场景成本。例如远程恢复时重复计算 git status 可能没有必要,还可能引入延迟。

第四,环境上下文是 system facts,而不是 user facts。它最终与 system prompt 组合,目的是给模型稳定的运行环境说明。它不应该混入用户消息,否则模型可能把它误认为用户要求。

一个可迁移模式是:

type EnvironmentSnapshot = {
  capturedAt: Date
  facts: Record<string, string>
  staleAfter?: Duration
  refreshTool?: string
}

async function captureEnvironment(): Promise<EnvironmentSnapshot> {
  const facts = await collectBoundedFacts([
    gitBranchFact(),
    gitStatusFact({ maxChars: 2000 }),
    recentCommitsFact({ limit: 5 }),
  ])

  return {
    capturedAt: new Date(),
    facts,
    refreshTool: 'Bash',
  }
}

这个模式的关键是把“快照”写进类型。很多上下文系统出错,就是因为它们把快照、缓存、实时查询和长期记忆都表达成普通字符串。

机制四:有效 system prompt 是运行模式的产物

system prompt 不是一个静态常量。Claude Code 中有默认 prompt、custom prompt、append prompt、agent prompt、coordinator prompt、override prompt 等不同来源。src/utils/systemPrompt.ts 的职责是把这些来源合成为当前 turn 的 effective system prompt。

这说明 system prompt 实际上是 runtime policy 的渲染结果。不同模式下,Agent 的身份、工具说明、协作方式、是否处于 coordinator 模式、是否有自定义系统提示,都会影响最终控制文本。

为什么不能只让用户传一个 system prompt?因为 Coding Agent 的系统提示不只是人格设定,它还承载工具协议、安全规则、输出约束、任务边界、错误恢复策略。允许完全覆盖当然有价值,但覆盖必须是显式行为;追加也有价值,但追加应该在明确位置发生;多 Agent 模式下 coordinator prompt 和 worker prompt 也不应该混用。

这里可以把 system prompt 看作 policy stack:

type PromptPolicyStack = {
  override?: string
  coordinator?: string
  agentIdentity?: string
  defaultRuntimePolicy: string
  customReplacement?: string
  append?: string
}

function buildEffectivePrompt(stack: PromptPolicyStack): string {
  if (stack.override) return stack.override

  const base = stack.customReplacement ?? stack.defaultRuntimePolicy
  return joinSections([
    stack.coordinator,
    stack.agentIdentity,
    base,
    stack.append,
  ])
}

这个抽象有两个好处。第一,优先级透明。你可以解释为什么某段 prompt 出现在最终请求中。第二,可测试。每种模式可以测试最终 prompt 是否包含必要的控制段,而不是依赖人工审查大字符串。

机制五:对话历史要从 transcript 投影,而不是照搬

用户在终端看到的是 transcript:用户消息、助手消息、工具调用、工具结果、UI 提示、compact boundary、错误、恢复信息。但模型请求需要的是 message sequence。两者不是同一个对象。

src/query.ts 负责模型请求路径,里面能看到 compact boundary、tool result 预算、context collapse、microcompact / autocompact 等相关处理。这里的工程思想是:transcript 是可审计记录,model-visible messages 是当前推理输入。记录可以完整,输入必须受预算治理。

这点尤其影响工具结果。工具输出可能是上万行日志、图片、PDF、notebook、搜索结果、diff、LSP location 列表。直接把所有结果塞回模型,短期看“信息更全”,长期看会摧毁上下文窗口。更好的做法是让工具层提供模型可见序列化,让上下文层再按预算选择保留、摘要、折叠或持久化引用。

因此,Context Engineering 需要把“历史”拆成几种材料:

  • 用户目标与约束,优先保留。
  • 工具调用的语义结果,按任务相关性保留。
  • 大型原始输出,摘要或持久化引用。
  • 已 compact 的旧历史,以摘要代替原文。
  • 被中断、失败或无结果的 tool_use,按协议清理。

伪代码如下:

function projectConversation(transcript: Transcript, budget: TokenBudget): Message[] {
  const afterBoundary = transcript.afterLastCompactBoundary()
  const normalized = removeDanglingToolUses(afterBoundary)
  const ranked = rankMessagesByRuntimeValue(normalized)

  return fitToBudget(ranked, budget, {
    summarizeToolResults: true,
    preserveUserConstraints: true,
    preserveRecentTurns: true,
  })
}

这里的重点是 rankMessagesByRuntimeValue。上下文裁剪不应该只按时间顺序砍掉最旧消息。某些旧消息是关键约束,某些新消息只是噪音。成熟 Agent 需要把消息价值显式化。

机制六:上下文缓存要服务稳定性,而不是隐藏状态

src/constants/systemPromptSections.ts 中有 system prompt section 的 memoized 机制,并在 /clear/compact 等边界上清理。这个设计说明上下文缓存不是为了“偷懒不算”,而是为了让 prompt 前缀稳定、让会话边界清晰。

缓存有双重风险。没有缓存,每轮 prompt 变化太大,cache 命中率低,行为也不稳定。有缓存,旧信息可能滞留,导致模型看到过期事实。因此缓存必须绑定清晰生命周期:session start、turn、compact、clear、resume、mode switch。Claude Code 把 user context 和 system context memoize 到会话级,又在特定操作中清理相关状态,就是在处理这个生命周期问题。

对自己的 Agent 来说,可以建立以下原则:

type ContextCacheKey = {
  sessionId: string
  mode: RuntimeMode
  workspaceFingerprint: string
  promptPolicyVersion: string
}

type CachedContextPart = {
  key: ContextCacheKey
  value: string
  invalidatedBy: Array<'clear' | 'compact' | 'mode-change' | 'memory-change'>
}

不要把上下文缓存做成全局变量。它应该有 key,有失效条件,有调试可见性。否则用户问“为什么模型还记得刚才的规则”时,系统无法回答。

Claude Code 作为 Context Engineering 案例

从 Agent Runtime 的角度看,Claude Code 的上下文层不是一个 prompt 模板,而是一组 runtime 机制。它把控制面、项目事实、环境快照、消息历史、工具结果、memory 和 compact boundary 组织成模型当前可见的一次投影。

这也是 Context Engineering 的关键:上下文不是“更多文本”,而是 runtime state 在当前 turn 的受预算、受治理、可恢复视图。

工程启发

第一,不要用“字符串拼接”作为 Agent 上下文架构。拼接当然最终会发生,但在拼接之前,必须有来源分类、优先级、生命周期、预算和投影策略。

第二,把上下文分成控制面、项目事实、环境事实和对话历史。控制面应该优先稳定;项目事实应该可发现、可缓存、可关闭;环境事实应该明确是快照;对话历史应该从 transcript 投影,而不是照搬。

第三,对大型工具结果使用模型可见摘要和持久化引用。不要让日志、搜索结果或文件内容无上限吞掉用户意图。

第四,为上下文缓存设计失效边界。/clear/compact、resume、mode switch、memory update 都可能改变上下文语义。缓存如果没有生命周期,就会变成隐藏状态。

第五,在 prompt 中标注事实的时间语义。特别是 git status、测试结果、文件列表这类会变化的信息,应该告诉模型它们是快照,必要时通过工具刷新。

小结

Context Engineering 的本质,是把 runtime 保存的 session state 编译成当前 turn 可用的 model-visible context。Claude Code 的实现把 system prompt、user context、system context、conversation messages、tool result 和 compact 机制分层处理,避免把上下文退化成一条不断增长的字符串。

下一篇会继续往下走:当模型在这个上下文视图里决定调用工具时,runtime 如何把一个 tool_use 变成受 schema、权限、并发、UI 和结果序列化治理的执行协议。这就是 Tool Runtime 的问题。