“任何足够先进的技术都与魔法无异。” —— 阿瑟·克拉克
当你在终端中输入 claude "帮我重构这个函数",然后看着屏幕上的文字流动——它读取文件、思考方案、编辑代码、运行测试、发现错误、再次修改——这一切看似自然,却隐含着一个深刻的架构问题:这个程序,究竟是什么?
它不是传统意义上的 CLI 工具。grep 不会在搜索失败后决定换一个关键词再试;sed 不会在替换出错后自行回退并尝试另一种方案。Claude Code 做的事情本质上不同——它在循环中推理,在不确定性中决策,在反馈中调整行为。
这就是 Agent。
1.1 当我们说"AI Agent"时,我们在说什么
传统 CLI 工具:确定性的管道
Unix 哲学塑造了我们对命令行工具的基本认知:一个程序接受输入,执行固定的计算,产生输出。整个过程是确定性的——相同的输入必然产生相同的输出,执行路径在编写代码时就已完全确定。
输入 → 固定算法 → 输出
这是一种开环系统(Open-loop System)。程序不观察自己行为的后果,不根据执行结果调整策略。rm -rf 不会在删除一半文件后想:“也许我该先备份。”
AI Agent:不确定性中的自主决策者
Agent 的本质区别在于引入了一个闭环:它的输出会变成下一步决策的输入。
输入 → 推理 → 行动 → 观察结果 → 推理 → 行动 → ... → 最终输出
这个循环有三个关键特征:
行为不可完全预测。同样的用户请求,Agent 可能选择不同的工具组合、不同的执行顺序。这不是缺陷,而是面对复杂任务时的合理应对——正如同一位程序员面对相同需求,每次编码过程也不会完全一致。
执行路径由运行时决定。传统程序的控制流在编译时已知;Agent 的控制流在运行时由 LLM 动态生成。每一次工具调用都是 LLM 基于当前上下文做出的决策,而非预编程的固定序列。
具备反馈修正能力。当一个工具调用失败(文件不存在、命令报错、测试不通过),Agent 能够观察错误信息,推理失败原因,并调整后续行为。这种闭环反馈机制是 Agent 与简单的"LLM + 工具调用"之间的本质分水岭。
为什么 Claude Code 选择 Agent 架构
软件工程任务天然适合 Agent 架构,原因在于任务分解的不确定性。
当用户说"帮我修复这个 bug"时,没有任何算法能预先确定需要读取哪些文件、修改哪些代码、运行哪些测试。这个过程本质上是一个探索-验证循环:读代码以理解问题,提出修改假设,实施修改,运行测试验证,根据结果决定下一步。
这与人类程序员的工作方式是同构的。Claude Code 的 Agent 架构,本质上是将人类程序员的工作循环形式化为一个算法结构。
1.2 算法思想:感知-推理-行动循环
PRA 循环的基本模型
Agent 的核心算法可以抽象为感知-推理-行动(Perception-Reasoning-Action,PRA)循环。这个模型源自经典人工智能和机器人学,但在 LLM Agent 的语境下获得了新的实现方式:
- 感知(Perception):收集当前环境信息。对 Claude Code 而言,这包括用户输入、文件系统状态、工具执行结果、上下文窗口中的历史对话。
- 推理(Reasoning):基于感知到的信息做出决策。Claude Code 将整个上下文(系统提示词 + 对话历史 + 工具结果)发送给 LLM,LLM 返回下一步行动方案。
- 行动(Action):执行决策。当 LLM 的响应包含工具调用请求时,系统执行相应工具(读文件、写文件、运行命令等),并将结果注入上下文。
Claude Code 的 PRA 循环实现
在 Claude Code 的源码中,这个循环体现为 query.ts 中的核心 queryLoop 函数。以下是其精简后的算法骨架:
函数 queryLoop(消息列表, 系统提示词, 工具集合):
状态 = 初始化(消息列表)
循环:
// ====== 感知阶段 ======
待查询消息 = 预处理(状态.消息列表) // 压缩、裁剪上下文
完整提示词 = 组装(系统提示词, 用户上下文, 待查询消息)
// ====== 推理阶段 ======
工具调用列表 = []
需要后续处理 = false
对于 LLM流式响应 中的每条消息:
如果 消息包含工具调用:
工具调用列表.追加(消息.工具调用)
需要后续处理 = true
输出 消息 // 流式输出给用户
// ====== 决策分支 ======
如果 不需要后续处理:
// 终止条件:LLM 认为任务完成,未请求任何工具
返回 { 原因: "completed" }
// ====== 行动阶段 ======
工具结果 = 执行工具(工具调用列表)
// ====== 反馈注入 ======
状态.消息列表 = [待查询消息, 助手消息, 工具结果]
// ====== 终止条件检查 ======
如果 超过最大轮次:
返回 { 原因: "max_turns" }
如果 用户中断:
返回 { 原因: "aborted" }
// 进入下一轮循环
这个伪代码揭示了 Agent 循环的核心结构。让我们逐一分析其中的关键设计决策。
终止条件设计:Agent 何时停下来
一个无限循环必须有明确的终止条件,否则就是一个 bug。Claude Code 的 Agent 循环定义了多种终止条件,每种对应不同的退出场景:
| 终止原因 | 触发条件 | 语义 |
|---|---|---|
completed | LLM 响应不包含工具调用 | 任务自然完成 |
max_turns | 循环次数超过上限 | 安全阀门——防止无限循环 |
aborted | 用户发送中断信号 | 人类主动干预 |
prompt_too_long | 上下文超出模型窗口 | 资源限制 |
hook_stopped | 外部钩子阻止继续 | 策略拦截 |
model_error | API 调用失败 | 系统故障 |
最值得注意的是 completed 条件。在源码中,它的判断逻辑非常简洁:
如果 不需要后续处理: // needsFollowUp === false
返回 { 原因: "completed" }
这意味着终止决策权在 LLM 手中。当 LLM 判断任务已完成,它会生成一个不包含任何工具调用的纯文本响应,从而触发循环退出。这是一个优雅的设计——Agent 不需要外部的"完成检测器",LLM 本身就是终止条件的判断者。
而 max_turns 则是工程上的安全保障。无论 LLM 的判断如何,循环不会无限执行。在源码中:
if (maxTurns && nextTurnCount > maxTurns) {
return { reason: 'max_turns', turnCount: nextTurnCount }
}
这体现了防御性设计的原则:在信任 LLM 判断力的同时,设置不可逾越的硬性边界。
算法复杂度分析
Agent 循环的复杂度不同于传统算法分析。传统分析关注时间和空间复杂度;Agent 循环的关键指标是:
- 轮次复杂度(Turn Complexity):完成任务所需的 PRA 循环次数。这由任务性质决定,简单任务可能一轮完成(直接回答问题),复杂任务可能需要数十轮(读取多个文件、多次修改、反复测试)。
- 上下文增长率:每轮循环增加的上下文量。每轮会增加一条助手消息和若干工具结果,上下文窗口线性增长。
- 单轮延迟:每轮 PRA 循环的耗时,主要取决于 LLM 推理延迟和工具执行延迟。
Claude Code 通过自动压缩(Auto-Compact)机制管理上下文增长。当上下文接近模型窗口上限时,系统会自动总结历史对话,用压缩后的摘要替换原始消息。这使得 Agent 可以在有限的上下文窗口内处理需要大量轮次的复杂任务。
“有限自主性"的设计哲学
Claude Code 的 Agent 不是一个完全自主的系统。它在设计上引入了多层人类监督机制:
第一层:权限控制。每个工具调用都要经过权限检查。源码中,Tool 类型定义了 checkPermissions 方法和 isReadOnly、isDestructive 等属性,系统据此决定是否需要用户确认:
工具权限检查流程:
如果 工具是只读的:
通常自动允许
如果 工具是破坏性的:
必须获得用户显式确认
如果 权限模式是 "plan":
所有写操作需要确认
第二层:轮次限制。maxTurns 参数确保 Agent 不会无限运行。
第三层:用户中断。用户可以随时中断 Agent 的执行,系统会优雅地停止当前工具调用并退出循环。
第四层:成本控制。maxBudgetUsd 参数限制单次会话的最大 API 开销。
这种设计哲学可以称为有限自主性(Bounded Autonomy):Agent 在给定的自由度范围内自主决策,但这个范围由人类通过配置和实时交互来界定。它既不是需要人类逐步指导的简单工具,也不是脱离人类控制的自主系统,而是介于两者之间的受监督的自主体。
1.3 架构图解:Claude Code 的四层结构
Claude Code 的整体架构可以划分为四个层次,每层对应 PRA 循环中的一个环节:
┌─────────────────────────────────────────────────────────────┐
│ 感知层 (Perception Layer) │
│ │
│ ┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ │
│ │ CLI 输入 │ │ 文件系统状态 │ │ 工具执行结果 │ │
│ │ (main.tsx) │ │ (FileState) │ │ (ToolResult) │ │
│ └──────┬──────┘ └───────┬───────┘ └────────┬─────────┘ │
│ └─────────────────┼───────────────────┘ │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ 推理层 (Reasoning Layer) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ QueryEngine │ │
│ │ ┌──────────────┐ ┌──────────┐ ┌───────────────┐ │ │
│ │ │ 系统提示词组装 │ │ 上下文管理 │ │ LLM API 调用 │ │ │
│ │ │ (context) │ │ (compact) │ │ (claude.ts) │ │ │
│ │ └──────────────┘ └──────────┘ └───────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────┴─────┐ │
│ │ query() │ ← 核心 PRA 循环 │
│ └─────┬─────┘ │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ 行动层 (Action Layer) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 工具编排 (toolOrchestration) │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Bash │ │ Read │ │ Edit │ │ Glob │ ... │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ │ │ │
│ │ 并发安全的工具 → 并行执行 │ │
│ │ 非并发安全的工具 → 串行执行 │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
├─────────────────────────────────────────────────────────────┤
│ 反馈层 (Feedback Layer) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 工具结果 → 转换为消息 → 注入消息列表 → 进入下一轮推理 │ │
│ │ │ │
│ │ 权限拒绝 → 生成错误消息 → 注入消息列表 → LLM 重新决策 │ │
│ │ │ │
│ │ 附件系统 → 文件变更通知 → 上下文增强 → 辅助 LLM 决策 │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
感知层:多源信息汇聚
感知层负责收集 Agent 做出决策所需的一切信息。Claude Code 的感知源包括:
- 用户输入:通过 CLI 界面接收文本指令,由
main.tsx入口处理。 - 文件系统状态:通过
FileStateCache跟踪已读文件的内容和状态变化。 - 工具执行结果:每个工具调用返回的
ToolResult,包含执行结果和可能的上下文修改。 - 系统上下文:包括当前工作目录、Git 状态、项目结构等环境信息。
推理层:从上下文到决策
推理层以 QueryEngine 为核心。QueryEngine 负责管理整个对话的生命周期——维护消息历史、组装系统提示词、调用 LLM API、处理流式响应。
QueryEngine 的设计遵循一个会话一个实例的原则。源码中的注释明确指出:
“One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation.”
这意味着状态(消息历史、文件缓存、使用量统计)在同一会话的多轮对话间持续存在,但不会跨会话泄漏。
实际的推理循环由 query() 函数承载。QueryEngine.submitMessage() 负责会话级别的上下文准备(提示词组装、用户输入处理、权限设置),然后将组装好的参数传递给 query() 执行 PRA 循环。
行动层:工具编排的艺术
行动层的核心是工具编排(Tool Orchestration)。当 LLM 在一次响应中请求多个工具调用时,系统需要决定如何执行它们。
Claude Code 采用了一种精巧的分区执行策略:
函数 执行工具(工具调用列表):
批次列表 = 分区(工具调用列表)
对于每个批次:
如果 批次是并发安全的:
并行执行批次中的所有工具(上限10并发)
否则:
串行执行批次中的每个工具
分区规则基于工具的 isConcurrencySafe 属性:连续的并发安全工具被归入同一批次并行执行,非并发安全工具则独占一个批次串行执行。例如,多个 Read 操作可以并行,但 Edit 操作必须串行——因为后一个编辑可能依赖前一个编辑的结果。
反馈层:闭环的关键
反馈层是将行动层的输出转化为感知层的输入的桥梁。工具执行结果通过 mapToolResultToToolResultBlockParam 方法转换为 LLM 可理解的消息格式,然后注入消息列表。
反馈层还处理一些特殊情况:
- 权限拒绝:当用户拒绝某个工具调用时,拒绝信息作为错误消息注入上下文,LLM 可以据此调整策略(例如,改用只读操作来先确认变更范围)。
- 文件变更通知:工具执行后,如果有文件被修改,系统会生成变更附件注入上下文,帮助 LLM 了解当前文件状态。
- 上下文压缩:当累积的消息超过阈值时,自动压缩机制在反馈注入前对历史消息进行摘要压缩。
1.4 源码印证:核心循环的实现
现在让我们从源码中提取关键结构,以精简的伪代码形式展示 Claude Code Agent 循环的实际实现。
QueryEngine:会话管理器
类 QueryEngine:
属性:
配置: QueryEngineConfig
消息列表: Message[]
中断控制器: AbortController
文件缓存: FileStateCache
累计用量: Usage
方法 submitMessage(用户输入):
// 阶段一:上下文准备
系统提示词 = 组装系统提示词(配置.工具集, 配置.模型, 配置.MCP客户端)
处理后的输入 = 处理用户输入(用户输入)
消息列表.追加(处理后的输入.消息)
// 阶段二:进入 PRA 循环
对于 query(消息列表, 系统提示词, ...) 中的每条消息:
如果 消息是助手消息:
累积用量
输出给调用方
如果 消息是用户消息:
轮次计数++
输出给调用方
如果 消息是流事件:
处理使用量更新
// 阶段三:生成结果报告
输出最终结果(用量, 成本, 轮次数)
这里有一个重要的设计观察:QueryEngine 本身不包含 PRA 循环逻辑,它将核心循环委托给 query() 函数。QueryEngine 的职责是会话管理——它在 query() 的循环之外维护跨轮次的持久状态,处理会话级别的关注点(权限追踪、转录记录、用量统计)。
query():PRA 循环的心脏
函数 queryLoop(参数):
状态 = {
消息列表: 参数.消息列表,
工具上下文: 参数.工具上下文,
轮次: 1,
最大输出恢复次数: 0,
}
无限循环:
// ──── 感知:上下文预处理 ────
待查询消息 = 应用工具结果预算(状态.消息列表)
待查询消息 = 裁剪压缩(待查询消息) // snip + microcompact
待查询消息 = 自动压缩如果需要(待查询消息) // autocompact
// ──── 推理:调用 LLM ────
工具调用块列表 = []
需要后续处理 = false
对于 调用模型(待查询消息, 系统提示词) 中的流式消息:
如果 消息包含tool_use块:
工具调用块列表.追加(tool_use块)
需要后续处理 = true
// 流式工具执行:在流式接收的同时启动工具
如果 启用流式工具执行:
流式执行器.添加工具(tool_use块)
输出 消息(除非被暂扣用于错误恢复)
// ──── 终止判断 ────
如果 不需要后续处理:
执行停止钩子检查
返回 { 原因: "completed" }
// ──── 行动:执行工具 ────
对于 执行工具(工具调用块列表) 中的更新:
输出 更新.消息
更新工具上下文
// ──── 反馈:组装下一轮输入 ────
获取附件消息(文件变更通知, 内存预取, 队列命令)
// ──── 安全检查 ────
如果 用户中断: 返回 { 原因: "aborted" }
如果 超过最大轮次: 返回 { 原因: "max_turns" }
// ──── 状态转移 ────
状态 = {
消息列表: [待查询消息, 助手消息, 工具结果, 附件],
轮次: 轮次 + 1,
转换原因: "next_turn",
}
几个值得深入的实现细节:
流式工具执行。传统的 Agent 循环是串行的:等待 LLM 完整响应 → 执行工具 → 注入结果。Claude Code 通过 StreamingToolExecutor 实现了流水线并行——在 LLM 还在生成后续内容时,已经可以开始执行先收到的工具调用。这显著降低了端到端延迟。
状态机式的循环控制。循环的每次迭代不是简单的"下一轮”,而是一个带有明确转换原因(transition reason)的状态转移。源码中定义了多种转换类型:next_turn(正常下一轮)、reactive_compact_retry(压缩后重试)、max_output_tokens_recovery(输出截断恢复)等。这使得循环的行为可追踪、可调试。
多层错误恢复。当 LLM 响应被截断(max_output_tokens)时,系统不是直接失败,而是注入一条恢复指令让 LLM 从断点继续,最多重试 3 次。当上下文过长时,系统先尝试上下文折叠(Context Collapse),再尝试反应式压缩(Reactive Compact),最后才报错。这种层叠式恢复策略极大地提高了 Agent 的鲁棒性。
Tool:行动的原子单元
类型 Tool:
名称: string
输入模式: ZodSchema // 结构化输入定义
// ──── 能力声明 ────
是否启用(): boolean
是否只读(输入): boolean // 影响并发策略
是否并发安全(输入): boolean // 影响执行编排
是否破坏性(输入): boolean // 影响权限要求
// ──── 执行生命周期 ────
验证输入(输入, 上下文): 验证结果
检查权限(输入, 上下文): 权限结果
调用(输入, 上下文): 工具结果
// ──── 结果转换 ────
映射结果到消息块(内容, ID): ToolResultBlockParam
Tool 类型的设计体现了声明式能力描述的思想。每个工具不仅实现执行逻辑,还通过 isReadOnly、isConcurrencySafe、isDestructive 等方法向系统声明自己的特性。编排系统(toolOrchestration)基于这些声明来决定执行策略,而不需要硬编码对每个工具的特殊处理。
buildTool 工厂函数为工具定义提供了合理的默认值:默认启用、默认非并发安全(保守策略)、默认非只读(安全保守)。这种默认安全(Fail-Closed)的设计意味着新添加的工具在没有显式声明的情况下会采用最保守的执行策略。
工具编排:并发与串行的平衡
函数 分区工具调用(工具列表):
批次列表 = []
当前批次 = null
对于每个工具调用:
是否并发安全 = 查找工具(工具调用.名称).是否并发安全(工具调用.输入)
如果 是否并发安全:
如果 当前批次 不是并发批次:
当前批次 = 新建并发批次()
批次列表.追加(当前批次)
当前批次.追加(工具调用)
否则:
当前批次 = 新建串行批次(工具调用)
批次列表.追加(当前批次)
返回 批次列表
函数 执行工具(工具调用列表):
对于每个批次 in 分区工具调用(工具调用列表):
如果 批次.是并发的:
并行执行(批次.工具列表, 最大并发数=10)
否则:
串行执行(批次.工具列表)
这个编排算法的精妙之处在于它保持了 LLM 指定的工具顺序语义。LLM 输出的工具调用是有顺序的——先读文件再编辑是有意义的。分区算法尊重这个顺序:它只会将连续的并发安全工具归入同一批次,绝不会跨越非并发安全工具来合并批次。
1.5 思考题
1. 终止条件的哲学困境
Claude Code 将"任务是否完成"的判断权交给了 LLM 本身——当 LLM 不再请求工具调用时,循环终止。这种设计有什么潜在问题?如果 LLM 过早停止(任务未完成就不再调用工具)或过晚停止(任务已完成仍反复调用工具),系统该如何检测和应对?请思考除了 max_turns 之外,还有哪些终止条件的设计方案。
2. 反馈的粒度与质量
Agent 的能力上限很大程度上取决于反馈的质量。当一个 Bash 命令执行失败时,应该将完整的 stderr 输出反馈给 LLM,还是只反馈一个简洁的错误摘要?信息过多可能浪费上下文窗口,信息过少可能导致 LLM 无法正确诊断问题。Claude Code 通过 maxResultSizeChars 对工具结果设置了大小上限,超出部分持久化到文件并提供预览。请思考这种截断策略的利弊,以及是否存在更好的方案。
3. 并发安全的粒度
Claude Code 的工具并发策略基于工具级别的 isConcurrencySafe 声明。但实际上,并发安全性可能取决于具体的输入参数——读取不同文件的两个 Read 调用总是安全的,但编辑同一文件的两个 Edit 调用则不是。当前设计为什么选择工具级别而非输入级别的并发声明?这个权衡背后的工程考量是什么?
1.6 小结
本章探讨了 Agent 的本质——一个在不确定环境中通过感知-推理-行动循环自主决策的系统。我们从 Claude Code 的源码中发现:
Agent 的核心是一个带有终止条件的循环。
query.ts中的while(true)循环就是 Agent 的心脏,每一轮迭代包含上下文准备、LLM 推理、工具执行、结果反馈四个阶段。终止是 Agent 设计中最重要的问题之一。Claude Code 通过多重终止条件(LLM 自主停止、轮次上限、用户中断、资源耗尽、钩子拦截)确保 Agent 始终可控。
工具是 Agent 与外部世界交互的原子单元。
Tool类型通过声明式接口描述自己的能力特征,编排系统据此做出并发/串行决策,实现了关注点分离。有限自主性是 Agent 的设计哲学。Claude Code 既不是需要逐步引导的被动工具,也不是脱离监督的完全自主系统。它通过权限控制、轮次限制、用户中断、成本控制等多层机制,在自主性和可控性之间取得平衡。
反馈闭环是区分 Agent 与简单工具调用的本质特征。工具结果注入上下文、LLM 基于结果调整策略、多层错误恢复——这些机制共同构成了一个能够在失败中学习和适应的系统。
在下一章中,我们将深入推理层的核心——query() 函数的完整实现,探讨上下文管理、流式处理和错误恢复的算法细节。