tools[] 只是 API 请求中的一面,真正关键的是调用前、调用中、调用后的完整控制链

前四讲,我们一直在研究 Claude Code 怎么管理模型的“脑子”。
第2讲 看的是 System Prompt:Agent 当前应该遵守什么行动规则。
第3讲 看的是 Durable Memory:哪些信息值得跨会话长期保存。
第4讲 看的是 Session Notes 和 Compact:长任务被压缩以后,怎样恢复工作现场。
这一讲,我们把视角从“怎么想”移到“怎么动手”。
先看一个很普通的请求:
请打开 src/auth.ts,找到登录失败的原因并修复它。
如果只是聊天模型,它可以告诉你可能的原因。
但 Coding Agent 要真正完成任务,至少要读取文件、定位代码、修改文件、运行测试,再根据失败结果继续修正。
这些动作不可能由 LLM 自己在电脑上完成。
Claude Code 的做法,是在每次 LLM Request 里加入一个 tools 参数,让模型能够提出结构化的工具调用意图。
模型可能返回:
{
"type": "tool_use",
"id": "toolu_01",
"name": "Read",
"input": {
"file_path": "/project/src/auth.ts"
}
}
注意,这只是一个调用意图,不是调用本身。模型只是提出“我想用 Read 读这个文件”;真正执行读取、把结果送回来的,都是 Claude Code。
真正值得研究的问题,也不是“模型会不会输出 tool_use”,而是:
哪些工具可以进入 tools[]?
几十、几百个工具会不会把上下文撑大?
参数不合法时,谁负责拦截?
Edit 为什么必须先 Read?
哪些操作需要权限确认?
多个工具能不能并发?
结果太长、执行失败或会话 compact 时怎么办?
tool_result 最后又放回下一次请求的哪里?
这些问题合起来,才是 Claude Code 的工具治理。
模型只负责提出结构化行动意图;Claude Code 负责决定这个意图是否可见、是否合法、是否获准、何时执行,以及执行结果怎样回到下一轮上下文。
这一讲沿着一次工具调用的生命周期,把工具治理拆成三段:调用前管“模型能看见什么、这次调用获不获准”;调用中管“由谁执行、能不能并发”;调用后管“结果怎样安全回到下一轮”。
边界说明:本文的“调用前 / 调用中 / 调用后”是为了理解源码建立的观察框架,不是源码中三个完全串行的模块;文中的“统一控制平面”“乐观并发控制”“Agent 与工作流的差异”和最后的落地清单,是基于源码行为作出的工程归纳,不是 Claude Code 源码中的模块名或官方设计规范。
全文约 10000 字,预计阅读 25 分钟。
如果时间有限,可以先看第二章的一次完整调用,和第七章的总架构与落地清单。
一、一个 Tool,两张面孔
一次 LLM Request 大致由四部分组成:system 装行动规则,messages 装对话历史,tools 装本轮可选的工具定义,model 指定本次使用的模型。
这一讲只盯住 tools。先把本章结论放在前面:同一个工具,模型和 Runtime 看到的是两张完全不同的面孔。

图 1 就是全文最重要的边界:左侧是 API 接收的 Tool Definition,帮助模型选择能力;右侧是 Runtime 掌握的完整执行契约,负责把行动真正管起来。后面所有 Tool Search、校验、权限和结果处理,都发生在这条边界周围。下面把两侧分别拆开。
一个送给 Anthropic Messages API 的工具定义,可以简化成:
{
"name": "Read",
"description": "Reads a file from the local filesystem...",
"input_schema": {
"type": "object",
"properties": {
"file_path": { "type": "string" },
"offset": { "type": "number" },
"limit": { "type": "number" }
},
"required": ["file_path"]
}
}
模型靠三样东西理解一个工具:name 说明它叫什么,description 说明什么时候该用、什么时候不该用,input_schema 说明参数怎么写、哪些必填。
模型看不到 Claude Code 里真正执行文件读取的 TypeScript 函数。它只看到这份被转换后的协议。
但如果翻开 Claude Code 的 Tool 对象,会发现它远不止这三个字段。当前还原源码里的 Tool 接口,按顶层成员统计有 47 个字段或生命周期方法:是否启用、是否延迟加载、输入怎样校验、权限怎样判断、能否并发、收到新消息时是否中断、真实 call() 怎样执行、结果怎样映射给模型、超大结果怎样持久化、失败和进度怎样展示……
这说明 Claude Code 对“工具”有两个完全不同的观察面。
API 接收的是 Tool Definition。 除了 name、description、input_schema,还可能带 strict、defer_loading 等协议字段。启用 Tool Search 时,尚未发现的 deferred 工具不会进入当前请求的 tools[];模型先通过 ToolSearch 得到 tool_reference,Claude Code 再根据消息历史中的发现状态,把目标工具的完整 schema 加入后续请求。此时该定义可以带 defer_loading: true,由服务端结合 reference 展开给模型。
Runtime 管的是完整 Tool。 注册与启用、输入与业务校验、风险与权限元数据、Hooks 与人工确认、并发与中断策略、call() 真实执行、结果映射与体积治理、UI 与审计恢复——这是一份执行契约,负责回答“这个工具本轮能不能出现、参数真的合法吗、动作是否允许执行、结果怎样进入下一轮上下文”。
这两层不能混在一起。
description写得再严格,也只是给模型看的软规则。真正不能被绕过的限制,要放在 Runtime 的校验、权限和执行层。
二、先跑通一次:Read 的完整闭环
在分段拆解之前,先用最简单的 Read 完整走一遍,建立全文的基准路径。

