Claude Code 的工具系统是怎么设计的:一文读懂 Agent 为什么不只是会调函数

30 阅读16分钟

如果你已经会调用大模型,也做过最基础的“让模型选择工具并发起调用”的小实验,接下来很容易遇到一个落差:

模型明明已经能“调用工具”了,但系统还是不太像一个真正能交付任务的 agent。

它可能会查资料、会读文件、也会跑命令,但一旦任务稍微复杂一点,就会出现这些问题:

  • 工具虽然调用成功了,但结果没法稳定接回后续推理;
  • 工具很多,但模型不知道什么时候该用;
  • 工具并发一开,状态就乱;
  • 同样叫“工具”,读文件和改文件明明风险完全不同,runtime 却一视同仁。

这说明真正难的地方,从来不是“给模型接几个函数”,而是怎么把工具变成一套可调度、可约束的执行系统。

这篇文章不讲工具列表,也不做 API 说明书。我们换一个更适合 agent 开发者的视角:Claude Code 到底是怎么把工具组织成 runtime 的,以及这套设计为什么值得借鉴。

1. 先建立一个正确心智模型:工具不是函数表,而是 runtime 合同

很多新人第一次做 agent,会先写出这样一份工具表:

async function readFile(filePath) {
  return fs.readFile(filePath, 'utf8');
}

const tools = {
  readFile,
  editFile,
  searchWeb,
  runShell,
};

这当然能跑,但它只解决了一个很窄的问题:模型能不能按要求发起一次工具调用。

真正难的不是“会不会调”,而是这次调用能不能被校验、被约束、被执行,并且把结果稳定地接回后续推理。

把这种小实验往“能稳定完成任务”推进,马上会遇到另一组问题。这里先不急着下结论,先把问题摊开看:

  1. 模型看到的工具名字、描述、输入结构,到底由谁定义?
  2. 一个工具在当前会话里是否应该暴露,能不能动态下线?
  3. 模型生成的参数格式不对、值不合法时,谁来兜底?
  4. 这次调用是只读、写入,还是潜在破坏性操作?
  5. 调用前是否需要权限判断、hook(流程前后插入的额外逻辑)、审计或用户确认?
  6. 两个工具能不能并发,不该由“是否异步”决定,那该由什么决定?
  7. 工具执行完之后,结果应该怎样回流,模型下一轮才看得懂?
  8. UI 展示给用户的内容,为什么不能直接等于工具内部返回值?

这里先把问题压住,不急着解释。接下来几章,我们就按这条问题链往下走:先看一个工具是怎么被定义出来的,再看它如何进入会话、如何被执行、如何参与并发,最后再回到文章开头的那些问题。

2. 如何写一个工具:从 Tool 抽象到 readFile 的实现

Claude Code 的第一步,不是直接散落地实现一堆工具,而是先定义统一的 Tool 合同。你可以把它粗略理解成下面这个样子:

type Tool = {
  name: string;
  inputSchema: Schema;
  outputSchema: Schema;
  description(): Promise<string>;
  prompt(): Promise<string>;
  validateInput(input): ValidationResult;
  checkPermissions(input, context): PermissionResult;
  isReadOnly(input): boolean;
  isConcurrencySafe(input): boolean;
  call(input, context): Promise<Result>;
  // 概念层:把工具内部结果整理成“可回写”的标准结果
  formatResult?(result): ToolResult;
  // 实现层:把结果映射成真正写入消息流的 tool_result block 参数
  mapToolResultToToolResultBlockParam(
    result,
    toolUseId,
  ): ToolResultBlockParam;
};

这里补一层说明,避免把两个不同层次的接口看成冲突:上面的 formatResult 是为了帮助理解而抽象出来的“结果格式化”能力;落到 Claude Code 的实际实现时,更常见的是更具体的 mapToolResultToToolResultBlockParam(result, toolUseId)。它比 formatResult 多带一个 toolUseId,返回的也不是泛化的 ToolResult,而是可以直接写回会话消息流的 tool_result block 参数。下面进入结果回写时,我们统一按这个更贴近实现的名字展开。

第一次看到这段接口,很容易被字段数量吓到。更好的读法不是逐个背字段,而是先把它拆成 3 组:

2.1 模型接口:告诉模型“这个工具叫什么、怎么用”

这一组字段决定的是,模型眼里看到的工具协议是什么:

  • name
  • inputSchema
  • outputSchema
  • description()
  • prompt()

你可以把它们理解成“给模型看的那一面”。名字、描述和输入结构说不清,模型连怎么发起一次稳定调用都做不到。

2.2 runtime 控制:告诉系统“这次调用该怎么被约束”

