Skip to content

第18章 设计模式与架构决策

"Good architecture is not about doing everything right the first time. It's about making decisions that are easy to change." -- Martin Fowler, Patterns of Enterprise Application Architecture

本章要点

  • Generator 驱动的流式管道:为什么 AsyncGenerator 是 AI Agent 系统的最佳编排原语
  • 自描述工具模式:工具即对象、Schema 即文档、运行时校验三位一体
  • 多模式权限模型:从五种权限模式的设计推演出分层安全架构
  • 并行预加载与启动优化:Side-effect Imports、并行预读与特性标志死代码消除
  • 协议优先的扩展性:MCP 标准协议如何解耦 Agent 的能力与实现
  • 上下文窗口经济学:Token 预算管理、自动压缩与结果截断的协同策略
  • 安全与自由的平衡术:分层安全检查、沙箱隔离与用户信任级别
  • 构建你自己的 Agent 系统:从 Claude Code 提炼的架构原则与实现路线图

在前面十七章中,我们从 CLI 启动、Query 引擎、流式处理、工具系统、权限模型、MCP 协议到终端 UI,逐一拆解了 Claude Code 的每一个核心子系统。但正如一座建筑的价值不仅在于砖瓦的品质,更在于整体的结构设计——Claude Code 的真正精妙之处,在于这些子系统背后一以贯之的设计模式与架构决策。

本章是全书的收官。我们将站在更高的抽象层次上,从 Claude Code 源码中萃取出七个可迁移的设计模式。这些模式不仅适用于 Claude Code 本身,也适用于任何需要构建 AI Agent 系统的工程团队。对于每一个模式,我们都会追问三个问题:它解决了什么问题?它在源码中如何实现?如果我要构建自己的 Agent 系统,该如何复用它?

18.1 Generator 驱动的流式管道

Claude Code 中 AsyncGenerator 贯穿了从 API 响应到 UI 渲染的完整数据流。下图展示了 Generator 管道的核心组合模式:

18.1.1 模式描述与动机

在任何 AI Agent 系统中,最核心的架构挑战是如何编排一个多轮、流式、可中断的执行循环。这个循环需要同时满足以下约束:

  1. 流式输出:模型的响应是逐 token 流出的,UI 需要实时渲染
  2. 工具穿插:模型可能在响应中途请求调用工具,工具执行完毕后循环继续
  3. 可中断性:用户随时可能按下 Ctrl+C 中断当前操作
  4. 错误恢复:API 错误、Token 超限、模型降级都需要在循环内处理
  5. 多消费者:同一个循环的输出需要同时送给 UI 渲染、SDK 回调、日志系统

传统的异步编程范式在面对这些约束时往往力不从心。回调地狱(Callback Hell)让控制流碎片化;Promise 链无法表达"暂停并等待外部输入"的语义;RxJS 的 Observable 虽然强大,但引入了沉重的概念负担和运行时依赖。

Claude Code 选择了 JavaScript 的 AsyncGenerator 作为核心编排原语。这个选择看似简单,却深刻地影响了整个系统的架构风格。

18.1.2 在 Claude Code 中的应用

Claude Code 的核心查询循环 query() 函数就是一个异步生成器:

typescript
// 文件:src/query.ts

export async function* query(
  params: QueryParams,
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)
  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

这个函数的返回类型 AsyncGenerator<YieldType, ReturnType> 精确地编码了两层信息:YieldType 是循环过程中流出的中间产物(流式事件、消息、墓碑标记),ReturnType 是循环结束时的终态(成功、错误、中断的原因)。

核心的 queryLoop 是一个 while(true) 循环,通过 yield 将中间产物推送给消费者:

typescript
// 文件:src/query.ts

async function* queryLoop(
  params: QueryParams,
  consumedCommandUuids: string[],
): AsyncGenerator<...> {
  let state: State = {
    messages: params.messages,
    toolUseContext: params.toolUseContext,
    autoCompactTracking: undefined,
    maxOutputTokensRecoveryCount: 0,
    hasAttemptedReactiveCompact: false,
    // ...
  }

  while (true) {
    let { toolUseContext } = state

    yield { type: 'stream_request_start' }

    // 1. 消息预处理流水线
    // 2. API 调用与流式接收
    for await (const message of deps.callModel({...})) {
      yield yieldMessage  // 逐条推送给消费者
    }

    // 3. 工具执行
    // 4. 停止条件判断
    // 5. state 转移 -> continue 或 return
  }
}

这个模式在工具编排层同样得到了一致的应用。toolOrchestration.ts 中的 runTools 函数也是一个异步生成器:

typescript
// 文件:src/services/tools/toolOrchestration.ts

export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  assistantMessages: AssistantMessage[],
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
  let currentContext = toolUseContext
  for (const { isConcurrencySafe, blocks } of partitionToolCalls(
    toolUseMessages, currentContext,
  )) {
    if (isConcurrencySafe) {
      for await (const update of runToolsConcurrently(blocks, ...)) {
        yield { message: update.message, newContext: currentContext }
      }
    } else {
      for await (const update of runToolsSerially(blocks, ...)) {
        yield { message: update.message, newContext: currentContext }
      }
    }
  }
}

注意这里的关键设计:yield* 运算符让生成器可以无缝嵌套。query() 通过 yield* 委托给 queryLoop()queryLoop() 内部又通过 for await...of 消费 runTools() 的输出。整个流水线从 API 响应到工具执行再到 UI 渲染,形成了一条类型安全、可组合、可中断的管道。

18.1.3 与其他方案的对比

