主理人:by 渔夫 基于 Claude Code 开源代码库(~512,000 行,~1,900 文件)的全面深度分析 生成日期:2026-04-02

目录

本手册共 8 大模块,覆盖 Claude Code 全部核心子系统。 每个模块以「生活类比 → 核心概念 → 设计原理 → 架构图 → 精选代码 → 实战启示」六步结构展开,非计算机专业也能看懂。


总览:这个系统到底在干嘛?

Claude Code 是 Anthropic 出品的命令行 AI 编程工具。你在终端里输入一句话,它就能帮你写代码、改 bug、搜文件、跑命令。

听起来简单?其实背后是一套 51 万行代码 的完整工程系统。它不是一个简单的”调 API → 显示结果”的脚本,而是一个有”心跳”(循环)、有”双手”(工具)、有”安检”(权限)、有”记忆”(状态)、有”笔记本管理”(压缩)、有”智能家居规则”(Hook)、有”终端 App”(UI)的完整 AI Agent。

系统架构总览(通俗版)


你在终端打字



[UI 渲染层] 把你的输入变成漂亮的界面



[Agent 循环] 反复"思考→行动→反思",直到任务完成



[工具系统] AI 的"手"——读文件、跑命令、搜代码

↓ ↑

[权限系统] 每个动作先过安检 [消息系统] 所有对话整理成标准格式

↓ ↑

[状态管理] 记住"现在在干嘛" [Hook 系统] 可自定义的触发规则



[上下文压缩] 聊太多了?自动总结,腾出空间

八大模块速查表

| 模块 | 一句话解释 | 生活类比 |

|------|-----------|---------|

| Agent 循环 | AI 反复”思考→行动→反思”的核心引擎 | 超级管家的工作流程 |

| 工具系统 | 65+ 个工具让 AI 能读写文件、跑命令 | 万能工具箱 |

| 权限系统 | 每个操作都要过安检 | 机场安检5道关 |

| 消息系统 | 所有对话整理成 API 能接受的格式 | 快递物流分拣中心 |

| 状态管理 | 记住系统当前状态(用了多少 token 等) | 餐厅经理的短期记忆 |

| 上下文压缩 | 对话太长时自动总结 | 笔记本写满了做摘要 |

| Hook 系统 | 可编程的自动化规则 | 智能家居的触发器 |

| UI 渲染 | 在终端里跑 React 应用 | 在墨水屏上画彩色漫画 |

以下进入各模块详解。每个模块都以「生活类比」开场,确保非技术背景的读者也能理解。


模块一:Agent 循环——系统的「心跳」

1. 生活类比:超级管家的一天

想象你雇了一位超级管家,给他一个任务:“帮我把客厅重新装修一下”。

管家不会傻站着想半天再一口气干完。他的工作方式是这样的:先看看客厅现在什么样(理解指令),然后想一想需要做什么(调用大脑),发现需要买油漆(需要工具),于是出门去买(执行工具),买回来之后继续看看还需要什么(返回结果),发现还要换窗帘(又需要工具),再出门去买……如此反复,直到一切搞定,管家跟你说”好了,完成了”。

这就是 Agent 循环。它是 Claude Code 的核心引擎——一个不停转动的”思考-行动-检查”飞轮。

但这位管家还有几个聪明之处。第一,他不会等所有东西都买齐了再告诉你进度,而是每做完一件事就喊一嗓子:“油漆买好了!""旧窗帘拆了!“——这就是”流式输出”,你能实时看到进度。第二,他有成本意识:你给了他 500 块预算,他会随时算还剩多少钱,快花完了就用更省钱的方式干活。第三,他知道什么时候该停:活干完了、钱花光了、你喊停了、或者干了太多轮了——任何一个条件满足,他就收工。

理解了这位管家,你就理解了 Agent 循环的全部精髓。下面我们把这些概念一个一个拆开来看。


2. 核心概念

什么是 Agent 循环?为什么要「循环」?

Agent 循环就是一个 while(true) 无限循环。每一轮做三件事:

  1. 问 Claude:把所有对话消息发给 API,让模型思考

  2. 干活:如果模型说”我需要读个文件”或”我需要执行个命令”,就去执行对应的工具

  3. 检查:干完了吗?还需要继续吗?

为什么需要循环而不是调用一次就结束?因为大模型不是全知全能的。它可能读了一个文件发现还需要读另一个,改了一行代码发现还需要改测试——每一步都可能触发新的需求。循环就是让它能够”一步一步”地完成复杂任务。

AsyncGenerator:直播记者 vs 写完再发的记者

这里有一个关键的技术选择:Agent 循环用了 AsyncGenerator(异步生成器)而不是普通的 async 函数。

打个比方:普通 async 函数就像一个记者,跑去现场采访,写完整篇稿子,然后一口气交给编辑部。你等了半天什么也看不到,突然”哗”一大堆内容全出来了。

AsyncGenerator 就像一个直播记者。他在现场,看到什么就立刻播报:“我现在正在读取文件……文件内容是……现在开始修改代码……”你作为观众(也就是 UI 界面),能实时看到每一步进展。

在代码里,这体现为 yield 关键字——每当有新消息产生,就立刻 yield 出去,UI 立刻就能显示。

QueryEngine vs query():经理和干活的人

系统把工作分成了两层:

  • QueryEngine(经理):管全局。维护整个对话历史、记录花了多少钱、把内部消息转成 SDK 格式、决定整体策略。

  • query()(干活的人):管单次循环。反复调 API、执行工具、处理流式响应、决定这一轮该不该停。

经理不直接打电话给 Claude API,干活的人不管对话历史怎么存储。各司其职,互不干扰。

5 种停止条件

Agent 循环不会永远跑下去。以下任何一种情况都会让它停下来:

| 停止条件 | 通俗解释 | 类比 |

|---------|---------|------|

| 模型说”我做完了” | API 返回的响应里没有工具调用 | 管家说”活干完了” |

| 用户按了 Ctrl+C | 中止信号被触发 | 你对管家喊”停!” |

| 超过最大轮数 | maxTurns 限制到了 | 规定最多跑 5 趟商店 |

| Token 预算用尽 | 累积消耗的 token 超出预算 | 500 块钱花完了 |

| 遇到不可恢复的错误 | 消息太长、输出超限等,且恢复失败 | 商店关门了,买不到东西 |


3. 设计原理

为什么用生成器而不是简单的 async/await?

核心原因:用户体验

如果用普通 async 函数,用户发一条消息后可能要等 30 秒甚至几分钟,界面完全”卡住”,然后突然一大堆结果刷出来。这种体验就像微信发消息时对方永远”对方正在输入……”然后突然蹦出一篇论文。

用生成器后,每产生一条消息就立刻推送给 UI。用户能看到”正在读文件……""正在执行命令……""正在思考……”,体验丝滑得多。

为什么要分 QueryEngine 和 query()?

单一职责原则。如果把”管理对话历史""调用 API""执行工具""格式转换""成本统计”全塞进一个函数,那个函数会变成一个 2000 行的怪物,没人能维护。

分成两层后:query() 只关心”调 API → 执行工具 → 继续还是停”这个核心循环;QueryEngine 负责外围的一切杂务。修改 SDK 格式?改 QueryEngine。修改循环逻辑?改 query()。互不影响。

为什么要 5 种停止条件?

因为现实世界比理想情况复杂得多。你不能只靠”模型说做完了”来停止——万一模型陷入无限循环调工具呢(maxTurns 兜底)?万一 token 成本失控呢(预算兜底)?万一用户等不及了呢(Ctrl+C 兜底)?万一 API 报错呢(错误恢复兜底)?