这一组字段决定的是,runtime 要怎么管理这次调用:

  • validateInput(input)
  • checkPermissions(input, context)
  • isReadOnly(input)
  • isConcurrencySafe(input)

这里的重点不是“能不能调用”,而是“系统该不该放行、该怎么调度、能不能并发”。

2.3 执行与结果回写:工具做完以后,结果怎么回到会话里

这一组要解决的问题很具体:工具执行完以后,结果不能只停留在程序内部,还得回到会话里,成为模型下一轮真正能看到的上下文。

对应到代码里,通常会分成两步:

  • call(input, context) 负责真正执行工具;
  • mapToolResultToToolResultBlockParam(result, toolUseId) 负责把执行结果整理成要写回会话的结构。

在 Claude Code 里,工具执行完不是终点。对 agent 来说,结果还要被稳定地写回会话,后面的推理才能接上。

这也是为什么这里要把“执行”和“结果回写”分开看。

  • call() 返回的,通常是工具内部更方便处理的数据。比如读文件工具内部可能先返回 { content, filePath, lineCount } 这种结构,方便后续代码继续加工;
  • mapToolResultToToolResultBlockParam(...) 再把这些数据整理成标准化的 tool_result
  • 这个 tool_result 会被写回会话,变成模型下一轮真正能读到的内容。

如果用伪代码表示,大概是这样:

const result = await tool.call(input, context);

// 比如:工具内部先返回
// { content, filePath, lineCount }

const toolResult = tool.mapToolResultToToolResultBlockParam(result, toolUseId);

// 然后整理成会话里真正要写回的结构
// { type: 'tool_result', tool_use_id: 'xxx', content: '...' }

为什么不让 call() 直接返回最终要写回会话的结果?因为这两层处理的是两类不同的问题:

  • call() 关注的是“工具内部怎么把事情做完”;
  • mapToolResultToToolResultBlockParam(...) 关注的是“做完以后,怎样把结果变成统一的会话格式”。

分开以后,工具内部可以保留自己最自然的数据结构,而整个系统在写回会话时,仍然能保持统一格式。

如果没有这一步,就很容易出现一种很典型的情况:程序里明明拿到了结果,但模型下一轮像没看见一样,因为结果没有被整理成它真正能继续读取的会话内容。

所以这一组能力其实只在解决两件事:工具怎么真正执行,以及执行完以后,结果怎么回到会话里,变成后续对话还能继续使用的上下文。

2.4 buildTool:统一创建工具,并补齐默认行为

源码里还有一个很值得借鉴的小设计:buildTool。它不是语法糖,而是在用默认值强制大家走统一的安全基线:

const TOOL_DEFAULTS = {
  isEnabled: () => true,
  isConcurrencySafe: () => false,
  isReadOnly: () => false,
  isDestructive: () => false,
  checkPermissions: input =>
    Promise.resolve({ behavior: 'allow', updatedInput: input }),
};

这里最重要的一点是:安全相关默认保守,便利性相关默认补齐。你可以把 buildTool 理解成一个统一创建工具、并顺手补齐默认行为的函数。这样每个官方工具在进入系统之前,都会先落到同一套基础规则上,而不是各写各的。

2.5 用 readFile 看一个工具是怎么落地的

有了这个抽象,再看 readFile 就更容易理解了。Claude Code 里的 FileReadTool 并不是 fs.readFile 的薄封装,而是一个完整工具:

export const FileReadTool = buildTool({
  name: FILE_READ_TOOL_NAME,
  maxResultSizeChars: Infinity,
  strict: true,
  userFacingName,
  isConcurrencySafe() {
    return true;
  },
  isReadOnly() {
    return true;
  },
  async checkPermissions(input, context) {
    return checkReadPermissionForTool(
      FileReadTool,
      input,
      context.getAppState().toolPermissionContext,
    );
  },
  async validateInput({ file_path, pages }, toolUseContext) {
    // 参数值校验
  },
  async call(input, context) {
    const filePath = resolveFilePath(input.file_path);
    const content = await readFile(filePath, 'utf8');

    return {
      filePath,
      content,
      lineCount: content.split('\n').length,
    };
  },
});

这个例子能把 Tool 合同讲得非常具体。

第一,它先声明自己是只读、可并发的。也就是说,并发策略不是执行器拍脑袋猜出来的,而是工具自己声明。

第二,它有单独的 checkPermissions。读文件看起来风险低,但依然要走文件系统权限规则,而不是因为“只是 Read”就绕过 runtime。

第三,它有自己的 validateInput。模型就算知道 file_pathoffsetlimit 这些字段,也不代表它一定会给出合法值。比如 PDF 的 pages 范围、偏移参数的边界,都需要工具自己兜底。

