Skip to content

Agent 工程设计原则 —— 从 Claude Code 源码中提炼

把前 6 篇文档中分散的设计洞察,提炼为一份可复用的 Agent 工程设计原则清单。这是从「理解 Claude Code」到「设计自己的 Agent 框架」的桥梁。

每条原则附带:原则陈述 → 在 Claude Code 中的体现 → 为什么重要 → 反模式


当配置缺失或属性未声明时,系统应选择更安全/更保守的行为,而非更方便的行为。

Tool.ts 的 buildTool() 工厂:
isConcurrencySafe → false (默认串行)
isReadOnly → false (默认当写操作)
isDestructive → false (但需显式声明)
权限系统:
新工具默认需要用户确认 (ask)
只有显式的 allow 规则才能跳过确认

忘记声明 ≠ 安全事故。如果默认值是「允许并发」或「标记为只读」,一个粗心的工具作者就可能导致文件冲突或未授权的写操作。

❌ isConcurrencySafe 默认 true → 忘记声明的工具互相踩踏
❌ 新工具默认 allow → 未经审计的操作静默执行
❌ isReadOnly 默认 true → 有副作用的工具被分到并发批次

Agent 循环不应是简单的 while(true) + break,而应有显式的状态类型标注「为什么继续」和「为什么停止」。

query/transitions.ts
Continue = { reason: 'next_turn' | 'reactive_compact_retry' | ... }
Terminal = { reason: 'completed' | 'max_turns' | 'prompt_too_long' | ... }

Agent Loop 的每次迭代必须产生一个 Continue 或 Terminal,不存在「不知道为什么继续/停止」的情况。

  1. 可测试:测试断言 result.reason === 'reactive_compact_retry',而非检查消息内容
  2. 可观测:日志/遥测直接记录转换原因
  3. 可调试:一眼看出循环是正常结束还是异常恢复
❌ while (!done) { ... done = true } → done 被设置的地方分散在 10 处
❌ 用异常控制流程 → throw StopError('completed') → 错误处理和正常退出混在一起

对于可能自动恢复的错误,先扣住不暴露给用户,尝试恢复。恢复成功则用户无感知;恢复失败才暴露。

API 返回 prompt_too_long:
1. 扣住错误(不 yield 给 UI)
2. 尝试 Context Collapse
3. 尝试 Reactive Compact
4. 成功 → Continue(reactive_compact_retry) → 用户无感知
5. 失败 → yield 错误 → Terminal(prompt_too_long)
API 返回 max_output_tokens:
1. 扣住
2. 提升上限 8K→64K
3. 成功 → 继续
4. 失败(3 次后)→ yield 错误

用户体验从「红色错误 → 手动 /compact → 重试」变成「稍等一下 → 自动继续」。对于生产级工具,这种差异决定了用户是否继续使用。

❌ 所有错误立即暴露 → 用户频繁看到可自动恢复的错误
❌ 所有错误静默吞掉 → 真正的问题被隐藏
❌ 无限重试 → 没有熔断机制导致死循环

如果两个操作可以时间上重叠,就不要顺序执行。特别是工具执行可以在 API streaming 过程中开始。

StreamingToolExecutor:
API stream 第 2 秒收到 tool_use block
→ 立即开始执行工具(不等 stream 结束)
→ 工具在后台执行
→ stream 第 5 秒结束时,工具可能已经完成
→ 节省 3-5 秒/轮
投机性分类器:
BashTool 权限检查前就启动 ML 分类器
→ 分类器在权限检查期间运行
→ 权限检查通过时分类器结果已就绪

Agent 的每一轮至少有 5-15 秒延迟(API 调用 + 工具执行)。如果 10 轮对话每轮节省 3 秒,总节省 30 秒——对用户体验影响显著。

❌ 等 API 完全返回 → 解析工具调用 → 逐个执行 → 收集结果
(每个步骤串行,总延迟 = 所有步骤之和)

权限系统的决策只有 allow / deny / ask 三种,没有 warn。每个决策都是硬性的、有确定结果的。

PermissionBehavior = 'allow' | 'deny' | 'ask'
// 注意:没有 'warn' | 'log' | 'soft-deny'
deny 规则不可绕过(即使 bypassPermissions 模式)
安全检查不可绕过(classifierApprovable = false)

如果有 warn,用户会习惯性忽略(告警疲劳)。每次交互都是「有意义的决策」,而不是「机械性点确认」。

