2026年3月31日,安全研究员Chaofan Shou在分析Claude Code的npm包(v2.1.88)时,意外发现了一个令人惊讶的“赠品”——一个体积高达59.8MB的.map文件。这事儿很简单,Claude Code用Bun运行时打包,而Bun默认会生成source map,打包脚本偏偏忘了把这玩意儿排除出去。结果就是,将近51.2万行、约1900个TypeScript文件的完整源码,以一种最不体面的方式暴露在了公众面前。
当研究者们深入挖掘这批代码后,一个碘伏性的结论浮出水面:整个项目中,真正属于AI决策逻辑的代码只占1.6%,剩下的98.4%全是确定性基础设施——权限控制、上下文管理、工具路由、错误恢复……这些工程性的“脚手架”才是支撑起这个产品的真正基石。
这个数字彻底打破了人们对AI编程工具的常规想象。大多数人以为Claude Code的核心是精妙的AI推理机制,但实际上,调用模型的代码仅仅是整个系统里薄薄的一层。它的工程启示非常明确:构建一个可靠的AI Agent,真正的难点不在于如何调用模型,而在于如何管理好模型周围的一切。
源码泄漏事件
泄漏经过
事情始于2026年3月31日。安全研究员Chaofan Shou在分析Claude Code的npm包(v2.1.88)时,发现包内藏了一个体积高达59.8MB的.map文件。这是TypeScript编译器生成的Source Map——一种将编译后的Ja vaScript代码映射回原始TypeScript源码的调试文件,按理说根本不应该出现在发布包里。
原因出奇地简单:Claude Code使用Bun运行时进行打包,而Bun默认会生成source map,打包脚本遗漏了排除该文件的步骤。就这么一个疏忽,约51.2万行、近1900个TypeScript文件的完整源码,以一种意外的方式进入了公众视野。
最令人意外的发现
研究者们更深一步的分析,得出了一个让所有人始料未及的结论:
其中只有1.6%是真正的AI决策逻辑,其余98.4%都是确定性基础设施——权限控制、上下文管理、工具路由和错误恢复逻辑。
这个数字彻底碘伏了很多人对AI编程工具的想象。大多数人以为Claude Code的核心是某种精妙的AI推理机制,但实际上,真正调用模型的代码只是整个系统里薄薄的一层。支撑起整个产品的,是大量工程性极强、逻辑严密的“脚手架”代码。
这个发现带来的工程启示非常深刻:构建一个可靠的AI Agent,难点不在于调用模型,而在于如何管理模型周围的一切。
整体架构
架构全景图
graph TDUser["用户输入"] --> 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 )。
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 TDStart(["用户消息进入"]) --> S1S1["① 上下文加载n读取 MEMORY.md 指针索引n注入 CLAUDE.md 静态配置n计算当前 token 预算"]S1 --> S2S2["② 工具路由 & 延迟加载n决定本次注入哪些工具 schemandefer_loading 工具按需加载"]S2 --> S3S3["③ 权限检查(预检)n查询拒绝记录n粗筛当前操作的权限要求"]S3 --> S4S4["④ QueryEngine → 模型调用n⬅ 唯一真正调用 AI 的地方n处理流式输出、retry、token 计费"]S4 --> S5S5["⑤ 响应解析 + stop_reason 路由n识别 end_turn / tool_use / max_tokensn决定走哪条分支"]S5 -->|"end_turn"| Done(["输出给用户,等待下条消息"])S5 -->|"max_tokens"| Compress["触发上下文压缩n预算重置后继续"]S5 -->|"tool_use"| S6S6["⑥ 工具执行n精细权限检查 → execute()n结果写入 messages[]"]S6 --> S7S7["⑦ 状态更新 & 压缩检查n更新 token 计数n持久化 sessionn检查是否触发压缩"]S7 --> S1Compress --> 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 预算})// ② 工具路由:按需决定注入哪些工具 schemaconst tools = selectTools(context)// ③ 权限预检(查拒绝记录,粗筛)// 主要在步骤 ⑥ 精细检查,这里是快速过滤// ④ 调用模型(唯一的 AI 步骤)const response = await queryEngine.call({messages: context.messages,tools: tools,system: context.systemPrompt,})// ⑤ 解析 stop_reason,决定走向const { stop_reason, content } = responseif (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() // 写磁盘,支持 --resumecheckCompression() // 是否需要触发压缩策略// 循环继续 → 模型将看到 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_tokensend_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协作架构的核心入口。
工具调用流程
sequenceDiagramparticipant AgentLoop as Agent 主循环participant Parser as 响应解析器participant Registry as Tool Registryparticipant Perm as 权限系统participant Tool as 具体工具AgentLoop ->> Parser: 解析模型响应Parser ->> AgentLoop: 返回 tool_use 和多个 tool callsNote over AgentLoop: API 允许一次返回多个 tool_use 块Note over AgentLoop: 例如并行读取多个文件或启动多个子 Agentpar 并行执行多个工具AgentLoop ->> Registry: get read_file toolRegistry ->> AgentLoop: ToolDefinitionAgentLoop ->> Perm: checkPermissionPerm ->> AgentLoop: allowedAgentLoop ->> Tool: execute a.tsTool ->> AgentLoop: tool_result_1andAgentLoop ->> Registry: get read_file toolRegistry ->> AgentLoop: ToolDefinitionAgentLoop ->> Tool: execute b.tsTool ->> AgentLoop: tool_result_2andAgentLoop ->> Registry: get agent toolRegistry ->> AgentLoop: ToolDefinitionAgentLoop ->> Perm: checkPermissionPerm ->> AgentLoop: ask userAgentLoop ->> Tool: execute sub agentTool ->> AgentLoop: tool_result_3endAgentLoop ->> AgentLoop: push tool results into messagesAgentLoop ->> 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: stringinput_schema: JSONSchemapermission_level: PermissionLeveldefer_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 = {//autoask manualread: { 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
优先级从高到低: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架构设计
sequenceDiagramparticipant Userparticipant Parent as 父 Agent(主进程)participant AgentTool as AgentToolparticipant Child as 子 Agent 进程User ->> Parent: "重构 auth 模块,同时更新测试和文档"Parent ->> Parent: 分析任务,决定拆分为 3 个子任务par 并行启动 3 个子 AgentParent ->> AgentTool: tool_use: agentn{ 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 模块重构完成..."andParent ->> AgentTool: tool_use: agentn{ task: "更新所有单元测试" }AgentTool ->> Child: spawn(claude, SUBAGENT_MODE=1)Child ->> AgentTool: 测试更新完成AgentTool ->> Parent: tool_resultandParent ->> AgentTool: tool_use: agentn{ task: "更新相关文档" }AgentTool ->> Child: spawn(claude, SUBAGENT_MODE=1)Child ->> AgentTool: 文档更新完成AgentTool ->> Parent: tool_resultendParent ->> 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.mdmcp_servers:- name: asanatype: httpurl: https://mcp.asana.com/sse- name: my-db-tooltype: stdiocommand: 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题。
