如果你已经会调用大模型,也做过最基础的“让模型选择工具并发起调用”的小实验,接下来很容易遇到一个落差:
模型明明已经能“调用工具”了,但系统还是不太像一个真正能交付任务的 agent。
它可能会查资料、会读文件、也会跑命令,但一旦任务稍微复杂一点,就会出现这些问题:
这说明真正难的地方,从来不是“给模型接几个函数”,而是怎么把工具变成一套可调度、可约束的执行系统。
这篇文章不讲工具列表,也不做 API 说明书。我们换一个更适合 agent 开发者的视角:Claude Code 到底是怎么把工具组织成 runtime 的,以及这套设计为什么值得借鉴。
很多新人第一次做 agent,会先写出这样一份工具表:
async function readFile(filePath) {
return fs.readFile(filePath, 'utf8');
}
const tools = {
readFile,
editFile,
searchWeb,
runShell,
};这当然能跑,但它只解决了一个很窄的问题:模型能不能按要求发起一次工具调用。
真正难的不是“会不会调”,而是这次调用能不能被校验、被约束、被执行,并且把结果稳定地接回后续推理。
把这种小实验往“能稳定完成任务”推进,马上会遇到另一组问题。这里先不急着下结论,先把问题摊开看:
这里先把问题压住,不急着解释。接下来几章,我们就按这条问题链往下走:先看一个工具是怎么被定义出来的,再看它如何进入会话、如何被执行、如何参与并发,最后再回到文章开头的那些问题。
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 组:
这一组字段决定的是,模型眼里看到的工具协议是什么:
nameinputSchemaoutputSchemadescription()prompt()你可以把它们理解成“给模型看的那一面”。名字、描述和输入结构说不清,模型连怎么发起一次稳定调用都做不到。
这一组字段决定的是,runtime 要怎么管理这次调用:
validateInput(input)checkPermissions(input, context)isReadOnly(input)isConcurrencySafe(input)这里的重点不是“能不能调用”,而是“系统该不该放行、该怎么调度、能不能并发”。
这一组要解决的问题很具体:工具执行完以后,结果不能只停留在程序内部,还得回到会话里,成为模型下一轮真正能看到的上下文。
对应到代码里,通常会分成两步:
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(...) 关注的是“做完以后,怎样把结果变成统一的会话格式”。分开以后,工具内部可以保留自己最自然的数据结构,而整个系统在写回会话时,仍然能保持统一格式。
如果没有这一步,就很容易出现一种很典型的情况:程序里明明拿到了结果,但模型下一轮像没看见一样,因为结果没有被整理成它真正能继续读取的会话内容。
所以这一组能力其实只在解决两件事:工具怎么真正执行,以及执行完以后,结果怎么回到会话里,变成后续对话还能继续使用的上下文。
buildTool:统一创建工具,并补齐默认行为源码里还有一个很值得借鉴的小设计:buildTool。它不是语法糖,而是在用默认值强制大家走统一的安全基线:
const TOOL_DEFAULTS = {
isEnabled: () =>true,
isConcurrencySafe: () =>false,
isReadOnly: () =>false,
isDestructive: () =>false,
checkPermissions: input =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
};这里最重要的一点是:安全相关默认保守,便利性相关默认补齐。你可以把 buildTool 理解成一个统一创建工具、并顺手补齐默认行为的函数。这样每个官方工具在进入系统之前,都会先落到同一套基础规则上,而不是各写各的。
readFile 看一个工具是怎么落地的有了这个抽象,再看 readFile 就更容易理解了。Claude Code 里的 FileReadTool 并不是 fs.readFile 的薄封装,而是一个完整工具:
export constFileReadTool = buildTool({
name: FILE_READ_TOOL_NAME,
maxResultSizeChars: Infinity,
strict: true,
userFacingName,
isConcurrencySafe() {
returntrue;
},
isReadOnly() {
returntrue;
},
asynccheckPermissions(input, context) {
returncheckReadPermissionForTool(
FileReadTool,
input,
context.getAppState().toolPermissionContext,
);
},
asyncvalidateInput({ file_path, pages }, toolUseContext) {
// 参数值校验
},
asynccall(input, context) {
const filePath = resolveFilePath(input.file_path);
const content = awaitreadFile(filePath, 'utf8');
return {
filePath,
content,
lineCount: content.split('\n').length,
};
},
});这个例子能把 Tool 合同讲得非常具体。
第一,它先声明自己是只读、可并发的。也就是说,并发策略不是执行器拍脑袋猜出来的,而是工具自己声明。
第二,它有单独的 checkPermissions。读文件看起来风险低,但依然要走文件系统权限规则,而不是因为“只是 Read”就绕过 runtime。
第三,它有自己的 validateInput。模型就算知道 file_path、offset、limit 这些字段,也不代表它一定会给出合法值。比如 PDF 的 pages 范围、偏移参数的边界,都需要工具自己兜底。
第四,它的 call 里处理的远不只是文本读取。源码里还能看到这些逻辑:
/dev/zero 这类会卡住进程的路径;所以从 runtime 视角看,readFile 的真实职责不是“把磁盘内容拿出来”,而是“把受约束、可解释、可继续推理的上下文安全注入会话”。
到这里,再回头看标题里的“runtime 合同”,它至少已经不只是一个比喻了:只要你开始认真处理 schema、权限、只读性、并发性和结果映射,工具就不再是一个裸函数。
换句话说,这一章真正回答的是:谁来定义工具协议,参数不合法时谁兜底,读写风险和权限检查又该放在哪一层。Claude Code 的答案不是“调度层临时判断”,而是把这些能力直接内建进 Tool 合同。
工具定义完,不代表模型立刻就能看到它。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 的做法是把“实现”和“暴露”明确分成两层,先有能力,再决定此刻要不要公开。
当模型真的产出一个 tool_use 之后,Claude Code 也不是立刻 tool.call()。它走的是一条明确分层的生命周期。
用源码里的 runToolUse 和 checkPermissionsAndCallTool 来压缩,大致是下面这条链:
模型产出 tool_use按 name 找到 Tool按输入结构先做基础解析validateInputPreToolUse hooks权限决策tool.call整理成标准化结果PostToolUse hooks写回会话,继续推理如果只看核心代码,味道是这样的:
// 先按输入结构做基础解析
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. 也可能直接阻断执行
forawait (const result ofrunPreToolUseHooks(
toolUseContext,
tool,
processedInput,
toolUseID,
messageId,
requestId,
mcpServerType,
mcpServerBaseUrl,
)) {
// 根据 hook 返回的类型,更新输入、记录消息或中止执行
}
// 综合 hook 和权限系统的结果,决定这次调用能不能继续
const permissionDecision = awaitresolveHookPermissionDecision(...)
// 真正执行工具
const result = await tool.call(processedInput, context, canUseTool, assistantMessage)
// 把工具内部结果整理成会话里统一的返回格式
const toolResultBlock = formatToolResult(result.data, toolUseID)
// 运行后置 hooks,做补充处理
forawait (const hookResult ofrunPostToolUseHooks(tool, result, context)) {
// hook 可以追加消息、记录信息,或补充处理结果
}这段流程可以先按 4 步来理解。
第一步,先处理输入。系统会先按输入结构做基础解析,再交给 validateInput 做更细的参数校验。前者更像“字段类型对不对”,后者更像“字段值能不能这样用”。
第二步,处理执行前的控制逻辑。PreToolUse hooks 会在真正执行之前跑一遍,它们可以补充消息、追加额外上下文、改写输入,甚至直接阻断这次调用。接着,权限系统再根据当前规则决定这次工具调用是否允许继续。
第三步,真正执行工具。到了 tool.call(...),系统才开始做这次调用真正要做的事情,比如读文件、改文件、执行命令,或者访问外部能力。
第四步,把结果写回会话。tool.call(...) 返回的往往还是工具内部更方便处理的数据,系统还要再把它整理成统一的结果格式,写回会话里。只有这样,模型下一轮才能继续读到这次调用真正产生了什么。
这里最容易被忽略的,其实就是第四步。很多系统把“工具执行成功”当成结束,但对 agent 来说,这还不够。工具结果只有重新进入会话,才会变成后续推理真正可用的上下文。
所以在 Claude Code 里,工具结果首先服务的是后续推理,其次才是界面展示。比如 FileReadTool 在界面里可能只显示“读取了多少行”,但写回会话的结果会带真正的文件内容、行号和必要提醒。这两层故意分开,就是为了同时服务系统推理和交互界面。
如果回到文章开头的问题,这一章真正补上的,是工具调用中间那条最容易被忽略的主链路:参数校验放在哪里,权限与 hook 插在什么位置,工具结果又是怎么重新回到下一轮推理里的。
工具并发是另一个最容易被做坏的地方。很多系统默认“能 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 能不能一起跑,还是必须排队执行。
也就是说,这里有两层分工:
真正决定并发是否成立的,不是模型想不想并发,而是这些工具在语义上是否允许并发执行。
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 的实现阶段。