❌ warn → 用户忽略 → 安全形同虚设
❌ soft-deny(拒绝但允许覆盖)→ 覆盖成为默认操作
❌ confirm-once-for-all → 一次确认授权所有后续操作

任何改变发送给 API 的消息前缀的操作都会破坏 prompt cache。系统设计应最大化前缀稳定性。

工具排序:
assembleToolPool() 按固定顺序排列(内置 → MCP,同类按名字排)
→ 同一会话中工具列表不变 → cache 命中
Fork 子 Agent:
所有 fork 子 Agent 使用相同的 tool_result 占位符
→ 前缀字节级相同 → 共享 cache
Microcompact:
在 cache 有效范围内编辑消息
→ 减少 token 但不破坏 cache
大结果外置:
flag: 'wx' 保证同一 toolUseId 的预览文本始终相同
→ 重放时 cache 仍然有效

Claude API 的 prompt cache 按消息前缀匹配。100K tokens 的上下文如果 cache 命中,只计费缓存读取(约 1/10 成本)。破坏 cache = 10x 成本增加。

❌ 每次请求随机化工具顺序 → cache 每次都失效
❌ 在消息中嵌入时间戳/随机数 → 前缀永远不同
❌ 压缩后不恢复上下文 → 前缀结构变化

工具自己声明是否可以并发执行,系统根据声明做调度。错报的后果由工具承担,而非系统猜测。

Tool 接口:
isConcurrencySafe(input): boolean ← 工具自己报告
isReadOnly(input): boolean ← 工具自己报告
调度器:
只将所有参与方都声明 concurrencySafe 的工具放入并发批次
任何一方不确定 → 串行执行
BashTool 的精细判断:
Read-only 命令 → safe
有 cd 命令 → not safe(改变共享状态)
不确定 → not safe(保守策略)

系统无法准确判断工具是否有副作用(停机问题)。让工具作者声明更准确,因为他们最了解工具的行为。结合 Fail-Closed 默认值(默认不安全),未声明的工具不会被错误地并发执行。

❌ 系统猜测并发安全性 → 猜错时文件冲突
❌ 全部串行 → 安全但慢(3 个 Read 本可以并行的)
❌ 全部并行 → 快但危险(FileEdit 与 Read 竞态)

工具不应直接修改全局状态,而是返回一个修改函数(contextModifier),由调度器在合适的时机应用。

// ToolResult
{
data: T,
contextModifier?: (ctx: ToolUseContext) => ToolUseContext
}
// 调度器的应用时机
concurrent batch → 批次全部完成后应用(但 concurrent 工具不应返回 modifier)
serial batch → 立即应用
  1. 可测试:assert modifier(oldCtx) 的输出,不需要检查全局状态
  2. 可组合:多个 modifier 可以链式应用
  3. 无竞态:并发工具不能返回 modifier → 不存在并发修改问题
❌ 工具直接修改全局变量 → 并发时竞态条件
❌ 工具通过事件通知状态变更 → 事件顺序不确定
❌ 工具返回 diff 由调度器 merge → merge 逻辑复杂易错

上下文管理不应「一刀切」,而应设计多级策略,从最轻量到最重量,按需升级。

Level 1: Snip → 最快,信息丢失最多(切除最早的块)
Level 2: Microcompact → 快,cache 友好(编辑旧 blocks)
Level 3: Collapse → 中等开销(语义折叠)
Level 4: Autocompact → 最慢最贵(全量摘要)
90% 的情况 Level 1-2 就够
→ 避免了不必要的 API 调用
→ 降低了压缩成本

全量压缩需要一次额外的 API 调用(费时 + 费钱)。如果每次都做全量压缩,成本可能增加 30-50%。

❌ 只有全量压缩 → 每次都要 API 调用,慢且贵
❌ 只有简单截断 → 丢失关键上下文,模型表现下降
❌ 不做压缩 → 上下文爆了才报错,用户体验差

原则 10:大结果外置 + 幂等写入

Section titled “原则 10:大结果外置 + 幂等写入”

工具输出超过阈值时,持久化到磁盘,消息中只保留预览。写入操作必须幂等(重放安全)。

持久化:
result > threshold → 写入 tool-results/{toolUseId}.json
消息中 → 2KB 预览 + 文件路径
幂等保证:
flag: 'wx' (O_EXCL) → 文件已存在则跳过
→ 对话恢复(/resume)时重放不会覆盖
→ 同一 toolUseId 的预览永远一致
→ Prompt Cache 不会因为预览文本变化而失效