图 2 把外层协议压缩成六步:模型只提出 tool_use,Runtime 执行真实动作,结果以 tool_result 回到下一轮 messages[]。下面按八个步骤把这条环真正走一遍。
用户说:打开 /project/src/auth.ts,看看登录校验是怎么实现的。
第一步:构造本轮工具池。 Claude Code 从内置工具、MCP 工具(通过 Model Context Protocol 接入的外部工具)和当前运行环境中组装候选工具,再按运行模式、功能开关、isEnabled()、可在请求前确定的整工具 blanket deny 规则和 MCP 连接状态过滤。只有本轮可能使用的工具,才有机会进入 API Request。
第二步:把 Read 转成 API schema。 从 Runtime Tool 中取出 name、prompt() 生成的模型可见 description、inputSchema 转换后的 JSON Schema,再根据模型能力和功能开关决定是否加入 strict、defer_loading 等协议字段。
第三步:模型输出 tool_use。 模型不会真的读取文件,它只在 assistant message 里提出调用意图:
{
"type": "tool_use",
"id": "toolu_read_01",
"name": "Read",
"input": {
"file_path": "/project/src/auth.ts"
}
}
第四步:Runtime 查找并校验工具。 Runtime 先通过输入 schema 检查 file_path、offset、limit 和 pages 的结构与数值约束;随后 Read 的 validateInput 预检 PDF 页范围、deny 路径、二进制扩展和可能阻塞的设备路径。文件是否真实存在、能否读取等 I/O 错误,要等 Read.call() 访问文件系统时才能确定。
第五步:Hooks 和权限裁决。 调用进入 PreToolUse Hooks(执行前预留的自定义检查点,用户和组织都可以在这里挂上自己的脚本)和统一权限系统。Read 通常比写入工具风险低,但“只读”不等于“自动拥有权限”——受保护路径、权限规则或组织 Hook 仍然可以拒绝它。
第六步:执行 Read.call()。 真正获准以后,Read 才访问文件系统,并按文件类型选择处理路径:普通文本、图片、Notebook、PDF 并不是同一种结果结构。
第七步:把内部结果映射成 tool_result。 工具内部结果不会原样塞给模型。Read 的 mapper 把它转换成 Anthropic 协议需要的结果块,tool_use_id 指回第三步的调用 ID。
第八步:回填下一轮 LLM Request。 这条结果不进 system,也不追加到 tools[].description。它作为一条 user role message 里的 tool_result content block,和前面的 assistant tool_use 通过 ID 配对。下一轮模型看到文件内容以后,才决定直接回答、继续 Grep,还是进入 Edit / Write / Bash。
所以 Tool Loop 的完整闭环是:
LLM Request(tools[])
-> assistant: tool_use
-> Runtime 治理与执行
-> user: tool_result
-> 下一次 LLM Request
这条线,是后面所有复杂工具的共同底座。接下来要拆开的,是中间那一段 Runtime 内部究竟设置了多少道控制关口。
三、调用前(上):先控制模型能看见什么
很多人把工具治理理解成权限弹窗。
但 Claude Code 的第一道治理发生得更早:在模型生成 tool_use 之前,先控制它能看到哪些工具,以及每个工具以什么定义出现。

