如果你已经会调用大模型,也做过最基础的“让模型选择工具并发起调用”的小实验,接下来很容易遇到一个落差:
模型明明已经能“调用工具”了,但系统还是不太像一个真正能交付任务的 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,
};
这当然能跑,但它只解决了一个很窄的问题:模型能不能按要求发起一次工具调用。
真正难的不是“会不会调”,而是这次调用能不能被校验、被约束、被执行,并且把结果稳定地接回后续推理。
把这种小实验往“能稳定完成任务”推进,马上会遇到另一组问题。这里先不急着下结论,先把问题摊开看:
- 模型看到的工具名字、描述、输入结构,到底由谁定义?
- 一个工具在当前会话里是否应该暴露,能不能动态下线?
- 模型生成的参数格式不对、值不合法时,谁来兜底?
- 这次调用是只读、写入,还是潜在破坏性操作?
- 调用前是否需要权限判断、hook(流程前后插入的额外逻辑)、审计或用户确认?
- 两个工具能不能并发,不该由“是否异步”决定,那该由什么决定?
- 工具执行完之后,结果应该怎样回流,模型下一轮才看得懂?
- 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 模型接口:告诉模型“这个工具叫什么、怎么用”
这一组字段决定的是,模型眼里看到的工具协议是什么:
nameinputSchemaoutputSchemadescription()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_path、offset、limit 这些字段,也不代表它一定会给出合法值。比如 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()。它走的是一条明确分层的生命周期。
用源码里的 runToolUse 和 checkPermissionsAndCallTool 来压缩,大致是下面这条链:
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 的实现阶段。