第四,它的 call 里处理的远不只是文本读取。源码里还能看到这些逻辑:

  • 图片、PDF、Notebook 走不同分支;
  • 大文件和 token 上限单独约束;
  • 特殊设备路径会被拦截,避免读 /dev/zero 这类会卡住进程的路径;
  • 结果会带行号、分页信息,方便模型继续引用;
  • 系统还会记住“这次到底读了哪个文件、读到什么版本”的内部状态,给后续编辑和一致性校验使用。

所以从 runtime 视角看,readFile 的真实职责不是“把磁盘内容拿出来”,而是“把受约束、可解释、可继续推理的上下文安全注入会话”。

到这里,再回头看标题里的“runtime 合同”,它至少已经不只是一个比喻了:只要你开始认真处理 schema、权限、只读性、并发性和结果映射,工具就不再是一个裸函数。

换句话说,这一章真正回答的是:谁来定义工具协议,参数不合法时谁兜底,读写风险和权限检查又该放在哪一层。Claude Code 的答案不是“调度层临时判断”,而是把这些能力直接内建进 Tool 合同。

3. 工具注册

工具定义完,不代表模型立刻就能看到它。Claude Code 还有一层专门的注册逻辑,用来回答另一个常被忽略的问题:

当前这一轮,到底该给模型开放哪些能力?

基础入口在 getAllBaseTools()。它先组出一套“理论上可用”的内建工具集合:

export function getAllBaseTools(): Tools {
  return [BashTool, FileReadTool, FileEditTool, WebFetchTool, ...extraTools];
}

但真正给当前会话用的,不是这份静态列表,而是 getTools(permissionContext) 再过滤一遍:

export const getTools = (permissionContext: ToolPermissionContext): Tools => {
  if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
    return [BashTool, FileReadTool, FileEditTool];
  }

  let allowedTools = filterToolsByDenyRules(
    getAllBaseTools(),
    permissionContext,
  );

  return allowedTools.filter(tool => tool.isEnabled());
};

这个注册链路至少做了三件事。

第一,区分“实现了”与“暴露了”。工具写在代码里,不代表本轮就该给模型看见。

第二,把环境和模式带进来。simple 模式下,系统会主动退化成极小工具集,而不是把所有能力都开放给模型。

第三,把 deny rules 和 isEnabled() 作为注册阶段的一部分,而不是等模型调用时才拒绝。这样做的意义很大,因为它减少了模型的决策噪音,也缩小了高风险能力的暴露面。

这也是为什么 Claude Code 的工具系统更像“能力管理系统”,而不是一个函数目录。注册层要解决的不是“还有哪些函数没挂上”,而是“当前这轮对话里,哪些能力应该被模型看见”。

所以这一章想回答的问题其实很简单:一个工具就算已经写好了,为什么这一轮会话里仍然可能不该暴露给模型。Claude Code 的做法是把“实现”和“暴露”明确分成两层,先有能力,再决定此刻要不要公开。

4. 工具的生命周期

当模型真的产出一个 tool_use 之后,Claude Code 也不是立刻 tool.call()。它走的是一条明确分层的生命周期。

用源码里的 runToolUsecheckPermissionsAndCallTool 来压缩,大致是下面这条链:

flowchart TD
    A["模型产出 tool_use"] --> B["按 name 找到 Tool"]
    B --> C["按输入结构先做基础解析"]
    C --> D["validateInput"]
    D --> E["PreToolUse hooks"]
    E --> F["权限决策"]
    F --> G["tool.call"]
    G --> H["整理成标准化结果"]
    H --> I["PostToolUse hooks"]
    I --> J["写回会话,继续推理"]

如果只看核心代码,味道是这样的:

// 先按输入结构做基础解析
const parsedInput = parseInputBySchema(tool.inputSchema, input)

// 再做更细的参数校验
const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)

let processedInput = isValidCall.updatedInput ?? parsedInput.data
let hookPermissionResult

// 运行前置 hooks:
// 1. 可能补充消息
// 2. 可能追加额外上下文
// 3. 可能改写输入
// 4. 也可能直接阻断执行
for await (const result of runPreToolUseHooks(
  toolUseContext,
  tool,
  processedInput,
  toolUseID,
  messageId,
  requestId,
  mcpServerType,
  mcpServerBaseUrl,
)) {
  // 根据 hook 返回的类型,更新输入、记录消息或中止执行
}

// 综合 hook 和权限系统的结果,决定这次调用能不能继续
const permissionDecision = await resolveHookPermissionDecision(...)

// 真正执行工具
const result = await tool.call(processedInput, context, canUseTool, assistantMessage)

// 把工具内部结果整理成会话里统一的返回格式
const toolResultBlock = formatToolResult(result.data, toolUseID)

// 运行后置 hooks,做补充处理
for await (const hookResult of runPostToolUseHooks(tool, result, context)) {
  // hook 可以追加消息、记录信息,或补充处理结果
}