这是典型的防御性编程思维:每一层都是一道安全网,任何单一机制失效都不会导致系统失控。

Token 预算的哲学

预算管理分三层:单轮预算(这一次 API 调用最多用多少 token)、任务预算(整个任务从头到尾最多用多少)、美元预算(最多花多少钱)。

有趣的设计是:预算快用完时不是直接掐断,而是发一条”请简洁回答”的提示给模型,给它一次体面收尾的机会。就像你对管家说:“钱快用完了,剩下的活挑最重要的先做。“


4. 架构图

简化流程图


用户发消息





┌──────────────┐

│ QueryEngine │ ← 经理层:管历史、管预算、管格式

│ (submitMsg) │

└──────┬───────┘

│ 调用



┌──────────────────────────────────────────┐

│ query() 核心循环 │

│ │

│ while (true) { │

│ ① 准备消息(压缩、裁剪、控制大小) │

│ ② 调用 Claude API(流式获取响应) │

│ ③ 有工具调用? │

│ ├─ 有 → 执行工具 → 检查停止条件 │

│ │ → 继续下一轮 │

│ └─ 无 → 检查是否需要错误恢复 │

│ → 返回结果,循环结束 │

│ } │

└──────────────────────────────────────────┘

QueryEngine vs query() 对比

| 维度 | QueryEngine (经理) | query() (执行者) |

|------|-------------------|-----------------|

| 维护对话历史 | 是 | 否 |

| 调用 Claude API | 否 | 是 |

| 执行工具 | 否 | 是 |

| 格式转换 (SDK) | 是 | 否 |

| 成本统计 | 是 | 否 |

| 决定循环继续/停止 | 否 | 是 |

| 处理错误恢复 | 否 | 是 |


5. 精选代码

代码片段一:主循环骨架

这是整个 Agent 循环最核心的结构,约 1400 行代码的精华浓缩:


// query.ts —— Agent 循环的心脏

async function* queryLoop(params): AsyncGenerator<Message, Terminal> {

let state = initialState // 初始状态

  

// 无限循环,直到某个停止条件触发

while (true) {

// 从 state 中取出本轮需要的信息

const { messages, turnCount, toolUseContext } = state

  

// ====== 第一步:准备消息 ======

// 太长了?压缩。太多了?裁剪。确保不超 API 限制。

let messagesForQuery = prepareMessages(messages)

  

// ====== 第二步:调用 Claude API ======

let needsFollowUp = false // 关键标记:模型是否需要工具

  

for await (const message of deps.callModel({ messages: messagesForQuery })) {

yield message // 立刻推给 UI,不等!

  

if (message.type === 'assistant') {

// 检查响应里有没有工具调用

const toolBlocks = message.content.filter(b => b.type === 'tool_use')

if (toolBlocks.length > 0) {

needsFollowUp = true // 有工具要执行,循环不能停

}

}

}

  

// ====== 第三步:决定下一步 ======

if (!needsFollowUp) {

return { reason: 'completed' } // 没有工具调用,任务完成!

}

  

// 执行所有工具,收集结果

const toolResults = await runTools(toolBlocks)

for (const result of toolResults) {

yield result // 工具结果也实时推送

}

  

// 检查各种停止条件

if (aborted) return { reason: 'aborted' }

if (turnCount + 1 > maxTurns) return { reason: 'max_turns' }

if (budgetExceeded) return { reason: 'completed' }

  

// 都没触发?更新状态,进入下一轮

state = {

messages: [...messagesForQuery, ...assistantMsgs, ...toolResults],

turnCount: turnCount + 1,

// ... 其他状态字段

}

} // while(true) 继续

}

代码片段二:AsyncGenerator 的 yield 模式

这段展示了为什么生成器是实时推送的关键:


// query() 是一个 AsyncGenerator

// 调用方可以用 for-await 逐条接收消息

export async function* query(params): AsyncGenerator<Message, Terminal> {

// yield* 的含义:

// "把 queryLoop 产生的每一条消息都原样转发出去,

// 最后拿到它的返回值作为我的返回值"

const terminal = yield* queryLoop(params)

return terminal

}

  

// ====== 调用方(QueryEngine)这样使用 ======

for await (const message of query(params)) {

// 每当 query 内部 yield 一条消息,这里就立刻收到

// 不需要等整个循环跑完!

updateUI(message) // 实时更新界面

saveToHistory(message) // 实时保存记录

trackCost(message) // 实时统计成本

}

6. 实战启示

从 Agent 循环的设计中,开发者可以学到几个通用模式:

  • 用 AsyncGenerator 做长任务:任何需要实时反馈的长流程(批量处理、爬虫、CI/CD),都可以用生成器代替”跑完再返回”的模式。

  • 状态不可变:每轮循环创建新的 state 对象而不是直接修改旧的,极大方便了调试和回溯。

  • 多层安全网:不要只靠一个条件来终止循环。maxTurns、预算、中止信号、错误检测——层层兜底,系统才稳健。

  • 分层架构:把”核心循环”和”外围管理”分开,让每一层都保持简单和可测试。


模块二:工具系统——AI 的「双手」

生活类比:万能工具箱

想象你请了一位超级聪明的顾问来帮你装修房子。他脑子里装满了建筑学知识,能画出完美的蓝图——但他没有手。他不能拿锤子、不能量尺寸、不能翻阅材料手册。

工具系统就是给这位顾问装上的一双万能手。

Claude 的大语言模型就是那个”脑子很好但没有手”的顾问。它能推理、规划、分析,但如果要读一个文件?它得伸手去”翻书”(FileRead)。要改一行代码?它得”握笔”(FileEdit)。要在代码库里找东西?它得举起”望远镜”(Grep/Glob)。要执行一条命令?它得按下”遥控器”(Bash)。

Claude Code 一共准备了 65+ 把工具,涵盖了日常编程的方方面面。但关键不在于数量多——关键在于:这 65+ 把工具全都长着同一种”握把”(统一接口),这让整个系统既强大又不混乱。


核心概念

工具分类一览

不需要记住全部 65 个,只需理解它们分成几大家族:

| 家族 | 代表工具 | 干什么的 | 生活比喻 |

|------|----------|----------|----------|

| 文件操作 | Read, Edit, Write, Glob, Grep | 读写搜索文件 | 翻书、写字、查字典 |

| 命令执行 | Bash, PowerShell | 运行系统命令 | 按遥控器 |

| 网络获取 | WebFetch, WebSearch | 抓取网页和搜索 | 上网查资料 |

| 子智能体 | Agent, Skill | 派小助手去干活 | 叫外卖/请帮手 |

| 任务管理 | TaskCreate, TaskList… | 管理多步任务 | 写待办清单 |

| 用户交互 | AskUser, Brief | 向用户提问或汇报 | 打电话确认 |

| 代码分析 | LSP | 代码跳转和引用 | 查地图导航 |

| MCP 扩展 | MCPTool 等 | 接入外部服务 | 连接各种家电 |

其中约 22 个”常驻工具”始终可用,另外 30+ 个根据环境和配置按需开启。

统一接口:万物通用的 USB 口

所有工具——不管是读文件还是搜网页——都实现同一个 Tool 接口。这就像 USB 接口:不管你插的是鼠标、键盘还是U盘,插口形状都一样。

好处是巨大的:AI 模型不需要为每个工具学一套”方言”。调用 Grep 和调用 Bash 的方式完全一致——传入参数、拿到结果,中间自动处理验证和权限。

读写分离并发:图书馆规则

这是工具系统最精妙的设计之一。规则很简单,和图书馆一样:

  • 读书(只读操作):大家可以同时读,互不干扰。Grep、Read、Glob 可以 10 个并发跑。

  • 写书(写操作):一次只能一个人写,其他人排队。Edit、Bash 必须一个一个来。