一次 Grep 搜索可能返回 100KB。50 次搜索 = 5MB 在上下文中。外置后只有 100KB 预览 + 5MB 在磁盘上(零 token 成本)。

❌ 全部保留在上下文中 → 上下文快速膨胀 → 频繁压缩
❌ 简单截断(丢弃超出部分)→ 关键信息可能在截断部分
❌ 非幂等写入 → 重放时覆盖旧文件 → 预览不一致 → cache 失效

原则 11:扩展影响但不覆盖安全

Section titled “原则 11:扩展影响但不覆盖安全”

扩展机制(Hook、插件)可以影响系统行为(添加上下文、提供建议),但不能覆盖安全决策。

Hook 的权限建议:
Hook 'allow' → 跳过交互对话框,但 deny 规则仍然检查
Hook 'deny' → 直接拒绝
Hook 不能覆盖 deny 规则(规则优先级永远更高)
Hook 的能力分级:
观察型 → 零风险
影响型 → 有限风险
阻止型 → 最高风险,只给最关键的事件

如果 Hook 可以覆盖权限规则,一个恶意插件就可以绕过所有安全检查。「影响但不覆盖」保证了安全层的完整性。

❌ 插件可以调用 setPermission('allow') → 安全层被旁路
❌ Hook 返回 allow 就直接放行 → deny 规则形同虚设
❌ 不分能力等级 → 所有 Hook 都能阻止所有操作 → 过度授权

依赖注入只暴露测试需要 mock 的 I/O 边界,其余保持纯函数。接口越窄,测试越简单。

// query/deps.ts — 只有 4 个函数
type QueryDeps = {
callModel: typeof queryModelWithStreaming
microcompact: typeof microcompactMessages
autocompact: typeof autoCompactIfNeeded
uuid: () => string
}

整个 Agent Loop 的测试只需要 mock 这 4 个函数,不需要 module spying 或全局状态 mock。

宽接口(如注入整个 API client 实例)导致测试需要 mock 大量方法,大部分与测试目标无关。窄接口只注入真正需要替换的部分。

❌ 注入整个 APIClient → mock 20 个方法只用到 1 个
❌ 全局单例 → 测试间状态泄露
❌ module spying (jest.mock) → 脆弱,重构就断

原则 13:Async Generator 作为核心抽象

Section titled “原则 13:Async Generator 作为核心抽象”

在需要流式输出 + 资源清理 + 背压控制的场景,async generator 优于 callback 和 event emitter。

全链路使用 async generator:
query() → queryLoop() → handleStopHooks() ← 嵌套组合(yield*)
runToolUse() → 流式 yield 进度 + 最终结果
StreamingToolExecutor → getRemainingResults()
好处:
1. 背压: 消费者控制消费速度
2. 组合: yield* 嵌套多层 generator
3. 清理: generator.return() 支持优雅退出
4. 类型: AsyncGenerator<YieldType, ReturnType> 区分中间值和最终值

Agent 循环天然是「产生一系列中间结果(工具输出、进度)+ 一个最终结果(完成/错误)」的模式。async generator 精确匹配这个模式。

❌ Callback → 无背压,消费者被 flood
❌ Event Emitter → 无类型安全,事件名字符串
❌ Promise → 只有最终值,无中间值
❌ Observable (RxJS) → 过重,大部分操作符用不到

#原则一句话关键文件
1Fail-Closed 默认值忘记声明 = 更安全Tool.ts
2显式状态转换每次循环都有明确的 whyquery/transitions.ts
3Withholding 错误先扣住,尝试恢复,再暴露query.ts Phase 4
4流式执行隐藏延迟边收 API 边执行工具StreamingToolExecutor.ts
5权限不可谈判没有 warn,只有 allow/deny/askpermissions.ts
6Prompt Cache 稳定性保持消息前缀不变tools.ts, forkSubagent.ts
7并发安全自报告工具自己说能否并发Tool.ts, toolOrchestration.ts
8副作用函数式封装contextModifier 而非直接修改Tool.ts
9渐进式压缩4 级策略,按需升级compact.ts
10大结果外置 + 幂等超限存磁盘,wx 保证幂等toolResultStorage.ts
11扩展影响不覆盖Hook 建议不能覆盖规则toolHooks.ts
12窄依赖注入只 mock I/O 边界query/deps.ts
13Async Generator流式 + 背压 + 类型安全query.ts