这段流程可以先按 4 步来理解。

第一步,先处理输入。系统会先按输入结构做基础解析,再交给 validateInput 做更细的参数校验。前者更像“字段类型对不对”,后者更像“字段值能不能这样用”。

第二步,处理执行前的控制逻辑。PreToolUse hooks 会在真正执行之前跑一遍,它们可以补充消息、追加额外上下文、改写输入,甚至直接阻断这次调用。接着,权限系统再根据当前规则决定这次工具调用是否允许继续。

第三步,真正执行工具。到了 tool.call(...),系统才开始做这次调用真正要做的事情,比如读文件、改文件、执行命令,或者访问外部能力。

第四步,把结果写回会话。tool.call(...) 返回的往往还是工具内部更方便处理的数据,系统还要再把它整理成统一的结果格式,写回会话里。只有这样,模型下一轮才能继续读到这次调用真正产生了什么。

这里最容易被忽略的,其实就是第四步。很多系统把“工具执行成功”当成结束,但对 agent 来说,这还不够。工具结果只有重新进入会话,才会变成后续推理真正可用的上下文。

所以在 Claude Code 里,工具结果首先服务的是后续推理,其次才是界面展示。比如 FileReadTool 在界面里可能只显示“读取了多少行”,但写回会话的结果会带真正的文件内容、行号和必要提醒。这两层故意分开,就是为了同时服务系统推理和交互界面。

如果回到文章开头的问题,这一章真正补上的,是工具调用中间那条最容易被忽略的主链路:参数校验放在哪里,权限与 hook 插在什么位置,工具结果又是怎么重新回到下一轮推理里的。

5. 工具并行相关,并发策略

工具并发是另一个最容易被做坏的地方。很多系统默认“能 async 就并发”,Claude Code 不是这个思路。

这里还有一个很关键的问题:并发不是凭空出现的,也不是开发者在业务代码里手动写死“这两个工具一起跑”。更常见的情况是,模型在一轮里提出了多个工具调用,执行器再进一步判断这些调用能不能并发执行。

更接近真实输出的形态,大概像这样:

{
  role: 'assistant',
  content: [
    {
      type: 'tool_use',
      id: 'toolu_01',
      name: 'Read',
      input: { file_path: 'src/a.ts' },
    },
    {
      type: 'tool_use',
      id: 'toolu_02',
      name: 'Read',
      input: { file_path: 'src/b.ts' },
    },
  ],
}

也就是说,模型这一轮不是只给出一个调用,而是一次性给出了两个读取请求。到了这一步,执行器才会继续判断:这两个 Read 能不能一起跑,还是必须排队执行。

也就是说,这里有两层分工:

  • 模型负责从任务角度提出“这几个动作可以一起做”的可能性;
  • runtime 负责从系统角度裁决“这次并发到底安不安全”。

真正决定并发是否成立的,不是模型想不想并发,而是这些工具在语义上是否允许并发执行。

StreamingToolExecutor 里的核心判断非常直接:

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}

它真正关心的是:这次调用在语义上是否并发安全。

再结合工具定义里的声明,你就能看懂这套策略:

  • FileReadTool 明确声明 isConcurrencySafe() { return true },所以多个读取类工具可以并发。
  • 没有显式声明并发安全的工具,默认按不安全处理。
  • 只要队列里出现非并发安全工具,它就要求独占执行。

这背后的价值,不是“更保守”,而是让并发策略与工具语义绑定,而不是与技术实现绑定。

因为 agent 的工具不是纯函数。它们操作的是文件系统、终端、外部服务和会话状态。只要涉及副作用,并发问题就不是吞吐问题,而是一致性问题。

Claude Code 在执行器里还做了两件很实用的事:

  • 即使并发执行,结果也会按工具出现顺序缓冲和回放,避免会话里的结果顺序被打乱。
  • 如果某个并发中的工具出错,兄弟工具可以被取消或生成合成错误结果,避免系统在半失效状态下继续推进。

结语

回头看文章开头那几个典型问题,其实都能在这条主线上找到位置。

工具为什么不该只是函数表,对应的是统一 Tool 合同;工具为什么不能全量暴露,对应的是注册层;工具为什么不能拿到名字就直接执行,对应的是完整生命周期;工具为什么不能盲目并发,对应的是语义驱动的并发策略。

Claude Code 的工具系统值得借鉴,不是因为它工具多,而是因为它把工具放回了 runtime 的中心位置。对刚从“能调 LLM”迈向“能做 agent”的开发者来说,这个转变尤其关键:当你开始把工具当成合同、能力入口、执行对象和结果回流节点来设计时,你才真正进入 agent runtime 的实现阶段。