图 3 把调用前治理分成三层:模型生成前管“能看见什么、能生成什么”;调度层先按输入判断并发安全性并排队;每个开始执行的调用再进入完整的 schema、业务校验、Hooks 和权限防线。权限只是单次调用防线的一个控制点,不是全部工具治理。
3.1 工具池不是一张写死的数组
Claude Code 确实有一组基础工具,但最终进入当前会话的工具池,会经过多层裁剪:
基础内置工具(构建类型 / 环境开关)
-> simple / REPL / coordinator 等专属处理
-> blanket deny 预过滤
-> tool.isEnabled()
当前 MCP 工具
-> blanket deny 预过滤
两路合并
-> built-in / MCP 分区稳定排序
-> 按 name 去重,built-in 优先
-> Agent 场景再走自己的专属过滤
这里有一个很重要的设计原则:
如果在请求构造阶段已经确定某个工具绝对不能使用,就没必要继续把它的名称、说明和 schema 暴露给模型。
这不仅减少误调用,也降低 prompt 体积。
3.2 prompt() 和 description() 不是一回事
Tool 接口里同时存在 prompt(...) 和 description(...),名字接近,用途不同。
prompt() 生成 API tools[].description,告诉模型什么时候用、参数怎么传、有哪些限制。例如 Edit 的模型说明会强调:先读取文件,old_string 必须精确匹配,不要在未理解上下文时修改。
description(input) 根据本次具体参数生成给人看的说明,用在权限确认和工具详情里。同一次 Edit,在权限 UI 里给人看的更像一句“修改 /project/src/auth.ts”。
一个面向模型选择,一个面向人类判断。
3.3 strict: true 到底严格在哪里?
当功能开关、模型和 API provider 都支持时,Claude Code 会在工具定义里加入 strict: true。
它作用在 API / 模型生成阶段:当功能开关、工具声明和模型能力都满足时,Claude Code 在工具定义中发送 strict: true,请求 API 更严格地遵循工具说明和参数 schema。至于服务端对具体 JSON Schema 子集提供哪些逐字段保证,属于 API 契约,不能只从这份客户端源码推导。
但 strict 不负责判断文件是否存在、old_string 是否能匹配、命令是否危险、用户是否授权。这些都在它的能力范围之外。
所以正确的分层关系是:
strict
请求 API 在受支持的 schema 子集内更严格遵循参数结构。
inputSchema.safeParse
在客户端执行前重新检查结构和类型。
validateInput
检查工具自己的业务条件和当前状态。
permissions
判断这个动作能不能执行。
strict请求的是更严格的 schema 遵循,不负责业务状态和动作授权;Claude Code 仍会在本地执行 inputSchema.safeParse、工具业务校验和权限检查。
3.4 工具太多时,Tool Search 自己也是一个工具
连接多个 MCP server 以后,把所有工具的完整 description 和 schema 都放进首轮请求,会占用大量上下文,也让模型在过多低频能力中更难选对工具。
Claude Code 的答案是 Tool Search。它不是藏在请求构造器里的普通搜索函数,而是通过统一 Tool 接口注册的标准工具:输入 query,只读、可并发,结果是能够展开工具定义的 tool_reference。
它不执行目标工具,也不联网安装新工具,只在当前已组装好的 deferred(延迟加载)工具池中搜索匹配项。
围绕延迟加载有三个容易混淆的字段:
shouldDefer 内置工具参与延迟判定的信号之一;
alwaysLoad 强制工具保持首轮加载,优先于默认延迟策略;
searchHint 只参与 Tool Search 的关键词匹配和排序。
shouldDefer: true 不表示工具被禁用;alwaysLoad: true 优先级更高,适合必须首轮完整可见的关键工具;searchHint 只提供检索关键词。三个字段都不负责权限。
一次完整的发现过程分两步:
第一步:ToolSearch(query) -> tool_reference(WebSearch)
第二步:下一轮请求恢复发现状态 -> 模型才能生成 tool_use(WebSearch)
它不是权限审批,也不是二级执行器。目标工具真正执行时,仍然要重新经过自己的 schema、业务校验、Hooks 和权限链路。
这里补一句背景:Prompt Cache 是 API 的前缀复用机制——本次请求开头那部分内容和上一轮完全相同时,API 可以直接复用之前的计算结果,更快也更便宜。所以工具定义最怕每一轮变来变去。
Claude Code 通过 session 内 schema 缓存和稳定排序减少基础工具定义的抖动;Tool Search 的发现状态来自消息历史,命中的 deferred 工具会在后续请求中加入 tools[]。因此请求并非只在消息尾部变化,但未发现的低频 schema 不必预先进入每一轮请求,稳定前缀仍有机会复用缓存。Tool Search 的目标不是“请求永远不变”,而是避免把所有低频工具 schema 都预先塞进请求。
3.5 Edit 必须先 Read,这个顺序谁来保证?
Edit 和覆盖已有文件的 Write 都要求先 Read,很容易据此推测存在 Edit.dependsOn = Read 这样的配置。
但当前 Tool 接口里并没有 dependsOn、requiresTool 这类依赖声明,也没有一张预先画好的工具依赖图。Claude Code 用两层方式处理依赖:
Prompt 负责让模型走正确顺序;
Runtime 状态负责强制关键前置条件。
Tool Prompt 告诉模型“修改前先读取”;Read 在会话状态里记录已读文件和时间信息,Edit / Write 在 validateInput 阶段检查这份状态。没有先 Read,或者 Read 之后文件又被外部修改,写入都会被拒绝。
由这条消息生成—执行顺序还可以推得一个约束:同一条 assistant message 里的多个 tool_use,后一个在生成时看不到前一个的结果。如果工具 B 的参数必须依赖工具 A 的输出,模型只能跨轮调用——第一轮拿到 A 的 tool_result,第二轮再构造 B 的 input。
从工程视角看,可以把这里体现出的差异概括为:传统工作流引擎通常把步骤依赖预先画成固定流程,Agent 的后续动作则可以由模型结合上一步结果动态决定,关键前置条件再交给运行时状态兜底。
四、调用前(下):参数合法,不等于动作已经获准
模型输出一个或多个 tool_use 以后,Runtime 先进行并发分类与排队,再让每个开始执行的调用进入自己的完整防线。源码里的两层关系更接近:
assistant: tool_use(s)
-> 调度层:findTool + safeParse(用于并发判定)
-> isConcurrencySafe(input)
-> concurrency batch / serial queue
每个开始执行的调用:
-> findToolByName
-> inputSchema.safeParse
-> validateInput(若工具实现)
-> 输入清洗与兼容字段回填
-> PreToolUse Hooks
-> 权限裁决 / 必要时人工确认
-> tool.call()
这里的第一次 safeParse 只服务于调度层的保守分类;进入单次执行链以后,Runtime 仍会重新解析输入并做完整治理。下面逐层拆开单次调用防线。
4.1 名称解析与结构校验
Runtime 先根据 tool_use.name 找工具定义,也可能处理兼容 alias(同一个工具的旧名或别名)。名称不存在时,系统不会随便猜一个工具执行,而是生成与原 tool_use.id 配对的错误结果,让模型下一轮修正。
找到工具后执行 inputSchema.safeParse(tool_use.input),检查字段、类型、enum 和对象结构。即使 API 已经启用 strict,这一步仍然保留——模型输出、第三方 provider、历史恢复和协议兼容都可能带来异常输入。
4.2 工具业务校验
schema 只知道“参数长得像不像”,不知道“当前能不能做”。所以工具还有自己的 validateInput(...):
Read:PDF 页范围、deny 路径、二进制扩展和阻塞设备路径等前置条件;文件存在性与可读性留到 call() 的 I/O 阶段处理;
Edit:old_string 是否唯一匹配,文件是否先被读取;
Write:目标已存在时是否满足先 Read 的条件;
Bash:命令、超时和运行方式是否符合限制;
可以把两层校验理解为:schema 判断“这是一把钥匙吗”,validateInput 判断“这把钥匙现在能开这扇门吗”。
4.3 PreToolUse Hooks:执行前的自定义检查点
进入权限系统之前,Claude Code 还留了一个可编程的扩展点:PreToolUse Hooks。
它的机制是:用户或组织在 settings 里按工具名注册外部命令。每次调用走到这一步,Claude Code 会在继续执行工具前运行匹配的 Hook,把本次的工具名和参数副本传给它们,并等待结果,再决定放行、拦截,还是使用 Hook 返回的更新输入继续。
这和提示词约束有本质区别。在 CLAUDE.md 里写“不要动 .env 文件”,是给模型看的软规则,模型可能忘、可能理解偏;写成 Hook,就变成一道程序化的门禁——不管模型怎么想,不合规的调用到这里直接被拦下,拦截原因还会作为配对的错误结果返回给模型,让它换方案或向用户解释。
典型用法:拦截危险命令、保护敏感文件、记录审计日志,或把调用接入组织的外部审批系统。
但 Hook 不是拥有最高权力的后门。即使 Hook 返回 allow,更高优先级的 deny、ask 和安全检查仍然可以阻止调用——收紧容易,放宽有上限。这个不对称设计保证了扩展点不会变成绕过安全边界的通道。
4.4 权限不是一个布尔值
Claude Code 的权限结果至少有四种语义:
allow 工具级判断允许,可以带 updatedInput;
deny 明确拒绝;
ask 需要进入人工确认;
passthrough 工具自己不下结论,交给统一权限系统继续判断。
权限规则可以来自用户设置、项目设置、组织策略、命令行参数、当前 session,以及用户刚刚在权限弹窗里保存的选择。
规则还可以只匹配某类内容,而不是粗暴地允许整个工具:只允许某类 Bash 命令、只允许某个目录、只允许某个域名、对某类修改始终询问。
4.5 什么情况下需要人介入
最终结果是 ask 时,系统把调用交给用户确认。用户不只是“同意 / 不同意”,还可以仅本次允许、拒绝并给模型反馈原因、修改参数后允许、保存一条以后复用的规则,甚至中止整个任务。
人类介入也是工具协议的一部分:它既是权限裁决,也是给模型提供纠偏信息的渠道。
如果后台 Agent 没有可用的交互界面,Claude Code 不会假装已经得到授权。它先看 PermissionRequest Hooks 能否给出决定;没有决定,就自动拒绝。
这是典型的 fail-closed:
没有人能确认,不等于默认获得权限。
顺带说清一个边界:权限模式可以改变默认行为,例如 acceptEdits 给安全文件编辑更快的允许路径,plan 限制真实执行。但即使进入 bypassPermissions,显式 deny、内容级 ask、必须交互的工具和某些安全检查仍然可以拦截调用。“跳过普通确认”和“关闭所有安全边界”不是一回事。
4.6 多个 tool_use 怎样并发
一个 assistant message 可以一次返回多个 tool_use。Claude Code 不会简单地全部并行,也不会一律串行,而是让每个工具通过 isConcurrencySafe(input) 根据本次参数判断能否并发:
连续的 concurrency-safe 调用
-> 组成并发 batch。
非 concurrency-safe 调用
-> 单独执行,前后调用都要等它跑完。
参数解析失败或并发判断异常
-> 保守地按不可并发处理。
注意 isReadOnly 描述的是副作用风险,isConcurrencySafe 描述的是能否和相邻调用同时运行——只读不必然等于并发安全。
并发结果不靠返回顺序配对,而是靠 tool_use_id。即使某个并行调用被取消,系统也会代填一条 synthetic error result(合成的错误结果),保证每个调用都有下文,协议不断裂。
调度器让某个调用开始运行后,只有当它自己的这些关口全部通过,才轮到真正的 tool.call()。
五、调用中:tool.call() 背后不止一种执行方式
Claude Code 统一的是外层治理协议:tool_use 进、校验和权限居中、tool_result 出。
但进入 tool.call() 以后,Runtime 可以把动作交给完全不同的执行后端。按“真正干活的是谁”来分类,当前还原源码里至少可以看到这样一张地图:
tool.call()
├─ 进程内本地执行:Read / Glob / Grep
├─ 本地协议适配与 Language Server 后端:LSP
├─ 带状态前置条件的本地写入:Edit / Write / NotebookEdit
├─ 子进程与后台任务:Bash / PowerShell / Monitor
├─ 能力发现与动态加载:ToolSearch / DiscoverSkills
├─ 直接网络访问与二次处理:WebFetch
├─ 嵌套 API 与服务端工具:WebSearch
├─ 外部协议适配:MCP tools / 远程连接
├─ 递归 Tool Loop:Agent / Team
├─ Prompt 展开与复合工具:Skill / Workflow / REPL
└─ 人工交互与内部控制面:AskUserQuestion / Plan / Todo / Task
这张地图上有三个值得停下来的观察。
其一,一次工具调用不一定直接返回最终结果。Bash 可以转成后台任务,先返回一个 task id,让后续工具继续查询、监控或停止同一个任务。
其二,“真实执行”不一定发生在本地。WebSearch 的执行器在 Anthropic 服务端,MCP 工具的执行器在外部 server,Agent 工具的内部是子 Agent 自己的多轮模型请求。
其三,工具不只用于操作外部世界。暂停下来问用户一个问题、更新任务清单、切换运行模式,也都通过同一套 Tool Loop 被治理。
本篇不逐类展开这张地图,只从中挑四个代表性分支讲透:文件状态依赖、工具发现、服务端二级调用和 Agent 内部状态。