为什么?因为如果两个 Bash 命令同时跑,第一个可能正在创建文件,第二个却已经在试图读取它——结果就乱套了。系统会自动把工具调用分成”可以一起跑的”和”必须排队的”两类。

Feature Flag 死代码消除:拆掉不用的房间

很多工具只在特定环境下才有意义(比如只有内部员工才用的调试工具)。系统在编译阶段就把不需要的工具代码彻底删除——不是关掉,是物理移除。就像装修时把不需要的房间直接拆掉,而不是锁上门。这样最终的程序更小、更快。


设计原理

为什么要统一接口?

如果 65 个工具各有各的调用方式,会怎样?权限系统要写 65 套检查逻辑。并发控制要写 65 套规则。UI 展示要写 65 个渲染器。一个字:灾难。统一接口让所有”横切关注点”(权限、并发、日志、进度展示)只需要写一次。

为什么用 Zod Schema 验证?

每个工具都用 Zod 定义了严格的输入格式。这就像海关检查:不管你是谁,行李都要过 X 光机。AI 模型有时会传入格式不对的参数(比如漏了必填字段),Zod 会立刻拦住并返回清晰的错误信息,而不是让工具带着垃圾数据往下跑。

为什么读写分离?

并发安全。读操作天然幂等(读 100 次结果一样),但写操作可能互相踩脚。系统通过 isConcurrencySafe 标记自动判断,开发者不需要操心锁的问题。

StreamingToolExecutor 的哲学

工具执行不是”发射后不管”。StreamingToolExecutor 让工具可以边跑边汇报进度(比如”已处理 50KB / 100KB”),而且如果一个 Bash 命令失败了,会自动取消同批次的其他 Bash 命令——避免在错误基础上继续执行。


架构图

工具生命周期(从 AI 想法到实际执行)


AI 模型产生调用意图



查找工具定义(按名字匹配)



Zod Schema 验证输入(海关检查)



PreToolUse 钩子(前置拦截/修改)



权限检查(需要用户点"允许"吗?)



执行工具(读操作可并发,写操作排队)



结果映射(统一格式化输出)



PostToolUse 钩子(后处理)



返回结果给 AI 模型

工具分类总览

| 类别 | 数量 | 可用性 |

|------|------|--------|

| 常驻基础工具 | ~22 个 | 始终可用 |

| 条件启用工具 | ~30 个 | 需 Feature Flag 开启 |

| 内部专用工具 | ~8 个 | 仅 Anthropic 员工 |

| 环境特定工具 | ~5 个 | 特定模式下可用 |


精选代码

下面是 Tool 接口的核心骨架(大幅简化),展示一个工具需要”长什么样”:


// 所有工具都要实现这个接口——就像所有电器都要有 USB 插头

export type Tool = {

name: string // 工具名,唯一标识

inputSchema: ZodSchema // 输入参数的格式定义(海关规则)

  

// 核心:工具的实际执行逻辑

call(args, context): Promise<ToolResult>

  

// 并发控制:这个操作是"读书"还是"写书"?

isConcurrencySafe(input): boolean // true = 可并发

isReadOnly(input): boolean // true = 只读

  

// 权限:需要问用户吗?

checkPermissions(input, context): Promise<PermissionResult>

  

// 把结果转成统一格式

mapToolResultToToolResultBlockParam(content, id): ToolResultBlock

}

实际使用时,buildTool() 函数会自动填充大量默认值(比如默认假设工具不可并发、不是只读),开发者只需覆盖自己关心的部分。


实战启示

  1. 统一抽象是扩展的前提:65+ 工具能共存,全靠一个 Tool 接口。任何需要扩展的系统,先设计好”插口形状”。

  2. 读写分离是并发的基石:不需要复杂的锁机制,只需回答一个问题——“这个操作改东西吗?”

  3. 编译时消除优于运行时判断:能在打包阶段删掉的代码,就别留到运行时去 if-else

  4. Schema 验证是防御的第一道墙:永远不要相信外部输入,哪怕输入来自你自己的 AI 模型。


模块三:权限系统——AI 的「安全边界」

生活类比

权限系统就像机场安检:不是所有行李都能带上飞机。AI 想执行 rm -rf?先过 5 道安检再说。

想象你雇了一个超级能干的助手,他能帮你装修房子、修水管、接电线。但你不可能一上来就把家门钥匙、银行卡密码、车钥匙全交出去吧?你会说:“你可以随便刷墙(安全操作),但要动电路(危险操作)必须先问我。至于保险箱——别碰(绝对禁止)。”

Claude Code 的权限系统就是这个逻辑。AI 要执行每一个动作——读文件、跑命令、访问网络——都得先过安检。不是简单的”行/不行”二选一,而是一套精密的五层决策链,像漏斗一样层层过滤。越危险的操作,过滤越严格;越安全的操作,越快放行。

这套系统的终极目标:让 AI 足够自由地干活,同时绝不让它搞出大事。


核心概念

五层决策链:Deny → Ask → Bypass → Allow → Smart

每当 AI 想执行一个操作,请求会依次经过五层检查,任何一层做出决定就立刻返回,不再往下走:


操作请求进来



第1层 [拒绝名单] 命令在黑名单里?→ 直接拒绝,没商量

↓ 没命中

第2层 [强制询问] 需要人工确认?→ 弹窗问用户

↓ 没命中

第3层 [绕过模式] 用户开了"全自动"?→ 直接放行

↓ 没开

第4层 [白名单] 命令在"总是允许"里?→ 直接放行

↓ 没命中

第5层 [智能判断] AI分类器评估危险度 / 问用户



输出:允许 / 询问 / 拒绝

关键原则:黑名单永远优先于白名单。 即使你开了最宽松的模式,被明确禁止的操作照样不能执行。

Bash 命令的”危险探测器”

系统维护了一份危险命令清单——pythonsudocurlsshgitaws 等。这些命令要么能执行任意代码,要么能访问网络,要么能修改基础设施。AI 想跑这些命令?额外加检。

在自动模式(Auto)下,系统还会调用另一个 AI 模型来评估:“这条命令在当前上下文里安全吗?” 这就像安检不光看你的行李清单,还找了个专家来判断你这瓶液体到底是不是矿泉水。

四种权限模式

| 模式 | 比喻 | 行为 |

|------|------|------|

| Default | 保守家长 | 每个操作都要逐一审批 |

| Plan | 先看菜单再下单 | AI 先展示计划,用户审核后再执行 |

| Auto | 聪明管家 | AI 分类器自动判断,低危放行,高危拒绝 |

| Bypass | 完全信任 | 几乎全部放行(但安全底线仍在) |


设计原理

为什么是 5 层而不是 2 层(允许/拒绝)?

两层系统只能处理黑白分明的情况。但现实中大部分操作处于灰色地带——npm install 通常安全,但 npm install malicious-package 就不是了。五层结构让每种灰度都有对应的处理方式:明确危险的直接挡住,明确安全的直接放行,中间地带要么问用户要么让 AI 判断。

为什么同时用 AI 分类器和静态规则?

静态规则像门锁——可靠但死板,curl 不管下载什么都会被标记。AI 分类器像保安——灵活但有误判风险。两者叠加:静态规则兜底保证不漏掉已知危险模式,AI 分类器处理新情况和上下文相关的判断。分类器挂了怎么办?系统会自动降级到弹窗问用户,而不是盲目放行。

为什么不能”全部允许”?

