02-想做 Code Agent,但不想只会调 API?我把 Claude Code 源码拆成了一套教程

8 阅读6分钟

关键词:Tool System / ToolUseContext / System Prompt / CLAUDE.md / Auto Compact / Context Engineering

Tool 不是函数,上下文不是消息:Claude Code 的执行内核怎么设计

如果第一篇解决的是“Agent 怎么启动起来”,那第二篇要解决的是另外两个更难的问题:

  1. 模型怎么真正拥有双手;
  2. 它在一次次行动之后,怎么不失忆。

Claude Code 在第三章和第四章里给出的答案很明确:工具必须被设计成协议,上下文必须被设计成结构。

一、Tool 在 Claude Code 里不是函数,而是一整个生命周期对象

很多 Agent 示例会这样做:

const tools = [
  { name: "read_file", schema: ..., handler: ... }
]

这在 Demo 阶段够用,但在真实系统里远远不够。

Claude Code 里的 Tool 接口,至少会关心这些事情:

call(args, context, canUseTool, parentMessage, onProgress): Promise<ToolResult>
checkPermissions(input, context): Promise<PermissionResult>
prompt(options): Promise<string>
description(input, options): Promise<string>
isConcurrencySafe(input): boolean
isReadOnly(input): boolean
renderToolUseMessage(...)
renderToolResultMessage(...)

这说明一个 Tool 不只是“我能做什么”,还包括:

  • 我什么时候能做;
  • 我要不要先过权限;
  • 我能不能并发;
  • 我是不是只读;
  • 我该怎么向模型介绍自己;
  • 我该怎么在终端里显示。

这和“函数列表”是两个层级的设计。

二、call() 的返回值,已经说明工具不是孤立动作

Claude Code 的 Tool 执行结果不是一段字符串,而是结构化结果:

export type ToolResult<T> = {
  data: T
  newMessages?: Message[]
  contextModifier?: (context: ToolUseContext) => ToolUseContext
}

这里最关键的是 contextModifier

它意味着工具执行完成后,不只是“交一份结果回来”,还可能修改后续工具看到的上下文。
这说明 Tool 不是 side effect 黑盒,而是 Agent 运行时的一部分。

三、为什么 prompt()description() 必须拆开

Claude Code 把工具的模型可见文本拆成两层:

  • prompt():完整工具手册,进入系统提示;
  • description():简短 API 描述,进入 tool schema。

这是一种非常实用的分层。

因为“教模型什么时候用工具”和“告诉协议层这个工具叫什么”根本不是一件事。
如果这两层混在一起,到后面很难维护,尤其是工具一多的时候。

四、isReadOnly()isConcurrencySafe() 这两个接口,决定了系统能不能稳

Claude Code 的工具安全性不是按工具名字判断,而是按“工具 + 参数”判断。

比如 BashTool:

  • cat file.txt 大概率是只读;
  • rm -rf /tmp/x 就已经是写操作;
  • grep pattern src/ 可以并发;
  • 修改文件、执行副作用命令就应该串行。

所以它把这两个判断做成函数:

isConcurrencySafe(input): boolean
isReadOnly(input): boolean

这一步看起来只是接口设计,实际上会直接决定后面的:

  • 权限系统怎么判;
  • StreamingToolExecutor 怎么调度;
  • plan 模式里哪些工具还能继续用。

五、ToolUseContext 不是“方便传参”,而是 Agent 的运行时世界观

Claude Code 的每个工具都会收到一个 ToolUseContext。里面装的不是一两个辅助字段,而是几乎整个运行现场:

  • 当前消息历史;
  • AppState 的 getter / setter;
  • AbortController;
  • 可用工具集合;
  • MCP 客户端;
  • 调试模式;
  • 预算限制;
  • 子 Agent 定义;
  • 模型配置。

这意味着 Tool 的地位不是“被调用的工具函数”,而是“运行在 Agent 世界里的执行节点”。

也正因为有这个上下文背包,工具层才能做这些事情:

  • 中途中断;
  • 上报执行进度;
  • 调整 UI 状态;
  • 读取当前权限模式;
  • 和 MCP、子 Agent、内容替换系统协作。

六、System Prompt 在 Claude Code 里不是一段长字符串,而是三层结构

Claude Code 构建系统提示时,不是把所有东西直接拼接,而是先并行拉三部分:

const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
  getSystemPrompt(...),
  getUserContext(),
  getSystemContext(),
])

