docs: 文档优化完成
This commit is contained in:
parent
221fb6eb05
commit
c57e6ee384
@ -1,36 +1,225 @@
|
||||
---
|
||||
title: "System Prompt 动态组装 - AI 工作记忆构建"
|
||||
description: "深入解析 Claude Code 的 System Prompt 动态组装过程:如何将 CLAUDE.md、项目上下文、工具定义和用户偏好拼装为 AI 的工作记忆。"
|
||||
keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "上下文构建"]
|
||||
description: "深入解析 Claude Code 的 System Prompt 动态组装过程:缓存策略、分界标记、Section 注册表、CLAUDE.md 多级合并,以及如何将零散上下文拼装为 API 可消费的缓存友好结构。"
|
||||
keywords: ["System Prompt", "系统提示词", "动态组装", "CLAUDE.md", "Prompt Cache", "缓存策略"]
|
||||
---
|
||||
|
||||
{/* 本章目标:解释 System Prompt 的组装过程和设计思想 */}
|
||||
## 从数组到 API 调用:System Prompt 的完整链路
|
||||
|
||||
## 什么是 System Prompt
|
||||
System Prompt 在 Claude Code 中不是一段写死的文本,而是一个 **`string[]` 数组**(品牌类型 `SystemPrompt`,定义于 `src/utils/systemPromptType.ts:8`),经过组装、分块、缓存标记后发送给 API。
|
||||
|
||||
每次调用 AI API 时,都需要发送一个 System Prompt——它是 AI 的"人设说明书",告诉 AI:
|
||||
### 三阶段管道
|
||||
|
||||
- 你是谁(Claude Code,一个编程助手)
|
||||
- 你能做什么(可用工具列表)
|
||||
- 你在什么环境(操作系统、当前目录、git 状态)
|
||||
- 你需要遵守什么规则(安全规范、输出格式)
|
||||
```
|
||||
getSystemPrompt() → string[] (组装内容)
|
||||
↓
|
||||
buildEffectiveSystemPrompt() → SystemPrompt (选择优先级路径)
|
||||
↓
|
||||
buildSystemPromptBlocks() → TextBlockParam[] (分块 + cache_control 标记)
|
||||
```
|
||||
|
||||
## 不是静态模板,而是动态组装
|
||||
1. **`getSystemPrompt()`**(`src/constants/prompts.ts:444`)—— 收集静态段 + 动态段,插入 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分界标记
|
||||
2. **`buildEffectiveSystemPrompt()`**(`src/utils/systemPrompt.ts:41`)—— 按 Override > Coordinator > Agent > Custom > Default 优先级选择
|
||||
3. **`buildSystemPromptBlocks()`**(`src/services/api/claude.ts:3214`)—— 调用 `splitSysPromptPrefix()` 分块,为每个块附加 `cache_control`
|
||||
|
||||
Claude Code 的 System Prompt 不是一段写死的文本,而是根据当前环境**实时组装**的:
|
||||
## SystemPrompt 品牌类型
|
||||
|
||||
<Frame caption="System Prompt 的 6 大组成部分">
|
||||
<img src="/docs/images/system-prompt-assembly.png" alt="System Prompt 动态组装图" />
|
||||
</Frame>
|
||||
```typescript
|
||||
// src/utils/systemPromptType.ts:8
|
||||
export type SystemPrompt = readonly string[] & {
|
||||
readonly __brand: 'SystemPrompt'
|
||||
}
|
||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||
return value as SystemPrompt // 零开销类型断言
|
||||
}
|
||||
```
|
||||
|
||||
| 组成部分 | 内容 | 来源 |
|
||||
|----------|------|------|
|
||||
| 基础人设 | 角色定义、行为准则 | 内置模板 |
|
||||
| 环境信息 | 操作系统、shell 类型、当前日期 | 运行时检测 |
|
||||
| Git 状态 | 当前分支、最近提交、工作区状态 | `git` 命令输出 |
|
||||
| 项目知识 | CLAUDE.md 文件内容 | 项目目录层级扫描 |
|
||||
| 记忆文件 | 用户偏好、项目约定 | 持久化记忆系统 |
|
||||
| 工具说明 | 每个可用工具的描述和参数 | 工具注册表 |
|
||||
品牌类型(branded type)防止普通 `string[]` 被意外传入 API 调用——只有通过 `asSystemPrompt()` 显式转换才能获得 `SystemPrompt` 类型。
|
||||
|
||||
## getSystemPrompt():内容组装的全景
|
||||
|
||||
`src/constants/prompts.ts:444` 是 System Prompt 的核心工厂函数,返回一个有序数组:
|
||||
|
||||
| 阶段 | 内容 | 缓存策略 |
|
||||
|------|------|----------|
|
||||
| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`) |
|
||||
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API) |
|
||||
| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) |
|
||||
|
||||
### 动态区的 Section 注册表
|
||||
|
||||
动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`:
|
||||
|
||||
```typescript
|
||||
// 缓存式 Section:计算一次,/clear 或 /compact 后才重新计算
|
||||
systemPromptSection('memory', () => loadMemoryPrompt())
|
||||
|
||||
// 危险:每轮重新计算,会破坏 Prompt Cache
|
||||
DANGEROUS_uncachedSystemPromptSection(
|
||||
'mcp_instructions',
|
||||
() => isMcpInstructionsDeltaEnabled() ? null : getMcpInstructionsSection(mcpClients),
|
||||
'MCP servers connect/disconnect between turns' // 必须给出破坏缓存的理由
|
||||
)
|
||||
```
|
||||
|
||||
`resolveSystemPromptSections()` 在每轮查询时解析所有 Section,对于 `cacheBreak: false` 的 Section,优先使用 `getSystemPromptSectionCache()` 中的缓存值。只有 MCP 指令等真正动态的内容使用 `DANGEROUS_uncachedSystemPromptSection`。
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 快速路径
|
||||
|
||||
当环境变量 `CLAUDE_CODE_SIMPLE` 为真时,整个 System Prompt 缩减为一行:
|
||||
|
||||
```typescript
|
||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`
|
||||
```
|
||||
|
||||
跳过所有 Section 注册、缓存分块、动态组装——用于最小化 token 消耗的测试场景。
|
||||
|
||||
## buildEffectiveSystemPrompt():五级优先级
|
||||
|
||||
`src/utils/systemPrompt.ts:41` 决定最终使用哪个 System Prompt:
|
||||
|
||||
| 优先级 | 条件 | 行为 |
|
||||
|--------|------|------|
|
||||
| **0. Override** | `overrideSystemPrompt` 非空 | 完全替换,返回 `[override]` |
|
||||
| **1. Coordinator** | `COORDINATOR_MODE` feature + 环境变量 | 使用协调者专用提示词 |
|
||||
| **2. Agent** | `mainThreadAgentDefinition` 存在 | Proactive 模式:追加到默认提示词尾部;否则:替换默认提示词 |
|
||||
| **3. Custom** | `--system-prompt` 参数指定 | 替换默认提示词 |
|
||||
| **4. Default** | 无特殊条件 | 使用 `getSystemPrompt()` 完整输出 |
|
||||
|
||||
`appendSystemPrompt` 始终追加到末尾(Override 除外)。
|
||||
|
||||
## 缓存策略:分块、标记、命中
|
||||
|
||||
这是 System Prompt 设计中最精密的部分。
|
||||
|
||||
### Anthropic Prompt Cache 基础
|
||||
|
||||
Anthropic API 的 Prompt Cache 允许跨请求复用相同的 System Prompt 前缀,按缓存命中量计费(远低于完整输入价格)。缓存键由内容的 Blake2b 哈希决定——任何字符变化都会导致缓存失效。
|
||||
|
||||
### `splitSysPromptPrefix()`:三种分块模式
|
||||
|
||||
`src/utils/api.ts:321` 是缓存策略的核心,根据条件选择三种分块模式:
|
||||
|
||||
#### 模式 1:MCP 工具存在时(`skipGlobalCacheForSystemPrompt=true`)
|
||||
|
||||
```
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织缓存的基础,因此降级为组织级。
|
||||
|
||||
#### 模式 2:Global Cache + Boundary 存在(1P 专用)
|
||||
|
||||
```
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: null (不缓存)
|
||||
[static content] → cacheScope: 'global' (全局缓存!跨组织共享)
|
||||
[dynamic content] → cacheScope: null (不缓存)
|
||||
```
|
||||
|
||||
这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。
|
||||
|
||||
#### 模式 3:默认(3P 提供商 或 Boundary 缺失)
|
||||
|
||||
```
|
||||
[attribution header] → cacheScope: null (不缓存)
|
||||
[system prompt prefix] → cacheScope: 'org' (组织级缓存)
|
||||
[everything else] → cacheScope: 'org' (组织级缓存)
|
||||
```
|
||||
|
||||
### `getCacheControl()`:TTL 决策
|
||||
|
||||
`src/services/api/claude.ts:359` 生成的 `cache_control` 对象:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'ephemeral',
|
||||
ttl?: '1h', // 仅特定 querySource 符合条件时
|
||||
scope?: 'global', // 仅静态区
|
||||
}
|
||||
```
|
||||
|
||||
1 小时 TTL 的判定逻辑(`should1hCacheTTL()`,第 394 行):
|
||||
- **Bedrock 用户**:通过环境变量 `ENABLE_PROMPT_CACHING_1H_BEDROCK` 启用
|
||||
- **1P 用户**:通过 GrowthBook 配置的 `allowlist` 数组匹配 `querySource`,支持前缀通配符(如 `"repl_main_thread*"`)
|
||||
- **会话级锁定**:资格判定结果在 bootstrap state 中缓存,防止 GrowthBook 配置中途变化导致同一会话内 TTL 不一致
|
||||
|
||||
### 缓存破坏:Session-Specific Guidance 的放置
|
||||
|
||||
`getSessionSpecificGuidanceSection()`(`src/constants/prompts.ts:352`)的内容必须放在 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` **之后**。因为它包含:
|
||||
- 当前会话的 enabledTools 集合
|
||||
- `isForkSubagentEnabled()` 的运行时判定
|
||||
- `getIsNonInteractiveSession()` 的结果
|
||||
|
||||
这些运行时 bit 如果放在静态区,会产生 2^N 种 Blake2b 哈希变体(N = 运行时条件数),完全破坏缓存命中率。源码注释明确警告:
|
||||
|
||||
> Each conditional here is a runtime bit that would otherwise multiply the Blake2b prefix hash variants (2^N). See PR #24490, #24171 for the same bug class.
|
||||
|
||||
### `CLAUDE_CODE_SIMPLE` 模式
|
||||
|
||||
当设置了 `CLAUDE_CODE_SIMPLE` 环境变量时,整个系统提示词会大幅缩减:
|
||||
|
||||
```typescript
|
||||
return [`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`]
|
||||
```
|
||||
|
||||
## 上下文注入:System Context 与 User Context
|
||||
|
||||
System Prompt 数组本身不包含运行时上下文(git 状态、CLAUDE.md 内容)。上下文通过两个独立的管道注入:
|
||||
|
||||
### System Context(`src/context.ts:116`)
|
||||
|
||||
```typescript
|
||||
export const getSystemContext = memoize(async () => {
|
||||
return {
|
||||
gitStatus, // git 分支、状态、最近提交(截断至 MAX_STATUS_CHARS=2000)
|
||||
cacheBreaker, // 仅 ant 用户的缓存破坏器
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 使用 `lodash.memoize` 缓存——**整个会话期间只计算一次**
|
||||
- Git 状态快照包含 5 个并行 `git` 命令(branch、defaultBranch、status、log、userName)
|
||||
- `status` 超过 2000 字符时截断并附加提示使用 BashTool 获取更多信息
|
||||
- `systemPromptInjection` 变更时,通过 `getUserContext.cache.clear?.()` 清除所有上下文缓存
|
||||
|
||||
### User Context(`src/context.ts:155`)
|
||||
|
||||
```typescript
|
||||
export const getUserContext = memoize(async () => {
|
||||
return {
|
||||
claudeMd, // 合并后的 CLAUDE.md 内容
|
||||
currentDate, // "Today's date is YYYY-MM-DD."
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- **CLAUDE.md 禁用条件**:`CLAUDE_CODE_DISABLE_CLAUDE_MDS` 环境变量,或 `--bare` 模式(除非通过 `--add-dir` 显式指定目录)
|
||||
- `--bare` 模式的语义是"跳过我没要求的东西"而非"忽略所有"
|
||||
|
||||
### 注入位置
|
||||
|
||||
在 `src/query.ts:449`:
|
||||
|
||||
```typescript
|
||||
// System Context 追加到 System Prompt 尾部
|
||||
const fullSystemPrompt = asSystemPrompt(
|
||||
appendSystemContext(systemPrompt, systemContext) // 简单拼接
|
||||
)
|
||||
```
|
||||
|
||||
User Context 通过 `prependUserContext()`(`src/utils/api.ts:449`)注入为 `<system-reminder>` 标签包裹的首条用户消息,放在所有对话消息之前。
|
||||
|
||||
## Attribution Header:计费与安全
|
||||
|
||||
每个 API 请求的 System Prompt 首块是 Attribution Header(`src/constants/system.ts:30`),包含:
|
||||
- **`cc_version`**:Claude Code 版本 + 指纹
|
||||
- **`cc_entrypoint`**:入口点标识(REPL / SDK / pipe 等)
|
||||
- **`cch=00000`**(NATIVE_CLIENT_ATTESTATION 启用时):Bun 原生 HTTP 层在发送前将零替换为计算出的哈希值,服务器验证此 token 确认请求来自真实 Claude Code 客户端
|
||||
|
||||
Header 始终 `cacheScope: null`——它因版本和指纹不同而变化,不适合缓存。
|
||||
|
||||
## CLAUDE.md:项目级知识注入
|
||||
|
||||
@ -49,10 +238,15 @@ Claude Code 的 System Prompt 不是一段写死的文本,而是根据当前
|
||||
└── /project/src/CLAUDE.md ← 子目录(模块特定)
|
||||
```
|
||||
|
||||
## 缓存策略
|
||||
加载逻辑在 `src/utils/claudemd.ts` 中的 `getClaudeMds()` 和 `getMemoryFiles()` 实现——从 CWD 向上遍历目录树,合并所有匹配的 CLAUDE.md 文件内容。
|
||||
|
||||
System Prompt 的 token 消耗不小(可能占总量的 30%+)。为了降低成本,系统使用了缓存机制:
|
||||
## 设计洞察:为什么是 `string[]` 而非单个 `string`
|
||||
|
||||
- 不变的部分(基础人设、工具说明)可以跨请求复用
|
||||
- 变化的部分(git 状态、记忆文件)每次重新生成
|
||||
- 缓存节点的位置经过精心设计,最大化缓存命中率
|
||||
将 System Prompt 设计为数组而非单段文本,是为了 **缓存分块**:
|
||||
|
||||
1. Anthropic Prompt Cache 以 **内容块**(TextBlock)为缓存单位
|
||||
2. 将 System Prompt 拆为多个块,可以让不变的部分(Intro、Rules)获得独立的缓存命中
|
||||
3. 如果是单个 `string`,任何一个字符变化(如日期更新)都会导致整个 System Prompt 的缓存失效
|
||||
4. `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记允许 `splitSysPromptPrefix()` 精确地将静态区标记为 `scope: 'global'`,动态区不标记或标记为 `scope: 'org'`
|
||||
|
||||
这是 Claude Code 在 token 成本优化上的核心设计——一次典型的 System Prompt 约 20K+ tokens,通过缓存分块可以节省 30-50% 的输入 token 费用。
|
||||
|
||||
@ -1,69 +1,182 @@
|
||||
---
|
||||
title: "Agentic Loop:AI 自主循环的核心机制"
|
||||
description: "深入解析 Claude Code 的 Agentic Loop 机制——AI 如何通过思考-行动-观察的循环,自主决策工具调用链,直到任务完成。包含源码级流程分析。"
|
||||
keywords: ["Agentic Loop", "AI 循环", "工具调用", "自主决策", "ReAct 模式"]
|
||||
description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
|
||||
keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
|
||||
---
|
||||
|
||||
{/* 本章目标:解释 Agentic Loop 这个核心机制 */}
|
||||
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
|
||||
|
||||
## 什么是 Agentic Loop
|
||||
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
||||
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环):
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数(第 241 行)。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
|
||||
<Frame caption="Agentic Loop 循环示意">
|
||||
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
|
||||
</Frame>
|
||||
|
||||
<Steps>
|
||||
<Step title="思考">
|
||||
AI 分析当前上下文,决定下一步该做什么
|
||||
</Step>
|
||||
<Step title="行动">
|
||||
AI 发出工具调用请求(比如"读取这个文件"、"执行这条命令")
|
||||
</Step>
|
||||
<Step title="观察">
|
||||
工具执行完毕,结果回传给 AI
|
||||
</Step>
|
||||
<Step title="循环或结束">
|
||||
AI 根据观察结果决定:继续下一步操作,还是任务已完成、直接回答用户
|
||||
</Step>
|
||||
</Steps>
|
||||
## 循环的完整结构
|
||||
|
||||
## 一个真实的例子
|
||||
`queryLoop()` 的每次迭代(`src/query.ts:307` `while(true)`)包含以下阶段:
|
||||
|
||||
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
|
||||
|
||||
AI 的内部过程:
|
||||
在调用 API 之前,依次执行 5 个压缩/优化步骤:
|
||||
|
||||
1. **思考**:我需要先了解项目结构 → **行动**:调用 Glob 工具扫描所有源文件
|
||||
2. **观察**:拿到文件列表 → **思考**:逐个检查 → **行动**:调用 Grep 搜索 import 语句
|
||||
3. **观察**:发现 3 个文件有未使用导入 → **行动**:调用 FileEdit 逐个删除
|
||||
4. **观察**:编辑成功 → **结束**:告诉用户"已清理 3 个文件中的 5 条未使用导入"
|
||||
```
|
||||
messagesForQuery(原始消息)
|
||||
↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
|
||||
↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
|
||||
↓ microcompact() — 微压缩(工具结果摘要)
|
||||
↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
|
||||
↓ autocompact() — 自动压缩(超出阈值时触发)
|
||||
messagesForQuery(处理后的消息)→ 发往 API
|
||||
```
|
||||
|
||||
整个过程可能涉及 10+ 次工具调用,但用户只需要说一句话。
|
||||
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
|
||||
|
||||
### 阶段 2:流式 API 调用(Streaming Loop)
|
||||
|
||||
`deps.callModel()` 发起流式请求(第 659 行),返回一个 AsyncGenerator。在流式过程中:
|
||||
|
||||
- **AssistantMessage** 被收集到 `assistantMessages[]` 数组
|
||||
- **tool_use 块** 被提取到 `toolUseBlocks[]`,设置 `needsFollowUp = true`
|
||||
- **StreamingToolExecutor** 在流式过程中就开始并行执行工具(不等流结束)
|
||||
- 可恢复的错误(prompt-too-long、max-output-tokens)被**暂扣**(withheld),先尝试恢复
|
||||
|
||||
流式回调中的关键守卫:
|
||||
- `backfillObservableInput()`(第 763 行)—— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone(第 717 行),清空后重试
|
||||
|
||||
### 阶段 3:工具执行(Tool Execution)
|
||||
|
||||
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
|
||||
|
||||
```typescript
|
||||
// 两种工具执行器(互斥)
|
||||
const toolUpdates = streamingToolExecutor
|
||||
? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
|
||||
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
|
||||
```
|
||||
|
||||
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
|
||||
|
||||
### 阶段 4:终止或继续
|
||||
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
|
||||
|
||||
## 7 种终止条件(源码级)
|
||||
|
||||
| 终止原因 | 触发位置 | 机制 |
|
||||
|----------|---------|------|
|
||||
| **completed** | 第 1360 行 | AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **blocking_limit** | 第 646 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1054 行 | `abortController.signal.aborted` → 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **model_error** | 第 999 行 | `callModel()` 抛出异常 → 生成错误消息 → 返回 |
|
||||
| **prompt_too_long** | 第 1178 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **image_error** | 第 980/1178 行 | 图片尺寸/大小错误 → 直接返回 |
|
||||
| **stop_hook_prevented** | 第 1282 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
|
||||
|
||||
## 4 种继续条件(恢复路径)
|
||||
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
|
||||
### 1. 正常工具循环
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → `continue`
|
||||
|
||||
### 2. max_output_tokens 恢复(第 1191-1255 行)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`):
|
||||
- **首次**:尝试将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K),无 meta 消息,静默重试
|
||||
- **后续**:注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次
|
||||
- 恢复耗尽后,暂扣的错误消息被释放
|
||||
|
||||
### 3. Prompt-Too-Long 恢复(第 1088-1186 行)
|
||||
当遇到 413 错误时,有两个恢复阶段:
|
||||
- **Context Collapse Drain**(第 1097 行):提交所有已暂存的折叠,释放空间后重试。如果上一轮已经是 collapse_drain_retry 则跳过
|
||||
- **Reactive Compact**(第 1123 行):触发即时压缩,生成摘要后重试。`hasAttemptedReactiveCompact` 防止无限循环
|
||||
|
||||
### 4. Stop Hook 阻塞重试(第 1285-1308 行)
|
||||
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
||||
|
||||
## 模型降级(Fallback)
|
||||
|
||||
当主模型不可用时(`FallbackTriggeredError`,第 897 行):
|
||||
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
|
||||
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
||||
3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
|
||||
4. 生成系统消息:"Switched to {fallback} due to high demand for {original}"
|
||||
5. 重新发起流式请求
|
||||
|
||||
## 状态机:State 对象
|
||||
|
||||
每次迭代的状态通过 `State` 类型(第 204 行)传递:
|
||||
|
||||
```typescript
|
||||
type State = {
|
||||
messages: Message[] // 当前对话消息
|
||||
toolUseContext: ToolUseContext // 工具上下文(含权限)
|
||||
autoCompactTracking: AutoCompactTrackingState // 压缩跟踪
|
||||
maxOutputTokensRecoveryCount: number // 输出截断恢复计数
|
||||
hasAttemptedReactiveCompact: boolean // 是否已尝试即时压缩
|
||||
maxOutputTokensOverride: number | undefined // 输出 token 上限覆盖
|
||||
pendingToolUseSummary: Promise<...> | undefined // 异步工具摘要
|
||||
stopHookActive: boolean | undefined // Stop hook 是否激活
|
||||
turnCount: number // 轮次计数
|
||||
transition: Continue | undefined // 上一次继续的原因
|
||||
}
|
||||
```
|
||||
|
||||
每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。
|
||||
|
||||
## Token Budget(实验性)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时(第 1311 行),循环在终止前会检查 token 消耗:
|
||||
|
||||
- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**:检测到收益递减 → 提前终止
|
||||
- 预算数据来自 `createBudgetTracker()`,跨迭代累计
|
||||
|
||||
## 为什么不是"一次规划,批量执行"
|
||||
|
||||
<Note>
|
||||
一个常见的替代方案是:AI 先生成一个完整的计划,然后一次性执行所有步骤。Claude Code 选择了逐步循环,原因是:
|
||||
源码揭示了为什么 Claude Code 选择逐步循环:
|
||||
</Note>
|
||||
|
||||
- **每一步都能看到真实结果**:文件内容、命令输出、错误信息——这些只有执行后才知道
|
||||
- **动态调整**:如果第 3 步发现了意外情况,AI 可以立刻改变策略
|
||||
- **错误恢复**:某步失败了,AI 可以当场诊断和修复,不需要推倒重来
|
||||
- **用户可控**:用户可以在任何一步中断,AI 的循环不会失控
|
||||
- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
||||
- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
|
||||
- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
||||
- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1018、1048、1488 行),用户按 ESC 可以优雅中断
|
||||
- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
|
||||
|
||||
## 循环的终止条件
|
||||
## 一个完整的迭代示例
|
||||
|
||||
Agentic Loop 不会无限运行,以下情况会让循环停止:
|
||||
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
|
||||
| 条件 | 说明 |
|
||||
|------|------|
|
||||
| AI 主动结束 | AI 判断任务完成,返回纯文本回答(不再调用工具) |
|
||||
| 用户中断 | 用户按 Ctrl+C 或 ESC 打断当前操作 |
|
||||
| Token 预算耗尽 | 单轮对话的 token 用量达到上限 |
|
||||
| 输出过长自动续写 | AI 回复被截断时,系统自动发起续写请求(有次数上限) |
|
||||
| 成本上限 | 累计 API 花费超过用户设定的预算 |
|
||||
```
|
||||
迭代 1: 思考→行动
|
||||
预处理: 无需压缩(上下文很短)
|
||||
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
||||
工具执行: 返回 42 个文件路径
|
||||
→ needsFollowUp = true, continue
|
||||
|
||||
迭代 2: 思考→行动
|
||||
预处理: 42 个文件结果仍在预算内
|
||||
API 调用: 返回 tool_use(Grep, "import.*from")
|
||||
工具执行: 在 15 个文件中找到 120 条 import
|
||||
→ needsFollowUp = true, continue
|
||||
|
||||
迭代 3: 思考→行动(多轮)
|
||||
预处理: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
||||
工具执行: 删除 5 条未使用导入
|
||||
→ needsFollowUp = true, continue
|
||||
|
||||
迭代 4: 总结
|
||||
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
||||
→ needsFollowUp = false
|
||||
→ Stop hooks 通过
|
||||
→ return { reason: 'completed' }
|
||||
```
|
||||
|
||||
@ -1,61 +1,221 @@
|
||||
---
|
||||
title: "Skills 技能系统 - 可复用的 AI 工作流封装"
|
||||
description: "详解 Claude Code Skills 系统:如何将常用工作流封装为可复用的技能包,Skills 与 Tools 的区别,以及技能定义和加载机制。"
|
||||
keywords: ["Skills", "技能系统", "工作流封装", "可复用技能", "AI 工作流"]
|
||||
title: "Skills 技能系统 - Prompt 即能力的架构哲学"
|
||||
description: "深入剖析 Claude Code Skills 系统的完整实现:从磁盘加载、Frontmatter 解析、预算感知描述截断、双模式执行(inline/fork)、权限白名单、条件激活、动态发现到远程技能加载,揭示一条完整的 Skill 生命周期链路。"
|
||||
keywords: ["Skills", "SkillTool", "技能加载", "Frontmatter", "whenToUse", "allowedTools", "fork执行", "动态发现"]
|
||||
---
|
||||
|
||||
{/* 本章目标:解释 Skills 系统的设计思想 */}
|
||||
{/* 本章目标:揭示 Skill 系统从文件到执行的全链路实现 */}
|
||||
|
||||
## Tool vs Skill
|
||||
## Tool vs Skill:本质差异
|
||||
|
||||
| | Tool | Skill |
|
||||
|---|---|---|
|
||||
| 粒度 | 单个原子操作(读文件、执行命令) | 一套完整的工作流(代码审查、创建 PR) |
|
||||
| 触发方式 | AI 自主选择 | 用户主动调用(`/skill-name`)或 AI 根据场景推荐 |
|
||||
| 本质 | 执行逻辑 | 预制的 Prompt + 工具权限配置 |
|
||||
| 触发方式 | AI 自主选择 | 用户 `/skill-name` 或 AI 通过 `SkillTool` 自动匹配 |
|
||||
| 本质 | TypeScript 执行逻辑 | **Prompt + 权限配置**的声明式封装 |
|
||||
| 注册位置 | `src/tools.ts` → `getTools()` | `src/commands.ts` → `getCommands()` |
|
||||
| 执行器 | 各 Tool 的 `call()` 方法 | `SkillTool.call()` → 两条分支(inline / fork) |
|
||||
|
||||
## Skill 的三个来源
|
||||
Skill 的核心洞见:**复杂任务的关键不在代码逻辑,而在 Prompt 质量**。一个代码审查 Skill 不需要审查引擎,只需告诉 AI "审查什么、按什么顺序、输出什么格式"——Skill 把这种"经验"封装为可复用的 Markdown。
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="内置 Skill" icon="box">
|
||||
编译进 CLI 的技能包。如 `/commit`、`/review`、`/debug`
|
||||
</Card>
|
||||
<Card title="项目 Skill" icon="folder-open">
|
||||
项目 `.claude/skills/` 目录中的 Markdown 文件。团队共享
|
||||
</Card>
|
||||
<Card title="MCP Skill" icon="plug">
|
||||
通过 MCP Server 提供的技能。动态发现
|
||||
</Card>
|
||||
</CardGroup>
|
||||
## Skill 的五个来源与加载链路
|
||||
|
||||
## 一个 Skill 包含什么
|
||||
### 1. 内置命令(Built-in Commands)
|
||||
|
||||
每个 Skill 本质上是一个"AI 行为的预设":
|
||||
硬编码在 `src/commands.ts:258` 的 `COMMANDS` memoize 数组中,包含 70+ 条命令(`/commit`、`/review`、`/compact` 等)。这些是 TypeScript 模块而非 Markdown,但实现了相同的 `Command` 接口(`src/types/command.ts`)。
|
||||
|
||||
| 组成部分 | 作用 |
|
||||
|----------|------|
|
||||
| **名称和描述** | 告诉 AI 和用户这个技能做什么 |
|
||||
| **whenToUse** | 什么场景下应该使用这个技能(AI 据此自动推荐) |
|
||||
| **Prompt 模板** | 注入给 AI 的详细指令——相当于"操作手册" |
|
||||
| **allowedTools** | 这个技能允许使用哪些工具(能力边界) |
|
||||
| **model** | 可选指定使用的模型 |
|
||||
### 2. Bundled Skills(编译时打包)
|
||||
|
||||
## 设计精妙之处
|
||||
通过 `registerBundledSkill()`(`src/skills/bundledSkills.ts:53`)在模块初始化时注册。关键特性:
|
||||
|
||||
Skill 的核心洞见是:**很多复杂任务的关键不在于代码逻辑,而在于 Prompt 的质量**。
|
||||
- **延迟文件提取**:如果 Skill 声明了 `files`(参考文件),首次调用时才解压到临时目录(`getBundledSkillExtractDir()`),使用 `O_NOFOLLOW | O_EXCL` 防止符号链接攻击(`safeWriteFile`,第 186 行)
|
||||
- **闭包级 memoize**:并发调用共享同一个 extraction promise,避免竞态写入
|
||||
- 来源标记为 `source: 'bundled'`,在 Prompt 预算中享有**不可截断**的特权
|
||||
|
||||
一个好的代码审查,不是写了什么代码来审查,而是:
|
||||
- 告诉 AI 审查的标准是什么
|
||||
- 告诉 AI 按什么顺序审查
|
||||
- 告诉 AI 输出什么格式的报告
|
||||
- 限制 AI 只能用读取类工具(不要边审查边改代码)
|
||||
### 3. 磁盘 Skills(`.claude/skills/`)
|
||||
|
||||
Skill 把这些"经验"封装起来,任何人都能一键调用。
|
||||
由 `loadSkillsFromSkillsDir()`(`src/skills/loadSkillsDir.ts:407`)加载,这是最重要的加载路径:
|
||||
|
||||
## 技能发现
|
||||
```
|
||||
管理策略: $MANAGED_DIR/.claude/skills/ (policySettings)
|
||||
用户全局: ~/.claude/skills/ (userSettings)
|
||||
项目级: .claude/skills/ (projectSettings, 向上遍历至 home)
|
||||
附加目录: --add-dir 指定的路径下 .claude/skills/
|
||||
```
|
||||
|
||||
当可用技能很多时,AI 可以通过 **SkillTool** 搜索匹配的技能:
|
||||
**加载协议**:只识别 `skill-name/SKILL.md` 目录格式,不再支持单文件 `.md`。加载流程:
|
||||
|
||||
- 用户说"帮我做代码审查"
|
||||
- AI 搜索已注册的技能,发现 `code-review` 匹配
|
||||
- AI 调用该技能,按预设的流程执行
|
||||
1. `readdir` 扫描目录 → 仅保留 `isDirectory()` 或 `isSymbolicLink()` 的条目
|
||||
2. 在每个子目录中查找 `SKILL.md`,未找到则跳过
|
||||
3. `parseFrontmatter()` 解析 YAML 头部,提取 `whenToUse`、`allowedTools`、`context` 等字段
|
||||
4. `parseSkillFrontmatterFields()`(第 185 行)统一解析 17 个 frontmatter 字段
|
||||
5. `createSkillCommand()`(第 270 行)构造 `Command` 对象
|
||||
|
||||
**去重机制**:使用 `realpath()` 解析符号链接获得规范路径(`getFileIdentity`,第 118 行),避免通过符号链接或重叠父目录导致的重复加载。
|
||||
|
||||
### 4. MCP Skills(动态发现)
|
||||
|
||||
通过 `registerMCPSkillBuilders()` 注册构建器,MCP Server 的 prompt 被 `mcpSkillBuilders.ts` 转换为 `Command` 对象。标记为 `loadedFrom: 'mcp'`。
|
||||
|
||||
**安全边界**:MCP Skills 的 Prompt 内容**禁止执行内联 shell 命令**(`loadSkillsDir.ts:374` 的 `loadedFrom !== 'mcp'` 守卫),因为远程内容不可信。
|
||||
|
||||
### 5. Legacy Commands(`/commands/` 目录)
|
||||
|
||||
向后兼容的旧格式,由 `loadSkillsFromCommandsDir()`(第 566 行)加载。同时支持 `SKILL.md` 目录格式和单 `.md` 文件格式。
|
||||
|
||||
## Frontmatter 字段全景
|
||||
|
||||
一个 `SKILL.md` 的完整 frontmatter(`parseSkillFrontmatterFields`,第 185 行):
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: code-review # 显示名称(覆盖目录名)
|
||||
description: 系统性代码审查 # 描述(或从 Markdown 首段提取)
|
||||
when_to_use: "用户说审查代码、找 bug" # AI 自动匹配依据
|
||||
allowed-tools: # 工具白名单
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
argument-hint: "<file-or-directory>" # 参数提示
|
||||
arguments: [path] # 声明式参数名(用于 $ARGUMENTS 替换)
|
||||
model: opus # 模型覆盖
|
||||
effort: high # 努力级别
|
||||
context: fork # 执行模式:inline(默认)| fork
|
||||
agent: code-reviewer # 指定 Agent 定义文件
|
||||
user-invocable: true # 用户是否可 /调用
|
||||
disable-model-invocation: false # 禁止 AI 自主调用
|
||||
version: "1.0" # 版本号
|
||||
paths: # 条件激活的文件路径模式
|
||||
- "src/**/*.ts"
|
||||
hooks: # Hook 配置
|
||||
PreToolUse:
|
||||
- command: ["echo", "checking"]
|
||||
shell: ["bash"] # Shell 执行环境
|
||||
---
|
||||
```
|
||||
|
||||
解析后有 17 个字段被提取,其中 `allowedTools`、`model`、`effort` 在执行时动态修改 `toolPermissionContext`。
|
||||
|
||||
## 两条执行路径:Inline vs Fork
|
||||
|
||||
SkillTool(`src/tools/SkillTool/SkillTool.ts:332`)在 `call()` 中根据 `command.context` 分流:
|
||||
|
||||
### Inline 模式(默认)
|
||||
|
||||
Skill 的 Prompt 内容被注入为 **UserMessage**,在主对话流中继续执行:
|
||||
|
||||
1. `processPromptSlashCommand()` 处理参数替换(`$ARGUMENTS`)和 shell 命令展开(`` !`...` ``)
|
||||
2. `${CLAUDE_SKILL_DIR}` 被替换为 Skill 所在目录的绝对路径
|
||||
3. `${CLAUDE_SESSION_ID}` 被替换为当前会话 ID
|
||||
4. 返回 `newMessages`(注入到对话流)+ `contextModifier`(修改权限上下文)
|
||||
|
||||
`contextModifier`(第 776 行)做了三件事:
|
||||
- **工具白名单注入**:将 `allowedTools` 合并到 `alwaysAllowRules.command`
|
||||
- **模型切换**:`resolveSkillModelOverride()` 处理模型覆盖,保留 `[1m]` 后缀以避免 200K 窗口截断
|
||||
- **努力级别覆盖**:修改 `effortValue`
|
||||
|
||||
### Fork 模式(`context: fork`)
|
||||
|
||||
Skill 在**独立子 Agent** 中执行(`executeForkedSkill`,第 122 行):
|
||||
|
||||
1. `prepareForkedCommandContext()` 构建隔离的 Agent 定义和 Prompt
|
||||
2. `runAgent()` 启动子 Agent 循环,拥有独立的 token 预算
|
||||
3. 通过 `onProgress` 回调报告工具使用进度
|
||||
4. 结果通过 `extractResultText()` 提取,子 Agent 的全部消息在提取后被释放(`agentMessages.length = 0`)
|
||||
5. 最终通过 `clearInvokedSkillsForAgent()` 清理状态
|
||||
|
||||
Fork 模式适用于需要强隔离的场景(如长时间运行的审查任务),避免污染主对话的上下文。
|
||||
|
||||
## 权限模型:Safe Properties 白名单
|
||||
|
||||
`checkPermissions()`(第 433 行)实现了一个四层权限检查:
|
||||
|
||||
```
|
||||
1. Deny 规则匹配(支持精确匹配和 prefix:* 通配符)
|
||||
↓ 未命中
|
||||
2. 官方市场 Skill 自动放行(plugin + isOfficialMarketplaceName)
|
||||
↓ 未命中
|
||||
3. Allow 规则匹配
|
||||
↓ 未命中
|
||||
4. Safe Properties 白名单检查(skillHasOnlySafeProperties,第 911 行)
|
||||
↓ 有非安全属性
|
||||
5. Ask 用户确认(附带精确匹配和前缀匹配两条建议规则)
|
||||
```
|
||||
|
||||
**Safe Properties**(`SAFE_SKILL_PROPERTIES`,第 876 行)是一个包含 28 个属性名的白名单。任何不在白名单中的**有意义的属性值**(排除 `undefined`、`null`、空数组、空对象)都会触发权限请求。这是**正向安全**设计——未来新增的属性默认需要权限。
|
||||
|
||||
## Prompt 预算:1% 上下文窗口的截断策略
|
||||
|
||||
Skill 列表注入 System Prompt 时有严格的字符预算(`prompt.ts`):
|
||||
|
||||
- **预算计算**:`contextWindowTokens × 4 chars/token × 1%`(约 8000 字符)
|
||||
- **单条上限**:`MAX_LISTING_DESC_CHARS = 250` 字符(超出截断为 `…`)
|
||||
- **Bundled Skills 不可截断**:它们始终保留完整描述,预算不足时只截断非 bundled 的
|
||||
- **降级策略**:
|
||||
1. 尝试完整描述 → 超预算?
|
||||
2. Bundled 保留完整,非 bundled 均分剩余预算 → 每条描述低于 20 字符?
|
||||
3. 非 bundled 仅保留名称
|
||||
|
||||
`formatCommandsWithinBudget()`(`prompt.ts:70`)实现了这个三级降级。
|
||||
|
||||
## 动态发现与条件激活
|
||||
|
||||
### 基于文件路径的动态发现
|
||||
|
||||
`discoverSkillDirsForPaths()`(`loadSkillsDir.ts:861`)在文件操作时触发:
|
||||
|
||||
1. 从被操作的文件路径开始,**向上遍历**至 CWD(不包含 CWD 本身)
|
||||
2. 在每层查找 `.claude/skills/` 目录
|
||||
3. 使用 `realpath` 去重,`git check-ignore` 过滤 gitignored 目录
|
||||
4. 按路径深度排序(**深层优先**),更接近文件的 Skill 优先级更高
|
||||
|
||||
### 条件激活(paths frontmatter)
|
||||
|
||||
带有 `paths` 模式的 Skill 在加载时不会立即可用,而是存入 `conditionalSkills` Map。当被操作的文件路径匹配某个 Skill 的 paths 模式时(使用 `ignore` 库做 gitignore 风格匹配),该 Skill 才被**激活**——从 `conditionalSkills` 移入 `dynamicSkills`。
|
||||
|
||||
这意味着一个只在 `*.test.ts` 上激活的测试 Skill,平时完全不可见,只有当 AI 读取或编辑测试文件时才会出现。
|
||||
|
||||
## 使用频率排名
|
||||
|
||||
`recordSkillUsage()`(`skillUsageTracking.ts`)使用指数衰减算法计算 Skill 排名分数:
|
||||
|
||||
```
|
||||
score = usageCount × max(0.5^(daysSinceUse / 7), 0.1)
|
||||
```
|
||||
|
||||
- **7 天半衰期**:一周前的使用权重减半
|
||||
- **最低 0.1 保底**:避免老但高频使用的 Skill 完全沉底
|
||||
- **60 秒去抖**:同一 Skill 在 1 分钟内的多次调用只计一次,减少文件 I/O
|
||||
|
||||
排名数据持久化在全局配置的 `skillUsage` 字段中。
|
||||
|
||||
## 远程技能加载(Experimental)
|
||||
|
||||
通过 `EXPERIMENTAL_SKILL_SEARCH` feature flag 控制,支持从远程(AKI/GCS/S3)加载 `_canonical_<slug>` 格式的 Skill:
|
||||
|
||||
1. `validateInput()` 中 `stripCanonicalPrefix()` 拦截 canonical 名称
|
||||
2. `executeRemoteSkill()`(第 970 行)从远程 URL 加载 SKILL.md
|
||||
3. 支持 `gs://`、`https://`、`s3://` 等 URL 协议
|
||||
4. 内容经过 frontmatter 剥离、`${CLAUDE_SKILL_DIR}` 替换后直接注入
|
||||
5. 通过 `addInvokedSkill()` 注册到 compaction 保留状态,确保压缩后仍可恢复
|
||||
6. 远程 Skill 不经过 `processPromptSlashCommand`——无 `!command` 替换、无 `$ARGUMENTS` 展开
|
||||
|
||||
## 完整生命周期总结
|
||||
|
||||
```
|
||||
磁盘 SKILL.md
|
||||
↓ parseFrontmatter()
|
||||
↓ parseSkillFrontmatterFields() → 17 个字段
|
||||
↓ createSkillCommand() → Command 对象
|
||||
↓ 去重(realpath + seenFileIds)
|
||||
↓ 条件 Skill → conditionalSkills Map(等待路径匹配激活)
|
||||
↓ getSkillDirCommands() memoize 缓存
|
||||
↓ getAllCommands() 合并 local + MCP
|
||||
↓ formatCommandsWithinBudget() → 截断后的 Skill 列表注入 System Prompt
|
||||
↓ AI 选择匹配的 Skill
|
||||
↓ SkillTool.validateInput() → 名称校验 + 存在性检查
|
||||
↓ SkillTool.checkPermissions() → 四层权限检查
|
||||
↓ SkillTool.call() → inline 或 fork 执行
|
||||
↓ contextModifier() → 注入 allowedTools + model + effort
|
||||
↓ recordSkillUsage() → 更新使用频率排名
|
||||
```
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "架构全景 - Claude Code 五层架构详解"
|
||||
description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。涵盖 React/Ink UI、QueryEngine 编排、Agentic Loop 核心循环、Tool 系统和 API 层。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "系统设计"]
|
||||
description: "从交互层到基础设施层,详解 Claude Code 的五层架构设计。基于 src/main.tsx、src/QueryEngine.ts、src/query.ts、src/tools.ts、src/services/api/claude.ts 的源码级数据流分析。"
|
||||
keywords: ["Claude Code 架构", "五层架构", "QueryEngine", "Agentic Loop", "数据流"]
|
||||
---
|
||||
|
||||
{/* 本章目标:一张图讲清楚整体架构,为后续章节建立坐标系 */}
|
||||
@ -14,53 +14,99 @@ Claude Code 从上到下分为五个层次,每一层职责清晰、边界分
|
||||
<img src="/docs/images/architecture-layers.png" alt="Claude Code 五层架构图" />
|
||||
</Frame>
|
||||
|
||||
| 层次 | 职责 | 关键词 |
|
||||
|------|------|--------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | React/Ink、REPL |
|
||||
| **编排层** | 管理多轮对话、会话生命周期、成本追踪 | QueryEngine、会话持久化 |
|
||||
| **核心循环层** | 单轮对话:发请求 → 拿响应 → 执行工具 → 再发请求 | Agentic Loop |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令、搜索代码 | Tool System、MCP |
|
||||
| **通信层** | 与 Claude API 的流式通信、多云 Provider 适配 | Streaming、Bedrock/Vertex |
|
||||
| 层次 | 职责 | 入口源码 | 关键词 |
|
||||
|------|------|---------|--------|
|
||||
| **交互层** | 终端 UI、用户输入、消息展示 | `src/screens/REPL.tsx` | React/Ink、PromptInput |
|
||||
| **编排层** | 多轮对话、会话持久化、成本追踪 | `src/QueryEngine.ts` | QueryEngine、transcript |
|
||||
| **核心循环层** | 单轮:发请求 → 拿响应 → 执行工具 → 循环 | `src/query.ts` | Agentic Loop、State |
|
||||
| **工具层** | AI 的"双手"——读写文件、执行命令 | `src/tools.ts` → `src/Tool.ts` | Tool 接口、MCP |
|
||||
| **通信层** | 与 Claude API 的流式通信 | `src/services/api/claude.ts` | Streaming、Provider |
|
||||
|
||||
## 一条主数据流
|
||||
## 一条主数据流的源码追踪
|
||||
|
||||
<Frame caption="核心数据流">
|
||||
<img src="/docs/images/data-flow.png" alt="Claude Code 核心数据流" />
|
||||
</Frame>
|
||||
|
||||
整个系统的运转可以浓缩为一条核心数据流:
|
||||
整个系统的运转可以浓缩为一条核心数据流,以下是每一步对应的源码路径:
|
||||
|
||||
<Steps>
|
||||
<Step title="用户输入">
|
||||
用户在终端键入自然语言需求
|
||||
</Step>
|
||||
<Step title="上下文组装">
|
||||
系统自动拼接项目信息、git 状态、配置文件、记忆,形成完整的 System Prompt
|
||||
</Step>
|
||||
<Step title="API 调用">
|
||||
将 System Prompt + 对话历史发送给 Claude API,流式接收响应
|
||||
</Step>
|
||||
<Step title="工具调用循环">
|
||||
若 AI 返回工具调用请求 → 权限检查 → 执行工具 → 结果回传 → AI 继续思考 → 循环
|
||||
</Step>
|
||||
<Step title="任务完成">
|
||||
AI 不再调用工具,输出最终回答,等待用户下一条输入
|
||||
</Step>
|
||||
</Steps>
|
||||
### 1. 用户输入 → REPL
|
||||
|
||||
`src/screens/REPL.tsx` 是基于 React/Ink 的终端 UI 组件。用户输入经 `processUserInput()`(`src/utils/processUserInput/processUserInput.ts`)处理,支持斜杠命令、文件附件、图片等。
|
||||
|
||||
### 2. QueryEngine 编排
|
||||
|
||||
`src/QueryEngine.ts` 是 REPL 与 `query()` 之间的中间层,管理:
|
||||
- **会话状态**:消息数组、工具权限上下文(`ToolPermissionContext`)、文件历史快照
|
||||
- **成本追踪**:`accumulateUsage()` / `getTotalCost()` 累计 token 用量
|
||||
- **Transcript 持久化**:`recordTranscript()` 将对话序列化到磁盘,支持 `--resume`
|
||||
- **文件历史**:`fileHistoryMakeSnapshot()` 在修改前创建快照,支持 undo
|
||||
|
||||
关键方法:`queryEngine.query()` 构造 `QueryParams`,调用 `query()` 异步生成器。
|
||||
|
||||
### 3. Agentic Loop(`src/query.ts`)
|
||||
|
||||
`query()` 是一个 `AsyncGenerator`,`while(true)` 循环的每次迭代包含:
|
||||
|
||||
```
|
||||
① 上下文预处理管道:
|
||||
applyToolResultBudget → snipCompact → microcompact → contextCollapse → autocompact
|
||||
|
||||
② 流式 API 调用:
|
||||
deps.callModel() → AsyncGenerator<StreamEvent | Message>
|
||||
收集 assistantMessages[]、toolUseBlocks[]
|
||||
|
||||
③ 工具执行:
|
||||
StreamingToolExecutor(并行) 或 runTools(串行)
|
||||
→ toolResults[]
|
||||
|
||||
④ 终止/继续判定:
|
||||
needsFollowUp ? continue : return { reason }
|
||||
```
|
||||
|
||||
完整的状态机通过 `State` 类型(`src/query.ts:204`)在迭代间传递,包含 10 个字段(messages、autoCompactTracking、maxOutputTokensRecoveryCount 等)。
|
||||
|
||||
### 4. 工具层(`src/tools.ts` → `src/Tool.ts`)
|
||||
|
||||
`getAllBaseTools()`(`src/tools.ts:191`)组装 50+ 工具列表,经过 `filterToolsByDenyRules()` 权限过滤后传给 API。
|
||||
|
||||
每个工具实现 `Tool<Input, Output, Progress>` 接口(`src/Tool.ts:362`),核心方法链:
|
||||
```
|
||||
validateInput() → canUseTool()(UI 层)→ checkPermissions() → call() → ToolResult
|
||||
```
|
||||
|
||||
### 5. 通信层(`src/services/api/claude.ts`)
|
||||
|
||||
API 客户端支持 4 种 Provider:
|
||||
- **Anthropic Direct**:默认
|
||||
- **AWS Bedrock**:`ANTHROPIC_BEDROCK_BASE_URL`
|
||||
- **Google Vertex**:`ANTHROPIC_VERTEX_PROJECT_ID`
|
||||
- **Azure**:通过自定义 base URL
|
||||
|
||||
`deps.callModel()` 发起流式请求,返回 `BetaRawMessageStreamEvent` 事件流。支持 Prompt Cache(`cache_control`)、thinking blocks、multi-turn tool use。
|
||||
|
||||
## 四个核心设计原则
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="流式优先 (Streaming-first)">
|
||||
所有 API 通信都是流式的——用户看到 AI "逐字打出"回答,而不是等待完整响应。工具执行结果也实时反馈。这不仅提升体验,更是处理长时间任务的工程必需。
|
||||
所有 API 通信都是流式的——`deps.callModel()` 返回 AsyncGenerator,用户看到 AI "逐字打出"回答。StreamingToolExecutor 在流式过程中就开始并行执行工具,不等流结束。模型降级(Fallback)时,已收集的 assistantMessages 被标记为 tombstone 并清空,重试整个流式请求。
|
||||
</Accordion>
|
||||
<Accordion title="工具即能力 (Tool as Capability)">
|
||||
AI 的每一项"动手能力"都被抽象为一个 Tool。想让 AI 能做新事情?注册一个新 Tool 就好。统一的接口让能力可插拔、可组合。
|
||||
每个工具是 `Tool<Input, Output, Progress>` 结构化类型,通过 `buildTool()` 工厂创建。`getTools()` 在每次 API 调用时组装(非全局缓存),因为 `isEnabled()` 可能随运行时状态变化。MCP 工具通过 `mcpInfo` 字段标记来源,支持 server 级别的 blanket deny。
|
||||
</Accordion>
|
||||
<Accordion title="权限即边界 (Permission as Boundary)">
|
||||
AI 能操作真实环境是强大的,也是危险的。每个工具调用都经过权限系统的裁决——该放行的自动放行,该拦截的坚决拦截。
|
||||
每次工具调用经过 `validateInput() → checkPermissions()` 双重检查。权限规则从 5 个来源汇聚(session → project → user → managed → default),支持工具名、命令模式、路径前缀等匹配方式。Plan Mode 通过 `prepareContextForPlanMode()` 切换为只读模式,退出时自动恢复。
|
||||
</Accordion>
|
||||
<Accordion title="上下文即记忆 (Context as Memory)">
|
||||
AI 没有真正的记忆,但通过精心的 System Prompt 组装、对话压缩、持久化记忆文件,系统营造出"AI 理解你的项目"的效果。这是上下文工程的核心。
|
||||
System Prompt 由 `fetchSystemPromptParts()` 动态组装,包含 CLAUDE.md、git 状态、日期、MCP 服务器列表。Auto-compact 在每轮迭代前评估 token 阈值,超出时触发压缩。压缩后的摘要通过 `buildPostCompactMessages()` 替换原始消息,`taskBudgetRemaining` 跨压缩边界累计。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## 入口与引导
|
||||
|
||||
| 入口 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| CLI 启动 | `src/entrypoints/cli.tsx` | 注入 `feature()` polyfill(始终返回 false)、MACRO 全局变量 |
|
||||
| 命令定义 | `src/main.tsx` | Commander.js 解析参数,初始化 auth/analytics/policy |
|
||||
| 一次性初始化 | `src/entrypoints/init.ts` | 遥测配置、信任对话框 |
|
||||
| 管道模式 | `src/main.tsx` `-p` flag | `echo "say hello" \| bun run dev -p` |
|
||||
|
||||
@ -1,69 +1,170 @@
|
||||
---
|
||||
title: "权限模型 - Allow/Ask/Deny 三级权限体系"
|
||||
description: "详解 Claude Code 的三级权限模型:Allow 自动放行、Ask 确认对话框、Deny 直接拒绝。权限规则的层级来源与优先级机制。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "权限控制", "安全权限", "工具权限"]
|
||||
description: "详解 Claude Code 的三级权限模型实现:基于 src/utils/permissions/permissions.ts 的规则匹配引擎、五层规则来源优先级、工具名/命令/路径三维度匹配、Denial Tracking 死循环防护、权限模式切换机制。"
|
||||
keywords: ["权限模型", "Allow Ask Deny", "PermissionRule", "checkPermissions", "Denial Tracking", "权限规则"]
|
||||
---
|
||||
|
||||
{/* 本章目标:详解权限系统的设计 */}
|
||||
{/* 本章目标:基于源码揭示权限系统的完整实现 */}
|
||||
|
||||
## 三种权限行为
|
||||
|
||||
每一次工具调用,系统都会做出三种裁决之一:
|
||||
|
||||
| 行为 | 含义 | 典型场景 |
|
||||
|------|------|---------|
|
||||
| **Allow** | 自动放行,用户无感知 | Read 工具读取项目内的文件 |
|
||||
| **Ask** | 弹出确认对话框,等待用户决定 | Bash 执行一条未知命令 |
|
||||
| **Deny** | 直接拒绝,AI 收到"权限被拒"的反馈 | 尝试执行被禁止的命令 |
|
||||
| 行为 | 含义 | 返回类型 | 典型场景 |
|
||||
|------|------|---------|---------|
|
||||
| **Allow** | 自动放行,用户无感知 | `{ behavior: 'allow', updatedInput, decisionReason }` | Read 读取项目内文件 |
|
||||
| **Ask** | 弹出确认对话框 | `{ behavior: 'ask', message, suggestions, metadata }` | Bash 执行未知命令 |
|
||||
| **Deny** | 直接拒绝 | `{ behavior: 'deny', message, decisionReason }` | 尝试执行被禁止的命令 |
|
||||
|
||||
## 权限规则的层级
|
||||
这些行为由 `PermissionResult` 类型定义(`src/utils/permissions/PermissionResult.ts`)。
|
||||
|
||||
规则可以来自多个来源,优先级从高到低:
|
||||
## 权限规则的五层来源
|
||||
|
||||
<Frame caption="权限规则层级(优先级从上到下递减)">
|
||||
<img src="/docs/images/permission-layers.png" alt="权限层级图" />
|
||||
</Frame>
|
||||
规则从 5 个来源汇聚(`PERMISSION_RULE_SOURCES`,`permissions.ts:109`),优先级从高到低:
|
||||
|
||||
<Steps>
|
||||
<Step title="用户会话内设置">
|
||||
用户在当前对话中手动授权的("对这个工具始终允许")
|
||||
</Step>
|
||||
<Step title="项目配置">
|
||||
项目目录中的 `.claude/settings.json`,团队共享
|
||||
</Step>
|
||||
<Step title="用户全局配置">
|
||||
`~/.claude/settings.json`,跨项目生效
|
||||
</Step>
|
||||
<Step title="托管设置">
|
||||
企业管理员下发的策略,用户不可覆盖
|
||||
</Step>
|
||||
<Step title="默认规则">
|
||||
系统内置的基线规则
|
||||
</Step>
|
||||
</Steps>
|
||||
```
|
||||
1. session — 用户在当前对话中手动授权("Always allow")
|
||||
2. cliArg — 命令行 --allow/--deny 参数
|
||||
3. command — Skill 工具的 allowedTools 白名单
|
||||
4. projectSettings — .claude/settings.json(团队共享)
|
||||
5. userSettings — ~/.claude/settings.json(跨项目)
|
||||
6. policySettings — 企业管理员下发的策略(用户不可覆盖)
|
||||
```
|
||||
|
||||
## 规则的匹配方式
|
||||
每个来源维护三个数组:`alwaysAllowRules[source]`、`alwaysAskRules[source]`、`alwaysDenyRules[source]`。
|
||||
|
||||
权限规则不是简单的"允许/禁止某个工具",它支持丰富的匹配条件:
|
||||
规则数据结构为 `PermissionRule`:
|
||||
```typescript
|
||||
{
|
||||
source: PermissionRuleSource // 来自哪个层级
|
||||
ruleBehavior: 'allow' | 'ask' | 'deny'
|
||||
ruleValue: {
|
||||
toolName: string // 如 "Bash"、"mcp__server1"
|
||||
ruleContent?: string // 如 "git *"、"src/**"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **工具名匹配**:`"tool": "Bash"` → 针对所有 Bash 命令
|
||||
- **命令模式匹配**:`"command": "git *"` → 只针对 git 开头的命令
|
||||
- **路径匹配**:`"path": "src/**"` → 只允许操作 src 目录下的文件
|
||||
- **组合条件**:以上条件可以组合使用
|
||||
## 规则匹配引擎
|
||||
|
||||
### 三维度匹配
|
||||
|
||||
`permissions.ts` 实现了三种匹配维度:
|
||||
|
||||
**1. 工具名匹配**(`toolMatchesRule()`,第 238 行)
|
||||
|
||||
匹配整个工具,仅当规则没有 `ruleContent`:
|
||||
```typescript
|
||||
// 精确匹配
|
||||
rule "Bash" → 匹配 BashTool
|
||||
rule "mcp__server1" → 匹配该 MCP Server 的所有工具(server 级别)
|
||||
rule "mcp__server1__*" → 通配符匹配(同上)
|
||||
```
|
||||
|
||||
MCP 工具使用 `getToolNameForPermissionCheck()` 获取匹配名称,支持有前缀(`mcp__server__tool`)和无前缀模式。
|
||||
|
||||
**2. 命令模式匹配**(BashTool 的 `checkPermissions()`)
|
||||
|
||||
BashTool 通过 `preparePermissionMatcher()`(`Tool.ts:514`)解析命令模式:
|
||||
```json
|
||||
{"tool": "Bash", "ruleContent": "git *"} → 匹配 "git commit -m 'fix'"
|
||||
```
|
||||
|
||||
命令通过 AST 解析(`readOnlyValidation.ts` 使用 tree-sitter bash),提取第一个子命令进行匹配。
|
||||
|
||||
**3. 路径匹配**(文件工具的 `checkPermissions()`)
|
||||
|
||||
Read/Edit/Write 工具通过 `getPath()` 提取文件路径,与 `ruleContent` 中的 glob 模式匹配:
|
||||
```json
|
||||
{"tool": "Edit", "ruleContent": "src/**"} → 匹配 "src/utils/foo.ts"
|
||||
```
|
||||
|
||||
### 权限检查的完整流程
|
||||
|
||||
每次工具调用的权限检查(`canUseTool()` → `checkPermissions()`)经过以下步骤:
|
||||
|
||||
```
|
||||
1a. Blanket deny 检查
|
||||
getDenyRuleForTool() → 工具名完全匹配 deny 规则?
|
||||
↓ 命中 → deny(工具在 getTools() 阶段就被过滤掉)
|
||||
|
||||
1b. Blanket allow 检查
|
||||
toolAlwaysAllowedRule() → 工具名完全匹配 allow 规则?
|
||||
↓ 命中 → allow
|
||||
|
||||
2. 工具自身 checkPermissions()
|
||||
各工具有自定义逻辑:
|
||||
- BashTool: readOnlyValidation → sandbox 判定 → AST 解析 → 模式匹配
|
||||
- FileEditTool: 路径白名单检查
|
||||
- SkillTool: safe properties 白名单 + 精确/前缀匹配
|
||||
↓ 返回 PermissionResult
|
||||
|
||||
3. Hook 系统
|
||||
executePermissionRequestHooks() → PreToolUse hook 可以 override
|
||||
↓ hook 返回 deny → deny
|
||||
↓ hook 返回 ask → 升级为 ask
|
||||
|
||||
4. Ask 规则检查
|
||||
getAskRules() → 命中 → ask
|
||||
|
||||
5. 默认行为
|
||||
根据当前 permissionMode 决定默认行为
|
||||
- 'default': 大部分工具 ask
|
||||
- 'plan': 写操作 deny,读操作 allow
|
||||
- 'bypass': 全部 allow
|
||||
```
|
||||
|
||||
## 权限模式
|
||||
|
||||
系统提供几种预设的权限模式,适应不同信任级别:
|
||||
| 模式 | `PermissionMode` 值 | 适用场景 | 行为 |
|
||||
|------|---------------------|---------|------|
|
||||
| **Default** | `'default'` | 日常使用 | 敏感操作逐一确认 |
|
||||
| **Plan Mode** | `'plan'` | 探索阶段 | 只能读不能写(`isReadOnly()` 检查) |
|
||||
| **Auto** | `'auto'` | 信任 AI | 通过 transcript classifier 自动决策 |
|
||||
| **Bypass** | `'bypassPermissions'` | 完全信任 | 所有操作自动放行(需显式 `--dangerously-skip-permissions`) |
|
||||
|
||||
| 模式 | 适用场景 |
|
||||
|------|---------|
|
||||
| **Default** | 日常使用,敏感操作逐一确认 |
|
||||
| **Plan Mode** | 探索阶段,AI 只能读不能写 |
|
||||
| **Bypass** | 完全信任 AI,所有操作自动放行(需显式开启) |
|
||||
Plan Mode 切换由 `EnterPlanModeTool.call()` 触发:
|
||||
```typescript
|
||||
// EnterPlanModeTool.ts:88
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
## Denial Tracking
|
||||
退出时由 `ExitPlanModeV2Tool` 恢复为之前的模式。
|
||||
|
||||
系统不仅记录"允许了什么",还追踪"拒绝了什么":
|
||||
## Denial Tracking:死循环防护
|
||||
|
||||
- 如果 AI 被连续拒绝多次同一类操作,系统会调整策略
|
||||
- 这防止 AI 陷入"反复请求同一个被拒操作"的死循环
|
||||
`src/utils/permissions/denialTracking.ts` 实现了拒绝追踪机制:
|
||||
|
||||
```typescript
|
||||
const DENIAL_LIMITS = {
|
||||
maxDenialsPerTool: 3, // 同一工具连续拒绝上限
|
||||
cooldownPeriodMs: 30_000, // 冷却期 30 秒
|
||||
}
|
||||
```
|
||||
|
||||
当 AI 被连续拒绝同一类操作达到上限时:
|
||||
1. `recordDenial()` 记录拒绝,增加计数
|
||||
2. `shouldFallbackToPrompting()` 检测到连续拒绝,返回 true
|
||||
3. 系统向 AI 注入消息:"Your previous tool call was rejected..."
|
||||
4. AI 被迫改变策略,避免"反复请求同一个被拒操作"的死循环
|
||||
|
||||
操作成功时调用 `recordSuccess()` 重置计数。
|
||||
|
||||
## 规则的运行时更新
|
||||
|
||||
权限规则可以在运行时动态更新(`applyPermissionUpdate()`,`PermissionUpdate.ts`):
|
||||
|
||||
```typescript
|
||||
type PermissionUpdate =
|
||||
| { type: 'addRule', behavior, rule, destination }
|
||||
| { type: 'removeRule', behavior, rule, destination }
|
||||
| { type: 'setMode', mode, destination }
|
||||
```
|
||||
|
||||
当用户在 Ask 对话框中选择 "Always allow",系统调用 `persistPermissionUpdates()` 将规则写入对应层级的 settings 文件(project/user/managed),同时更新内存中的 `toolPermissionContext`。
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
---
|
||||
title: "计划模式 - Plan Mode 先看后做的安全机制"
|
||||
description: "解析 Claude Code Plan Mode 设计:给 AI 一个只读探索阶段,先分析再执行,避免不可逆操作,让用户在 AI 行动前审查方案。"
|
||||
keywords: ["Plan Mode", "计划模式", "只读模式", "安全执行", "预览方案"]
|
||||
description: "基于源码解析 Claude Code Plan Mode 的完整实现:EnterPlanModeTool/ExitPlanModeV2Tool 的工具设计、权限上下文切换机制、Prompt-based 权限请求、计划文件持久化、Teammate 审批流程。"
|
||||
keywords: ["Plan Mode", "计划模式", "EnterPlanMode", "ExitPlanMode", "prepareContextForPlanMode", "allowedPrompts"]
|
||||
---
|
||||
|
||||
{/* 本章目标:解释 Plan Mode 的设计理念 */}
|
||||
{/* 本章目标:基于源码揭示 Plan Mode 的完整实现 */}
|
||||
|
||||
## 问题场景
|
||||
|
||||
@ -12,50 +12,140 @@ keywords: ["Plan Mode", "计划模式", "只读模式", "安全执行", "预览
|
||||
|
||||
## Plan Mode 的解决方案
|
||||
|
||||
计划模式给对话加了一个"只读阶段":
|
||||
计划模式给对话加了一个"只读阶段",通过两个工具实现闭环:
|
||||
|
||||
<Steps>
|
||||
<Step title="进入计划模式">
|
||||
AI 自主判断任务需要规划(或用户主动触发),进入计划模式
|
||||
<Step title="EnterPlanMode — 进入计划模式">
|
||||
AI 自主判断(或用户触发)任务需要规划,调用 `EnterPlanModeTool`(`src/tools/EnterPlanModeTool/EnterPlanModeTool.ts:36`)。该工具需要**用户审批**(`checkPermissions` 返回 `ask`)。
|
||||
</Step>
|
||||
<Step title="探索阶段">
|
||||
在这个阶段,AI 只能使用读取和搜索类工具——不能编辑文件、不能执行命令
|
||||
<Step title="探索阶段 — 只读工具集">
|
||||
权限模式切换为 `'plan'`,AI 只能使用 `isReadOnly()` 为 true 的工具(Read、Grep、Glob、Agent 等)。写操作被自动拒绝。
|
||||
</Step>
|
||||
<Step title="形成计划">
|
||||
AI 把理解和方案写入计划文件,提交给用户审阅
|
||||
<Step title="ExitPlanMode — 提交方案审批">
|
||||
AI 完成探索后,调用 `ExitPlanModeV2Tool`(`src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts:147`),将计划文件提交给用户审阅。这是第二个**需要用户审批**的节点。
|
||||
</Step>
|
||||
<Step title="用户审批">
|
||||
用户阅读计划,提出修改意见或直接批准
|
||||
</Step>
|
||||
<Step title="退出计划模式">
|
||||
计划被批准后,AI 恢复全部工具权限,按计划执行
|
||||
<Step title="恢复执行 — 全部工具权限">
|
||||
用户批准后,权限模式恢复为进入前的状态,AI 按计划执行。
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 权限的自动收窄与恢复
|
||||
|
||||
计划模式的精妙之处在于**自动改变权限上下文**:
|
||||
### 进入:`prepareContextForPlanMode()`
|
||||
|
||||
- 进入时:系统自动保存当前权限模式,切换为"只读"
|
||||
- 退出时:系统自动恢复之前的权限模式
|
||||
`EnterPlanModeTool.call()`(第 77 行)的核心逻辑:
|
||||
|
||||
AI 和用户都不需要手动调整权限设置。
|
||||
```typescript
|
||||
// 1. 记录转换状态(保存之前的模式)
|
||||
handlePlanModeTransition(currentMode, 'plan')
|
||||
|
||||
// 2. 切换权限上下文为 plan 模式
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
toolPermissionContext: applyPermissionUpdate(
|
||||
prepareContextForPlanMode(prev.toolPermissionContext),
|
||||
{ type: 'setMode', mode: 'plan', destination: 'session' },
|
||||
),
|
||||
}))
|
||||
```
|
||||
|
||||
`prepareContextForPlanMode()`(`src/utils/permissions/permissionSetup.ts`)做了什么:
|
||||
- 创建新的 `ToolPermissionContext`,`mode` 设为 `'plan'`
|
||||
- 在 plan 模式下,工具的 `isReadOnly()` 检查成为唯一准入条件
|
||||
- 如果用户的默认模式是 `'auto'`,还会激活 classifier 的副作用
|
||||
|
||||
### 退出:权限恢复 + Prompt-based 权限
|
||||
|
||||
`ExitPlanModeV2Tool` 的退出逻辑做了两件关键的事:
|
||||
|
||||
**1. 恢复权限模式**
|
||||
|
||||
通过 `handlePlanModeTransition()` 和 `applyPermissionUpdate()` 恢复到进入前的模式。
|
||||
|
||||
**2. 注入 Prompt-based 权限**
|
||||
|
||||
这是 Plan Mode 最精妙的设计——AI 可以在计划中声明它需要执行的命令类别:
|
||||
|
||||
```typescript
|
||||
// ExitPlanModeV2Tool 的 inputSchema
|
||||
allowedPrompts: z.array(z.object({
|
||||
tool: z.enum(['Bash']),
|
||||
prompt: z.string().describe('Semantic description, e.g. "run tests"'),
|
||||
})).optional()
|
||||
```
|
||||
|
||||
当 AI 提交计划时,如果声明了 `allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }]`,用户批准后,"run tests" 这类 Bash 命令会被自动放行——不再需要逐个确认。
|
||||
|
||||
## 计划文件的持久化
|
||||
|
||||
计划内容被写入磁盘文件(由 `getPlanFilePath()` 确定路径),这与简单的"AI 说一段话然后开始执行"有本质区别:
|
||||
|
||||
1. `ExitPlanModeV2Tool` 的 `normalizeToolInput` 从磁盘读取计划内容,注入到 `input.plan` 和 `input.planFilePath`
|
||||
2. 计划文件是用户**可编辑**的——用户可以在审批前修改 AI 的方案
|
||||
3. `planWasEdited` 字段标记用户是否修改了计划,影响后续的 tool_result 回显
|
||||
4. `persistFileSnapshotIfRemote()` 在远程场景下保存文件快照
|
||||
|
||||
## Teammate 场景下的计划审批
|
||||
|
||||
在 Agent Swarms(`isAgentSwarmsEnabled()`)模式下,计划审批有额外的协作流程:
|
||||
|
||||
```typescript
|
||||
// 如果是 Teammate 角色
|
||||
if (isTeammate()) {
|
||||
// 发送计划到 Team Leader 的 mailbox 等待审批
|
||||
const requestId = generateRequestId()
|
||||
writeToMailbox(getTeamName(), {
|
||||
type: 'plan_approval_request',
|
||||
plan, requestId, ...
|
||||
})
|
||||
// 返回 awaitingLeaderApproval: true
|
||||
// Team Leader 审批后通过 mailbox 通知 Teammate
|
||||
}
|
||||
```
|
||||
|
||||
这意味着在蜂群模式下,计划可能不是由直接用户审批,而是由 Team Leader 审批。
|
||||
|
||||
## 什么时候该用计划模式
|
||||
|
||||
| 场景 | 是否需要计划 |
|
||||
|------|-------------|
|
||||
| 修复一个明确的 typo | 不需要,直接修 |
|
||||
| 添加一个简单的函数 | 不需要 |
|
||||
| 重构一个大模块 | 需要——先搞清楚影响范围 |
|
||||
| 涉及多个文件的 feature | 需要——先统一方案 |
|
||||
| 不确定该怎么做 | 需要——让 AI 先调研 |
|
||||
`EnterPlanModeTool` 的 Prompt(`src/tools/EnterPlanModeTool/prompt.ts`)定义了两套触发标准——外部版本更积极(鼓励规划),内部版本更克制(仅在真正模糊时使用):
|
||||
|
||||
| 场景 | 外部版本 | 内部版本 |
|
||||
|------|---------|---------|
|
||||
| 修复 typo | 跳过 | 跳过 |
|
||||
| 添加删除按钮 | **进入**(涉及多个文件) | **跳过**(路径明确) |
|
||||
| 重构认证系统 | **进入** | **进入**(高影响重构) |
|
||||
| "开始做 X" | — | **跳过**(直接开始) |
|
||||
| 架构决策(Redis vs 内存缓存) | **进入** | **进入**(真正模糊) |
|
||||
|
||||
## 计划模式 + 任务系统
|
||||
|
||||
计划模式通常与任务系统配合使用:
|
||||
|
||||
1. 在计划模式中,AI 把实施步骤创建为任务列表
|
||||
1. 在计划模式中,AI 把实施步骤创建为任务列表(`TodoWrite`)
|
||||
2. 用户审批计划(包含任务列表)
|
||||
3. 退出计划模式后,AI 按任务列表逐项执行
|
||||
4. 用户可以通过任务列表追踪进度
|
||||
|
||||
## 完整生命周期
|
||||
|
||||
```
|
||||
用户: "重构这个模块"
|
||||
↓
|
||||
AI 判断需要规划 → 调用 EnterPlanModeTool
|
||||
↓ 用户审批(Ask 对话框)
|
||||
handlePlanModeTransition(default, 'plan') // 保存 default
|
||||
prepareContextForPlanMode() // 创建只读上下文
|
||||
↓
|
||||
AI 使用 Read/Grep/Glob/Agent 探索代码库
|
||||
↓ (可能 10+ 轮只读工具调用)
|
||||
AI 形成方案 → 调用 ExitPlanModeV2Tool({
|
||||
allowedPrompts: [
|
||||
{ tool: 'Bash', prompt: 'run tests' },
|
||||
{ tool: 'Bash', prompt: 'install dependencies' }
|
||||
]
|
||||
})
|
||||
↓ 用户审批计划(可编辑计划文件)
|
||||
恢复权限模式 → 注入 prompt-based 权限
|
||||
↓
|
||||
AI 使用全部工具执行计划,"run tests" 等命令自动放行
|
||||
```
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
---
|
||||
title: "工具系统设计 - AI 如何从说到做"
|
||||
description: "深入理解 Claude Code 的 Tool 抽象设计。每个工具是标准化的能力单元,包含名称、描述、输入 Schema 和执行函数。了解工具注册与调用机制。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "工具调用"]
|
||||
description: "深入理解 Claude Code 的 Tool 抽象设计:从类型定义、注册机制、调用链路到渲染系统,揭示 50+ 内置工具如何通过统一的 Tool 接口协同工作。"
|
||||
keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "buildTool", "getTools"]
|
||||
---
|
||||
|
||||
{/* 本章目标:让读者理解 Tool 抽象的设计思想 */}
|
||||
{/* 本章目标:基于 src/Tool.ts 和 src/tools.ts 揭示工具系统的完整架构 */}
|
||||
|
||||
## AI 为什么需要工具
|
||||
|
||||
@ -14,33 +14,161 @@ keywords: ["工具系统", "Tool 抽象", "AI 工具", "function calling", "工
|
||||
|
||||
工具是 AI 的双手。AI 说"我想读这个文件",工具系统替它真正去读;AI 说"我想执行这条命令",工具系统替它真正去跑。
|
||||
|
||||
## 一个工具长什么样
|
||||
## Tool 类型:35 个字段的统一接口
|
||||
|
||||
每个工具都是一个标准化的"能力单元",包含四个要素:
|
||||
所有工具都实现 `src/Tool.ts:362` 的 `Tool<Input, Output, Progress>` 类型。这不是一个 class,而是一个包含 35+ 字段的**结构化类型**(structural typing),任何满足该接口的对象就是一个工具:
|
||||
|
||||
| 要素 | 说明 | 示例(FileRead 工具) |
|
||||
|------|------|----------------------|
|
||||
| **名称** | 工具的唯一标识 | `Read` |
|
||||
| **描述** | 告诉 AI 这个工具能做什么(AI 据此决定是否使用) | "读取本地文件系统中的文件" |
|
||||
| **参数定义** | 工具接受什么输入 | `file_path`(必填)、`offset`、`limit` |
|
||||
| **执行逻辑** | 工具被调用时实际做什么 | 读取文件内容并返回 |
|
||||
### 核心四要素
|
||||
|
||||
## AI 如何选择工具
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | `string` | 唯一标识(如 `Read`、`Bash`、`Agent`) |
|
||||
| `description()` | `(input) => Promise<string>` | **动态描述**——根据输入参数返回不同描述(如 `Execute skill: ${skill}`) |
|
||||
| `inputSchema` | `z.ZodType` | Zod schema,定义参数类型和校验规则 |
|
||||
| `call()` | `(args, context, canUseTool, parentMessage, onProgress?) => Promise<ToolResult<Output>>` | 执行函数 |
|
||||
|
||||
AI 不是从下拉菜单里选工具——它是根据**工具描述**和**当前任务**自主决策的:
|
||||
### 注册与发现
|
||||
|
||||
1. 系统把所有可用工具的名称、描述、参数告诉 AI
|
||||
2. AI 在思考过程中决定"我需要用某个工具"
|
||||
3. AI 生成一个结构化的工具调用请求(工具名 + 参数)
|
||||
4. 系统执行工具,将结果返回给 AI
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `aliases` | 别名数组(向后兼容重命名) |
|
||||
| `searchHint` | 3-10 词的短语,供 ToolSearch 关键词匹配(如 `"jupyter"` for NotebookEdit) |
|
||||
| `shouldDefer` | 是否延迟加载(配合 ToolSearch 按需加载) |
|
||||
| `alwaysLoad` | 永不延迟加载(如 SkillTool 必须在 turn 1 可见) |
|
||||
| `isEnabled()` | 运行时开关(如 PowerShellTool 检查平台) |
|
||||
|
||||
<Note>
|
||||
工具描述的质量直接影响 AI 的决策准确性。一段好的描述不仅说明"能做什么",还说明"什么时候该用、什么时候不该用"。
|
||||
</Note>
|
||||
### 安全与权限
|
||||
|
||||
## 50+ 内置工具
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `validateInput()` | 输入校验(在权限检查之前),返回 `ValidationResult` |
|
||||
| `checkPermissions()` | 权限检查(在校验之后),返回 `PermissionResult` |
|
||||
| `isReadOnly()` | 是否只读操作(影响权限模式) |
|
||||
| `isDestructive()` | 是否不可逆操作(删除、覆盖、发送) |
|
||||
| `isConcurrencySafe()` | 相同输入是否可以并行执行 |
|
||||
| `preparePermissionMatcher()` | 为 Hook 的 `if` 条件准备模式匹配器 |
|
||||
| `interruptBehavior()` | 用户中断时的行为:`'cancel'` 或 `'block'` |
|
||||
|
||||
Claude Code 内置了覆盖软件开发全流程的工具集:
|
||||
### 输出与渲染
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `maxResultSizeChars` | 结果字符上限(超出则持久化到磁盘,如 `100_000`) |
|
||||
| `mapToolResultToToolResultBlockParam()` | 将 Output 映射为 API 格式的 `ToolResultBlockParam` |
|
||||
| `renderToolResultMessage()` | React 组件渲染工具结果到终端 |
|
||||
| `renderToolUseMessage()` | React 组件渲染工具调用过程 |
|
||||
| `backfillObservableInput()` | 在不破坏 prompt cache 的前提下回填可观察字段 |
|
||||
|
||||
### 上下文与 Prompt
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `prompt()` | 返回该工具的详细使用说明,注入到 System Prompt |
|
||||
| `outputSchema` | 输出 Zod schema(用于类型安全的结果处理) |
|
||||
| `getPath()` | 提取操作的文件路径(用于权限匹配和 UI 显示) |
|
||||
|
||||
## 工具注册:`getTools()` 的分层组装
|
||||
|
||||
`src/tools.ts` 的 `getAllBaseTools()`(第 191 行)是工具注册的核心:
|
||||
|
||||
```
|
||||
固定工具(始终可用):
|
||||
AgentTool, BashTool, FileReadTool, FileEditTool, FileWriteTool,
|
||||
NotebookEditTool, WebFetchTool, WebSearchTool, TodoWriteTool,
|
||||
AskUserQuestionTool, SkillTool, EnterPlanModeTool, ExitPlanModeV2Tool,
|
||||
TaskOutputTool, BriefTool, ListMcpResourcesTool, ReadMcpResourceTool
|
||||
|
||||
条件工具(运行时检查):
|
||||
← hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]
|
||||
← isTodoV2Enabled() ? V2 Tasks : []
|
||||
← isWorktreeModeEnabled() ? Worktree : []
|
||||
← isAgentSwarmsEnabled() ? Teams : []
|
||||
← isToolSearchEnabled() ? ToolSearch: []
|
||||
← isPowerShellToolEnabled() ? PowerShell: []
|
||||
|
||||
Feature-flag 工具:
|
||||
← feature('COORDINATOR_MODE') ? [coordinatorMode tools]
|
||||
← feature('KAIROS') ? [SleepTool, SendUserFileTool, ...]
|
||||
← feature('WEB_BROWSER_TOOL') ? [WebBrowserTool]
|
||||
← feature('HISTORY_SNIP') ? [SnipTool]
|
||||
|
||||
Ant-only 工具:
|
||||
← process.env.USER_TYPE === 'ant' ? [REPLTool, ConfigTool, TungstenTool]
|
||||
```
|
||||
|
||||
`getTools()`(第 269 行)在 `getAllBaseTools()` 基础上应用权限过滤:
|
||||
|
||||
```typescript
|
||||
export const getTools = (permissionContext): Tools => {
|
||||
const base = getAllBaseTools()
|
||||
// 过滤 blanket deny 规则命中的工具
|
||||
return filterToolsByDenyRules(base, permissionContext)
|
||||
}
|
||||
```
|
||||
|
||||
**关键设计**:工具列表在每次 API 调用时组装(而非全局缓存),因为 `isEnabled()` 的结果可能随运行时状态变化。
|
||||
|
||||
## `buildTool()` 工厂函数
|
||||
|
||||
大多数工具通过 `buildTool()` 创建(`src/Tool.ts:721`),它是一个类型安全的构造器:
|
||||
|
||||
```typescript
|
||||
export const BashTool: Tool<...> = buildTool({
|
||||
name: 'Bash',
|
||||
inputSchema: lazySchema(() => z.object({command: z.string(), ...})),
|
||||
// ...其他字段
|
||||
}) satisfies ToolDef<Input, Output, Progress>
|
||||
```
|
||||
|
||||
`satisfies ToolDef` 确保编译时类型检查,`lazySchema` 延迟 Zod schema 解析(避免循环依赖)。
|
||||
|
||||
## 工具调用的完整链路
|
||||
|
||||
从 AI 发出 `tool_use` 到结果回传,经过以下步骤:
|
||||
|
||||
```
|
||||
1. API 返回 tool_use block(包含 name + input)
|
||||
↓
|
||||
2. StreamingToolExecutor.addTool() / runTools()
|
||||
↓
|
||||
3. findToolByName() 查找工具
|
||||
↓
|
||||
4. validateInput() — 输入校验
|
||||
↓ 失败 → 返回错误 tool_result
|
||||
5. canUseTool() — 权限 UI(Ask 模式下弹确认)
|
||||
↓ 拒绝 → 返回拒绝 tool_result
|
||||
6. checkPermissions() — 规则匹配
|
||||
↓
|
||||
7. call() — 执行实际操作
|
||||
↓ onProgress() 回调实时更新 UI
|
||||
8. 返回 ToolResult<Output>
|
||||
↓
|
||||
9. mapToolResultToToolResultBlockParam() — 转为 API 格式
|
||||
↓
|
||||
10. 新消息追加到对话 → 进入下一轮迭代
|
||||
```
|
||||
|
||||
## 工具结果的预算控制
|
||||
|
||||
每个工具通过 `maxResultSizeChars` 声明输出上限:
|
||||
|
||||
- **BashTool**:`30_000`(命令输出)
|
||||
- **SkillTool**:`100_000`(技能执行结果)
|
||||
- **FileReadTool**:`Infinity`(文件内容不走持久化,避免 Read→file→Read 循环)
|
||||
|
||||
超出上限的结果被 `applyToolResultBudget()`(`src/utils/toolResultStorage.ts`)持久化到磁盘,AI 只收到预览 + 文件路径。
|
||||
|
||||
## MCP 工具的扩展
|
||||
|
||||
MCP Server 提供的工具通过 `mcpInfo` 字段标记来源:
|
||||
|
||||
```typescript
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
```
|
||||
|
||||
MCP 工具的 `inputJSONSchema` 直接使用 JSON Schema(而非 Zod),因为 schema 来自远程协议。它们通过 `filterToolsByDenyRules()` 支持 `mcp__server` 前缀的 blanket deny 规则。
|
||||
|
||||
## 50+ 内置工具全景
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card title="文件操作" icon="file">
|
||||
@ -53,23 +181,26 @@ Claude Code 内置了覆盖软件开发全流程的工具集:
|
||||
Agent / SendMessage / AskUserQuestion
|
||||
</Card>
|
||||
<Card title="任务追踪" icon="list-check">
|
||||
TaskCreate / TaskUpdate / TaskList / TaskGet
|
||||
TaskCreate / TaskUpdate / TaskList / TaskGet / TaskOutput / TaskStop
|
||||
</Card>
|
||||
<Card title="Web 能力" icon="globe">
|
||||
WebFetch / WebSearch
|
||||
WebFetch / WebSearch / WebBrowser
|
||||
</Card>
|
||||
<Card title="规划与版本" icon="map">
|
||||
EnterPlanMode / Worktree / TodoWrite
|
||||
EnterPlanMode / ExitPlanMode / Worktree / TodoWrite / ToolSearch
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 工具的可视化渲染
|
||||
|
||||
工具不仅能"做事",还能"展示"。每个工具可以定义自己的 UI 渲染方式:
|
||||
工具不仅能"做事",还能"展示"。每个工具通过 React 组件定义 UI 渲染:
|
||||
|
||||
- **FileEdit** → 在终端里展示语法高亮的 diff 视图
|
||||
- **Bash** → 实时显示命令输出,带进度指示
|
||||
- **FileEdit** → `renderToolResultMessage` 展示语法高亮的 diff 视图
|
||||
- **Bash** → 实时显示命令输出(通过 `onProgress` 回调),带进度指示
|
||||
- **Grep** → 高亮匹配结果,显示文件路径和行号链接
|
||||
- **Agent** → 显示子 Agent 的进度条和状态
|
||||
- **SkillTool** → 渲染技能执行进度
|
||||
|
||||
这让用户能直观地看到"AI 在做什么、做到哪了"。
|
||||
`isSearchOrReadCommand()` 允许工具声明自己是搜索/读取操作,触发 UI 的折叠显示模式(避免大量搜索结果占满屏幕)。
|
||||
|
||||
`getActivityDescription()` 为 spinner 提供活动描述(如 "Reading src/foo.ts"、"Running bun test"),替代默认的工具名显示。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user