即使在最宽松的 Bypass 模式下,访问 .git/ 目录、shell 配置文件(.bashrc)、AI 自己的配置文件(.claude/)仍然会强制弹窗确认。这些是”安全底线”——因为修改它们可能导致代码仓库被篡改、shell 被注入后门,或 AI 自己改写自己的规则。这就像再怎么信任助手,保险箱密码也不能让他自己改。


架构图

权限决策流程:


┌─────────────┐

│ AI想执行操作 │

└──────┬──────┘



┌────────────────┐

┌──→ │ 1.黑名单命中? │──是──→ ❌ 拒绝

│ └───────┬────────┘

│ ↓ 否

│ ┌────────────────┐

│ │ 2.安全检查/ │──是──→ ⚠️ 弹窗询问

│ │ 强制确认? │

│ └───────┬────────┘

│ ↓ 否

│ ┌────────────────┐

│ │ 3.绕过模式? │──是──→ ✅ 允许

│ └───────┬────────┘

│ ↓ 否

│ ┌────────────────┐

│ │ 4.白名单命中? │──是──→ ✅ 允许

│ └───────┬────────┘

│ ↓ 否

│ ┌────────────────┐

│ │ 5.AI分类器/ │──安全──→ ✅ 允许

│ │ 问用户 │──危险──→ ❌ 拒绝

└────┴────────────────┘

四种模式对比:

| | Default | Plan | Auto | Bypass |

|---|---------|------|------|--------|

| 低危操作 | 问用户 | 先看计划 | 自动放行 | 自动放行 |

| 高危操作 | 问用户 | 先看计划 | AI判断/拒绝 | 自动放行 |

| 安全底线 | 问用户 | 问用户 | 问用户 | 仍然问用户 |

| 黑名单操作 | 拒绝 | 拒绝 | 拒绝 | 仍然拒绝 |


精选代码

五层决策的核心函数(大幅精简,保留骨架):


async function hasPermissionsToUseTool(tool, input, context) {

// 第1层:黑名单 —— 命中即死,最高优先级

const denyRule = getDenyRuleForTool(context, tool)

if (denyRule) return { behavior: 'deny' }

  

// 第2层:强制确认 —— 安全敏感操作必须过人

const askRule = getAskRuleForTool(context, tool)

if (askRule) return { behavior: 'ask' }

  

// 第3层:绕过模式 —— 用户说"我全信你"

if (mode === 'bypassPermissions') return { behavior: 'allow' }

  

// 第4层:白名单 —— 用户预先配置的"总是允许"

const allowRule = getAllowRuleForTool(context, tool)

if (allowRule) return { behavior: 'allow' }

  

// 第5层:兜底 —— 没人认领?那就问用户或让AI判断

return { behavior: 'ask' }

}

每一层都是一个”短路”——一旦某层做了决定,后面的层全部跳过。这保证了黑名单永远能拦住危险操作,不管后面的规则怎么配。


实战启示

  1. 分层防御比单点检查靠谱得多。 安全系统最怕”一招失灵全盘崩溃”,五层漏斗意味着任何一层被绕过,下一层还能兜住。

  2. 安全底线要”硬编码”。 不管用户怎么配置,.git/ 和 shell 配置的保护永远生效——这是防止 AI “改写自己规则”的最后防线。

  3. AI 辅助决策 + 人类兜底 = 最佳组合。 自动模式让 AI 判断大部分低危操作,减少打扰;但分类器挂了就自动回退到问人,绝不”静默放行”。

  4. 权限设计的本质是信任梯度。 不是”信/不信”的二元选择,而是根据操作危险程度给予不同级别的信任。


模块四:消息系统——数据的「流动脉络」

生活类比

消息系统就像快递物流中心:用户说的话、AI 的回复、工具执行结果,都是”包裹”。物流中心要分拣、打包、贴标签、去重复件——这就是消息系统干的活。

想象你经营一个超大型物流仓库。每天有三种包裹进来:客户寄来的(用户消息)、仓库回复的(AI 回复)、外包工人交回的成品(工具结果)。问题是,这些包裹格式五花八门——有的带照片、有的带文件、有的写着”这是上次那个订单的结果”。你的仓库必须把它们统一整理成标准快递单格式,才能交给下一环节处理。更麻烦的是,有些包裹是重复的,有些太大塞不进运输车,有些贴错了标签。你需要一条精密的流水线,逐步清洗、校验、重新包装每一件包裹,最后才能安心发出去。

Claude Code 的消息系统就是这条流水线。


核心概念

三种基本消息类型:

| 类型 | 比喻 | 说明 |

|------|------|------|

| user(用户消息) | 客户来信 | 你打的字、粘贴的图片、工具执行后的回执 |

| assistant(助手消息) | 仓库回信 | AI 的文字回复、发起的工具调用指令 |

| system(系统消息) | 仓库内部便签 | 进度提示、错误通知、压缩标记等,不会发给 API |

除此之外还有几个特殊角色:progress(执行进度)、attachment(Hook 附件)、tombstone(删除标记,像墓碑一样表示”这里曾经有条消息,现在撤了”)。

15 步规范化流水线: 消息从内部格式到发给 API,要经过一条 15 步的”装配线”。每一步解决一个具体问题——过滤虚拟消息、删除导致错误的图片/PDF、合并相邻的用户消息、清理孤立的思考块、去重系统提醒、校验图片大小……就像工厂质检线,一关一关过,确保最终出厂的”包裹”完全合规。

流式 Delta 事件: AI 的回复不是一次性返回的,而是像直播一样一个字一个字蹦出来。系统通过 text_delta(文字片段)和 input_json_delta(工具参数片段)逐步拼接完整内容,用户因此能看到”AI 正在打字”的实时效果。

去重机制: 消息拆分、合并、持久化过程中很容易产生重复。系统用”确定性 UUID 推导”(从原始 ID + 序号算出新 ID,而不是随机生成)来保证同一条消息无论处理多少遍,ID 始终一致,不会越存越多。

图片限制: 单张图片不超过 5MB(base64),分辨率上限 2000x2000。多张图片同时发送时,API 对分辨率的要求更严格。


设计原理

为什么要 15 步? 因为每一步都在解决一个真实踩过的坑。比如:Bedrock(AWS 的 API 通道)不支持连续两条用户消息,所以要合并;图片太大会导致 API 报错,而且错误消息里如果还带着那张大图,重试时会再次报错——所以要在错误消息里把图片删掉;消息压缩可能砍掉中间内容,留下只有”思考过程”没有实际回复的 assistant 消息,API 会拒绝——所以要清理。每一步都是”被 bug 教育出来的”。

为什么要规范化? 内部消息格式很灵活(方便 UI 显示、调试、统计),但 API 的要求很严格。规范化就是把”方便自己用的格式”翻译成”API 能接受的格式”。这跟你写完邮件要检查格式、删掉草稿备注再发出去是一个道理。

为什么图片限制 5MB / 多图更严? 图片是 token 消耗大户。一张大图可能吃掉几千个 token,多张更是指数膨胀。限制是为了控制成本和延迟,也是 API 端的硬性要求。

tool_use / tool_result 的配对设计: AI 说”我要调用 bash 工具”(tool_use),系统执行后必须回一个”bash 执行结果是这个”(tool_result),通过相同的 ID 配对。这就像你在餐厅点菜——服务员拿着菜单号去厨房,厨房按同一个菜单号上菜。如果有”点了菜但没上”的情况(未配对的 tool_use),API 直接拒绝整个请求。系统会自动检测并清理这些”漏单”。


架构图

消息流转路径:


用户输入 / 图片粘贴 / 工具结果





┌─────────────┐

│ 内部消息池 │ ← 各种类型混杂:user, assistant, system, progress...

└──────┬──────┘





┌──────────────────────┐

│ normalizeMessagesFor │ ← 15 步流水线