图 4 预览了这四条路径的差异:都从 tool_use 进入、以 tool_result 离开,但 Runtime 中间可以是文件状态约束、二级发现、服务端模型请求,也可以是纯内部状态更新。每个分支都沿着同一组问题看:模型为什么选择它、Runtime 在执行前拦什么、真正调用发生在哪里、结果或失败怎样回到下一轮。
5.1 Edit / Write:依赖不是 prompt 里的建议
模型准备修改已有文件时,正确路径是:
Read
-> 记录文件内容和修改时间(mtime)
-> Edit 或 Write
-> validateInput 检查是否先读过
-> 检查 Read 后文件是否被外部改变
-> 权限裁决
-> 安全写盘
-> 更新 readFileState
这里最值得借鉴的是“软规则 + 硬状态”组合:Tool Prompt 先告诉模型应该 Read,Runtime 再通过会话状态拒绝不满足前置条件的写入。
如果文件在 Read 以后被其他进程修改,Edit / Write 也不能拿旧认知覆盖新内容。思路是:先读取并记录版本,写入前确认版本没变,变了就让模型重新读取和规划。这可以类比为数据库领域的乐观并发控制。
它比给工具接口增加一个 dependsOn: Read 更有价值——真正需要保护的不是调用顺序本身,而是模型执行写入时,对目标文件的认知是否仍然新鲜。
模型选择:
Edit 提交 file_path / old_string / new_string;
Write 提交 file_path / content。
执行前:
schema 检查参数形状;
validateInput 检查是否 Read、内容是否匹配、文件是否变化;
写入权限仍可能进入 ask。
执行中:
Edit 做精确字符串替换;
Write 创建新文件或完整覆盖已有文件。
执行后:
成功结果更新 readFileState;
失败结果告诉模型重新 Read、修正匹配内容或换一种方案。
它代表的是“带版本前置条件的本地写入型工具”。
5.2 Tool Search:调用一个工具,是为了发现另一个工具
Tool Search 是元工具:一个专门用来发现其他工具的工具。它的 call() 不访问文件、不搜索网页,也不执行 MCP server,只在当前 deferred 工具池中匹配名称、searchHint 和 prompt,把匹配项映射为 tool_reference。
关键约束是:Tool Search 的 allow,不等于目标工具的 allow。
发现能力和授权能力必须分开。否则“知道系统里有一个工具”,就会错误地等价于“已经允许执行它”。
模型选择:
输入 query,按名称或能力关键词寻找 deferred 工具。
执行前:
ToolSearch 自身是只读工具;
输入只负责搜索定义,不携带目标工具的执行参数。
执行中:
call() 只在 deferred 工具池中做匹配。
执行后:
mapper 返回 tool_reference;
找不到时模型可以改写 query;
找到后下一轮再调用目标工具,并重新经过目标工具的权限链。
它代表的是“能力发现型元工具”。
5.3 WebSearch:外层 Tool Loop 里还有一次内层模型请求
WebSearch 最容易被误解成一个本地搜索 SDK。当前还原源码里的路径更接近:
主模型
-> 外层 tool_use(name=WebSearch)
-> Claude Code 本地 WebSearch.call(...)
-> 再发起一次独立的 Anthropic Messages API 请求
-> 内层请求携带 server tool: web_search_20250305
-> 内层模型生成 server_tool_use
-> Anthropic 服务端执行搜索
-> 返回 web_search_tool_result 和文本
-> 本地包装成外层普通 tool_result
-> 回给主模型
这里同时存在两套工具协议:外层是 Claude Code 的 Tool Loop(tool_use / tool_result),内层是 Anthropic 服务端工具调用(server_tool_use / web_search_tool_result)。外层主模型决定“当前任务需要搜索”,内层模型负责组织真正的搜索请求。所谓 server tool,就是由 Anthropic 在服务端托管执行的工具——搜索动作既不发生在模型里,也不发生在 Claude Code 本地。
这也解释了为什么某些 provider 即使支持普通 tool_use,也不一定能直接使用这个内建 WebSearch——它们还需要支持内层服务端搜索协议。
allowed_domains 也不能替代外层权限:前者约束内层搜索范围,Claude Code 仍要先决定这次 WebSearch 是否允许执行。
模型选择:
外层输入 query,可选 allowed_domains 或 blocked_domains。
执行前:
validateInput 检查 query 非空;
allowed_domains 和 blocked_domains 不能同时出现;
外层 WebSearch 权限独立裁决。
执行中:
call() 创建独立内层 Messages API 请求;
内层模型驱动 web_search_20250305 服务端工具。
执行后:
内层结果被整理成外层 tool_result;
provider 不支持、权限拒绝或内层搜索失败都是可恢复的错误路径。
它代表的是“本地包装 + 服务端托管执行型工具”。
5.4 TodoWrite:没有操作外部世界,也完整走 Tool Loop
TodoWrite 不读文件、不写业务代码、不执行命令,只更新当前 Agent 的结构化任务状态。但它仍然是一个标准工具:tools[] 里有定义,模型输出 tool_use,Runtime 通过 inputSchema.safeParse 校验每一项,call() 更新 AppState.todos(Claude Code 进程内的应用状态,不落到你的项目文件里),mapper 生成简短 tool_result。TodoWrite 没有单独实现 validateInput;列表结构、非空文本和状态 enum 由 TodoListSchema 在本地 schema 校验阶段处理。
TodoWrite 每次提交的是整张列表快照,不是增量 patch。所以在会话恢复路径里(尤其 SDK / 非交互场景),系统可以从后往前找到最后一次 TodoWrite tool_use,取出 input.todos 重建当前状态。这也是为什么完整列表存在于 assistant tool_use.input 中,而成功的 tool_result 可以非常短。
它还体现了三条输出通道的分离:AppState 保存结构化状态,终端 UI 展示成独立面板,LLM tool_result 只返回模型继续工作所需的简短确认。主 Agent 和子 Agent 使用不同的 todo key,避免共享一张清单相互污染。
模型选择:
多步骤任务需要显式跟踪时,提交完整 todos[] 快照。
执行前:
strict 请求更严格的服务端 schema 遵循,本地 inputSchema.safeParse 再检查列表结构和状态;
todo 更新本身不需要外部世界权限确认。
执行中:
call() 按 sessionId 或 agentId 更新 AppState.todos。
执行后:
UI 展示完整列表,模型只收到简短确认;
全部完成时清空活动状态,恢复时可从最后一次 tool_use 重建。
它代表的是“Agent 内部状态管理型工具”。
四条路径合起来,验证了这一章的结论:入口和出口统一,内部执行路径不同。Bash 的子进程、MCP 的协议桥接、Agent 的递归 Tool Loop,同属这张地图上尚未展开的分支。
六、调用后:结果不是原样塞回模型
工具执行完成,只代表真实动作结束。要让 Agent 继续工作,还要把结果治理成合法、可控、可恢复的下一轮上下文。这里不能把所有工具压成一条完全相同的固定顺序:
普通内置工具成功:
call -> mapper -> 单结果体积处理 -> tool_result message
-> PostToolUse 附件 / 审计 / continuation 控制
MCP 工具成功:
call -> PostToolUse
-> 可选 updatedMCPToolOutput
-> mapper -> 单结果体积处理 -> tool_result message
执行失败:
catch -> PostToolUseFailure
-> is_error tool_result + hook messages
三条路径随后进入消息规范化、配对修复和下一次 LLM Request。
6.1 模型结果和终端结果不是同一份内容
每个 Tool 都可以实现 mapToolResultToToolResultBlockParam(...),负责把内部 data 转成模型能看到的 tool_result;终端 UI 则通过另一组 render 方法展示进度、成功、失败、权限拒绝和可展开详情。
两个通道服务的对象不同:给模型看的结果追求协议合法、信息足够、上下文成本可控;给用户看的结果追求可读、可审查。TodoWrite 的独立任务面板、Bash 的运行进度,都说明 UI 没必要机械复刻模型上下文。
工具成功后还可以运行 PostToolUse Hooks,用于审计、附加提醒或控制后续流程。对 MCP 工具,Hook 还可以通过 updatedMCPToolOutput 改写输出后再映射;普通内置工具的结果已经先完成映射和体积处理,不能把“过滤输出”概括成所有 PostToolUse Hook 的通用能力。call() 抛错则进入 PostToolUseFailure Hooks,系统会尽量把失败转换成 is_error: true 的匹配结果。
6.2 错误也必须生成 tool_result
Claude Code 会尽量给每一个 tool_use 生成一个可配对的结果,包括:
工具名不存在;
schema 校验失败;
validateInput 失败;
权限被拒绝;
PreToolUse Hook 阻止;
真实执行抛错;
并行 sibling 被取消;
用户中止调用。
为什么失败也要返回结果?因为下一轮模型需要区分:参数写错了要重试、前置条件不满足要先 Read、权限不允许要换方案、执行环境失败要诊断错误。错误如果只出现在终端日志里,模型看不到,就无法恢复。
6.3 大结果不能无限占用上下文
一条 Bash 命令可能打印几万行日志,MCP 工具可能返回大型 JSON。Claude Code 有一层常规的单结果体积治理:结果超过工具的有效阈值时,完整内容写入当前会话目录下的 tool-results 文件,模型只收到大约 2,000 bytes 的预览和完整文件路径。
在相应功能开关启用、contentReplacementState 可用时,还会执行第二层消息级聚合预算:同一条 API user message 里的多个结果合计过大时,系统继续选择较大的结果做持久化替换,直到总量回到预算内。
空字符串和纯空白结果也会被规范成类似 (ToolName completed with no output) 的占位文本,避免模型把空工具结果误认为对话边界。
顺带一个边界:Tool 接口里可以有 outputSchema,但当前还原源码的主执行链里,没有看到所有普通工具都统一执行 outputSchema.safeParse。当前可以直接确认的消费点主要是 MCP entrypoint 的结构暴露,以及部分 UI 恢复和渲染路径的安全解析;声明它不等于自动完成结果治理。
6.4 tool_result 最后拼接到 request 的哪里
这是整篇最容易记错的地方。

