大家好,我是双越。wangEditor 作者,前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP,前端面试派 作者。
我正致力于两个项目的开发和升级,感兴趣的可以私信我,加入项目小组。
本文介绍 Claude Code 智能体的设计和实现,核心的模块架构和流程。
源码泄漏事件
泄漏经过
2026 年 3 月 31 日,安全研究员 Chaofan Shou 在分析 Claude Code 的 npm 包(v2.1.88)时,发现包内附带了一个体积高达 59.8MB 的 .map 文件。这是 TypeScript 编译器生成的 Source Map——一种将编译后的 JavaScript 代码映射回原始 TypeScript 源码的调试文件,本不应该出现在发布包里。
原因很简单:Claude Code 使用 Bun 运行时进行打包,而 Bun 默认会生成 source map,打包脚本遗漏了排除该文件的步骤。就这样,整整约 51.2 万行、近 1900 个 TypeScript 文件的完整源码,以一种意外的方式进入了公众视野。
最令人惊讶的发现
研究者们深入分析之后,得出了一个出乎所有人意料的结论:
其中只有 1.6% 是真正的 AI 决策逻辑,其余 98.4% 都是确定性基础设施——权限控制、上下文管理、工具路由和错误恢复逻辑。
这个数字彻底颠覆了很多人对 AI 编程工具的想象。大多数人以为 Claude Code 的核心是某种精妙的 AI 推理机制,但实际上,真正调用模型的代码只是整个系统里薄薄的一层。支撑起整个产品的,是大量工程性极强、逻辑严密的"脚手架"代码。
这个发现有深刻的工程启示:构建一个可靠的 AI Agent,难点不在于调用模型,而在于如何管理模型周围的一切。
整体架构
架构全景图
graph TD
User["用户输入"] --> Entry["入口层 main.tsx"]
Entry --> ModeDetect["模式检测"]
ModeDetect --> Interactive["Interactive 模式"]
ModeDetect --> Pipe["Pipe 模式"]
ModeDetect --> Headless["Headless 模式"]
ModeDetect --> SDK["SDK 模式"]
ModeDetect --> SubAgent["SubAgent 模式"]
Interactive --> AgentLoop["Agent 主循环 while(true)"]
AgentLoop --> CtxLoad["① 上下文加载"]
AgentLoop --> ToolRoute["② 工具路由"]
AgentLoop --> PermCheck["③ 权限检查"]
AgentLoop --> QueryEngine["④ QueryEngine → Anthropic API"]
AgentLoop --> ParseResp["⑤ 响应解析"]
AgentLoop --> ToolExec["⑥ 工具执行"]
AgentLoop --> StateUpdate["⑦ 状态更新"]
ToolExec --> ToolSystem["工具系统 Tool Registry"]
ToolSystem --> BashTool["BashTool"]
ToolSystem --> FileReadTool["FileReadTool"]
ToolSystem --> AgentTool["AgentTool"]
ToolSystem --> MCPTools["MCP 外部工具"]
StateUpdate --> CtxCompress["上下文压缩系统"]
StateUpdate --> MemorySystem["Memory 系统"]
AgentTool --> SubAgentProc["子 Agent 进程"]
MCPTools --> MCPServer["MCP Server"]
各模块职责速览
- 入口层:解析 CLI 参数,完成初始化,按条件分发到五种运行模式之一。
- Agent 主循环:整个系统的驱动引擎,一个
while(true)循环,负责协调所有其他模块。 - QueryEngine:与 Anthropic API 通信的唯一入口,封装了所有网络细节。
- 工具系统:插件化架构,40+ 内置工具 + 无限扩展的 MCP 外部工具。
- 权限控制:三种全局模式 × 四种工具权限等级的矩阵管控。
- 上下文压缩:五级梯度压缩策略,防止长任务因上下文溢出而崩溃。
- Memory 系统:三层架构,用"指针索引"代替"全量注入",高效管理长期知识。
入口层
初始化阶段
main.tsx 是整个系统的入口文件,但它本身几乎不包含业务逻辑——它只负责"搭舞台",然后把控制权交出去。启动时,它按顺序执行四个初始化步骤:
loadConfig() 按优先级合并多个配置源。优先级从高到低依次是:环境变量 → 项目级 CLAUDE.md → 用户级 ~/.claude/config → 内置默认值。这里有一个重要细节:CLAUDE.md 在这一步被一次性读入内存,后续不会再解析,这就是为什么修改 CLAUDE.md 之后需要重启 Claude Code 才能生效。
checkAuth() 查找 API Key,顺序是:ANTHROPIC_API_KEY 环境变量 → ~/.claude/auth 文件 → 提示用户登录。找不到则直接报错退出,这是最高优先级的前置条件。
registerTools() 将 tools/ 目录下所有工具加载到工具注册表(Tool Registry)。注意:此时只是"注册"元数据,不是真正初始化——标记了 defer_loading: true 的工具,要等到被实际调用时才会初始化。
detectMode() 读取命令行参数和环境变量,判断应该进入哪种运行模式,然后把控制权移交给对应的模块。从这一刻起,main.tsx 退出舞台。
五种入口方式
Claude Code 支持五种截然不同的运行模式,覆盖了从日常交互到 CI 自动化的全部场景。
Interactive 模式(默认):直接输入 claude 启动,进入带有完整 UI 的交互对话循环。适合日常开发时的人机协作。UI 层由 React/Ink 驱动,支持键盘输入、流式输出和历史会话恢复(--resume <session-id>)。
Pipe 模式:当系统检测到 stdin.isTTY === false(即输入来自管道而非终端键盘),自动进入此模式。一次性读取 stdin 全部内容,执行完毕后退出,不进入交互循环。典型用法:
git diff | claude -p "帮我根据这份 diff 写一条规范的 commit message"
cat error.log | claude -p "分析这个报错的根本原因"
Headless 模式:使用 -p 或 --print 参数时激活。不启动 UI,直接执行给定的 prompt,输出纯文本结果。与 Pipe 模式的区别在于触发条件——Pipe 是"输入来自管道",Headless 是"显式声明无 UI 执行"。典型用法:
claude -p "给这段代码写单元测试" < utils.ts > utils.test.ts
SDK 模式:环境变量 CLAUDE_CODE_SDK_MODE=1 时激活,通常由官方 SDK 自动设置。通过 stdin/stdout 交换 JSON 消息,供其他程序(Python、Go 等)以编程方式控制 Claude Code,类似 Language Server Protocol 的设计思路。
SubAgent 模式:当环境变量 CLAUDE_SUBAGENT_MODE=1 时激活。这是被主 Agent 的 AgentTool 内部调用时自动触发的模式。子 Agent 拥有完全独立的上下文窗口,完成任务后将结果作为工具返回值传回父 Agent。
模式检测的优先级顺序是:SubAgent → SDK → Headless → Pipe → Interactive(默认兜底)。
React/Ink 终端渲染器
Interactive 模式下,UI 层由 Ink 驱动。Ink 的核心思想是把 React 的组件树渲染到终端——你可以用写 Web 组件的方式写终端 UI。
这套渲染器采用游戏引擎式的脏检查优化:只重绘发生变化的行,而非每次刷新整个屏幕。这确保了在模型流式输出时,屏幕不会产生闪烁或撕裂。
架构上,UI 层和业务层通过共享的 AppState 对象通信,互不感知内部实现:
- UI 层负责捕获键盘输入、渲染消息气泡、展示流式 token
- 业务层(Agent 主循环)负责调用 QueryEngine、执行工具、管理状态
- 共享状态包括:
messages[]、isLoading、currentToolCall、tokenUsage等
这种分离让两层可以独立测试和替换,也是整个系统保持可维护性的基础之一。
Agent Loop 主循环
七个阶段
Agent 主循环是整个系统的心脏。理解它,就理解了 Claude Code 的一切。
flowchart TD
Start(["用户消息进入"]) --> S1
S1["① 上下文加载\n读取 MEMORY.md 指针索引\n注入 CLAUDE.md 静态配置\n计算当前 token 预算"]
S1 --> S2
S2["② 工具路由 & 延迟加载\n决定本次注入哪些工具 schema\ndefer_loading 工具按需加载"]
S2 --> S3
S3["③ 权限检查(预检)\n查询拒绝记录\n粗筛当前操作的权限要求"]
S3 --> S4
S4["④ QueryEngine → 模型调用\n⬅ 唯一真正调用 AI 的地方\n处理流式输出、retry、token 计费"]
S4 --> S5
S5["⑤ 响应解析 + stop_reason 路由\n识别 end_turn / tool_use / max_tokens\n决定走哪条分支"]
S5 -->|"end_turn"| Done(["输出给用户,等待下条消息"])
S5 -->|"max_tokens"| Compress["触发上下文压缩\n预算重置后继续"]
S5 -->|"tool_use"| S6
S6["⑥ 工具执行\n精细权限检查 → execute()\n结果写入 messages[]"]
S6 --> S7
S7["⑦ 状态更新 & 压缩检查\n更新 token 计数\n持久化 session\n检查是否触发压缩"]
S7 --> S1
Compress --> S1
① 上下文加载:每轮循环开始时,系统构建本轮发送给模型的完整上下文。这包括:从 MEMORY.md 读取指针索引(体积小,始终驻留)、按需拉取被指针引用的主题文件、注入 CLAUDE.md 静态配置、以及计算当前剩余的 token 预算。
② 工具路由 & 延迟加载:决定本轮 API 调用中注入哪些工具的 schema。内置工具 40+,加上 MCP 外部工具可能有几百个,全部注入会耗尽大量 token。defer_loading 机制确保只有"本轮可能用到的"工具才会被注入(详见工具系统章节)。
③ 权限检查(预检) :在发送 API 请求之前,对当前上下文中待执行的操作做粗粒度的权限过滤,并查询拒绝记录(DenialLog)——如果用户曾经拒绝过某个操作,这里会提前过滤掉,不再打扰。
④ 模型调用:整个循环中唯一真正调用 AI 的步骤,通过 QueryEngine.call() 完成。QueryEngine 内部处理所有网络细节:流式输出、错误重试、token 计费等。主循环只关心输入和输出,完全不感知 QueryEngine 的内部实现。
⑤ 响应解析 + stop_reason 路由:解析模型返回的内容,识别 stop_reason 并决定下一步走向。这是整个循环的控制流核心(详见下一节)。
⑥ 工具执行:当 stop_reason === 'tool_use' 时进入此阶段。先做精细的权限检查(包括向用户弹出确认提示),通过后调用对应工具的 execute() 函数,将返回的 tool_result 追加到 messages[]。
⑦ 状态更新 & 压缩检查:更新 token 计数,将当前 session 状态持久化到磁盘(支持 --resume 恢复),并检查是否需要触发上下文压缩策略。
伪代码
async function agentLoop(userMessage: string) {
// 将用户消息加入历史
messages.push({ role: 'user', content: userMessage })
while (true) {
// ① 上下文加载
const context = buildContext({
messages, // 完整对话历史
memoryIndex, // MEMORY.md 指针索引(始终在内存中)
claudeConfig, // CLAUDE.md 静态配置(启动时加载一次)
tokenBudget, // 当前剩余 token 预算
})
// ② 工具路由:按需决定注入哪些工具 schema
const tools = selectTools(context)
// ③ 权限预检(查拒绝记录,粗筛)
// 主要在步骤 ⑥ 精细检查,这里是快速过滤
// ④ 调用模型(唯一的 AI 步骤)
const response = await queryEngine.call({
messages: context.messages,
tools: tools,
system: context.systemPrompt,
})
// ⑤ 解析 stop_reason,决定走向
const { stop_reason, content } = response
if (stop_reason === 'end_turn') {
// 模型说"我完成了" → 输出给用户,结束本轮
displayToUser(content)
break
}
if (stop_reason === 'max_tokens') {
// 上下文撑满 → 触发压缩,重置预算,重试
await compressContext()
continue
}
// stop_reason === 'tool_use' → 执行工具
// ⑥ 工具执行
const toolCalls = extractToolCalls(content)
for (const call of toolCalls) {
// 精细权限检查(可能弹出用户确认)
if (!await checkPermission(call)) {
messages.push(toolResult(call.id, 'Permission denied'))
continue
}
// 执行并写回结果
const result = await executeTool(call)
messages.push({ role: 'user', content: toolResult(call.id, result) })
}
// ⑦ 状态更新
updateTokenCount()
persistSession() // 写磁盘,支持 --resume
checkCompression() // 是否需要触发压缩策略
// 循环继续 → 模型将看到 tool_result 后决定下一步
}
}
工具调用不会退出循环,而是把结果追加回 messages,让模型在下一轮看到工具执行结果再决定下一步。这就是 Claude Code 能"自主完成多步任务"的根本原因。
stop_reason 状态机
stateDiagram-v2
[*] --> 模型调用
模型调用 --> end_turn : stop_reason = end_turn
模型调用 --> tool_use : stop_reason = tool_use
模型调用 --> max_tokens : stop_reason = max_tokens
end_turn --> 输出给用户
输出给用户 --> [*] : 等待下条消息
tool_use --> 权限检查
权限检查 --> 工具执行 : 通过
权限检查 --> 写入拒绝结果 : 拒绝
工具执行 --> 追加tool_result
写入拒绝结果 --> 追加tool_result
追加tool_result --> 模型调用 : 循环继续
max_tokens --> 触发压缩策略
触发压缩策略 --> 预算重置
预算重置 --> 模型调用 : 重试本轮
stop_reason 只有三个值,但它们决定了循环的全部控制流:
end_turn:模型认为任务完成,输出内容给用户,break跳出循环,等待下一条消息。tool_use:模型要调用工具,附带工具名和参数。执行工具、将结果写回messages[],continue回到循环顶部。max_tokens:生成过程中上下文窗口被填满,无法继续。触发压缩策略,重置 token 预算后重试当前轮次。
实例演示
来看一个真实场景:你让 Claude Code "找出项目里所有未使用的变量并删除"。
第 1 轮(stop_reason = tool_use):模型思考后决定先了解项目结构。调用 BashTool,执行 find . -name "*.ts" | head -50,返回 38 个 TypeScript 文件的列表。tool_result 追加到 messages[],循环继续。
第 2 轮(stop_reason = tool_use):模型看到文件列表,决定运行静态检查。调用 BashTool,执行 npx tsc --noEmit 2>&1,返回 12 条"变量已声明但未读取"的 warning。由于输出较大,QueryEngine 自动用 MicroCompact 压缩工具输出后存入上下文。循环继续。
第 3 轮(stop_reason = tool_use):模型分析 12 条 warning,决定一次性修改多个文件。它返回了 5 个 tool_use 块(Anthropic API 支持一次返回多个),对应 5 个文件的 FileEditTool 调用。权限检查弹出确认(ask 模式),用户确认后,5 个文件被依次修改。
第 4 轮(stop_reason = end_turn):模型再次运行 npx tsc --noEmit 验证,0 个 warning。生成最终回复:"已在 5 个文件中删除 12 个未使用变量,编译检查通过。" break 退出循环。
整个过程,用户只输入了一句话。模型自主决定了"读结构 → 静态分析 → 修改 → 验证"四步,每一步都是它在看到上一步的 tool_result 后做出的独立决策。
QueryEngine 的作用
一句话说明
QueryEngine 是 Claude Code 与 Anthropic API 通信的唯一入口和智能 HTTP 客户端——你给它对话历史和工具列表,它替你处理好所有网络层的复杂性,返回模型的响应。
输入与输出
输入:
messages[] 完整对话历史
tools[] 工具 schema 列表(只含 name/description/input_schema,不含 execute 函数)
system 系统提示词
输出:
stop_reason 'end_turn' | 'tool_use' | 'max_tokens'
content[] 文本块 + 工具调用块的混合数组
usage { input_tokens, output_tokens, cache_read_tokens, ... }
核心能力详解
流式输出(Streaming) :模型的 token 是一个个生成的,QueryEngine 通过 Server-Sent Events 接收流式响应,边接收边推送给 UI 层。用户看到的"字符一个个出现"的效果就来自这里。流式模式还有一个好处:如果用户中途按 Ctrl+C,可以立即中断,不必等到整个响应生成完毕。
缓存(Prompt Caching) :Anthropic API 支持对系统提示词和长对话历史做服务端缓存(Cache Breakpoints)。QueryEngine 自动在合适的位置插入缓存标记,让重复内容(如固定的工具 schema、项目上下文)命中缓存,显著降低 API 成本和响应延迟。usage 字段中的 cache_read_tokens 就是缓存命中的 token 数。
错误后重试:QueryEngine 内置了完整的重试策略:
- 网络错误:指数退避重试,最多 3 次,间隔 1s → 2s → 4s。
- 429 Rate Limit:解析响应头中的
Retry-After,精确等待对应时间后重试,不做无效轮询。 - 500/502/503 服务端错误:同样指数退避,与网络错误共享重试计数。
- 超时:单次请求超过 120 秒则超时,触发重试逻辑。
Token 计费与成本追踪:每次 API 调用后,QueryEngine 从 usage 字段提取 token 消耗,累加到会话级的成本统计。这是 Claude Code 能在右上角实时显示"本次会话花费 $X.XX"的数据来源。同时,token 消耗会用于更新上下文预算,触发压缩策略的判断。
双模型策略:QueryEngine 内部并非只调用一个模型。对于需要深度推理的主循环调用,使用 Opus;对于上下文压缩摘要、工具输出摘要等辅助任务,自动切换到 Haiku。Opus 更强但更贵,Haiku 更快且便宜——这个切换对主循环完全透明,每天节省大量 API 成本。
工具系统
类型定义
所有工具都继承自 Tool.ts 中定义的抽象基类,该基类只有四个核心字段:
abstract class Tool {
// ① 工具名:模型调用时使用的唯一标识
abstract name: string
// 例:"bash", "read_file", "agent"
// ② 输入 Schema:定义模型调用时的参数格式(JSON Schema)
abstract input_schema: JSONSchema
// 模型必须按此格式传参,否则直接报错,不执行
// ③ 权限等级:决定需要什么授权才能运行
abstract permission_level: 'read' | 'write' | 'execute' | 'network'
// 主循环在步骤 ③ 和步骤 ⑥ 都会检查这个字段
// ④ 执行函数:真正做事的地方
abstract execute(input: ValidatedInput): Promise<ToolResult>
// 返回的 ToolResult 会被追加到 messages[] 作为 tool_result
}
40+ 个工具,每一个都是在实现这四个字段,没有其他魔法。工具系统之所以可以无限扩展,正是因为接口足够简单——任何人实现这个接口,就能给 Agent 增加新能力。
三个经典工具
BashTool:权限等级 execute,风险最高。接受 command、timeout、workdir 三个参数,在指定目录执行任意 shell 命令。有黑名单保护(禁止 rm -rf / 等危险命令),默认 30 秒超时强制中止。这是工具系统里能力最强的工具,权限系统的大部分复杂度都是为了管控它而存在的。
FileReadTool:权限等级 read,风险最低。接受 path、offset、limit 三个参数,读取指定文件的内容。单次最多返回 2000 行,超出自动截断并提示,防止大文件直接撑满上下文窗口。它是 ask 模式下唯一无需用户确认即可自动执行的工具类别。
AgentTool:权限等级 execute,性质特殊。接受 task、context、tools 三个参数,在内部以 sub-agent 模式启动一个全新的 Claude Code 子进程,将任务交给它独立完成,最终把子 Agent 的输出作为 tool_result 返回给父 Agent。这是多 Agent 协作架构的核心入口。
工具调用流程
sequenceDiagram
participant AgentLoop as Agent 主循环
participant Parser as 响应解析器
participant Registry as Tool Registry
participant Perm as 权限系统
participant Tool as 具体工具
AgentLoop ->> Parser: 解析模型响应
Parser ->> AgentLoop: 返回 tool_use 和多个 tool calls
Note over AgentLoop: API 允许一次返回多个 tool_use 块
Note over AgentLoop: 例如并行读取多个文件或启动多个子 Agent
par 并行执行多个工具
AgentLoop ->> Registry: get read_file tool
Registry ->> AgentLoop: ToolDefinition
AgentLoop ->> Perm: checkPermission
Perm ->> AgentLoop: allowed
AgentLoop ->> Tool: execute a.ts
Tool ->> AgentLoop: tool_result_1
and
AgentLoop ->> Registry: get read_file tool
Registry ->> AgentLoop: ToolDefinition
AgentLoop ->> Tool: execute b.ts
Tool ->> AgentLoop: tool_result_2
and
AgentLoop ->> Registry: get agent tool
Registry ->> AgentLoop: ToolDefinition
AgentLoop ->> Perm: checkPermission
Perm ->> AgentLoop: ask user
AgentLoop ->> Tool: execute sub agent
Tool ->> AgentLoop: tool_result_3
end
AgentLoop ->> AgentLoop: push tool results into messages
AgentLoop ->> AgentLoop: continue next agent loop
关于并行调用:Anthropic API 允许模型在一次响应中返回多个 tool_use 块。主循环用 Promise.all 并发执行所有工具,然后将所有 tool_result 一起追加到 messages[]。这是 Claude Code 能并行读取多个文件、或同时启动多个子 Agent 的底层机制。
延迟加载 defer_loading
Claude Code 内置 40+ 工具,加上用户配置的 MCP Server 工具,总数可能超过 200 个。每个工具的 input_schema 平均约 300 token。如果每次 API 调用都注入全部工具,仅工具 schema 就会消耗 6 万+ token,严重压缩留给对话内容的空间。
defer_loading 机制解决了这个问题:
interface ToolDefinition {
name: string
input_schema: JSONSchema
permission_level: PermissionLevel
defer_loading: boolean // 是否延迟加载
load_when?: (ctx: Context) => boolean // 触发条件
execute: (input: unknown) => Promise<ToolResult>
}
function selectTools(context: ConversationContext): ToolDefinition[] {
return [...toolRegistry.values()].filter(tool => {
if (!tool.defer_loading) return true // 核心工具:始终注入
if (!tool.load_when) return false // 无条件:始终不注入
return tool.load_when(context) // 按条件判断
})
}
核心工具(bash、read_file、glob、grep)标记 defer_loading: false,始终注入。上下文相关工具(如 web_fetch)和 MCP 外部工具标记 defer_loading: true,只有当 load_when(ctx) 返回 true 时才注入。实践中,每轮调用只注入 8-12 个工具,节省了约 96% 的工具 schema token 消耗。
权限控制
全局权限模式
Claude Code 提供三种全局权限策略,通过 --permission-mode 参数或 CLAUDE.md 配置:
- auto 模式:所有工具调用自动执行,不询问用户。适合 CI/CD 流水线或完全信任的自动化场景,但出错时没有任何拦截机制。
- ask 模式(默认):
write/execute/network级别的操作需要用户确认,read级别自动放行。日常开发推荐使用,在效率和安全之间取得平衡。 - manual 模式:所有操作(包括
read)都需要确认。极度谨慎的场景使用,但会严重降低效率。
工具权限等级
每个工具在定义时静态声明自己的 permission_level,共四个级别:
- read:只读操作,
FileReadTool、GlobTool、GrepTool。不修改任何状态,ask模式下自动放行。 - write:修改磁盘文件,
FileEditTool、FileCreateTool。ask模式下首次需要确认。 - execute:执行任意命令,
BashTool、AgentTool。影响范围最广,需要明确授权。 - network:发起网络请求,
WebFetchTool和 MCP 工具。防止数据意外外泄。
两个维度交叉形成权限判断矩阵:
// 权限判断矩阵(两个维度交叉)
const permissionMatrix = {
// auto ask manual
read: { auto: true, ask: true, manual: false },
write: { auto: true, ask: false, manual: false },
execute:{ auto: true, ask: false, manual: false },
network:{ auto: true, ask: false, manual: false },
}
// false = 需要用户确认才能执行
拒绝跟踪与优雅降级
当用户拒绝某个操作后,系统需要记录这个意图——否则 Agent 可能在同一任务中反复请求同样的权限,持续打扰用户。这就是拒绝跟踪(Denial Tracking)系统的作用。
下面是这个系统的 46 行核心实现:
class DenialLog {
// 会话级拒绝记录(重启后清空,不做持久化)
private denied = new Set<string>()
private allowed = new Set<string>() // "本次会话全部允许"的工具
// 检查是否已被拒绝(最高优先级)
isDenied(toolName: string): boolean {
return this.denied.has(toolName) && !this.allowed.has(toolName)
}
// 记录拒绝
record(toolName: string) {
this.denied.add(toolName)
}
// 用户选择"本次会话全部允许" → 覆盖之前的拒绝
allowForSession(toolName: string) {
this.allowed.add(toolName)
}
}
async function checkPermission(tool: ToolDefinition): Promise<PermissionResult> {
// 第一关:查拒绝记录(最高优先级,直接拒绝不再询问)
if (denialLog.isDenied(tool.name)) {
return { allowed: false, reason: 'previously_denied' }
}
// 第二关:查权限矩阵
const needsConfirm = !permissionMatrix[tool.permission_level][currentMode]
if (!needsConfirm) {
return { allowed: true } // 直接放行
}
// 第三关:弹出用户确认
const answer = await askUser({
message: `Allow ${tool.name}?`,
options: ['Allow once', 'Allow this session', 'Deny', 'Deny this session']
})
if (answer === 'Deny' || answer === 'Deny this session') {
denialLog.record(tool.name) // 写入拒绝记录
return { allowed: false, reason: 'user_denied' }
}
if (answer === 'Allow this session') {
denialLog.allowForSession(tool.name)
}
return { allowed: true }
}
代码之所以只有 46 行,是因为简单性是刻意追求的。权限系统越复杂,出现漏洞的可能性越高。这套实现编码了一个核心原则:当用户失去信任时,优雅降级,不反复打扰。 被拒绝的操作返回 "Permission denied" 作为 tool_result,模型看到后会寻找其他方案或告知用户,而非陷入无限重试。
上下文压缩
五级上下文压缩策略
Agent 运行时,messages[] 随着每一轮工具调用不断膨胀。不加管理,10-20 轮后必然触达上下文窗口上限,Agent 崩溃或被迫截断历史。Claude Code 设计了五级梯度压缩策略,从轻到重按需触发:
Snip(零成本) :直接从 messages[] 头部删除最旧的若干轮对话(保留系统消息和最近 N 轮)。有损且粗糙,但零延迟、零成本,是最后的紧急兜底手段。
MicroCompact(零成本) :在工具输出写入 messages[] 之前,检查其长度。超过阈值(约 2000 行)则直接截断,末尾追加 [Output truncated: X lines omitted]。纯本地字符串操作,无语义理解,快但精度差——适合日志、编译输出等信息密度低的场景。
ApiMicroCompact(低成本) :与 MicroCompact 的区别在于"有语义"。把超大的工具输出发给 Haiku,生成结构化摘要后存入磁盘缓存(key 是输出内容的 hash)。后续引用摘要而非原始输出。相同命令重复执行时,可直接命中缓存,无需再次 API 调用。
AutoCompact(中成本) :当上下文剩余 token 低于 13,000 时触发(留出压缩本身所需的空间)。调用 Haiku,生成最多 20,000 token 的结构化摘要替换旧历史,压缩后预算大幅恢复。内置熔断机制:连续失败 3 次(如摘要本身太长),停止重试,降级到 Snip。
Full Compact(高成本) :用户手动执行 /compact 或系统开启 ContextCollapse feature flag 时触发。彻底压缩整个对话历史,同时重新注入:最近访问的文件(每文件上限 5,000 token)、当前活跃的任务计划、相关工具 schema。完成后工作预算重置为 50,000 token,相当于给长任务一个全新的"干净起点"。
压缩调用流程
async function checkCompression(state: AgentState): Promise<void> {
const remaining = state.totalBudget - state.usedTokens
// 工具输出截断:在步骤 ⑥ executeTool 的输出阶段执行
// (MicroCompact / ApiMicroCompact 在这里,不在 checkCompression 里)
// AutoCompact:接近上限时触发
if (remaining < 13_000) {
const success = await autoCompact(state)
if (!success) {
// 压缩失败,熔断计数
state.compactFailCount++
if (state.compactFailCount >= 3) {
// 连续失败 3 次 → 降级到 Snip
snip(state)
state.compactFailCount = 0
}
} else {
state.compactFailCount = 0
// 压缩成功,预算恢复
state.usedTokens = state.usedTokens * 0.3
}
return
}
// Snip:极端情况下的兜底(ratio > 0.98)
if (state.usedTokens / state.totalBudget > 0.98) {
snip(state)
}
}
优先级从高到低:AutoCompact > Snip。MicroCompact 和 ApiMicroCompact 在工具执行阶段独立运作,不通过 checkCompression 触发。Full Compact 是用户主动触发的独立操作。
AutoCompact 并不随意
AutoCompact 不是让模型"随便总结一下",而是生成固定结构的摘要,确保关键信息一定被保留:
const AUTOCOMPACT_PROMPT = `
你是一个对话历史压缩助手。将以下对话压缩为结构化摘要。
必须包含以下章节,不得省略:
## 已完成的任务
(列出本次会话中已经完成的所有操作,要具体)
## 关键发现
(代码结构、重要文件位置、已知问题、重要约束等)
## 当前状态
(此刻正在做什么,进行到哪一步)
## 待完成事项
(还需要做什么,按优先级排列)
## 重要决策
(已经做出的技术决策和原因,避免重复讨论)
压缩后长度不得超过 20,000 token。
`
固定章节的设计有一个深层用意:每次压缩后,模型都能从同样结构的上下文里找到它需要的信息,行为保持一致。 如果摘要格式每次不同,模型在压缩后的表现可能会出现难以预测的漂移。这也是 AutoCompact 的"优雅"之处——它不只是缩短了上下文,而是重新整理了上下文,让 Agent 能以稳定的状态继续工作。
三层 Memory 架构
架构设计
上下文压缩解决了"历史如何瘦身",但还有另一个问题:项目相关的长期知识(认证逻辑、数据库 schema、API 规范……)该如何在多次会话之间持久保存,又不占满上下文?
答案是三层 Memory 架构:
第一层:MEMORY.md 指针索引(始终在上下文中)
这是唯一保证始终驻留在上下文窗口的文件,但它本身非常轻量——每条索引约 150 字符,整个文件保持在 ~2,000 token 以内。它只存"指针",不存内容:
# Memory Index
- auth-system → memory/auth.md (JWT 实现, refresh token 逻辑)
- db-schema → memory/db-schema.md (users 表, orders 表结构)
- api-design → memory/api-design.md (REST 规范, 错误码定义)
- deployment → memory/deploy.md (CI/CD 流程, 环境变量清单)
第二层:主题文件(按需加载)
被 MEMORY.md 引用的具体知识文件,存储在 memory/ 目录下。每个文件聚焦一个主题,可以任意详细。需要时,模型通过 FileReadTool 读取对应文件,用完后无需保留在上下文——下次需要时再读即可。
第三层:CLAUDE.md 静态配置
项目级的固定偏好和约定,启动时一次性读入,始终驻留。适合存放:编码规范、工具链偏好、项目特殊约束等不会频繁变化的配置。
关键洞见
永远不把全量知识放入上下文,只放指针。
这个设计和数据库索引的思路完全一致:数据库不会把所有数据加载到内存,而是维护一个精简的 B+ Tree 索引,需要时按索引定位磁盘上的具体数据。Memory 系统做的是同样的事——用 2,000 token 的索引管理任意大小的知识库,按需取用,不预先占用上下文空间。
SubAgent
SubAgent 架构设计
sequenceDiagram
participant User
participant Parent as 父 Agent(主进程)
participant AgentTool as AgentTool
participant Child as 子 Agent 进程
User ->> Parent: "重构 auth 模块,同时更新测试和文档"
Parent ->> Parent: 分析任务,决定拆分为 3 个子任务
par 并行启动 3 个子 Agent
Parent ->> AgentTool: tool_use: agent\n{ task: "重构 auth 模块" }
AgentTool ->> Child: spawn(claude, SUBAGENT_MODE=1)\nstdin: { task, context, tools }
Note over Child: 独立上下文窗口\n独立运行主循环\n多轮工具调用...
Child ->> AgentTool: stdout: 最终输出文本
AgentTool ->> Parent: tool_result: "auth 模块重构完成..."
and
Parent ->> AgentTool: tool_use: agent\n{ task: "更新所有单元测试" }
AgentTool ->> Child: spawn(claude, SUBAGENT_MODE=1)
Child ->> AgentTool: 测试更新完成
AgentTool ->> Parent: tool_result
and
Parent ->> AgentTool: tool_use: agent\n{ task: "更新相关文档" }
AgentTool ->> Child: spawn(claude, SUBAGENT_MODE=1)
Child ->> AgentTool: 文档更新完成
AgentTool ->> Parent: tool_result
end
Parent ->> Parent: 收集 3 个 tool_result,生成最终回复
Parent ->> User: "三个子任务均已完成..."
关键设计决策:父子 Agent 完全隔离。 子 Agent 拿不到父 Agent 的 messages[],也感知不到其他子 Agent 的存在。父子之间的接口只有两个:输入是 task + context 文本描述,输出是子 Agent 的最终回复文本。
这个强隔离带来三个好处:① 上下文干净——子 Agent 的多轮工具调用不污染父 Agent 的上下文;② 可并行——多个子 Agent 互不依赖,可以真正同时运行;③ 可替换——父 Agent 不关心子 Agent 内部实现,只要最终结果符合预期。
AgentTool
const AgentTool: ToolDefinition = {
name: 'agent',
permission_level: 'execute', // 继承调用方权限
defer_loading: false, // 核心工具,始终可用
input_schema: {
type: 'object',
properties: {
task: { type: 'string', description: '子任务的完整描述,越具体越好' },
context: { type: 'string', description: '传递给子 Agent 的背景信息' },
tools: { type: 'array', description: '允许子 Agent 使用的工具列表' },
},
required: ['task']
},
execute: async (input) => {
// ① 以 sub-agent 模式启动子进程
const child = spawn('claude', [], {
env: {
...process.env,
CLAUDE_SUBAGENT_MODE: '1', // main.tsx 据此进入 subagent 模式
CLAUDE_PARENT_TASK: input.task,
}
})
// ② 通过 stdin 传入任务描述
// 注意:父 Agent 的 messages[] 不传给子 Agent
// 子 Agent 只知道自己的任务,完全不知道父 Agent 的上下文
child.stdin.write(JSON.stringify({
task: input.task,
context: input.context,
tools: input.tools ?? defaultSubAgentTools,
}))
// ③ 等待子进程完成(可与其他子 Agent 并行等待)
const result = await waitForCompletion(child)
// ④ 子 Agent 的最终输出作为 tool_result 返回
// 父 Agent 只看到这一句话,不知道子 Agent 内部跑了多少轮
return {
type: 'tool_result',
content: result.finalOutput,
}
}
}
父 Agent 调用多个 AgentTool 时,主循环用 Promise.all 并发执行,实现真正的并行处理:
// 主循环步骤 ⑥:并行执行多个工具(包括多个 AgentTool)
const results = await Promise.all(
toolCalls.map(call => executeToolCall(call))
)
// 所有结果一起追加到 messages[]
for (const [call, result] of zip(toolCalls, results)) {
messages.push(toolResult(call.id, result))
}
MCP 集成
MCP 是什么
MCP(Model Context Protocol)是 Anthropic 提出的开放协议,解决一个核心问题:如何让外部服务以标准方式暴露工具给 Claude Code,而无需 Anthropic 为每个服务单独编写内置工具。
任何服务——Asana、GitHub、自建数据库、企业内部 API——只要实现 MCP 协议,就能被 Claude Code 当作工具使用,不需要修改 Claude Code 的任何代码。MCP 之于 Claude Code,类似 USB 协议之于电脑:定义了标准接口,让外设可以即插即用。
集成过程
启动时握手与工具发现:Claude Code 启动时,对每个配置的 MCP Server 发起 initialize 请求,握手成功后立即请求 tools/list,获取该 Server 提供的工具列表和每个工具的 schema。
注册到 Tool Registry:Claude Code 将 MCP 返回的工具 schema 包装成内部 ToolDefinition 格式,注入工具注册表,并标记 defer_loading: true(MCP 工具几乎全部延迟加载)。从这一刻起,MCP 工具和内置工具在主循环眼里完全一致。
运行时调用:模型需要调用 MCP 工具时,execute() 函数内部由 mcpProxy 将调用转发给对应的 MCP Server,返回结果包装成标准 tool_result,写入 messages[]。整个过程对主循环透明。
配置方式:
# CLAUDE.md
mcp_servers:
- name: asana
type: http
url: https://mcp.asana.com/sse
- name: my-db-tool
type: stdio
command: node ./mcp-server/index.js
MCP Server 支持三种传输方式:stdio(本地子进程)、SSE(HTTP Server-Sent Events)和 HTTP(标准 REST)。无论哪种传输方式,Claude Code 侧的集成逻辑完全相同。
总结
学完 Claude Code 的整个架构,最深的感受是:这不是一个"AI 项目",而是一个"以 AI 为核心的工程项目"。
整个系统中,真正属于 AI 的部分只占 1.6%——就是 QueryEngine 里调用 Anthropic API 的那一段代码。其余 98.4% 都是严肃的工程:精心设计的状态机、多层次的权限系统、梯度化的资源管理策略、可组合的插件架构。
这揭示了一个对所有 AI 应用开发者都有价值的洞见:
让 AI Agent 能做什么,取决于工具系统。让 AI Agent 做得好不好,取决于上下文管理。让 AI Agent 在真实任务中稳定运行,取决于权限控制和错误恢复。 模型本身的能力固然重要,但包裹在模型外面的工程基础设施,才是决定产品体验的关键。
Claude Code 的每一个设计决策都体现了这种思维:用 defer_loading 把 token 留给真正有用的内容,用 DenialLog 的 46 行代码保证用户体验不被权限弹窗破坏,用五级压缩策略让 Agent 在任意长的任务中都能稳定工作,用强隔离的 SubAgent 实现安全的并行协作。
构建可靠的 AI Agent,本质上是一道工程题,不是一道 AI 题。