│ API() │

└──────┬───────────────┘





┌─────────────┐

│ 干净的消息 │ ← 只剩 user + assistant,格式合规

└──────┬──────┘





Anthropic API





流式 Delta 事件 → text_delta / input_json_delta → 拼接为完整回复

消息类型一览:

| 大类 | 子类型 | 发给 API? | 用途 |

|------|--------|-----------|------|

| user | 普通文本/图片/tool_result | 是 | 用户输入和工具回执 |

| assistant | 文字回复/tool_use | 是 | AI 的输出 |

| system | 信息提示/错误/压缩边界等 14 种 | 否 | 仅 CLI 显示 |

| progress | 工具执行进度 | 否 | 仅 CLI 显示 |

| attachment | Hook 执行记录 | 合并后是 | 并入 user 消息 |

| tombstone | 删除标记 | 否 | 通知 UI 移除消息 |


精选代码

normalizeMessagesForAPI 的核心骨架(简化版):


function normalizeMessagesForAPI(messages) {

// 第1步:过滤虚拟消息

let result = reorderAttachments(messages).filter(m => !m.isVirtual)

// 第2-3步:构建"哪些消息的哪些块要删掉"的映射

const stripTargets = buildStripTargets(result)

// 第4步:只保留 user / assistant / attachment

result = result.filter(isAPIRelevant)

// 第5-8步:逐条处理,合并相邻消息,规范化工具输入

result = processEachMessage(result, stripTargets)

// 第9-12步:清理孤立思考块、空白消息、去重系统提醒

result = multiPassCleanup(result)

// 第13步:删除错误消息中的图片

result = sanitizeErrorToolResults(result)

// 第14步:附加消息 ID 标签(供 /snip 引用)

result = appendMessageTags(result)

// 第15步:最终图片大小校验

validateImagesForAPI(result)

return result

}

这 15 步的顺序不能随意调换——前面的步骤会制造后面步骤需要处理的新状况,就像多米诺骨牌一样环环相扣。


实战启示

  • 数据管道设计要分步、可审计: 一个大函数拆成 15 个明确的小步骤,每步职责单一,出 bug 时能精确定位是哪一关出了问题。

  • ID 设计决定系统上限: 确定性 UUID 推导看似小细节,却直接防止了”消息无限膨胀”的灾难。

  • 配对协议是可靠性基石: tool_use 和 tool_result 的强制配对,保证了工具执行的完整性——有调用就必须有结果,没有”悬空”的操作。

  • 容错比正确更重要: JSON 解析失败不崩溃,降级为空对象继续走——在复杂系统中,“优雅地错”比”正确地崩”更有价值。


模块五:状态管理——系统的「记忆中枢」

生活类比

状态管理就像你大脑的短期记忆:当前在做什么任务?用了多少 token?权限模式是什么?这些信息需要随时更新、随时读取,而且不能搞混。

想象你是一个餐厅经理。你脑子里要同时记住:3 号桌点了什么菜、厨房还剩多少食材、今天哪个服务员值班、VIP 客人的忌口是什么。这些信息每分钟都在变——上菜了要更新、新客人来了要记录、食材用完了要标记。而且,你不能一个人闷着——服务员需要知道菜好了,厨师需要知道新订单来了,收银台需要知道账单金额。

Claude Code 的状态管理干的就是这件事。整个系统运行时产生大量「此刻的信息」——当前用哪个 AI 模型、用户开了什么权限、有几个插件在跑、哪些任务正在执行。这些信息要做到三件事:存得住(不能丢)、改得快(不能卡)、通知到位(该知道的人都知道)。

最妙的是,Claude Code 团队没有用任何第三方状态库,而是用 35 行代码 手搓了一个。


核心概念

35 行的极简 Store——这是整个模块的灵魂。它本质上是一个「信息公告板」:任何人都可以贴新信息上去,任何人都可以订阅变化通知。

Store 只暴露三个能力:

  • getState:读取当前信息(看公告板)

  • setState:更新信息(贴新公告)

  • subscribe:订阅变化(“有新公告就通知我”)

Object.is 优化——这是防止「假警报」的机制。每次有人要更新信息时,系统先比对一下:新信息和旧信息是不是完全一样的?如果一模一样,就直接忽略,不通知任何人。好比餐厅经理说”3 号桌还是那两位”,这种没变化的信息就不用广播给所有服务员。这个小小的检查,避免了大量无意义的界面刷新。

DeepImmutable——“不可变更新”听起来吓人,其实就是一条规矩:不许直接涂改公告板上的内容,只能贴一张新的把旧的替换掉。这样旧信息永远保留原样,方便对比和追溯。TypeScript 在写代码时就强制执行这条规矩,写错了直接报错。

React 集成(useSyncExternalStore)——Store 是独立于界面的「信息中心」,但界面需要展示这些信息。React 提供了一个标准接口叫 useSyncExternalStore,让界面组件可以「订阅」Store 的某一小块信息。比如某个按钮只关心”是否开了详细模式”,那其他信息怎么变都不会让这个按钮重新渲染。精准订阅,精准刷新。

pluginReconnectKey 计数器——当用户执行 /reload-plugins 时,系统需要重新连接所有插件。怎么触发?不是去逐个比对插件列表有没有变化,而是把一个数字从 0 变成 1。React 看到数字变了,就知道该重新执行连接逻辑了。一个 +1 操作,代替了一大堆复杂的对象比较。简单到令人发指。


设计原理

为什么不用 Redux 或 Zustand? Redux 是前端状态管理的”老大哥”,功能强大但代码冗长——光改一个布尔值就需要定义 action type、action creator、reducer,起步几十行。Zustand 简洁得多,但也有一个关键缺陷:没有「集中式副作用钩子」。

Claude Code 面临一个独特挑战:状态变化会触发系统级副作用。比如用户切换了权限模式,系统不仅要更新界面,还要通知远程服务器、同步 SDK 通道、写入配置文件。代码库里有 8 个不同的地方可能改权限模式——如果用 Zustand,你得在这 8 个地方都手动加通知逻辑,漏一个就是 bug。

Claude Code 的方案是 onChangeAppState——一个集中式的「副作用分发中心」。不管谁改了状态,变化都会流经这个函数。它检查”哪些字段变了”,然后执行对应的副作用:权限变了通知服务器,模型变了写配置文件,设置变了清缓存。一个入口管所有副作用,永远不会遗漏。

为什么要不可变更新? 因为 Object.is 比较的是「引用」而不是「内容」。如果你直接修改旧对象,新旧对象指向同一个引用,系统就以为什么都没变。只有创建新对象替换旧对象,系统才能检测到变化。这也是为什么 DeepImmutable 要在编译期强制执行——防止开发者手滑直接改了旧状态。


架构图

Store 读写流程:


调用 setState(更新函数)





┌──────────────────┐

│ Object.is 比较 │──── 没变化 ──→ 直接返回(跳过一切)

│ 新旧状态一样吗? │

└───────┬──────────┘

│ 有变化



更新内部状态



┌─────┴─────┐

▼ ▼

onChange 通知订阅者

(副作用) (界面刷新)



├→ 权限变了?→ 通知远程服务器

├→ 模型变了?→ 写 settings.json

├→ 视图变了?→ 写 config.json

└→ 设置变了?→ 清除缓存

对比表:Claude Code Store vs Redux vs Zustand

| 维度 | Redux | Zustand | Claude Code Store |

|------|-------|---------|-------------------|

| 代码量 | ~70KB | ~10KB | 35 行 |

| 上手难度 | 高 | 中 | 极低 |

| 改一个字段要写多少代码 | ~20 行 | ~5 行 | 1 行 |

