Agent 工程设计原则 —— 从 Claude Code 源码中提炼
把前 6 篇文档中分散的设计洞察,提炼为一份可复用的 Agent 工程设计原则清单。这是从「理解 Claude Code」到「设计自己的 Agent 框架」的桥梁。
每条原则附带:原则陈述 → 在 Claude Code 中的体现 → 为什么重要 → 反模式。
原则 1:Fail-Closed 默认值
Section titled “原则 1:Fail-Closed 默认值”当配置缺失或属性未声明时,系统应选择更安全/更保守的行为,而非更方便的行为。
Tool.ts 的 buildTool() 工厂: isConcurrencySafe → false (默认串行) isReadOnly → false (默认当写操作) isDestructive → false (但需显式声明)
权限系统: 新工具默认需要用户确认 (ask) 只有显式的 allow 规则才能跳过确认忘记声明 ≠ 安全事故。如果默认值是「允许并发」或「标记为只读」,一个粗心的工具作者就可能导致文件冲突或未授权的写操作。
❌ isConcurrencySafe 默认 true → 忘记声明的工具互相踩踏❌ 新工具默认 allow → 未经审计的操作静默执行❌ isReadOnly 默认 true → 有副作用的工具被分到并发批次原则 2:显式状态转换
Section titled “原则 2:显式状态转换”Agent 循环不应是简单的
while(true)+ break,而应有显式的状态类型标注「为什么继续」和「为什么停止」。
Continue = { reason: 'next_turn' | 'reactive_compact_retry' | ... }Terminal = { reason: 'completed' | 'max_turns' | 'prompt_too_long' | ... }Agent Loop 的每次迭代必须产生一个 Continue 或 Terminal,不存在「不知道为什么继续/停止」的情况。
- 可测试:测试断言
result.reason === 'reactive_compact_retry',而非检查消息内容 - 可观测:日志/遥测直接记录转换原因
- 可调试:一眼看出循环是正常结束还是异常恢复
❌ while (!done) { ... done = true } → done 被设置的地方分散在 10 处❌ 用异常控制流程 → throw StopError('completed') → 错误处理和正常退出混在一起原则 3:Withholding 可恢复错误
Section titled “原则 3:Withholding 可恢复错误”对于可能自动恢复的错误,先扣住不暴露给用户,尝试恢复。恢复成功则用户无感知;恢复失败才暴露。
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 → 重试」变成「稍等一下 → 自动继续」。对于生产级工具,这种差异决定了用户是否继续使用。
❌ 所有错误立即暴露 → 用户频繁看到可自动恢复的错误❌ 所有错误静默吞掉 → 真正的问题被隐藏❌ 无限重试 → 没有熔断机制导致死循环原则 4:流式执行隐藏延迟
Section titled “原则 4:流式执行隐藏延迟”如果两个操作可以时间上重叠,就不要顺序执行。特别是工具执行可以在 API streaming 过程中开始。
StreamingToolExecutor: API stream 第 2 秒收到 tool_use block → 立即开始执行工具(不等 stream 结束) → 工具在后台执行 → stream 第 5 秒结束时,工具可能已经完成 → 节省 3-5 秒/轮
投机性分类器: BashTool 权限检查前就启动 ML 分类器 → 分类器在权限检查期间运行 → 权限检查通过时分类器结果已就绪Agent 的每一轮至少有 5-15 秒延迟(API 调用 + 工具执行)。如果 10 轮对话每轮节省 3 秒,总节省 30 秒——对用户体验影响显著。
❌ 等 API 完全返回 → 解析工具调用 → 逐个执行 → 收集结果 (每个步骤串行,总延迟 = 所有步骤之和)原则 5:权限不可谈判
Section titled “原则 5:权限不可谈判”权限系统的决策只有 allow / deny / ask 三种,没有 warn。每个决策都是硬性的、有确定结果的。
PermissionBehavior = 'allow' | 'deny' | 'ask'// 注意:没有 'warn' | 'log' | 'soft-deny'
deny 规则不可绕过(即使 bypassPermissions 模式)安全检查不可绕过(classifierApprovable = false)如果有 warn,用户会习惯性忽略(告警疲劳)。每次交互都是「有意义的决策」,而不是「机械性点确认」。
❌ warn → 用户忽略 → 安全形同虚设❌ soft-deny(拒绝但允许覆盖)→ 覆盖成为默认操作❌ confirm-once-for-all → 一次确认授权所有后续操作原则 6:Prompt Cache 稳定性
Section titled “原则 6:Prompt Cache 稳定性”任何改变发送给 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 每次都失效❌ 在消息中嵌入时间戳/随机数 → 前缀永远不同❌ 压缩后不恢复上下文 → 前缀结构变化原则 7:并发安全性自报告
Section titled “原则 7:并发安全性自报告”工具自己声明是否可以并发执行,系统根据声明做调度。错报的后果由工具承担,而非系统猜测。
Tool 接口: isConcurrencySafe(input): boolean ← 工具自己报告 isReadOnly(input): boolean ← 工具自己报告
调度器: 只将所有参与方都声明 concurrencySafe 的工具放入并发批次 任何一方不确定 → 串行执行
BashTool 的精细判断: Read-only 命令 → safe 有 cd 命令 → not safe(改变共享状态) 不确定 → not safe(保守策略)系统无法准确判断工具是否有副作用(停机问题)。让工具作者声明更准确,因为他们最了解工具的行为。结合 Fail-Closed 默认值(默认不安全),未声明的工具不会被错误地并发执行。
❌ 系统猜测并发安全性 → 猜错时文件冲突❌ 全部串行 → 安全但慢(3 个 Read 本可以并行的)❌ 全部并行 → 快但危险(FileEdit 与 Read 竞态)原则 8:副作用的函数式封装
Section titled “原则 8:副作用的函数式封装”工具不应直接修改全局状态,而是返回一个修改函数(contextModifier),由调度器在合适的时机应用。
// ToolResult{ data: T, contextModifier?: (ctx: ToolUseContext) => ToolUseContext}
// 调度器的应用时机concurrent batch → 批次全部完成后应用(但 concurrent 工具不应返回 modifier)serial batch → 立即应用- 可测试:assert
modifier(oldCtx)的输出,不需要检查全局状态 - 可组合:多个 modifier 可以链式应用
- 无竞态:并发工具不能返回 modifier → 不存在并发修改问题
❌ 工具直接修改全局变量 → 并发时竞态条件❌ 工具通过事件通知状态变更 → 事件顺序不确定❌ 工具返回 diff 由调度器 merge → merge 逻辑复杂易错原则 9:渐进式压缩
Section titled “原则 9:渐进式压缩”上下文管理不应「一刀切」,而应设计多级策略,从最轻量到最重量,按需升级。
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 都能阻止所有操作 → 过度授权原则 12:窄依赖注入接口
Section titled “原则 12:窄依赖注入接口”依赖注入只暴露测试需要 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) → 过重,大部分操作符用不到| # | 原则 | 一句话 | 关键文件 |
|---|---|---|---|
| 1 | Fail-Closed 默认值 | 忘记声明 = 更安全 | Tool.ts |
| 2 | 显式状态转换 | 每次循环都有明确的 why | query/transitions.ts |
| 3 | Withholding 错误 | 先扣住,尝试恢复,再暴露 | query.ts Phase 4 |
| 4 | 流式执行隐藏延迟 | 边收 API 边执行工具 | StreamingToolExecutor.ts |
| 5 | 权限不可谈判 | 没有 warn,只有 allow/deny/ask | permissions.ts |
| 6 | Prompt 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 |
| 13 | Async Generator | 流式 + 背压 + 类型安全 | query.ts |