图 5 里最关键的不是 role=user 这个表面名称——它只是 Anthropic 工具协议中的消息结构,不表示用户亲手输入了这段结果——而是两端共享同一个调用 ID。没有这层配对,模型就无法确定结果属于哪一次行动意图。
落到 API 请求里,tool_result 不进入 system prompt,不进入 tools[],也不追加在 assistant 文本尾部,而是进入下一次 LLM Request 的 messages:
[
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "toolu_123",
"name": "Read",
"input": {
"file_path": "/project/src/auth.ts"
}
}
]
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_123",
"content": "...result..."
}
]
}
]
发出下一次请求前,Claude Code 还会做最后一道整理。先合并清理本地消息结构(normalizeMessagesForAPI);再专门检查配对完整性(ensureToolResultPairing):有调用没结果的,代填一条错误结果;有结果找不到调用的,直接删掉;最后剥离当前模型不支持的内容块和多余媒体。
所以 tool_use_id 不只是一个方便调试的字段。它是工具执行结果能够安全进入下一轮推理的协议主键。
6.5 Compact / resume 后怎样保持工具历史合法
工具历史也是上下文治理的一部分。会话 compact 或恢复以后,Claude Code 需要继续保持 tool_use / tool_result 配对、Tool Search 已发现工具集合、大结果已被替换成文件预览的状态。
这和第4讲的结论一致:
Compact 不是简单压短文本,还要恢复一个符合 API 协议、能够继续执行的现场。
七、总架构与落地清单
到这里,可以把整条链路放回一张图。