| 副作用管理 | 中间件(复杂) | 中间件(复杂) | 单一钩子(简洁) |

| 外部依赖 | 是 | 是 | 零依赖 |

| 定制能力 | 通用化 | 通用化 | 为 AI Agent 量身定做 |


精选代码

这就是那个传说中的 35 行 Store,整个系统的记忆中枢:


// 两种回调类型:

// Listener = 订阅者,状态变了就被调用

// OnChange = 副作用钩子,能拿到新旧状态做对比

type Listener = () => void

type OnChange<T> = (args: { newState: T; oldState: T }) => void

  

export function createStore<T>(

initialState: T, // 初始状态(餐厅开业时的信息)

onChange?: OnChange<T>, // 副作用钩子(可选)

): Store<T> {

let state = initialState // 闭包保护:外部无法直接篡改

const listeners = new Set<Listener>() // 用 Set 而非数组,增删更快

  

return {

getState: () => state, // 读取当前状态,随时可调用

  

setState: (updater) => {

const prev = state

const next = updater(prev) // 用函数更新,保证拿到最新值

if (Object.is(next, prev)) return // 没变?什么都不做!

state = next // 替换状态

onChange?.({ newState: next, oldState: prev }) // 触发副作用

for (const listener of listeners) listener() // 通知所有订阅者

},

  

subscribe: (listener) => {

listeners.add(listener)

return () => listeners.delete(listener) // 返回取消订阅函数

},

}

}

没有 action、没有 reducer、没有 middleware、没有 dispatch。一个闭包、一个 Set、一个 Object.is 检查——齐活了。


实战启示

Claude Code 的状态管理给我们三个启发:

  1. 别迷信框架——35 行代码就够用,关键是想清楚你的场景需要什么。通用库为通用场景设计,你的场景可能不通用。

  2. 副作用要集中管理——onChangeAppState 模式让所有”状态变了之后要干嘛”的逻辑汇聚到一个地方,永远不会漏。如果你的项目有”状态变了要同步到多个地方”的需求,这个模式值得抄。

  3. 计数器触发器是个宝——pluginReconnectKey 用一个 +1 操作代替复杂的对象比较,简单高效。任何时候你需要”触发某个流程重新执行”,都可以用这个技巧。


模块六:上下文压缩——应对「记忆容量」限制

生活类比

上下文压缩就像你的笔记本只有100页:聊天越来越长,笔记本快写满了。怎么办?把前面50页的内容总结成2页摘要,腾出空间继续写。

但这里有个讲究——你不能随便撕页。有些页上记着关键的代码文件路径、报错信息、还没完成的任务,撕掉就忘了。所以Claude Code的做法更聪明:它先看看哪些页只是”工具执行的中间结果”(比如读文件的输出),这些能直接扔掉,连总结都不用。实在不够了,再请一个”速记员”(另一个Claude调用)把所有旧内容浓缩成一份结构化摘要。摘要写完后,还会把你最近看过的5个文件重新附上,确保接下来干活不掉链子。

整个过程就像考试时的草稿纸管理:先擦掉验算过程(microCompact),空间还不够就把前面的题目答案抄到新纸上(autoCompact),实在急了就自己动手整理(手动/compact)。


核心概念

上下文窗口(Context Window) 是模型一次能”看到”的全部内容,包括系统提示、工具定义、所有历史对话。它有固定大小(按Token计,大约4个字符=1个Token)。一旦满了,新内容就塞不进去。

Claude Code设计了三级压缩机制来应对:

| | microCompact | autoCompact | 手动 /compact |

|---|---|---|---|

| 做什么 | 只删工具执行结果 | 调用AI生成完整摘要 | 同上,但用户主动触发 |

| 需要AI吗 | 不需要,秒完成 | 需要,10-30秒 | 需要 |

| 丢信息吗 | 不丢对话内容 | 旧对话被摘要替代 | 同上 |

| 触发方式 | 自动(时间间隔长/token紧张) | 自动(token超阈值) | 用户输入 /compact |

| 支持自定义指令 | 不支持 | 不支持 | 支持(告诉AI重点总结什么) |

安全截断点:当压缩请求本身太长时,系统按”API调用轮次”分组,从最旧的组开始删除,直到腾出足够空间。最多重试3次,保证至少留一轮对话用于总结。

摘要生成:用一个结构化的9部分模板,涵盖用户意图、技术概念、文件列表、报错记录、待办任务等,确保压缩后不丢关键上下文。


设计原理

为什么要三级? 因为压缩有成本。调用AI生成摘要需要时间和费用,而且会打破提示缓存(Prompt Cache)——之前缓存的对话前缀全部失效,下次调用要重新付费。所以能不动AI就不动:microCompact零成本清理垃圾,只有真正快满了才启动重量级的AI摘要。

阈值公式 contextWindow - 13000 的含义:有效上下文窗口减去13000个token作为缓冲区。为什么是13000?太小了会来不及压缩(压缩本身也消耗token),太大了会浪费可用空间。13000大约是3-4轮正常对话的量,给系统留出从容处理的余地。

为什么不直接截断? 简单截断会丢失关键信息——用户的原始需求、已修改的文件列表、尚未解决的bug。AI生成的结构化摘要能保留这些”骨架信息”,只丢弃细节。

提示缓存配合:压缩时优先用”fork agent”复用主线程的缓存前缀(省90%费用)。压缩完成后主动通知缓存系统”这次缓存命中率下降是正常的”,避免触发误报。而microCompact的cache_edits方案更妙——直接在API层面删除内容,缓存热度100%保持。


架构图


用户持续对话,Token不断增长



┌──── Token量检测 ────┐

│ │

│ < 阈值-13000 │ → 正常继续

│ ≈ 阈值-13000 │ → microCompact(清理工具结果)

│ ≥ 阈值-13000 │ → autoCompact(AI生成摘要)

│ ≥ 阈值-3000 │ → 阻止输入,必须压缩

│ │

└──────────────────────┘

↓ (压缩后)

[边界标记] + [摘要] + [恢复最近5个文件] + [恢复技能]



继续新对话

精选代码

阈值计算——决定什么时候该压缩:


// 自动压缩缓冲区:距离上限还剩13000 tokens时触发

export const AUTOCOMPACT_BUFFER_TOKENS = 13_000

  

export function getAutoCompactThreshold(model: string): number {

const effectiveContextWindow = getEffectiveContextWindowSize(model)

// 阈值 = 有效窗口 - 13000

return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS

}

触发判断——简洁的核心逻辑:


export async function shouldAutoCompact(

messages: Message[], model: string, querySource?: QuerySource

): Promise<boolean> {

// 防止压缩过程中再次触发压缩

if (querySource === 'compact') return false

if (!isAutoCompactEnabled()) return false

  

const tokenCount = tokenCountWithEstimation(messages)

const { isAboveAutoCompactThreshold } = calculateTokenWarningState(tokenCount, model)

return isAboveAutoCompactThreshold

}

实战启示

  • 分层处理是通用模式:面对任何资源限制,先用低成本方案(删垃圾),不够再用高成本方案(AI总结),最后交给用户兜底。

  • 压缩不是免费的:每次完整压缩都会打破缓存、消耗API调用。设计系统时要考虑”压缩的成本”本身。

  • 断路器很重要:连续失败3次后自动停止重试,避免在API故障时陷入死循环烧钱。

  • 恢复和压缩同样重要:压缩后自动恢复最近文件、技能定义、执行计划,这才是保证用户体验连续性的关键。


模块七:Hook 系统——可编程的「中间件」

生活类比

Hook 系统就像智能家居的自动化规则:“当有人开门时(事件) —> 自动开灯(动作)“。Claude Code 有 27 个这样的”触发点”,你可以在任何一个点插入自己的逻辑。