维度回调Promise 链RxJS ObservableAsyncGenerator
控制流可读性差(嵌套回调)中(线性链)中(操作符链)优(同步写法)
暂停/恢复不支持不支持支持(Subject)原生支持(yield)
取消/中断手动AbortControllerunsubscribe.return()
背压控制有(背压策略)天然(拉取模式)
运行时依赖大(RxJS 库)无(语言原生)
嵌套组合好(mergeMap 等)优(yield*)

AsyncGenerator 的核心优势在于拉取模型(Pull Model)。消费者调用 .next() 才会推动生产者继续执行到下一个 yield。这意味着背压控制是天然的——如果 UI 渲染跟不上模型输出的速度,生产者自动暂停。相比之下,Promise 和 Observable 都是推送模型(Push Model),需要额外的机制来处理背压。

18.1.4 可迁移性

如果你要构建自己的 AI Agent 系统,AsyncGenerator 模式值得优先采用。核心实现只需要三层:

  1. 最外层:会话管理器消费生成器,将 yield 出的事件分发给不同的消费者
  2. 中间层:查询循环生成器,编排 API 调用、工具执行和停止判断
  3. 最内层:工具执行生成器,处理并发控制和权限检查

这三层通过 yield*for await...of 自然组合,无需任何框架依赖。唯一的前提是你的语言运行时支持异步生成器——在 JavaScript/TypeScript、Python、C# 中这已经是标准特性。

18.2 自描述工具模式

18.2.1 工具即对象 vs 类继承

在 AI Agent 系统中,"工具"是模型与外部世界交互的桥梁。工具系统的设计直接决定了 Agent 的可扩展性和安全性。

传统的工具系统设计有两种常见路径。第一种是类继承模式:定义一个 BaseTool 抽象类,每个具体工具继承它并覆盖 execute() 方法。这种方式简单直观,但存在钻石继承、难以组合、测试困难等问题。第二种是函数式模式:每个工具就是一个函数,通过装饰器或注册表来管理元数据。这种方式灵活,但元数据散落在各处,难以做静态检查。

Claude Code 走了第三条路:工具即对象。每个工具是一个满足 Tool 接口的普通对象,通过 buildTool() 工厂函数构造:

typescript
// 文件:src/Tool.ts

export type Tool<
  Input extends AnyObject = AnyObject,
  Output = unknown,
  P extends ToolProgressData = ToolProgressData,
> = {
  readonly name: string
  readonly inputSchema: Input           // Zod schema
  call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
  description(input, options): Promise<string>
  checkPermissions(input, context): Promise<PermissionResult>
  isReadOnly(input): boolean
  isConcurrencySafe(input): boolean
  isEnabled(): boolean
  prompt(options): Promise<string>
  // ... 渲染、校验、分类等 30+ 方法
}

buildTool() 函数提供了安全的默认值,使得新工具只需要定义真正独特的部分:

typescript
// 文件:src/Tool.ts

const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: (_input?: unknown) => false,  // 假设不安全
  isReadOnly: (_input?: unknown) => false,           // 假设有写操作
  isDestructive: (_input?: unknown) => false,
  checkPermissions: (input, _ctx?) =>
    Promise.resolve({ behavior: 'allow', updatedInput: input }),
  toAutoClassifierInput: (_input?: unknown) => '',
  userFacingName: (_input?: unknown) => '',
}

export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>
}

这个设计的精妙之处在于默认值的安全方向isConcurrencySafe 默认为 false(假设不安全,需要串行),isReadOnly 默认为 false(假设有写操作,需要权限检查)。这意味着如果工具作者忘记实现某个方法,系统的行为是保守的而不是危险的。这种"失败安全"(Fail-Safe)原则贯穿了整个工具系统。

18.2.2 Schema 即文档

Claude Code 的工具系统最具创新性的设计之一是 Schema 即文档。每个工具的 inputSchema 不仅用于运行时校验,还直接作为发送给模型的 JSON Schema。模型读到这个 Schema 后,就知道如何正确调用工具——不需要额外的自然语言描述来教模型填写参数。

typescript
// 文件:src/Tool.ts

export type ToolInputJSONSchema = {
  [x: string]: unknown
  type: 'object'
  properties?: {
    [x: string]: unknown
  }
}

export type Tool<...> = {
  readonly inputSchema: Input              // Zod schema,用于运行时校验
  readonly inputJSONSchema?: ToolInputJSONSchema  // 可选的原始 JSON Schema
  // ...
}

这意味着同一份定义同时服务于三个目的:

  1. 编译时:TypeScript 通过 z.infer<Input> 推导出精确的输入类型
  2. 运行时:Zod 的 .safeParse() 对模型输出进行校验
  3. 提示时:Schema 被转换为 JSON Schema 发送给 API,告诉模型参数的名称、类型和约束

18.2.3 运行时校验

工具执行的安全链条是:Schema 校验 -> validateInput() -> checkPermissions() -> call()。每一环都有明确的职责:

typescript
// 文件:src/Tool.ts

export type Tool<...> = {
  // 第一道关卡:Schema 校验(由框架自动执行)
  readonly inputSchema: Input

  // 第二道关卡:业务逻辑校验(工具自己定义)
  validateInput?(input, context): Promise<ValidationResult>

  // 第三道关卡:权限检查(通用权限系统 + 工具自定义规则)
  checkPermissions(input, context): Promise<PermissionResult>

  // 第四道关卡:实际执行
  call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
}

ValidationResult 的设计也值得注意——它明确区分了"校验通过"和"校验失败及原因":

typescript
export type ValidationResult =
  | { result: true }
  | { result: false; message: string; errorCode: number }

校验失败的消息会被直接返回给模型,让模型了解为什么它的工具调用被拒绝,从而在下一轮中修正参数。这种"让模型从错误中学习"的设计理念,是 Agent 系统特有的。

基于 VitePress 构建