三层分别是:

  • defaultSystemPrompt:角色、工具说明、运行规则;
  • userContext:用户和项目的长期约定;
  • systemContext:当前环境快照,比如 Git 状态。

这一步非常关键,因为它把“Prompt 工程”从写文案,变成了搭结构。

七、CLAUDE.md 的价值,不在配置,而在持久记忆

Claude Code 有个非常重要的设计:允许通过 CLAUDE.md 给 Agent 提供长期指令。

const claudeMd = shouldDisableClaudeMd
  ? null
  : getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))

它会沿着目录树向上搜:

  • 项目级 CLAUDE.md
  • 父目录级 CLAUDE.md
  • 用户级 ~/.claude/CLAUDE.md
  • 企业级策略文件
graph TD
    P["项目级 CLAUDE.md"] --> M["合并 userContext"]
    F["父目录 CLAUDE.md"] --> M
    U["用户级 CLAUDE.md"] --> M
    E["企业级策略"] --> M

这件事非常像“给 Agent 加长期记忆层”。

因为很多约定不应该每轮都靠用户重新解释,比如:

  • 这个仓库用什么包管理器;
  • 哪些目录不能改;
  • 提交信息遵循什么规范;
  • 哪类测试默认不要跑。

把这些东西结构化放进 CLAUDE.md,等于把“用户习惯”和“项目规矩”从临时对话里抽出来了。

八、Git 状态注入,不是实时同步,而是“会话开始时的快照”

Claude Code 在系统上下文里会注入 Git 信息,但会明确标注:

This is the git status at the start of the conversation.
Note that this status is a snapshot in time, and will not update during the conversation.

这个提示非常专业。它避免模型误以为:

  • 这份 Git 状态会自动更新;
  • 它可以一直依赖同一份状态做判断。

这本质上是在给模型划信息边界:
你现在拿到的是起始快照,不是实时世界。

九、对话历史不能当字符串堆,它必须保持 tool_use / tool_result 成对完整

Claude Code 的消息历史里,一个工具调用会展开成这种结构:

assistant: [tool_use: { id: "abc", name: "Bash", input: {...} }]
user:      [tool_result: { tool_use_id: "abc", content: "..." }]

这里最关键的不是格式,而是约束:

每个 tool_use 都必须有匹配的 tool_result

这意味着做历史裁剪和上下文压缩时,不能把这对消息切断。
不然到 API 那一层,整段对话结构就会非法。

很多自制 Agent 在这一步出问题,就是因为只把消息当文本,而没把它当结构化事件流。

十、真正难的不是“塞更多上下文”,而是“在有限窗口里保留正确的东西”

Claude Code 的上下文管理至少有两道防线。

第一层:工具结果预算

工具返回太大时,不允许原样塞回模型上下文,而是经过 applyToolResultBudget() 做截断、替换或文件引用化。

第二层:自动压缩

一旦消息逼近窗口上限,就在每轮循环入口主动触发 autocompact

const { compactionResult } = await deps.autocompact(
  messagesForQuery,
  toolUseContext,
  { systemPrompt, userContext, systemContext, ... },
  querySource,
  tracking,
  snipTokensFreed,
)
flowchart TD
    A["新一轮 queryLoop"] --> B["取 compact boundary 后的消息"]
    B --> C["applyToolResultBudget"]
    C --> D{"接近 token 上限?"}
    D --> |"是"| E["autocompact 压缩"]
    D --> |"否"| F["继续调用模型"]
    E --> F

关键不是“会压缩”,而是它在入口就压,不是等模型报错以后再补救。
这体现的是主动预防,而不是被动恢复。

十一、第三章和第四章真正讲的,不是 Tool 和 Prompt,而是执行内核

把 Claude Code 的第三、四章压成一句话,其实就是:

Tool 必须结构化,上下文也必须结构化。

因为只有这样,Agent 才能同时满足:

  • 工具可调度;
  • 权限可判定;
  • UI 可渲染;
  • 上下文可压缩;
  • 长对话不失真。

最后

很多人做 Agent,最大的误判是把复杂度都归因到模型上。
但 Claude Code 在这一段真正告诉我们的,是另一件事:

  • Tool 不是函数;
  • Prompt 不是文案;
  • 上下文不是聊天记录;
  • 历史也不是字符串数组。

它们全都是执行内核的一部分。
只有按这个标准来设计,Agent 才可能从“能跑”走向“能长期工作”。