想象你家装了一套智能安防:前门有摄像头(会话启动时检查环境),厨房有烟雾报警器(执行危险命令前拦截),车库有自动关门器(会话结束时清理资源)。你不需要 24 小时盯着,只需要提前设好规则,系统就会在对应时刻自动执行。Hook 系统就是 Claude Code 的”智能安防系统”——你告诉它”什么时候”做”什么事”,它就替你守着。

更妙的是,这些规则不只能”报警”,还能”拦截”。比如你可以设一条规则:AI 要删文件?先检查一下是不是在删重要目录,是的话直接挡回去。这就像门禁系统——不是所有人刷卡都能进,得看你是谁、要去哪。


核心概念

27 个生命周期事件,分 7 大类:

| 类别 | 事件数 | 典型事件 | 通俗理解 |

|------|--------|---------|---------|

| 核心工作流 | 4 | SessionStart、UserPromptSubmit | “开机/关机/用户说话时” |

| 工具执行 | 3 | PreToolUse、PostToolUse | “AI 动手前/动手后” |

| 权限控制 | 2 | PermissionRequest、PermissionDenied | “申请通行证/被拒” |

| 停止验证 | 3 | Stop、TaskCompleted | “AI 说’我做完了’时” |

| 多代理协作 | 4 | SubagentStart、TeammateIdle | “派小弟干活/小弟摸鱼了” |

| 文件配置 | 4 | FileChanged、ConfigChange | “文件被改了/设置变了” |

| 其他 | 5+ | Notification、PreCompact | “系统通知/数据库整理” |

5 种执行类型:

| 类型 | 一句话解释 | 速度 | 谁能用 |

|------|-----------|------|--------|

| command (同步) | 跑一段脚本,等它跑完 | 中 | 所有人 |

| command (异步) | 跑一段脚本,不等它 | 快(不阻塞) | 所有人 |

| prompt | 让一个小模型帮忙判断 | 中 | 所有人 |

| agent | 派一个完整的 AI 代理去验证 | 慢 | 所有人 |

| http | 发一个网络请求到外部服务 | 取决于网络 | 所有人 |

输出格式: Hook 通过 JSON 告诉系统下一步该怎么办——继续?拦截?还是修改参数?

超时机制: 每个 Hook 都有倒计时(默认 10 分钟,会话结束时仅 1.5 秒)。超时后系统先”礼貌地敲门”(SIGTERM),等一会儿没反应就”踹门”(SIGKILL)。


设计原理

为什么用事件驱动? 因为 AI 编程助手的工作流天然是”一连串事件”:用户说话 —> AI 思考 —> 调用工具 —> 返回结果。在这条流水线的任意节点插入自定义逻辑,比”改源码”优雅得多。就像高速公路的收费站——你不用改路,只需要在合适的位置设卡。

为什么要 5 种类型? 不同场景需要不同武器。快速检查用 command(几毫秒),需要 AI 判断的用 prompt/agent(几秒),要通知外部系统用 http。就像灭火:小火用灭火器,大火叫消防车,化学火用特殊泡沫——一种工具解决不了所有问题。

onError 策略——Hook 出错了怎么办? 三种策略:(1) 允许继续:通知发送失败?无所谓,不影响主流程。(2) 阻止继续:安全检查脚本崩了?宁可停下来也不能放行。(3) 询问用户:拿不准?弹窗让用户自己决定。这体现了一个设计哲学:安全相关的宁严勿松,辅助功能的宁松勿严。

为什么超时默认 10 分钟(工具 Hook)和 1.5 秒(会话结束)? 工具 Hook 可能要跑测试、调 API,需要充裕时间。但会话结束时用户已经在关程序了,不能让一个清理脚本卡住退出——所以只给 1.5 秒,快刀斩乱麻。


架构图

Hook 执行流水线(6 步):


用户操作(比如让 AI 执行一条命令)

|

v

[1] 匹配:这个事件有没有人关心?从配置里找到所有匹配的 Hook

|

v

[2] 准备:给每个 Hook 分配 ID、序列化输入、设置超时倒计时

|

v

[3] 并行执行:所有匹配的 Hook 同时开跑(command/prompt/agent/http)

|

v

[4] 收集结果:每个 Hook 返回 JSON,说明"放行/拦截/修改参数"

|

v

[5] 投票决策:任何一个 Hook 说"拦截" --> 整体拦截(一票否决制)

|

v

[6] 输出信号:告诉主流程最终决定,附带修改后的参数或额外上下文

精选代码

实用示例:拦截危险的 rm 命令

~/.claude/settings.json 中配置:


{

"hooks": {

"PreToolUse": [

{

"matcher": "Bash",

"hooks": [

{

"type": "command",

"command": "bash ~/.claude/hooks/security-filter.sh",

"timeout": 5

}

]

}

]

}

}

脚本 security-filter.sh 的核心逻辑很简单:从 stdin 读取 JSON,检查命令里有没有 rm -r,有就返回 {"decision":"block"},没有就返回 {"continue":true}。一票否决,危险命令根本到不了执行阶段。

配置结构的要点:hooks 下按事件名分组,每组里用 matcher 指定匹配哪个工具,然后挂上具体的 Hook(可以挂多个,并行执行)。timeout 单位是秒。


实战启示

  1. 安全兜底:把 Hook 当安全网,而不是主防线。它是”最后一道检查”,不是替代代码审查。

  2. 轻量优先:Hook 越快越好。能用 command 解决的别用 agent,能异步的别同步。

  3. 一票否决很强大也很危险:一个写错的 Hook 可能拦住所有操作。建议先用 "decision":"ask" 观察一段时间,确认无误再改成 "block"

  4. 分层配置:全局规则放 ~/.claude/settings.json,项目规则放项目目录下的 .claude/settings.json,两者会合并生效——就像公司有公司的规章,部门有部门的细则。


模块八:UI 渲染——终端里的「React 应用」

生活类比

UI 渲染就像在一块黑白电子墨水屏上画彩色漫画:终端本来只能显示文字,但 Claude Code 硬是用 React 框架在里面跑出了滚动列表、权限弹窗、进度动画……

你可以把终端想象成一块老式的字符屏幕——每个格子只能放一个字。传统软件在上面做界面,就好比用打字机排版杂志,费力又呆板。而 Claude Code 的做法相当于:请了一位专业的”翻译官”(Ink 框架),把现代网页开发最流行的 React 写法,逐字逐句翻译成终端能理解的字符指令。这样开发者写界面代码时,感觉跟写网页一模一样,但最终输出的却是终端里的文字和色块。更神奇的是,它还请了一位”版面设计师”(Yoga 引擎)来自动排版——你说”这个框居中、那个列表占满宽度”,设计师就帮你算好每个字该放在第几行第几列。最终效果:一个看起来像现代 App 的终端程序。


核心概念

Ink 框架:终端里的 React。正常的 React 把组件渲染成网页上的 HTML 元素。Ink 做了一件大胆的事——它用同样的 React 语法,但输出的不是网页,而是终端字符流(带颜色的 ANSI 转义码)。开发者写 <Box> 不是创建网页的 <div>,而是在终端上画一个框。

Yoga 布局引擎:自动排版师。网页有 CSS 帮你排版,终端没有。Yoga 就是终端版的 CSS 引擎,它计算每个元素该放在屏幕的哪一行、哪一列、占多宽多高。它来自 Facebook,React Native(手机 App 框架)也用它。

