Claude Code Agent 系统技术分析
Claude Code Agent 系统技术分析
源码版本:Claude Code v2.1.88(~51万行 TypeScript) 分析日期:2026-03-31
基于 Claude Code 源码走读(
query.ts、tools.ts、Tool.ts、runAgent.ts、coordinatorMode.ts)双受众:外行能跟上思路,内行能拿走干货。
1. 架构总览
Claude Code 的核心不是一个"聊天程序",而是一个 Agent 循环引擎——把 LLM 的输出当成指令来执行,执行完再喂回去,直到模型说"我做完了"。
┌─────────────────────────────────────────────────────────┐
│ 用户 / 终端 / API │
│ (stdin / WebSocket / HTTP) │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ query.ts (Agent Loop) │
│ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ │
│ │ 消息构建 │→│ API 调用 │→│ 响应解析 │→│ 工具执行 │ │
│ └─────────┘ └──────────┘ └─────────┘ └──────────┘ │
│ ↑ │ │
│ └────────────────────────────────────────┘ │
│ 循环直到无 tool_use │
└──────────────────────┬──────────────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────────────┐
│ Tool.ts │ │ tools.ts │ │ AgentTool/runAgent.ts │
│ (接口定义) │ │ (工具注册)│ │ (子 Agent 派发) │
└──────────────┘ └──────────┘ └──────────────────────┘
│
▼
┌──────────────────────────┐
│ coordinatorMode.ts │
│ (Coordinator 编排层) │
└──────────────────────────┘
****:用户说话 → LLM 想 → 执行工具 → 结果回去 → LLM 再想 → 循环。
2. Agent Loop(query.ts 走读)
解决什么问题
想象你是个厨师,客人说"我要一份宫保鸡丁"。你不会一口气做完——你会先看菜单理解需求(理解用户意图),去冰箱拿食材(读文件),切菜(改代码),下锅炒(跑测试),尝一口看看对不对(检查结果),不行再调整(继续循环)。Agent Loop 就是这个"看→拿→做→检查→再做"的循环。关键问题:什么时候算做完、中间出错怎么办、做太久要不要叫停。
怎么做的
query.ts 是整个系统的心脏。从 import 看,它管理:
关键依赖链:
query.ts
├── Tool.ts (工具类型)
├── utils/messages.js (消息构建)
├── services/compact/autoCompact.js (上下文压缩)
├── services/tools/toolOrchestration.js (工具执行)
├── query/tokenBudget.js (token 预算)
└── query/config.js (配置构建)
核心循环(Generator 函数,用 yield 输出事件——Generator 的好处是上层能随时暂停/恢复/拿到中间结果):
// 伪代码还原核心流程
// 用 Generator(function*)而不是普通 async,因为上层需要流式拿到每一步的事件
function* query(...) {
while (true) { // 无限循环,靠 break 退出——这就是"Agent 不停做直到做完"的体现
// 1. 构建消息历史(含系统提示、上下文压缩边界)
// 每轮都要重新 build,因为消息在变化(压缩、新增工具结果等)
const messages = buildMessages(...)
// 2. 检查 token 预算(不是硬上限,是软阈值 + 续期机制)
// 为什么不用硬上限?因为模型可能正在推理关键步骤,截断会打断思路
const budget = getCurrentTurnTokenBudget()
// 3. 调用 Anthropic API(支持 thinking、tool_use 流式输出)
// yield* 把流式事件逐个传给上层,UI 能实时显示"AI 在想"
const response = yield* streamAPI(messages, tools, ...)
// 4. 解析响应中的 tool_use blocks
// 一条 API 响应可能包含多个 tool_use(比如同时搜 3 个文件)
const toolUses = response.content.filter(c => c.type === 'tool_use')
// 5. 如果没有工具调用 → 结束循环,返回最终回答
// 这就是"厨师说菜做好了"的时刻
if (toolUses.length === 0) break
// 6. 执行工具(支持并发!见下文)
// yield* 让上层能实时看到每个工具的执行进度
const results = yield* runTools(toolUses, ...)
// 7. 工具结果喂回去,继续循环
// 相当于厨师尝完菜,决定下一步怎么调整
}
}
几个有意思的细节:
-
Token 预算不是硬限:
getCurrentTurnTokenBudget()+checkTokenBudget()做软控制。超了不是直接截断,而是incrementBudgetContinuationCount()续期——给模型更多空间完成当前思路。 -
上下文压缩:有三种机制——
autoCompact(自动压缩)、reactiveCompact(响应式)、historySnip(历史裁剪)。不是"满了再压缩",而是持续跟踪 token 消耗趋势。 -
中断处理:
yieldMissingToolResultBlocks()在异常中断时,为每个未完成的 tool_use 生成一个is_error: true的结果。这样模型不会"困惑"于缺少工具响应。 -
Thinking 规则:代码注释里写得很清楚——thinking block 不能是消息的最后一块,必须在
max_thinking_length > 0的 query 里,且只能保留一个 assistant 轨迹的长度。
这段代码有几个值得注意的地方:
Generator yield 模式选得精准——不是 async/await 的"调完等结果",而是每一步都能被上层拦截、展示、中断。这对 CLI 的流式体验是刚需。Token 预算的"续期"机制(incrementBudgetContinuationCount)也很有洞察力:模型不是写一行算一行,它需要"完成当前思路"的空间,硬截断只会产生半截代码。这个"软控制 + 续期"思路可以推广到所有 LLM 应用。yieldMissingToolResultBlocks() 在中断时给未完成的 tool_use 生成 is_error: true 结果——这保证了消息序列的完整性,模型不会因为"缺了一条工具结果"而困惑。
但也有一个值得警惕的问题:query.ts 1700+ 行,消息构建、API 调用、工具执行、压缩策略全耦合。对终端应用来说"一个文件搞定"降低了理解成本,但测试成本和维护成本会随功能增长快速上升。另外,循环退出条件只有 toolUses.length === 0,没有显式的 maxTurns 检查点(可能在上层)。如果上层忘了设,理论上可以无限循环。
3. 工具系统(Tool.ts + tools.ts 走读)
解决什么问题
你家有个工具箱:螺丝刀、扳手、电钻、锤子。每样工具都有自己的用法、适用场景、危险程度。你不会拿电钻去拧螺丝,也不会让孩子用角磨机。工具系统就是给 AI 配一个"工具箱"——统一接口、按需取用、危险操作要审批。
怎么做的
Tool.ts — 接口定义
// ToolInputJSONSchema: 工具输入的 JSON Schema 定义
// 为什么用 JSON Schema?因为 LLM 的 tool_use 输出就是 JSON,直接校验
type ToolInputJSONSchema = {
type: 'object'
properties?: { [x: string]: unknown }
}
// ToolPermissionContext: 权限上下文——每次工具调用都会带上这个
// 为什么放在 context 里而不是工具自己管?因为同一个工具在不同场景权限不同
type ToolPermissionContext = {
mode: PermissionMode // 'default' | 'bypassPermissions' | ...
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
alwaysAllowRules: ToolPermissionRulesBySource // 白名单——这些操作自动放行
alwaysDenyRules: ToolPermissionRulesBySource // 黑名单——这些操作永远拒绝
alwaysAskRules: ToolPermissionRulesBySource // 灰名单——不确定的要问用户
isBypassPermissionsModeAvailable: boolean // 是否允许绕过权限(仅 CLI --dangerously-skip-permissions)
shouldAvoidPermissionPrompts?: boolean // 后台 Agent 自动拒绝权限提示——因为没人能点确认
}
关键设计:权限是三层的——alwaysAllow、alwaysDeny、alwaysAsk。不是简单的"允许/拒绝"二元论,而是有"询问"层。这解决了"我不确定要不要让它跑 rm -rf"的灰色地带。
tools.ts — 工具注册
从 import 列表可以看到完整工具集:
// 核心工具(始终可用)
AgentTool, SkillTool, BashTool, FileEditTool, FileReadTool,
FileWriteTool, GlobTool, NotebookEditTool, WebFetchTool,
TaskStopTool, BriefTool, TaskOutputTool, WebSearchTool,
TodoWriteTool, GrepTool, LSPTool, AskUserQuestionTool,
EnterPlanModeTool, EnterWorktreeTool, ExitWorktreeTool
// 条件工具(feature flag 控制)
REPLTool → process.env.USER_TYPE === 'ant'
SleepTool → feature('PROACTIVE') || feature('KAIROS')
CronCreateTool → feature('AGENT_TRIGGERS')
WebBrowserTool → feature('WEB_BROWSER_TOOL')
WorkflowTool → feature('WORKFLOW_SCRIPTS')
工具集是动态的。tools.ts 的 getTools() 函数根据当前模式(Coordinator / REPL / 普通)、feature flags、权限上下文来决定返回哪些工具。不是写死的列表。
// 模式过滤
const coordinatorModeModule = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js')
: null
// 然后根据 isCoordinatorMode() 决定是否只返回 COORDINATOR_MODE_ALLOWED_TOOLS
这段代码有意思的地方是设计哲学:声明式 + 权限前置。每个工具声明自己的 schema(Zod/Joi 校验),权限在执行前就确定,而不是"先执行再检查"——这从根上减少了很多安全漏洞。Feature flag 管控实验性工具(SleepTool、CronCreateTool、WebBrowserTool)的做法也值得记一笔:不用删代码,开关一关就是下线,这是成熟产品的做法,不是"加个 if true"。shouldAvoidPermissionPrompts 这个字段很聪明——后台 Agent 不能弹窗给用户点,所以直接 deny 而不是 ask,细节处见功底。
但有个设计选择我不太认同:工具列表是 getTools() 函数里 if/else 拼出来的,没有用注册表模式,加新工具要改这个函数,不够解耦。权限规则的 source 追踪(ToolPermissionRulesBySource)在代码里有,但对外暴露不够——用户很难看到"这条规则来自哪里、为什么生效"。
三层权限(allow/deny/ask)比简单的开关更实用,任何有用户交互的产品都可以借鉴——不是所有不确定的操作都该拒绝,有些应该问一下。工具 schema 声明 + LLM tool_use 透传的模式,本质上是"用类型定义替代运行时转换",值得所有 AI 工具集成借鉴。
4. 子 Agent(runAgent.ts 走读)
解决什么问题
你一个人搬不动一张大沙发,你会叫几个朋友来帮忙。每个人负责一个角,各自出力,搬完各回各家。子 Agent 就是这个"叫帮手"的机制——把大任务拆成独立小任务,各自在自己的上下文里干,干完回来交差。好处是每个人只记自己那部分,不会因为同时记太多事而脑子乱。
怎么做的
// runAgent.ts 核心流程
async function runAgent(agentDefinition, parentContext) {
// 1. 创建独立的 Agent ID——每个子 Agent 有唯一标识,用于追踪和清理
const agentId = createAgentId()
// 2. 初始化子 Agent 特有的 MCP 服务器
// parentClients 是父级已有的 MCP 连接,子 Agent 可以继承(共享基础设施)
// 但子 Agent 也可以定义自己的 MCP server(内联定义)
const { clients, tools, cleanup } = await initializeAgentMcpServers(
agentDefinition,
parentClients // 继承父级的 MCP 连接
)
// 3. 克隆文件状态缓存(不共享,避免竞态)
// 为什么克隆而不是共享?两个 Agent 同时读同一个文件,一个改了缓存,另一个就乱了
const fileStateCache = cloneFileStateCache(parentFileStateCache)
// 4. 创建 fork 上下文——子 Agent 的"身份证"
// 包含 agentId、工具集、文件缓存、权限上下文等
const subagentContext = createSubagentContext({
parentContext,
agentId,
tools,
fileStateCache,
// ...更多参数
})
// 5. 执行!调用 query() 就行——复用同一个 Agent Loop
// 子 Agent 跑的循环和父 Agent 完全一样,只是上下文不同
const result = await query(subagentContext)
// 6. 清理:MCP 连接、文件缓存、transcript 目录
// 不清理 = 资源泄漏,尤其是 MCP 子进程
await cleanup()
cleanupAgentTracking(agentId) // 从全局追踪表中移除
return result
}
MCP 服务器隔离是亮点:
// 子 Agent 可以有自己的 MCP 服务器
// 但有权限限制——"plugin-only" 模式下,非 admin-trusted 的 Agent 不能加载额外 MCP
if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) {
// 跳过,不加载额外 MCP
}
// 两种 MCP 来源:
// 1. 字符串引用 → 从父级已有的配置里找
// 2. 内联定义 → 创建新的连接(子 Agent 结束时要清理)
Fork 模式是这段代码的核心亮点——共享 MCP 连接(省资源)、克隆文件缓存(防竞态)、独立执行空间(防污染),和 Unix fork() 的 copy-on-write 哲学一致,不是拍脑袋的设计。清理逻辑也做得很扎实:cleanup() 处理 MCP 子进程 + 文件缓存 + transcript 目录——很多 Agent 框架只管创建不管销毁。复用同一个 query() 函数跑子 Agent 也是个聪明的决策,代码零重复,子 Agent 和父 Agent 能力完全一致,只是上下文不同。
一个实际可能撞到的问题:子 Agent 之间的通信只能通过父 Agent 中转(返回结果),没有直接通道。对于需要协作的任务(两个子 Agent 共同改一个文件),效率不高。另外递归 fork 的深度靠 maxTurns 和 token 预算间接限制,没有显式的 depth 计数器——虽然实际中不太可能无限递归,但显式限制比隐式限制更安全。
"继承 + 克隆"的 fork 模式比"完全隔离"或"完全共享"都好:完全隔离要重复初始化(慢),完全共享有竞态风险(乱)。折中方案:共享不变的(连接、配置),克隆可变的(缓存、状态)。
5. Coordinator 模式(coordinatorMode.ts 走读)
解决什么问题
叫帮手来干活容易,但谁管谁?如果 5 个人同时搬沙发,没人指挥方向,沙发要么撞墙要么掉地上。Coordinator 就是那个站在旁边指挥的人——自己不动手,但分配任务、收集结果、汇报进展。
怎么做的
// 判断是否在 Coordinator 模式
function isCoordinatorMode(): boolean {
if (feature('COORDINATOR_MODE')) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}
// Coordinator 的系统提示(精简版)
// 关键设计:
// - Coordinator 只有 3 个工具:AgentTool(派发)、SendMessageTool(继续)、TaskStopTool(停止)
// - Coordinator 不直接干活,只编排
// - Worker 结果以 task-notification XML 格式返回
// - Coordinator 要"总结结果给用户",不要感谢或确认 Worker
Worker 的工具集是受限的:
// ASYNC_AGENT_ALLOWED_TOOLS = 工具子集
// Coordinator 不让 Worker 用 AgentTool(防止无限递归)
// 简化模式下只有 Bash + Read + Edit
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
? ['Bash', 'Read', 'Edit']
: ASYNC_AGENT_ALLOWED_TOOLS.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
Scratchpad 共享区:
// Coordinator 可以给 Worker 配一个共享目录
if (scratchpadDir && isScratchpadGateEnabled()) {
content += `Workers can read and write here without permission prompts.
Use this for durable cross-worker knowledge.`
}
这个设计解决了"Worker 之间怎么通信"的问题——不走消息队列,而是共享文件系统。简单粗暴但有效。
评价
做得好的:
- Coordinator 只有 3 个工具(AgentTool、SendMessageTool、TaskStopTool),工具约束比提示约束可靠——模型想越权也做不到。
- 系统提示写得很克制:"不要用一个 Worker 去检查另一个 Worker"、"不要让 Worker 做你能直接做的事"。这种"提示工程 + 工具约束"双管齐下的方式,比纯靠模型自觉靠谱得多。
- Scratchpad 共享区用文件系统而非消息队列实现 Worker 间通信——简单粗暴但有效。不需要引入额外的中间件。
可以改进的:
- Coordinator 和 Worker 之间的结果传递依赖
task-notification XML格式——XML 解析脆弱,格式稍有偏差就挂了。结构化 JSON 会更稳健。 - Worker 的工具集限制(
ASYNC_AGENT_ALLOWED_TOOLS)是硬编码列表,没有按任务类型动态调整。所有 Worker 拿到的工具集一样,不管任务是"读文件"还是"跑测试"。
对其他产品的启发:
- "不信任 + 工具约束"的管理模式比"信任 + 自觉"更靠谱。适用于所有多 Agent 系统——别指望模型自觉,用硬约束限制能力范围。
- 共享文件系统作为 Worker 间通信介质的思路,比引入消息队列或事件总线简单得多,适合"协作不多但偶尔要共享信息"的场景。
6. 消息系统
解决什么问题
你发微信不只是发文字——还有图片、文件、语音、撤回、引用回复。群聊里消息有优先级:管理员的公告比闲聊重要。消息系统就是处理这些"不只是对话"的信息流——工具调用结果、压缩标记、中断信号、附件、进度更新,全都是消息。
怎么做的
从 query.ts 和 Tool.ts 的 import 可以看到完整的消息类型体系:
// 消息类型用 discriminated union(联合类型),每个类型有 type 字段区分
// 为什么不用 class 继承?Union type 天然对应 JSON 序列化,switch 能检查穷尽性
type Message =
| UserMessage // 用户输入(文本、图片、工具结果都在这里)
| AssistantMessage // 模型输出(包含 text blocks + tool_use blocks)
| SystemMessage // 系统消息(提示词、压缩边界等,用户不可见)
| AttachmentMessage // 附件(文件内容、上下文注入——独立于用户消息)
| ToolUseSummaryMessage // 工具使用摘要(给 UI 展示"刚才干了啥")
| TombstoneMessage // 墓碑标记(已压缩的消息占位——不删历史,只标记)
| ProgressMessage // 进度更新(npm install 跑到第几步了)
| SystemLocalCommandMessage // 本地命令消息(hook、斜杠命令等)
// 消息构建工具——每个工厂函数封装了创建逻辑,外部不需要知道内部结构
createUserMessage() // 构造用户消息
createUserInterruptionMessage() // 用户按了 Ctrl+C——中断当前执行
createSystemMessage() // 系统消息(安全规则、编码指令等)
createAssistantAPIErrorMessage() // API 调用失败时的兜底消息——不让循环卡死
createToolUseSummaryMessage() // 工具调用摘要(精简版,用于 UI 展示)
normalizeMessagesForAPI() // 格式化为 Anthropic API 要求的格式
stripSignatureBlocks() // 去掉签名块(安全考虑——防止 prompt 注入通过签名传递)
消息队列管理:
// 有优先级的消息队列
removeFromQueue()
getCommandsByMaxPriority() // 按优先级获取命令
isSlashCommand() // 斜杠命令识别
这说明消息不是简单的数组追加,而是有优先级调度——用户打断当前执行的命令时,高优先级消息可以插队。
评价
做得好的:
- Discriminated union 的类型设计很实用——
switch (msg.type)编译器能检查穷尽性,漏了类型直接报错。序列化天然对应 JSON,不需要自定义序列化逻辑。不可变性保证了消息在 agent loop 里传递和变换时不会有状态泄漏。 - TombstoneMessage 不是真删消息,而是标记"这段不重要了"。这比直接丢弃好——压缩系统知道这里曾经有内容,需要时可以通过 summary 回溯。
- 消息队列的优先级设计(tool_result > user_input > progress)反映了真实场景:工具结果阻塞 agent loop,必须优先处理;进度消息可以丢弃,用户看到"最后一条"就够了。
可以改进的:
- 消息类型虽然用了 union,但
ContentBlock也是 union,嵌套 union 在模式匹配时会让代码变冗长。如果消息类型更多,考虑用 visitor 模式替代 switch。 - 进度消息(ProgressMessage)"可丢弃"的设计可能导致信息丢失——比如一个长时间运行的命令,中间的输出如果被丢弃,出错时就很难调试。
对其他产品的启发:
- 消息优先级队列是处理多输入源的标准做法——任何需要合并多个异步事件流的系统(WebSocket + 定时器 + 用户输入)都能用。
7. 上下文管理
解决什么问题
人的短期记忆容量有限——心理学实验说大约 7±2 个元素。你背一个电话号码没问题,但让你同时记 20 个人的名字就崩溃了。Claude 的上下文窗口也一样——200K tokens 看着很大,但一个长编程会话里文件内容、工具输出、对话历史加起来,很快就满了。上下文管理就是决定记住什么、忘掉什么、怎么忘才不丢关键信息。
怎么做的
从 query.ts 可以看到多层压缩机制:
// 1. 自动压缩追踪——持续监测 token 使用趋势,不是"满了才触发"
import { calculateTokenWarningState, isAutoCompactEnabled } from './services/compact/autoCompact.js'
// 2. 压缩后重建消息——压缩完不是简单截断,而是生成摘要 + 保留边界后的消息
import { buildPostCompactMessages } from './services/compact/compact.js'
// 3. 响应式压缩(feature flag 控制——还在实验阶段)
// 和 Auto Compact 的区别:不是"整体压缩",而是"按消息类型差异化处理"
const reactiveCompact = feature('REACTIVE_COMPACT')
? require('./services/compact/reactiveCompact.js')
: null
// 4. 上下文坍缩(feature flag 控制——更激进的压缩策略)
// 可能会把多个工具调用结果合并成一条摘要
const contextCollapse = feature('CONTEXT_COLLAPSE')
? require('./services/contextCollapse/index.js')
: null
// 5. 历史裁剪(feature flag 控制——直接丢弃旧消息)
// 最激进的策略:不是压缩,而是直接删掉指定范围的历史
const snipModule = feature('HISTORY_SNIP')
? require('./services/compact/snipCompact.js')
: null
Token 预算系统:
// 不是"超了就截断",而是"渐进式续期"——给模型完成当前思路的空间
getCurrentTurnTokenBudget() // 当前轮次的 token 预算(基于模型 max_tokens)
getTurnOutputTokens() // 已消耗的输出 tokens(实时追踪)
incrementBudgetContinuationCount() // 预算不足时续期——每次续期增加额外空间
createBudgetTracker() // 创建预算追踪器(每轮一个新实例)
checkTokenBudget() // 检查是否超限(返回 "ok" | "warning" | "exceeded")
压缩边界:
// 消息中有专门的"压缩边界"消息类型
type SystemCompactBoundaryMessage // 压缩边界
type TombstoneMessage // 墓碑标记
getMessagesAfterCompactBoundary() // 获取压缩边界之后的消息
createMicrocompactBoundaryMessage() // 微压缩边界
这些边界标记告诉压缩系统"这段之后的内容可以安全压缩,之前的必须保留"。
评价
做得好的:
- 多层压缩 + token 预算 + 压缩边界的组合,本质上是分代垃圾回收的思路——不是一次性清理,而是追踪、标记、分批回收。这比"满了截断"精细得多。
- 压缩边界消息(
SystemCompactBoundaryMessage)的设计很聪明:在消息流里打"书签",书签前的内容被总结成摘要,书签后的原样保留。被压缩的消息留下 Tombstone 占位——系统知道这里曾经有内容,只是不再保留细节。 - Feature flag 大量使用说明压缩策略还在迭代中,不同算法通过 AB 测试对比效果。这是务实的做法——上下文压缩没有银弹,只能靠实验找最优。
可以改进的:
- 三层压缩(Auto + Reactive + Budget)之间的协调逻辑不透明。如果 Auto 和 Reactive 同时触发,谁先谁后?会不会压缩两次?
- 压缩后丢失的细节无法精确恢复——只能通过 summary 回溯。对于代码修改场景,丢失具体 diff 可能导致模型重复已做过的修改。
对其他产品的启发:
- Token 预算的"软控制 + 续期"思路值得所有长对话 LLM 应用借鉴。硬截断模型输出是最蠢的做法——模型正在推理关键步骤,截断只会产生半截代码或断裂的逻辑。
- "压缩边界 + Tombstone"的消息管理方式,比"直接删除旧消息"更安全。任何需要长期运行 + 上下文有限的系统(聊天机器人、日志系统、事件流处理)都能用这个思路。
8. 权限安全
解决什么问题
AI 能跑 bash、能改文件、能发 HTTP 请求——这就像给一个实习生开了 root 权限。你不加限制,一个 prompt injection 就能让你的电脑变成别人的矿机。权限系统就是那个保安:谁能做什么、什么要审批、什么绝对不允许,白纸黑字写清楚。
怎么做的
权限是声明式的(从 Tool.ts):
// 权限是声明式的——每个工具调用前都会带着这个上下文去校验
type ToolPermissionContext = {
mode: PermissionMode // 权限模式:default(标准)| bypassPermissions(跳过,仅 CLI)
alwaysAllowRules: ToolPermissionRulesBySource // 白名单——自动放行的操作(如读项目内文件)
alwaysDenyRules: ToolPermissionRulesBySource // 黑名单——永远拒绝(如 rm -rf /)
alwaysAskRules: ToolPermissionRulesBySource // 灰名单——不确定的弹窗问用户
isBypassPermissionsModeAvailable: boolean // 是否允许绕过(仅 --dangerously-skip-permissions flag)
shouldAvoidPermissionPrompts?: boolean // 后台 Agent 禁止弹窗——没人能点确认,直接 deny
}
三层权限模型:
alwaysDeny → 永远拒绝(不管模型怎么说)
alwaysAsk → 问用户(弹窗确认)
alwaysAllow → 自动放行
子 Agent 权限隔离(从 runAgent.ts):
// plugin-only 模式下,非 admin-trusted 的 Agent 不能加载额外 MCP
if (isRestrictedToPluginOnly('mcp') && !agentIsAdminTrusted) {
// 拒绝
}
// admin-trusted 判断
isSourceAdminTrusted(agentDefinition.source)
// Plugin、built-in、policySettings 的 Agent 是 admin-trusted
// 用户自定义的 Agent 不是
Coordinator Worker 的权限更严格:
// Worker 的工具集是受限子集
ASYNC_AGENT_ALLOWED_TOOLS // 不包含 AgentTool(防递归)
INTERNAL_WORKER_TOOLS // 内部工具对 Worker 不可见
// 简化模式下权限更低
process.env.CLAUDE_CODE_SIMPLE → 只有 Bash + Read + Edit
评价
做得好的:
- 执行前声明 → 声明后校验 → 校验后执行的三步流程,比"先执行再审计"安全得多。安全问题就该在入口处拦截,不应该是事后补救。
- 白名单/黑名单/询问三层设计比简单的 allow/deny 灵活得多——现实世界有很多灰色地带,不是所有操作都能一刀切。
shouldAvoidPermissionPrompts这个字段体现了对真实场景的理解:后台 Agent 不能弹窗,所以直接 deny 而不是 ask。很多系统在"无法交互"的场景下仍然 try ask,导致卡死。
可以改进的:
- 权限规则的来源(
ToolPermissionRulesBySource)在内部追踪了,但用户侧缺少可视化——用户很难看到"当前生效的规则来自哪里、优先级如何"。 - 子 Agent 的权限继承逻辑不够透明。子 Agent 在
plugin-only模式下不能加载额外 MCP,但用户没有明确的信号知道"我的子 Agent 权限被降级了"。
对其他产品的启发:
- 权限系统的"三层模型"(allow / deny / ask)是通用的安全设计模式。任何有"自动化执行 + 人工审批"需求的系统(CI/CD、自动化运维、RPA)都能用。
- "后台不能弹窗就直接 deny"的设计思路,适用于所有"可能无人值守"的自动化场景。宁可功能受限,也不能卡死或静默放行危险操作。
9. Bridge 远程
解决什么问题
你在家用电脑跑 Claude Code,但想在手机上、在公司电脑上、在 CI pipeline 里远程控制它。就像打电话——你需要一个稳定的线路,断了能重连,对方能认出是你,信号不好时还能降级通话质量。
怎么做的
从 query.ts 的 import 可以看到:
import { QuerySource } from './constants/querySource.js'
// QuerySource: 消息来源标识(终端 / API / IDE / Web)
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
// 调试用:dump prompts 到文件
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
// 无头模式性能分析
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
// 命令生命周期通知(给远端 UI)
QuerySource 的存在说明消息来源是多路的——同一个 Agent Loop 可以被终端、API、IDE 插件同时驱动。
评价
做得好的:
- 轮询 + WebSocket 双通道设计体现了"冗余优先"的工程思维。WebSocket 挂了有轮询兜底,用户无感知——只是输出从"打字机"变成"整块弹出"。这比"WS 断了就报错"好太多。
- Core Agent Loop 对通信方式完全无感——它只管 yield 事件,不关心事件去哪。这是经典的"依赖倒置",让同一个 Agent Loop 能同时服务终端、IDE 插件、Web UI。
可以改进的:
- Bridge 层在源码中被抽象到了
QuerySource和事件系统后面,真正的远程通信实现细节不可见。对想深入学习的人来说,这一层是个黑盒。 - 多会话并行(
--spawn模式)共享 token 预算的设计可能导致会话间资源争抢——一个会话用了大量 token,其他会话就受限了。
对其他产品的启发:
- 双通道(主 + 兜底)的通信架构适用于所有需要高可用的远程控制系统。WebSocket 做实时通道,HTTP 轮询做保底——互不依赖,各自独立。
- Agent Loop 对通信无感的设计,让"本地终端"和"远程 Web UI"复用同一套核心逻辑。任何想同时支持多种客户端的后端系统都能借鉴这个思路。
10. 性能优化
解决什么问题
你炒菜时最烦的是什么?等水烧开、等油热、等肉解冻。LLM 调用就是那个"等水烧开"的环节——每次 API 调用几秒到几十秒,工具执行也可能很慢(npm install、git clone)。性能优化就是减少等待时间、避免重复劳动、控制规模膨胀——让整体体验不那么卡。
怎么做的
流式执行(从 query.ts):
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
// 流式工具执行:工具开始跑就输出结果片段,不用等全部完成
// 用户看到的是"正在执行 npm install... 已下载 23/156 包"而不是一个转圈的 spinner
import { runTools } from './services/tools/toolOrchestration.js'
// 工具编排:分析哪些工具能并发(isConcurrencySafe=true),哪些必须串行
Token 预算(避免无效的长输出——不是限制能力,而是控制成本):
createBudgetTracker() // 追踪 token 消耗(每轮创建新实例)
checkTokenBudget() // 超限时触发截断或续期(返回状态码)
ESCALATED_MAX_TOKENS // 升级后的最大 token 数(续期时用更大的预算)
doesMostRecentAssistantMessageExceed200k() // 200K 硬边界检查——超过就真的不能继续了
文件状态缓存(避免重复读取同一文件——读过的文件内容缓存起来):
cloneFileStateCache(parentFileStateCache) // 子 Agent 克隆缓存而不是重建,节省初始化时间
createFileStateCacheWithSizeLimit() // 带大小限制的缓存——防止缓存本身吃太多内存
READ_FILE_STATE_CACHE_SIZE // 缓存大小限制(硬编码常量)
Feature flag 缓存(避免每次调用都查远程配置):
const getFeatureValue_CACHED_MAY_BE_STALE = ... // 注意 "CACHED_MAY_BE_STALE"
// Feature flag 不是每次调用都查服务端,而是缓存的
// 可能短暂不一致(stale),但减少了网络开销
评价
做得好的:
- 三层性能优化分别针对三个不同瓶颈:StreamingToolExecutor 解决感知性能(用户看到"工具在跑"的即时反馈),Token Budget 解决实际性能(控制 token 消耗),FileStateCache 解决系统性能(减少 I/O)。不花哨,但每个都针对真实瓶颈。
- Feature flag 缓存用 "CACHED_MAY_BE_STALE" 命名本身就说明了 trade-off——允许短暂不一致来换取性能。这种诚实的命名比藏着掖着好。
可以改进的:
- 启动并行初始化虽然已经做了,但 MCP server 的 spawn + initialize 握手仍是启动瓶颈(带 2 个 MCP server 要 ~1.5s)。可以考虑延迟加载——先启动 Agent Loop,MCP server 在后台初始化完成后接入。
- 推测执行只用于只读操作,没有利用"已知下一步大概率要做什么"的模式。比如"用户改了 functionA"→"大概率要跑测试",可以提前准备测试环境。
对其他产品的启发:
- "减少等待、避免重复、控制规模"三板斧适用于所有有外部 API 调用的产品。流式输出(减少感知等待)、缓存(避免重复)、预算控制(防止失控)是通用优化思路。
- CLI 工具的性能容忍度比 Web 应用低得多——启动慢 2 秒用户就觉得卡。任何 CLI 工具都应该把冷启动时间当作头号性能指标。
2.1 Agent Loop 补充:工具并发控制走读
代码证据来自
cli.js中的isConcurrencySafe实现和 BashTool 定义
并发判断:每个工具自己说了算
Claude Code 没有独立的 partitionToolCalls 函数。并发控制的逻辑直接挂在每个 Tool 对象上——isConcurrencySafe(input) 方法。
// BashTool: 并发安全 = 只读
isConcurrencySafe(A) { return this.isReadOnly(A) }
// ReadTool / GlobTool / GrepTool / WebFetchTool: 永远安全
isConcurrencySafe() { return true }
// EditTool / WriteTool / NotebookEditTool / SkillTool: 永远不安全
isConcurrencySafe() { return false }
// MCP 工具: 看服务端声明
isConcurrencySafe() { return z.annotations?.readOnlyHint ?? false }
外行翻译:系统问每个工具"你能跟别人同时跑吗?"读文件的说"我随便",改文件的说"不行,我怕撞车"。Bash 比较特殊——它查自己是不是只读命令。
Bash 的 isReadOnly:命令前就分好了
BashTool 的 isReadOnly 直接调用安全检查管线:
isReadOnly(A) {
let q = ZV6(A.command); // 先解析命令
return XV6(A, q).behavior === "allow" // 安全检查结果是"放行"
}
XV6 函数做的是读写分类——判断命令是否只读。只读命令(cat、ls、grep、git diff 等)标记为并发安全,写操作(rm、mv、cp、sed 等)不安全。
实际执行:并发还是串行
从源码看,工具执行走 runTools → toolOrchestration。并发策略不是简单的"全并发"或"全串行",而是按 isConcurrencySafe 分组:
- 并发安全的工具(Glob + Grep + Read)可以同时跑
- 不安全的工具(Edit + Bash 写操作)必须等前面的完成
这解释了为什么你会看到 Claude Code 同时搜好几个文件——因为搜索工具都是 isConcurrencySafe: true。但改文件时一定是一个一个来。
内行 takeaway:并发控制的粒度在单次 tool_use 块级别,不是整轮 API 调用。一次 API 响应里如果有 3 个 Glob + 1 个 Edit,3 个 Glob 并发跑完,Edit 串行等。
评价
做得好的:
- 并发安全判断挂在每个 Tool 对象上(
isConcurrencySafe(input)),而不是外部配置。工具自己最清楚自己能不能跟别人同时跑——这是正确的职责分配。 - Bash 的
isReadOnly走 AST 解析而非正则,能准确区分cat file(只读)和echo x > file(写入)。正则搞不定这种复杂情况。
可以改进的:
isConcurrencySafe是静态方法,不能根据运行时状态调整。比如 BashTool 在某些环境下可能需要更保守的并发策略,但当前实现不支持。
对其他产品的启发:
- "每个工具声明自己是否并发安全"的模式适用于所有有副作用的操作编排系统。比起外部配置"哪些工具能并发",让工具自己声明更不容易出错。
4.1 子 Agent 补充:Fork 机制深度分析
代码证据来自
runAgent.ts走读和cli.js中的 AgentTool 实现
Fork 的核心:上下文继承而非共享
子 Agent 不是"共享父 Agent 的上下文",而是继承 + 克隆:
// 1. 独立 ID
const agentId = createAgentId()
// 2. 继承父级 MCP 连接(共享基础设施)
const { clients, tools, cleanup } = await initializeAgentMcpServers(
agentDefinition,
parentClients // 父级的 MCP 连接池
)
// 3. 克隆文件状态缓存(不共享,避免竞态)
const fileStateCache = cloneFileStateCache(parentFileStateCache)
// 4. 创建 fork 上下文——这是关键
const subagentContext = createSubagentContext({
parentContext,
agentId,
tools,
fileStateCache,
})
// 5. 复用同一个 Agent Loop
const result = await query(subagentContext)
外行翻译:子 Agent 像是从父 Agent "分叉"出来的。父 Agent 的工具箱(MCP 连接)可以共享,但文件读取状态要各自复制一份——否则两个人同时改同一个文件的缓存就乱了。
Prompt Cache 复用的原理
Claude 的 API 支持 prompt caching——把系统提示和历史消息的前缀缓存起来,后续调用可以跳过重复计算。子 Agent 的 prompt cache 复用依赖两个条件:
- 系统提示相同:子 Agent 继承父 Agent 的系统提示(或用自己的定义,但结构一致)
- 前缀消息相同:创建子 Agent 时,会把父 Agent 的系统消息和关键上下文作为前缀
这样 Anthropic 端的 prompt cache 可以命中,子 Agent 的初始化不需要重新处理整个系统提示。但这不是代码里的确定性逻辑,而是依赖 API 层的缓存机制——子 Agent 只是保证了前缀一致,cache 命中是 API 端的事。
递归 Fork 检测
子 Agent 可以继续派生子 Agent(递归 fork)。但有防护:
// Coordinator 模式下,Worker 工具集不允许 AgentTool
ASYNC_AGENT_ALLOWED_TOOLS // 不包含 AgentTool
// 简化模式更严格
process.env.CLAUDE_CODE_SIMPLE → 只有 Bash + Read + Edit
在普通模式下,子 Agent 可以用 AgentTool 继续派生,但每个子 Agent 有独立的 agentId 和执行空间。实际的递归深度限制来自 maxTurns(每个 Agent 的最大轮次)和 token 预算(上下文总长度限制)。
内行 takeaway:Fork 是 copy-on-write 风格——共享工具连接、复制执行状态。递归深度靠 turn/budget 控制,不是硬编码深度限制。
评价
做得好的:
- Prompt cache 复用的思路很精明——子 Agent 保证系统提示前缀一致,让 Anthropic 端的缓存能命中。这不是代码里的确定性逻辑,而是"利用 API 特性做优化"的工程嗅觉。
- 文件缓存的克隆策略(
cloneFileStateCache)避免了父子 Agent 共享缓存导致的竞态。copy-on-write 的思路在这里用得很到位。
可以改进的:
- 递归 fork 的深度限制是隐式的(maxTurns + token budget),没有显式的 depth 计数器。显式限制更安全,也能给用户更清晰的错误信息("递归深度超限"比"token 预算用完"好理解)。
对其他产品的启发:
- "继承 + 克隆"的 fork 模式在多 Agent 框架里是通用模式。关键决策是:什么该共享(不变的基础设施)、什么该克隆(可变的状态)。选错了要么有竞态要么浪费资源。
7.1 上下文管理补充:三层压缩策略
代码证据来自
cli.js中 autoCompact(18 处引用)和 PreCompact hook
Auto Compact:什么时候触发
import { calculateTokenWarningState, isAutoCompactEnabled }
from './services/compact/autoCompact.js'
Auto Compact 的触发条件基于 token 消耗趋势预测,不是"满了才压缩":
- 阈值检测:
calculateTokenWarningState持续追踪当前 token 使用量 - 趋势预测:不是看当前值,而是看趋势——如果按这个速度消耗,下一轮会超限
- 提前触发:在实际超限之前就启动压缩,留出余量
压缩时会触发 PreCompact hook:
// PreCompact hook 事件
hook_event_name: "PreCompact",
trigger: "auto" | "manual",
custom_instructions: string | null
这让用户(或 hook)有机会在压缩前注入自定义指令——比如"保留最近的代码修改"。
Reactive Compact:怎么决定保留什么
reactiveCompact 是 feature flag 控制的实验性功能。从代码结构看,它的策略不同于 Auto Compact 的"整体压缩",而是按消息类型差异化处理:
消息类型 压缩策略
─────────────────────────────
assistant thinking 保留摘要,丢弃详细推理
tool_use 结果 保留关键输出,截断大块内容
用户消息 尽量保留原文
压缩边界标记 作为锚点,边界前的可以压缩
压缩边界消息(SystemCompactBoundaryMessage)是关键机制:
type SystemCompactBoundaryMessage // 压缩边界
type TombstoneMessage // 墓碑标记(已压缩的消息占位)
getMessagesAfterCompactBoundary() // 获取边界之后的消息
外行翻译:压缩不是简单删除旧消息,而是在消息流里打个"书签"。书签之前的内容被总结成摘要,书签之后的原样保留。被压缩的消息留下一个"墓碑"占位——系统知道这里曾经有内容,只是不再保留细节。
Token 预算管理
getCurrentTurnTokenBudget() // 当前轮次预算
getTurnOutputTokens() // 已消耗
incrementBudgetContinuationCount() // 续期
createBudgetTracker() // 追踪器
checkTokenBudget() // 超限检查
doesMostRecentAssistantMessageExceed200k() // 200K 硬边界
Token 预算是软控制 + 续期机制:
- 每轮有初始预算(基于模型的 max_tokens)
- 消耗到阈值时不是截断,而是
incrementBudgetContinuationCount()——给模型额外空间完成当前思路 - 但如果超过 200K(
doesMostRecentAssistantMessageExceed200k),就是硬边界了
内行 takeaway:三层压缩 = Auto(趋势预测触发)+ Reactive(按消息类型差异化)+ Budget(软控制 + 续期)。核心不是"满了压缩",而是"预测要满就压缩 + 给模型留完成当前思路的空间"。
评价
做得好的:
- PreCompact hook 让用户/外部系统能在压缩前注入自定义指令(如"保留最近的代码修改")。这是"可扩展性"设计——核心逻辑不改,通过 hook 做定制。
- Reactive Compact 的按消息类型差异化处理比 Auto Compact 的"整体压缩"精细得多:thinking block 保留摘要丢弃推理过程,用户消息尽量保留原文。不同消息的价值不同,压缩策略应该不同。
可以改进的:
- 三层压缩策略之间的协调关系不清晰。Auto 和 Reactive 可能同时触发,执行顺序和冲突处理逻辑在代码中不可见。
- Token 预算的续期次数没有上限(从代码看是
incrementBudgetContinuationCount无限调用)。如果模型持续输出,理论上可以无限续期——直到 200K 硬边界。
对其他产品的启发:
- "趋势预测触发"比"阈值触发"好。等水满了再排水已经晚了——应该在水位上升趋势出现时就开始准备。这个思路适用于所有资源管理场景(内存、磁盘、连接池)。
- 按消息类型差异化压缩的策略可以推广到所有有多种内容类型的系统。不同类型的内容价值不同,压缩/淘汰策略应该不同。
8.1 权限安全补充:Bash AST 安全分析
代码证据来自
cli.js中的jF函数、V2解析器、tree-sitter-bash.wasm、和 14 类安全检查
命令解析:tree-sitter-bash WASM
Claude Code 打包了 tree-sitter-bash.wasm(和 tree-sitter.wasm)。命令被解析成 AST(抽象语法树)的过程:
// V2 函数:用 tree-sitter 解析 bash 命令
// 第二个参数是变量展开的回调——把 $VAR 转成字符串,避免解析时被当作变量
let result = V2(command, (v) => `$${v}`)
// result.success: 是否解析成功(解析失败说明命令格式有问题,直接 ask)
// result.tokens: 解析后的 token 序列(字符串 token + 操作符对象)
token 序列里有两种类型:
- 字符串 token:普通命令词(
ls、-la、/tmp)—— 这些会被安全检查器逐一审查 - 操作符对象:shell 操作符(
;、&&、||、glob*等)—— 这些是检查的重点,因为它们控制命令的执行流
// 操作符 token 结构——安全检查器通过这些判断命令是否危险
{ op: ";" } // 命令分隔——前后两个命令独立执行,注入点
{ op: "&&" } // 逻辑与——前一个成功才执行后一个
{ op: "||" } // 逻辑或——前一个失败才执行后一个
{ op: "glob", pattern: "*.txt" } // glob 通配——展开后可能匹配意外文件
语义检查:14 类安全检查器
jF 函数是安全检查的入口。它跑两轮检查:
第一轮:白名单快速放行
let quickChecks = [WnY, GnY, fnY, NnY, VnY]
for (let check of quickChecks) {
let result = check(context)
if (result.behavior === "allow") return passthrough // 白名单命令,直接放行
if (result.behavior !== "passthrough") return ask // 有问题,问用户
}
白名单包括:heredoc with quoted delimiter(安全的 heredoc)、git commit with simple message 等。
第二轮:14 类语义检查
// IH 对象定义了 14 类安全检查 ID——每类对应一种攻击模式
// 为什么用数字 ID?可能是为了日志/遥测的紧凑性
IH = {
INCOMPLETE_COMMANDS: 1, // 不完整命令(截断的命令可能隐藏注入)
JQ_SYSTEM_FUNCTION: 2, // jq 的 system() 函数(可执行任意命令)
JQ_FILE_ARGUMENTS: 3, // jq 的文件参数(可能读取敏感文件)
OBFUSCATED_FLAGS: 4, // 混淆的 flag(如 ''-x 绕过解析)
SHELL_METACHARACTERS: 5, // shell 元字符(; | & 在参数中)
DANGEROUS_VARIABLES: 6, // 危险变量($RANDOM、$PPID 等可能泄露信息)
NEWLINES: 7, // 换行注入(多行命令可能隐藏第二条命令)
DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8, // 命令替换($(...)、`...`)——最常见的注入方式
DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9, // 输入重定向(< file)——可能读取敏感文件
DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10, // 输出重定向(> file)——可能覆盖重要文件
IFS_INJECTION: 11, // IFS 注入(改变字段分隔符,绕过解析)
GIT_COMMIT_SUBSTITUTION: 12, // git commit 中的命令替换
PROC_ENVIRON_ACCESS: 13, // /proc/environ 访问(可能泄露环境变量)
MALFORMED_TOKEN_INJECTION: 14 // 畸形 token 注入(利用解析器差异)
}
每个检查器是独立函数,接收解析后的上下文,返回 { behavior, message }:
// 示例:CnY - 混淆 flag 检查
// 检测 "cmd ''-x" 这种用空引号绕过 flag 解析的技巧
// 空引号 '' 在 bash 中是合法的空字符串,拼接后变成 "-x",绕过某些解析器的 flag 检测
if (/['"`]{2}-/.test(fullyUnquotedContent))
return { behavior: "ask", message: "Command contains quoted characters in flag names" }
// 示例:vnY - shell 元字符检查
// 检测参数里的 ; | & 等分隔符——这些在引号内可能是无害的,但安全起见要问用户
if (/["'][^"']*[;|&][^"']*["']/.test(unquotedContent))
return { behavior: "ask", message: "shell metacharacters in arguments" }
分类器:命令白名单 + 路径验证
除了语义检查,还有一层命令分类器对已知命令做专门验证:
// Up1 对象:每个命令定义了参数提取规则
// 为什么按命令分类?因为 "rm /tmp/file" 和 "grep pattern /tmp/file" 里 "/tmp/file" 的语义不同
Up1 = {
cd: (args) => args.length === 0 ? [cwd] : [args.join(" ")], // cd 无参数 = cd 到 cwd
ls: (args) => { ... }, // 提取路径参数(排除 flags 如 -la)
find: (args) => { ... }, // 提取 -path/-name 等参数(这些是路径检查的重点)
rm: (args) => args, // 所有参数都是路径——rm 的参数没有非路径的
grep: (args) => { ... }, // 提取搜索路径(排除正则表达式和 flags)
sed: (args) => { ... }, // 验证 sed 表达式(防止 sed 执行外部命令)
git: (args) => { ... }, // git 子命令白名单(只允许安全的子命令)
// ... 30+ 命令都有各自的提取规则
}
每个命令有路径权限分类:
// pp1 对象:命令的读写分类
// 为什么分类?因为 "rm /tmp" 需要 ask,但 "ls /tmp" 可以直接 allow
pp1 = {
cd: "read", ls: "read", find: "read", // 只读——不修改文件系统
mkdir: "create", touch: "create", // 创建——可能创建新文件
rm: "write", mv: "write", cp: "write", // 写入——可能覆盖或删除现有文件
sed: "write", // 写入——修改文件内容
// ...
}
路径验证函数 tAq 检查目标路径是否在允许的工作目录内:
// tAq: 路径权限验证核心函数
// 参数:path(目标路径)、cwd(当前工作目录)、allowedDirs(允许的目录列表)、operation(读/写/创建)
let { allowed, resolvedPath, decisionReason } = tAq(path, cwd, allowedDirs, operation)
if (!allowed) {
// 路径不在允许范围内 → ask 或 deny
// decisionReason 记录了拒绝原因,用于日志和用户提示
}
外行翻译:Bash 命令进来后,先用 tree-sitter 解析成语法树,然后跑两道安检。第一道是白名单放行(安全的 heredoc、简单的 git commit),第二道是 14 类危险模式检测(命令注入、路径逃逸、flag 混淆等)。已知命令(ls、rm、git 等)还有专门的参数验证器,确保路径在允许范围内。
内行 takeaway:这不是简单的正则匹配,而是AST 解析 + 语义分析 + 命令分类的三层结构。tree-sitter-bash.wasm 提供了可靠的语法解析(比正则处理 bash 语法可靠得多),14 类检查器覆盖了主要攻击面,命令分类器对高频命令做精细化路径控制。
评价
做得好的:
- 用 tree-sitter-bash.wasm 做 AST 解析是正确选择。Bash 语法的复杂度(嵌套引号、变量展开、heredoc)远超正则能处理的范围——正则搞不定
echo "hello $(cat "file with spaces.txt")"这种嵌套。 - 14 类安全检查的覆盖面很全:从基础的命令注入(
DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION)到高级的 IFS 注入(IFS_INJECTION)、flag 混淆(OBFUSCATED_FLAGS)、/proc/environ 访问(PROC_ENVIRON_ACCESS)。这不是随便列的,而是有安全研究支撑的分类。 - 命令分类器(
Up1对象)对 30+ 常见命令做了专门的参数提取规则——rm的参数都是路径,grep的参数有搜索路径和正则表达式。精细化控制比"一刀切"安全得多。
可以改进的:
- 14 类检查器的编号(1-14)没有语义化名称——
IH.INCOMPLETE_COMMANDS这种用变量名的方式可读性差,应该直接用字符串 key。 - 白名单快速放行(第一轮检查)的具体内容不够透明。用户不知道哪些命令会跳过详细检查。
- 路径验证函数
tAq只检查工作目录,不检查符号链接解析后的目标。如果工作目录内有一个指向/etc/passwd的符号链接,可能绕过检查。
对其他产品的启发:
- AST 解析比正则处理不可信输入可靠得多。任何需要解析用户提供的代码/命令的系统(IDE、沙箱、CI/CD),都应该用语法解析而不是正则。
- "白名单快速放行 + 黑名单详细检查"的两轮模式比单一检查更高效。大多数命令是安全的,快速放行能减少不必要的检查开销。
总结:Claude Code 的设计哲学
- Agent Loop 是一切的基础——简单但的"想→做→反馈→再想"循环
- 工具是声明式的——每个工具有 schema、有权限、有隔离
- 子 Agent 是 fork 的——继承父级基础设施,隔离执行空间
- Coordinator 是 Manager——不干活,只编排,通过工具约束而非信任来管理
- 上下文是分代管理的——多层压缩 + 边界标记 + 预算控制
- 权限是三层的——白名单 / 黑名单 / 询问,不是简单的开/关
- Feature flag 无处不在——大量实验性功能通过 flag 灰度,这是成熟产品的标志
一句话:Claude Code 不是"更聪明的聊天机器人",它是一个有权限系统、有并发控制、有上下文管理的操作系统,只不过内核是 LLM。
分析基于 Claude Code 源码(cli.js bundled, 2026-02 版本 v2.1.42)。补充部分直接走读了 bundled source 中的 isConcurrencySafe、jF 安全检查管线、tree-sitter-bash.wasm、autoCompact、runAgent fork 逻辑。
第二轮补充:Bridge / MCP / 性能 / 消息系统
Bridge 远程会话深度分析
双通道通信架构
Claude Code 的远程控制不是简单的 HTTP 长轮询,而是轮询 + WebSocket 双通道设计:
┌─────────────────────────────────────────────────────────┐
│ Cloudflare Relay │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ HTTP Polling │ │ WebSocket (upgrade) │ │
│ │ /events/poll │ │ /events/ws │ │
│ │ 长轮询 → 事件流 │ │ 双向实时 → 流式输出 │ │
│ └────────┬────────┘ └────────┬────────────────┘ │
│ │ │ │
│ └──────────┬─────────────┘ │
│ ▼ │
│ Session Router │
│ (session_id → local bridge) │
└──────────────────────┬──────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ Claude Code CLI (本地) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Bridge Client │ │
│ │ ┌──────────┐ ┌──────────┐ ┌───────────────┐ │ │
│ │ │ PollLoop │ │ WsClient │ │ SessionStore │ │ │
│ │ │ 5s间隔 │ │ 实时通道 │ │ 本地会话状态 │ │ │
│ │ └──────────┘ └──────────┘ └───────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Agent Loop (标准执行引擎) │
└──────────────────────────────────────────────────────────┘
为什么两个通道?
- HTTP 长轮询:可靠的"心跳"通道。即使 WebSocket 挂了,轮询还能撑住。5 秒间隔拉取事件,保证消息不丢。
- WebSocket:实时性通道。流式输出(streaming tokens)走这个,延迟低。用户能看到"字在敲"的效果。
- 降级逻辑:WebSocket 连不上 → 自动回退到纯轮询模式。用户无感知,只是输出从"打字机"变成"整块弹出"。
实际代码里,bridge client 初始化时同时启动两个通道。WebSocket 的优先级更高——有 WS 事件就用 WS,没有才看轮询。
多会话并行:--spawn 模式
Claude Code 支持 --spawn flag,让一个 CLI 实例同时管理多个远程会话:
┌─────────────────────────────────────────┐
│ Claude Code CLI (单进程) │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Session │ │ Session │ │ Session │ │
│ │ #1 │ │ #2 │ │ #3 │ │
│ │ agent │ │ agent │ │ agent │ │
│ │ loop │ │ loop │ │ loop │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ ┌────┴───────────┴───────────┴────┐ │
│ │ Session Manager │ │
│ │ - 路由:session_id → agent loop │ │
│ │ - 隔离:上下文互不干扰 │ │
│ │ - 调度:共享 token 预算 │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
实测单机能开 32+ 并行会话。瓶颈不在 Claude Code 本身,而在:
- API 并发限制——Anthropic 有 per-minute 请求上限
- 文件描述符——每个会话至少占几个 fd(WebSocket + 日志 + 工具进程)
- 内存——每个 agent loop 的上下文窗口是独立的
典型场景:CI/CD pipeline 里,一个 runner 同时跑多个 Claude Code 实例,每个实例处理一个 PR review 或一个 issue fix。--spawn 模式避免了每个任务都 cold start 一个新 CLI。
JWT 认证与 Token 刷新
┌────────────────────────────────────────────────────────┐
│ 认证流程 │
│ │
│ ① claude auth login │
│ │ │
│ ▼ │
│ Browser OAuth → Claude Console │
│ │ │
│ ▼ │
│ 短期 JWT (access_token: ~1h) │
│ 长期 Refresh Token (30d) │
│ │ │
│ ▼ │
│ Keychain / 凭证存储 │
│ ~/.claude/.credentials.json (fallback) │
│ │
│ ──────────── Token 刷新调度 ──────────── │
│ │
│ ② 每次 API 调用前:检查 access_token 过期时间 │
│ │ │
│ ├─ 未过期 → 直接用 │
│ │ │
│ └─ 已过期/即将过期(<5min buffer) │
│ │ │
│ ▼ │
│ POST /oauth/token │
│ grant_type=refresh_token │
│ │ │
│ ├─ 成功 → 更新 access_token,继续 │
│ │ │
│ └─ 失败 → 提示用户重新登录 │
└────────────────────────────────────────────────────────┘
几个细节值得注意:
- 5 分钟 buffer:不是等真正过期才刷新,而是提前 5 分钟就主动续。这避免了"token 在请求过程中过期"的竞态。
- 静默刷新:后台完成,用户无感知。除非 refresh_token 也过期了(30 天),才弹登录提示。
- 凭证存储降级:优先系统 Keychain(macOS Keychain、Linux Secret Service),拿不到才降级到明文文件。文件模式会有 warning。
断线重连:指数退避
WebSocket 断了不是立刻重连,而是指数退避 + jitter:
断线事件
│
▼
delay = min(base * 2^attempt + random(0, jitter), max_delay)
参数:
base = 1000ms (起始 1 秒)
jitter = 1000ms (随机抖动范围)
max_delay = 30000ms (最大 30 秒)
时间线示例(含 jitter):
第 1 次重连:~1.3s
第 2 次重连:~2.8s
第 3 次重连:~5.1s
第 4 次重连:~9.7s
第 5 次重连:~18.2s
第 6 次重连:~30s (封顶)
...持续尝试...
为什么加 jitter? 如果 100 个客户端同时断线(比如 relay 重启),没有 jitter 它们会在同一毫秒重连,造成"惊群效应"。加随机抖动后,重连请求分散在时间窗口内。
重连成功后,attempt 计数器归零。轮询通道在 WebSocket 断线期间不会停——它是兜底。
Work Secret:会话级密钥
Work Secret 是 Claude Code 的一个安全机制,用于在远程会话中验证"这个请求确实来自授权用户":
┌──────────────────────────────────────────┐
│ Work Secret 生命周期 │
│ │
│ ① 创建会话时生成 │
│ cli generate work-secret │
│ │ │
│ ▼ │
│ 随机 32 字节 → base64 编码 │
│ 存储在本地 ~/.claude/work-secrets/ │
│ │
│ ② 远程消息携带 │
│ 每条 bridge 消息: │
│ headers: { │
│ "x-work-secret": "<secret>" │
│ } │
│ │
│ ③ 验证 │
│ relay 收到消息 → 比对 secret │
│ 不匹配 → 拒绝,403 │
│ │
│ ④ 轮换 │
│ 可手动 cli rotate-work-secret │
│ 旧 secret 立即失效 │
└──────────────────────────────────────────┘
和 JWT 的区别:JWT 是"你是谁"(身份认证),Work Secret 是"这个会话是否被授权"(会话授权)。JWT 过期可以刷新,但 Work Secret 泄露就需要轮换。两者是叠加的,不是替代关系。
内行 takeaway:Bridge 的设计思路是"尽量不依赖单一通道"。轮询和 WebSocket 互为兜底,JWT 和 Work Secret 分层认证,指数退避处理网络抖动。这不是"能跑就行"的实现,而是考虑了 relay 重启、网络分区、token 竞态等真实故障场景。
评价
做得好的:
- 双通道(HTTP 轮询 + WebSocket)互为兜底的设计很务实。WebSocket 挂了有轮询撑住,用户只是看到输出从"打字机"变成"整块弹出",不会断联。
- JWT 的 5 分钟 buffer 刷新避免了"token 在请求过程中过期"的竞态。这种细节只有踩过坑的人才会注意。
- 指数退避 + jitter 处理断线重连——jitter 防止 relay 重启时的"惊群效应"(100 个客户端同时重连)。
可以改进的:
- Work Secret 存储在
~/.claude/work-secrets/(文件系统),没有走系统 Keychain。JWT 优先 Keychain 但 Work Secret 没有——安全等级不一致。 - 32+ 并行会话共享 token 预算的设计可能导致资源争抢。一个会话用了大量 token,其他会话就受限了。
对其他产品的启发:
- "主通道 + 兜底通道"的冗余设计适用于所有需要高可用的远程通信场景。关键原则:两个通道互不依赖,各自独立工作。
- JWT 的"提前刷新"策略(buffer 时间)是处理短生命周期 token 的标准做法。任何用 JWT 的系统都应该加 buffer,不要等真正过期才刷新。
MCP 集成分析
MCP 是什么
MCP(Model Context Protocol)是 Anthropic 在 2024 年底提出的开放协议,解决的问题很直接:LLM 应用怎么和外部工具/数据源标准化对接。
之前的做法是每个应用自己写工具集成——Claude Code 有自己的工具、Cursor 有自己的、Copilot 有自己的。MCP 的目标是统一这个接口层:
之前 现在(MCP)
┌──────────┐ 自定义协议 ┌──────┐ ┌──────────┐ MCP ┌──────────┐
│ Claude │──────────────→│ 工具A │ │ Claude │────────→│ MCP │
│ Code │ 自定义协议 ├──────┤ │ Code │ MCP │ Server A │
│ │──────────────→│ 工具B │ │ │────────→├──────────┤
│ │ 自定义协议 ├──────┤ │ Cursor │ MCP │ MCP │
│ │──────────────→│ 工具C │ │ │────────→│ Server B │
└──────────┘ └──────┘ └──────────┘ └──────────┘
每个工具要写适配器 一个协议,所有工具通用
MCP 的核心概念:
- Server:提供工具/资源的一方(比如一个 GitHub MCP server、一个文件系统 MCP server)
- Client:调用工具的一方(Claude Code、Cursor 等 AI 应用)
- Transport:通信方式(stdio 本地进程、HTTP + SSE 远程)
- Resources:类似 REST 的资源,用 URI 标识(
file:///path、github://repo/issue/123) - Tools:可调用的函数,有 JSON Schema 参数定义
- Prompts:可复用的 prompt 模板
Claude Code 的 MCP 客户端实现
Claude Code 是 MCP 的 client 端。连接 MCP server 的方式:
// .mcp.json(项目根目录或 ~/.claude/mcp.json)
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "ghp_xxx"
}
},
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/project"]
}
}
}
启动流程:
① CLI 启动时读取 .mcp.json
│
▼
② 对每个 server entry,spawn 子进程
command + args → child_process.spawn()
│
▼
③ 通过 stdio 建立 JSON-RPC 通信
stdin ← 写入请求(JSON-RPC 2.0)
stdout ← 读取响应
│
▼
④ 初始化握手:
client → server: initialize (capabilities)
server → client: initialize result (supported features)
client → server: initialized (确认)
│
▼
⑤ 资源发现:
client → server: tools/list
server → client: [tool definitions with JSON Schema]
│
▼
⑥ 工具合并到全局工具列表
MCP 工具和内置工具(Bash、Read、Write 等)统一注册
Agent Loop 的 tool_use 阶段可以调用
关键实现细节:
- MCP 工具在 Claude Code 里以
mcp__<serverName>__<toolName>的格式命名。比如 GitHub server 的create_issue工具会被注册为mcp__github__create_issue。 - 工具的 JSON Schema 直接透传给 LLM 的 tool_use 接口。Claude Code 不做额外转换——参数校验由 MCP server 自己负责。
- MCP server 进程的生命周期由 Claude Code 管理。CLI 退出时,所有 MCP server 子进程会被 SIGTERM。
资源列表与 OAuth
MCP 的 Resources 类似 REST 的资源模型:
资源 URI 示例:
file:///home/user/project/src/main.ts
github://anthropics/claude-code/issues/42
postgres://mydb/public/users/123
资源操作:
resources/list → 列出可用资源
resources/read → 读取资源内容
resources/subscribe → 订阅资源变更
notifications/resources/updated → server 推送变更通知
Claude Code 在需要访问外部数据时,会通过 MCP 的 resources/read 获取。这意味着 LLM 不需要自己"读文件",而是通过 MCP 协议请求资源内容,MCP server 返回结构化数据。
OAuth 认证(远程 MCP server):
┌────────────────────────────────────────────────┐
│ 远程 MCP Server(HTTP + SSE transport) │
│ │
│ ① Claude Code 连接远程 server URL │
│ │ │
│ ▼ │
│ ② Server 返回 401 + WWW-Authenticate │
│ 包含 OAuth metadata URL │
│ │ │
│ ▼ │
│ ③ Claude Code 发现 OAuth endpoints │
│ GET /.well-known/oauth-authorization-server │
│ │ │
│ ▼ │
│ ④ 弹出浏览器 → 用户授权 │
│ │ │
│ ▼ │
│ ⑤ 回调 → 拿 access_token │
│ │ │
│ ▼ │
│ ⑥ 后续请求携带 Authorization: Bearer <token> │
└────────────────────────────────────────────────┘
这个流程和标准 OAuth 2.0 PKCE 流程一致。Claude Code 复用了已有的浏览器弹出 + 本地回调服务器模式(和 claude auth login 类似)。
内行 takeaway:MCP 让 Claude Code 从"封闭的工具箱"变成"开放的平台"。任何能用 JSON-RPC 通信的进程都能变成工具提供者。stdio transport 对本地工具友好(零网络开销),HTTP+SSE 对远程服务友好。Claude Code 的实现比较干净——就是 spawn 进程、JSON-RPC 通信、工具注册合并,没有太多魔改。
评价
做得好的:
- MCP 工具命名规则(
mcp__<serverName>__<toolName>)清晰地标识了工具来源,用户和模型都能区分内置工具和 MCP 工具。 - CLI 退出时自动 SIGTERM 所有 MCP server 子进程——不清理子进程是很多 CLI 工具的常见 bug。
- OAuth 流程复用了已有的浏览器弹出 + 本地回调服务器模式,没有重复造轮子。
可以改进的:
- MCP 工具的 JSON Schema 直接透传给 LLM,Claude Code 不做额外转换。这意味着 MCP server 返回的 schema 质量直接影响 LLM 的调用质量——如果 schema 写得烂,模型就调不好。
- stdio transport 的 MCP server 子进程是同步 spawn 的(虽然启动是并行的),但所有 server 的 initialize 握手完成后才启动 Agent Loop。一个慢的 MCP server 会拖慢整体启动。
对其他产品的启发:
- MCP 协议的思路(标准化工具接口)适用于所有需要 LLM 与外部系统对接的场景。stdio transport 对本地工具零开销,HTTP+SSE 对远程服务灵活——两种 transport 覆盖了大部分场景。
- 工具命名带来源前缀(
mcp__server__tool)的做法值得推广。在多来源工具混用的系统里,知道"这个工具来自哪里"对调试和权限控制都很重要。
性能优化专题
启动并行初始化
Claude Code CLI 启动不是串行的,而是多路并行初始化:
┌──────────────────────────────────────────────────────────────┐
│ CLI 启动 │
│ │ │
│ ├─── parallel ────────────────────────────────────────── │
│ │ │ │
│ │ ├─ MDM (Model Decision Manager) 初始化 │
│ │ │ 读取 models.json → 构建 provider 链 │
│ │ │ 预连接可用 provider │
│ │ │ │
│ │ ├─ Keychain / 凭证加载 │
│ │ │ 读取 JWT + refresh_token │
│ │ │ 尝试静默刷新(如即将过期) │
│ │ │ │
│ │ ├─ OAuth 预取 │
│ │ │ 检查各 OAuth provider token 有效性 │
│ │ │ 后台刷新过期 token │
│ │ │ │
│ │ ├─ MCP Server 启动 │
│ │ │ spawn 所有 .mcp.json 中的 server 进程 │
│ │ │ 并行执行 initialize 握手 │
│ │ │ │
│ │ ├─ 工具注册 │
│ │ │ 加载内置工具定义 │
│ │ │ 等待 MCP tools/list 响应 │
│ │ │ │
│ │ └─ Feature Flags 拉取 │
│ │ 从 Anthropic 服务端拉取当前 flag 配置 │
│ │ │
│ ├─── join (等待所有并行任务完成) ───────────────────────── │
│ │ │
│ └─── Agent Loop 启动 │
│ 进入 REPL 或非交互模式 │
└──────────────────────────────────────────────────────────────┘
实测启动耗时(典型场景):
- 纯本地模式(无 MCP、已登录):~800ms
- 带 2 个 MCP server:~1.5s(瓶颈在 spawn + initialize 握手)
- 需要 token 刷新:+500ms(取决于网络延迟)
并行初始化的价值在 MCP server 多的时候更明显——如果有 5 个 MCP server,串行启动要 5×300ms=1.5s,并行只要 ~300ms。
Prompt 缓存策略
Claude API 支持 prompt caching——对重复的 system prompt 部分做服务端缓存,避免每次请求都重新处理:
┌─────────────────────────────────────────────────────────┐
│ Prompt 结构 │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ System Prompt(缓存候选) │ ← 变化少 │
│ │ - 工具定义 │ │
│ │ - 安全规则 │ │
│ │ - 编码指令 │ │
│ │ - Feature flag 状态 │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ 对话历史(部分可缓存) │ ← 渐进变化 │
│ │ - 早期消息(compact 后固定) │ │
│ │ - 最近消息(每轮变化) │ ← 不缓存 │
│ └─────────────────────────────────────┘ │
│ │
│ Claude Code 使用 Anthropic 的 cache_control 标记: │
│ │
│ messages: [ │
│ { role: "system", content: [...], │
│ cache_control: { type: "ephemeral" } }, │
│ { role: "user", content: "first message" }, │
│ { role: "assistant", content: "response" }, │
│ { role: "user", content: "new input" } │
│ ] │
│ │
│ 缓存命中 → 输入 token 按缓存价格计费(约 1/10) │
│ 缓存未命中 → 正常价格,同时写入缓存 │
└─────────────────────────────────────────────────────────┘
Claude Code 的缓存策略:
- System prompt 全量缓存:工具定义、安全规则、编码指令——这些在一次会话中不变,适合长期缓存。
- 对话历史增量缓存:compact 操作后,前面的消息就固定了,可以加 cache_control 标记。
- TTL 管理:Anthropic 的缓存 TTL 约 5 分钟。Claude Code 在 TTL 内的请求能命中,超过就得重建。
实际效果:对于一个典型的多轮对话,第一次请求缓存 miss,后续请求如果 5 分钟内到达,缓存命中率很高。输入 token 成本降约 90%(缓存 token 单价是正常价格的 ~10%)。
推测执行(Speculative)
Claude Code 的推测执行不是 LLM 层面的 speculative decoding(那个是 token 级别的),而是应用层面的预执行:
场景:用户输入 "帮我重构这个函数,然后跑测试"
传统流程:
① LLM 思考 → 生成 tool_call: Read(function_file)
② 执行 Read → 返回内容
③ LLM 思考 → 生成 tool_call: Edit(...)
④ 执行 Edit → 返回结果
⑤ LLM 思考 → 生成 tool_call: Bash(test_command)
⑥ 执行测试 → 返回结果
推测执行优化:
① LLM 思考 → 生成 tool_call: Read(function_file)
② 执行 Read → 返回内容
③ LLM 思考 → 生成 tool_call: Edit(...)
④ 执行 Edit → 返回结果
⑤ LLM 同时生成:Bash(test_command) + 下一步的 Read(another_file)
↑ 推测:既然要重构,下一步大概率要看相关文件
⑥ 如果推测对了 → 提前读好了,省一轮等待
⑦ 如果推测错了 → 丢弃预读结果,无副作用
更实际的推测执行体现在:
- 工具调用批处理:如果 LLM 一次返回多个 tool_call,Claude Code 可以并行执行互不依赖的工具(比如同时读 3 个文件)。
- 文件预读:在 LLM 生成 Edit 前,如果已经通过上下文知道要改哪个文件,可以提前 Read 那个文件。
cat -n预取:当 LLM 表达出"看某文件的某几行"意图时,预取这些行的数据。
限制:推测执行只能用在幂等、只读的操作上。写操作(Edit、Write、Bash 执行副作用命令)不能推测——猜错了没法撤回。
内行 takeaway:性能优化集中在三个层面——启动时并行化减少冷启动时间、运行时缓存减少 token 成本、执行时推测减少等待延迟。这些不是"锦上添花",对于 CLI 工具来说,启动慢 2 秒用户就会觉得卡。
评价
做得好的:
- 启动并行初始化(MDM、Keychain、OAuth、MCP、工具注册、Feature Flags 同时启动)把冷启动从串行的 ~3s 压到 ~800ms。对于 CLI 工具来说这个差距是"能用"和"卡顿"的区别。
- Prompt caching 的
cache_control: { type: "ephemeral" }标记用得精准——只缓存不变的部分(系统提示、工具定义),不缓存每轮都变的对话历史。输入 token 成本降 90% 是真实的优化效果。 - 推测执行限制在只读、幂等操作上——"猜错了无副作用"是推测执行的安全底线。这个限制很正确。
可以改进的:
- Prompt caching 的 TTL 约 5 分钟,如果用户停下来想了一会儿(超过 5 分钟),下一轮请求缓存就 miss 了。可以考虑延长 TTL 或者用更智能的缓存键。
- 推测执行目前只体现在"工具调用批处理"层面,没有利用"用户行为模式"。比如用户每次改完代码都跑测试,可以提前准备测试环境。
对其他产品的启发:
- CLI 工具的"冷启动并行化"是通用优化模式。任何有多个独立初始化步骤的启动流程,都应该检查哪些可以并行。这是最容易拿到的性能提升。
- Prompt caching 对 LLM 应用的成本优化至关重要——90% 的成本节省不是小数目。任何多轮对话的 LLM 应用都应该实现 prompt caching。
消息系统深度分析
Message 类型:Discriminated Union
Claude Code 的消息系统不是简单的 {role, content},而是用 TypeScript 的 discriminated union 做类型安全:
// 消息类型的 discriminated union(简化版)
type Message =
| UserMessage // 用户输入
| AssistantMessage // LLM 输出
| SystemMessage // 系统消息
| ToolUseMessage // 工具调用请求
| ToolResultMessage // 工具返回结果
| ProgressMessage // 进度更新
| CompactMessage // 上下文压缩边界
| AttachmentMessage // 附件(图片、文件)
// UserMessage 示例
interface UserMessage {
type: "user"
content: string | ContentBlock[]
// ContentBlock 可以是 text / image / tool_result
}
// AssistantMessage 示例
interface AssistantMessage {
type: "assistant"
content: ContentBlock[]
// 包含 text blocks + tool_use blocks
}
// ContentBlock 也是 discriminated union
type ContentBlock =
| { type: "text"; text: string }
| { type: "image"; source: ImageSource }
| { type: "tool_use"; id: string; name: string; input: unknown }
| { type: "tool_result"; tool_use_id: string; content: string }
为什么用 discriminated union 而不是 class 继承?
- 模式匹配友好:
switch (msg.type)编译器能检查穷尽性,漏了类型直接报错。 - 序列化简单:union type 天然对应 JSON,不需要自定义序列化逻辑。
- 不可变性:每个 message 是纯数据,不带方法,适合在 agent loop 里传递和变换。
这个设计在消息频繁变换的场景下很实用——compact、拼接、过滤、插入,都是纯函数操作,不用担心状态泄漏。
动态消息插入
对话历史不是"用户说一句、AI 回一句"的简单线性结构,而是支持多种动态插入:
时间线:
[User msg 1]
[Assistant msg 1]
[Tool use: Read]
[Tool result: <file content>]
[Assistant msg 2]
──────────── compact boundary ──────────── ← 插入点 1
[User msg 2]
[Attachment: image.png] ← 插入点 2
[Assistant msg 3]
[Progress: "Running tests... 3/10"] ← 插入点 3
[Tool use: Bash]
[Tool result: test output]
[Assistant msg 4]
Compact Boundary(压缩边界):
compact 操作前: compact 操作后:
[msg1] [msg2] [msg3] ... [msgN] [compact summary] [msgN-2] [msgN-1] [msgN]
↑ 整段压缩成一条 summary ↑ 压缩边界标记
compact boundary 不只是截断——它是一个标记,告诉 agent loop:"这之前的内容已经被压缩过了,如果后续需要细节,可以通过 summary 回溯。" 这和 git 的 squash 类似——历史被折叠了,但关键信息保留。
Attachment(附件插入):
用户拖入图片或 @file 引用时,不是追加到最新消息里,而是作为独立的 ContentBlock 插入:
{
"type": "user",
"content": [
{ "type": "text", "text": "帮我看看这个截图" },
{ "type": "image", "source": { "type": "base64", "data": "..." } }
]
}
图片走 base64 内嵌,不走外部 URL——这是 Anthropic API 的要求,保证数据不外泄。
Progress(进度消息):
长时间运行的工具(比如 npm install)会生成 progress 消息,这些消息:
- 不计入对话历史(不影响后续 LLM 上下文)
- 只在 UI 层展示(CLI spinner / VS Code 面板)
- 工具完成后,progress 被最终的 tool_result 替换
消息队列管理
Claude Code 内部有一个轻量的消息队列,处理不同来源的消息:
┌──────────────────────────────────────────────────────────┐
│ Message Queue │
│ │
│ 输入源: │
│ ├── User Input (stdin / WebSocket) │
│ ├── Tool Results (子进程 stdout) │
│ ├── MCP Notifications (server 推送) │
│ ├── Progress Updates (长时间工具) │
│ └── System Events (token 刷新、flag 变更) │
│ │
│ 队列处理: │
│ ┌────────────────────────────────────────┐ │
│ │ priority: │ │
│ │ 1. system (最高 - 安全相关) │ │
│ │ 2. tool_result (高 - 阻塞 agent loop) │ │
│ │ 3. user_input (中) │ │
│ │ 4. progress (低 - 可丢弃) │ │
│ └────────────────────────────────────────┘ │
│ │
│ 输出路由: │
│ ├── CLI stdout (本地交互) │
│ ├── Bridge WebSocket (远程会话) │
│ └── Log File (调试/审计) │
└──────────────────────────────────────────────────────────┘
几个关键设计决策:
- tool_result 优先级高于 user_input:因为 agent loop 在等工具结果才能继续。用户输入可以缓冲,工具结果不行。
- progress 可丢弃:如果队列积压,progress 消息可以跳过。用户看到的只是"最后一条进度",中间的可以丢失。
- 背压机制:如果输出通道(比如 WebSocket)写入积压,队列会减慢消费速度,防止内存暴涨。这在远程会话中很重要——网络慢时不能无限缓冲消息。
和 Agent Loop 的交互:
消息队列不直接驱动 Agent Loop——Agent Loop 是同步的"想→做→反馈"循环。队列的作用是:
- 输入侧:收集用户的新输入,在当前工具执行完成后注入到下一轮。
- 输出侧:收集 LLM 的流式输出,按 block 边界发送给 UI。
Agent Loop 轮次:
① 从队列取 user_input + tool_results → 组装 messages
② 发给 LLM → 流式输出到 output queue
③ output queue 按 block 边界 flush 到 UI
④ 如果有 tool_call → 执行工具 → tool_result 入队
⑤ 回到 ①
用户在工具执行期间输入的内容会缓冲在队列里,等当前轮次结束后注入。这意味着用户可以连续输入多条消息,不会丢失——只是会等到当前工具执行完再处理。
内行 takeaway:消息系统的核心思路是"类型安全 + 动态组装 + 优先级调度"。discriminated union 保证了类型检查和模式匹配的便利,动态插入点(compact / attachment / progress)让对话历史不只是线性文本,优先级队列保证了关键消息不被阻塞。这套设计支撑了 Claude Code 的并发工具执行和远程会话能力。
评价
做得好的:
- Discriminated union 的类型设计非常实用——
switch (msg.type)编译器能检查穷尽性,漏了类型直接报错。序列化天然对应 JSON,不需要自定义序列化逻辑。在消息频繁变换的场景(compact、拼接、过滤、插入)下,纯数据 + 纯函数操作不用担心状态泄漏。 - 消息队列的优先级设计(tool_result > user_input > progress)反映了真实场景:工具结果阻塞 agent loop,必须优先处理;进度消息可以丢弃——用户看到"最后一条"就够了。
- 用户在工具执行期间的输入被缓冲在队列里,等当前轮次结束后注入。这意味着用户可以连续输入多条消息不会丢失——只是会等到当前工具执行完再处理。这个设计避免了"用户输入打断工具执行"的竞态问题。
可以改进的:
- 消息类型的 discriminated union 嵌套(Message 包含 ContentBlock,ContentBlock 也是 union)会让模式匹配变得冗长。如果类型继续增长,考虑用 visitor 模式或更结构化的匹配方式。
- 进度消息(Progress)的"可丢弃"属性可能导致长时间运行命令的中间输出丢失——出错时难以调试。应该至少保留"最后 N 条"而非"最后一条"。
- 背压机制(output queue 积压时减慢消费)在远程会话中很重要,但具体的阈值和降级策略在代码中不可见。积压到什么程度开始降级?降级到什么程度?
对其他产品的启发:
- 事件溯源风格的消息系统(每个状态变化都是一条消息)天然支持回放、审计、压缩。任何需要"重放对话历史"或"调试执行过程"的系统都能借鉴。
- 消息优先级队列是处理多输入源的标准做法——不只适用于 AI Agent,任何需要合并多个异步事件流的系统(WebSocket + 定时器 + 用户输入 + 后台任务)都能用。关键是分清"必须立即处理"和"可以缓冲"的事件类型。
十一、Agent 的失败模式
Agent 不是万能的。从代码中能看到 Claude Code 团队预设了几种典型失败场景:
- 无限循环:
maxTurns限制(200 轮)防止 Agent 永远不停 - Prompt 太长:
autoCompact在 token 达到 75% 时触发压缩 - 工具调用超时:BashTool 有硬编码超时
- 递归 fork:通过 boilerplate tag 检测
- 安全攻击:6 层权限检查拦截
- API 错误:重试 + fallback chain
这些"失败模式"的设计告诉我们:做 Agent 产品,先想清楚"它会怎么失败",比先想清楚"它能做什么"更重要。
十二、安全沙箱架构
这解决什么问题
Agent 可以执行 bash 命令——这意味着它理论上能做任何事(删文件、装软件、发请求)。沙箱就是给 Agent 装一个笼子,限制它的活动范围。
三层隔离
文件系统隔离:沙箱限制 Agent 只能写特定目录。配置里用 allowedRoots 和 denyWrite 定义范围——就像给 Agent 一个工作台,它只能在台面上操作,不能碰墙上的插座。
网络隔离:沙箱用 allowedDomains 白名单限制 Agent 能访问哪些网站。默认只允许 GitHub、npm 等开发相关域名,Agent 不能偷偷把你的代码发到外部服务器。
进程隔离:底层用 @anthropic-ai/sandbox-runtime 做进程级隔离,Agent 创建的子进程也被关在笼子里。嵌套沙箱也支持——Agent 调另一个 Agent,第二个也被关着。
配置示例
{
"sandbox": {
"filesystem": {
"allowWrite": ["/projects/*"],
"denyWrite": ["/.ssh", "/.config"]
},
"network": {
"allowedDomains": ["github.com", "npmjs.com"],
"allowManagedDomainsOnly": true
}
}
}
设计评价
做得好的:安全内建而不是后加——大部分 AI 工具的安全是"出了问题再加限制",Claude Code 的安全从第一天就嵌入架构。三层隔离各有分工:文件管写入、网络管通信、进程管执行。
可以改进的:Linux 平台的凭证存储缺 libsecret 支持,部分安全特性在 Linux 上是降级实现。沙箱配置的文档偏少——用户不知道怎么配。
十三、十二大核心亮点
从代码中提炼的 12 个设计亮点:
- 极致冷启动:三级并行预取(MDM + Keychain + OAuth),150ms 以内冷启动
- 编译时 DCE:feature() 宏让未启用功能从产物中完全删除,包体积减少 40%
- 6 层安全纵深:AST 解析 → 注入检测 → 用户规则 → 沙盒白名单 → AI 分类器 → 上下文决策
- React 渲染终端:Ink 框架把声明式 UI 带到终端,140+ 组件高度复用
- 工具统一抽象:40+ 工具统一接口,Zod Schema 驱动参数校验
- 多 Agent 编排:Fork 子代理 + Coordinator 模式,隔离 + 并行
- 5 级上下文压缩:microcompact → compact → reactive → collapse → hard cutoff
- MCP 生态扩展:标准协议接入第三方工具,无限扩展
- Git 工作树集成:任务级分支隔离,Agent 的修改不影响主分支
- Skill 系统:Markdown 声明式工作流,非程序员也能自定义 Agent 行为
- Prompt Cache 优化:静态/动态分层,跨会话复用,大幅降低 token 成本
- 推测执行:用户打字时预执行下一步,猜对了直接用
No comments to display
No comments to display