图 6 可以压缩成四个观察面。前三个沿着一次 Tool Loop 展开,第四个贯穿整个会话生命周期。
1. 调用前|选择、校验与授权
治理工具暴露、按需发现、并发分类与排队、schema、业务校验、调用前 Hooks、权限和人工确认。
2. 调用中|启动、执行与中断
治理已排队调用的启动与中断、tool.call(),以及本地或服务端的真实执行。
3. 调用后|映射、配对与回填
治理结果映射、错误配对、体积控制、tool_result 回填和恢复。
4. 横向能力|稳定、扩展与恢复
治理缓存稳定性、Hooks 扩展、审计,以及 compact / resume。
最重要的边界可以浓缩成四句话:
发现不等于授权; strict不等于业务校验;只读不等于必然免审批;tool_result不进入 System Prompt。
自己构建 Agent 时,工具治理怎么落地
如果你在构建自己的 Agent,想把这套工具治理搬过去,可以直接用下面这份清单——自己照着做,或者整段丢给编程助手(Codex、Claude Code),让它帮你实现。
下面是基于前述源码实现归纳的最小可行版本,不是 Claude Code 源码中的规范、模块划分或逐行复刻:
目标:
为一个 Agent 搭建位于模型和真实世界之间的工具控制平面。
核心模块:
1. 双层工具契约
模型侧:name、description、input schema。
Runtime 侧:enable、validate、permission、schedule、call、result mapper。
2. 工具池构造
每轮请求前按运行模式、开关和权限过滤候选工具;
确定不可用的工具不进入 tools[];
工具按稳定顺序排列,保护 prompt 缓存前缀。
3. 两层输入校验
schema 校验结构和类型;
业务校验检查当前状态,例如文件是否读过、目标是否被外部修改。
4. 权限裁决器
规则 = 工具名 + 内容模式 + allow/deny/ask + 来源;
deny 优先;没有明确允许时进入 ask;
无人可确认时自动拒绝,fail-closed。
5. 并发调度器
isConcurrencySafe(input) 按本次参数判断能否并发;
不确定就保守串行;
被取消的调用也要生成配对的错误结果。
6. 结果治理
每个 tool_use 都有配对的 tool_result,失败也不例外;
给模型的结果和给用户的展示分成两条通道;
超大结果落盘存档,模型只收预览和文件路径。
7. 恢复协议
会话压缩或恢复后,保持调用与结果配对、
已发现工具集合和大结果替换关系。
关键约束:
- 安全底线写在 Runtime 校验和权限里,不能只写在工具说明里。
- 工具被看见、被发现、被授权,是三个独立判断。
- 错误只出现在日志里,模型就无法恢复;必须回到 tool_result。
- 给模型和给用户的内容不共用同一份渲染。
如果只做最低可行版,可以先实现四件事:
1. 双层契约:schema + call + result mapper 分开。
2. 两层输入校验:结构一层,业务状态一层。
3. deny 优先、fail-closed 的权限裁决。
4. 每个调用必有配对结果,失败也不例外。
做到这些,工具系统才不只是“能调用”,而是能够在长任务里持续、可控地运行。
结尾:工具能力的核心,不是“能调用多少工具”
这一讲从 LLM Request 的 tools[] 出发,看了 Claude Code 如何治理一次工具调用。
Read 和 Edit 说明,工具之间的关键前置条件要由 Runtime 状态兜底。
Tool Search 说明,发现能力和执行权限必须分开。
WebSearch 说明,一个外层工具内部还可能启动另一套模型与服务端工具协议。
TodoWrite 说明,即使不操作外部世界,Agent 内部状态也可以通过标准 Tool Loop 被管理、展示和恢复。
从工程视角,可以把 Claude Code 的这些机制概括为模型与真实世界之间的一套统一控制平面。
模型负责决定下一步想做什么,工具治理系统负责确保这一步看得见、说得清、验得过、批得下、跑得稳、收得回来。
在本文的工程归纳中,它是 Agent 从“会回答”走向“能可靠行动”的重要边界之一。
当然,这套控制平面里还有不少值得单独拆开的话题:每种执行后端由谁真正执行、权限规则怎样精确关联到具体工具、Hooks 怎样承接组织策略。这个系列会继续沿着 Claude Code 的源码,把它们一个个讲清楚。
夜雨聆风