虚拟滚动:只画看得见的部分。想象一个 3000 条消息的聊天记录。如果把每条都”画”出来,终端会卡死。虚拟滚动的策略是:只渲染屏幕上可见的那几十条,上面和下面用”空白占位符”撑住高度,造成”全部都在”的错觉。用户滚动时,旧的消息被卸载,新的消息被加载——就像火车窗外的风景,你只看得到窗口范围内的那一段。

流式文本更新:偷偷记账,攒够了再刷新。Claude 回复时文字是一个字一个字蹦出来的。如果每蹦一个字就刷新整个消息列表,界面会非常卡。解决办法:用一个”小本子”(Ref)悄悄记下当前收到了多少字,只有等一整段消息完成后,才正式更新列表。而加载动画(Spinner)直接看”小本子”就行,不用等列表刷新。

权限弹窗:Claude 要执行敏感操作(运行命令、编辑文件)时,会弹出一个模态对话框,展示具体要做什么,等你按 y 同意或拒绝。后台还有一个”安全分类器”会自动判断操作是否安全,安全的会打个绿色勾,但最终还是你说了算。

Vim 模式输入:输入框支持 Vim 快捷键(h/j/k/l 移动光标、dd 删行等),对习惯 Vim 的程序员来说非常舒适。


设计原理

为什么用 React 写终端界面? 终端 UI 传统上用 ncurses 之类的底层库,写起来像在用汇编画画——繁琐且难维护。React 的声明式写法(“我要什么样子”而不是”怎么一步步画出来”)让 UI 代码清晰得多。Claude Code 的界面包含消息列表、弹窗、动画、输入框等几十个组件,如果不用 React 这样的框架,代码量和维护成本会翻好几倍。

为什么需要虚拟滚动? 终端的渲染能力远弱于浏览器。每次渲染都要计算 Yoga 布局、生成 ANSI 字符序列、写入 stdout。如果 3000 条消息全部挂载,光布局计算就会把 CPU 吃满。虚拟滚动把实际挂载的组件控制在 300 个以内,并且用二分查找(O(log n))定位滚动位置——27000 条消息只需查找 15 次,而不是遍历 27000 次。

React Compiler 自动记忆化:React 的一个痛点是”不必要的重渲染”——父组件更新时,子组件即使没变化也会重新执行。传统解决办法是手动加 useMemouseCallback,但容易遗漏。React Compiler(React 19+ 的新功能)在编译时自动插入缓存逻辑:如果输入没变,就复用上次的结果。Claude Code 全面启用了这项优化,GC(垃圾回收)压力降低约 80%。

Ink 与浏览器 React 的关键差异:Ink 的最小单位是”字符格”而非像素;没有鼠标点击事件,只有键盘输入;渲染速度取决于终端 I/O 而非 GPU。但开发体验几乎一致——useState、useEffect、组件嵌套,写法完全相同。


架构图

简化组件树:


App

├── REPL(主界面)

│ ├── Messages → VirtualMessageList(虚拟滚动消息列表)

│ │ ├── TopSpacer(上方空白占位)

│ │ ├── MessageRow × N(只渲染可见的几十条)

│ │ └── BottomSpacer(下方空白占位)

│ ├── SpinnerWithVerb("Thinking..." 加载动画)

│ ├── PermissionRequest(权限弹窗,模态覆盖)

│ └── PromptInput(输入框 + 自动补全 + Vim 模式)

└── Dialogs(历史搜索、快捷打开等浮层)

渲染管线:


React 状态变化

→ Fiber 调和(对比新旧组件树)

→ 更新 Ink DOM(字符级虚拟 DOM)

→ Yoga 计算布局(行/列坐标)

→ 生成字符网格(Screen Buffer)

→ ANSI 差分(只输出变化的部分)

→ write() 写入终端

精选代码:虚拟滚动核心逻辑


// 高度缓存 + 累积偏移数组

const DEFAULT_ESTIMATE = 3; // 没测量过的消息,估计占 3 行

  

for (let i = 0; i < n; i++) {

arr[i + 1] =

arr[i]! + (heightCache.current.get(itemKeys[i]!) ?? DEFAULT_ESTIMATE)

}

  

// 二分查找:根据滚动位置找到第一条可见消息

let l = 0, r = n;

while (l < r) {

const m = (l + r) >> 1;

if (offsets[m + 1]! <= scrollTop) l = m + 1;

else r = m;

}

start = l; // 从这条开始渲染

  

// 向下扩展,直到覆盖视口 + 80 行预加载

let coverage = 0;

end = start;

while (end < n && coverage < viewportHeight + 2 * 80) {

coverage += heightCache.current.get(itemKeys[end]!) ?? 1;

end++;

}

核心思路:用数组记录每条消息的累积高度,二分查找快速定位可见范围,只渲染 [start, end) 区间内的消息,上下用空白占位符”假装”其余消息还在。


实战启示

  • 受限环境不是简陋的借口:终端只有字符格,但照样可以做出媲美 GUI 的复杂交互——关键是选对抽象层(React + Yoga)。

  • 虚拟化是长列表的必修课:无论终端还是网页,上千条数据不做虚拟滚动就是在等卡顿。二分查找 + 高度缓存是标配组合。

  • “不渲染”是最快的渲染:用 Ref 代替 State、用编译器自动记忆化、用滚动量化减少触发次数——所有优化的本质都是”少做事”。

  • 把成熟方案搬到新场景:React 本为网页而生,但它的声明式思想放到终端里同样好用。遇到陌生领域,不妨先问”有没有现成的好工具可以迁移过来”。


总结:六大设计哲学

通过对 Claude Code 全部八大模块的解读,可以提炼出以下核心设计理念:

1. 极简主义——够用就好

35 行代码实现状态管理,不依赖 Redux/MobX。统一的 Tool 接口代替复杂的继承层级。消息系统用 15 步管道处理复杂转换,每步职责单一。

2. 安全边界前置——先检查,再执行

权限检查永远发生在工具执行之前。5 层决策模型确保没有遗漏。Bash 命令分类器用静态规则 + AI 分类器双重保障。

3. 渐进式降级——从简单到复杂

上下文压缩提供三级策略:microCompact → autoCompact → 手动 /compact。权限系统从完全拒绝到完全允许有五个渐进层级。

4. 用户始终掌控——AI 是助手,不是主人

所有敏感操作都需要用户确认。Hook 系统让用户可以在任何节点注入自己的逻辑。安全底线硬编码,即使最宽松模式也无法绕过。

5. 流式优先——边做边反馈

Agent 循环基于 AsyncGenerator,实现逐字输出。工具执行支持流式进度。UI 用虚拟滚动保证大量消息下仍然流畅。

6. 搬运成熟方案——不重复造轮子

用 React 写终端界面(Ink),用 Yoga 做布局计算,用 Zod 做参数校验。在陌生领域借用成熟工具,比从零开始更高效。


值得借鉴的架构模式

| 模式 | 来源 | 适用场景 |

|------|------|---------|

| AsyncGenerator Agent Loop | 模块一 | 任何需要多轮 AI 交互的应用 |

| 统一工具接口 + Zod 验证 | 模块二 | 插件系统、API Gateway |

| 多层权限决策链 | 模块三 | 需要细粒度权限控制的系统 |

| 消息规范化管道 | 模块四 | API 通信、数据清洗 |

| 35 行响应式 Store | 模块五 | 轻量级状态管理 |

| 多级上下文压缩 | 模块六 | 长对话 AI 应用 |

| 事件驱动 Hook 框架 | 模块七 | 可扩展的中间件系统 |

| 终端 React 渲染 | 模块八 | 复杂终端 UI |


by 渔夫 | 基于 Claude Code 开源代码深度分析

本报告由 8 个并行分析 Agent 协作完成,覆盖 ~1,900 个源文件、~512,000 行代码