如果你已经会调用大模型也做过最基础的“让模型选择工具并发起调用”的小实验接下来很容易遇到一个落差模型明明已经能“调用工具”了但系统还是不太像一个真正能交付任务的 agent。它可能会查资料、会读文件、也会跑命令但一旦任务稍微复杂一点就会出现这些问题工具虽然调用成功了但结果没法稳定接回后续推理工具很多但模型不知道什么时候该用工具并发一开状态就乱同样叫“工具”读文件和改文件明明风险完全不同runtime 却一视同仁。这说明真正难的地方从来不是“给模型接几个函数”而是怎么把工具变成一套可调度、可约束的执行系统。这篇文章不讲工具列表也不做 API 说明书。我们换一个更适合 agent 开发者的视角Claude Code 到底是怎么把工具组织成 runtime 的以及这套设计为什么值得借鉴。1. 先建立一个正确心智模型工具不是函数表而是 runtime 合同很多新人第一次做 agent会先写出这样一份工具表asyncfunctionreadFile(filePath){returnfs.readFile(filePath,utf8);}consttools{readFile,editFile,searchWeb,runShell,};这当然能跑但它只解决了一个很窄的问题模型能不能按要求发起一次工具调用。真正难的不是“会不会调”而是这次调用能不能被校验、被约束、被执行并且把结果稳定地接回后续推理。把这种小实验往“能稳定完成任务”推进马上会遇到另一组问题。这里先不急着下结论先把问题摊开看模型看到的工具名字、描述、输入结构到底由谁定义一个工具在当前会话里是否应该暴露能不能动态下线模型生成的参数格式不对、值不合法时谁来兜底这次调用是只读、写入还是潜在破坏性操作调用前是否需要权限判断、hook流程前后插入的额外逻辑、审计或用户确认两个工具能不能并发不该由“是否异步”决定那该由什么决定工具执行完之后结果应该怎样回流模型下一轮才看得懂UI 展示给用户的内容为什么不能直接等于工具内部返回值这里先把问题压住不急着解释。接下来几章我们就按这条问题链往下走先看一个工具是怎么被定义出来的再看它如何进入会话、如何被执行、如何参与并发最后再回到文章开头的那些问题。2. 如何写一个工具从 Tool 抽象到 readFile 的实现Claude Code 的第一步不是直接散落地实现一堆工具而是先定义统一的Tool合同。你可以把它粗略理解成下面这个样子typeTool{name:string;inputSchema:Schema;outputSchema:Schema;description():Promisestring;prompt():Promisestring;validateInput(input):ValidationResult;checkPermissions(input,context):PermissionResult;isReadOnly(input):boolean;isConcurrencySafe(input):boolean;call(input,context):PromiseResult;// 概念层把工具内部结果整理成“可回写”的标准结果formatResult?(result):ToolResult;// 实现层把结果映射成真正写入消息流的 tool_result block 参数mapToolResultToToolResultBlockParam(result,toolUseId,):ToolResultBlockParam;};这里补一层说明避免把两个不同层次的接口看成冲突上面的formatResult是为了帮助理解而抽象出来的“结果格式化”能力落到 Claude Code 的实际实现时更常见的是更具体的mapToolResultToToolResultBlockParam(result, toolUseId)。它比formatResult多带一个toolUseId返回的也不是泛化的ToolResult而是可以直接写回会话消息流的tool_resultblock 参数。下面进入结果回写时我们统一按这个更贴近实现的名字展开。第一次看到这段接口很容易被字段数量吓到。更好的读法不是逐个背字段而是先把它拆成 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会被写回会话变成模型下一轮真正能读到的内容。如果用伪代码表示大概是这样constresultawaittool.call(input,context);// 比如工具内部先返回// { content, filePath, lineCount }consttoolResulttool.mapToolResultToToolResultBlockParam(result,toolUseId);// 然后整理成会话里真正要写回的结构// { type: tool_result, tool_use_id: xxx, content: ... }为什么不让call()直接返回最终要写回会话的结果因为这两层处理的是两类不同的问题call()关注的是“工具内部怎么把事情做完”mapToolResultToToolResultBlockParam(...)关注的是“做完以后怎样把结果变成统一的会话格式”。分开以后工具内部可以保留自己最自然的数据结构而整个系统在写回会话时仍然能保持统一格式。如果没有这一步就很容易出现一种很典型的情况程序里明明拿到了结果但模型下一轮像没看见一样因为结果没有被整理成它真正能继续读取的会话内容。所以这一组能力其实只在解决两件事工具怎么真正执行以及执行完以后结果怎么回到会话里变成后续对话还能继续使用的上下文。2.4buildTool统一创建工具并补齐默认行为源码里还有一个很值得借鉴的小设计buildTool。它不是语法糖而是在用默认值强制大家走统一的安全基线constTOOL_DEFAULTS{isEnabled:()true,isConcurrencySafe:()false,isReadOnly:()false,isDestructive:()false,checkPermissions:inputPromise.resolve({behavior:allow,updatedInput:input}),};这里最重要的一点是安全相关默认保守便利性相关默认补齐。你可以把buildTool理解成一个统一创建工具、并顺手补齐默认行为的函数。这样每个官方工具在进入系统之前都会先落到同一套基础规则上而不是各写各的。2.5 用readFile看一个工具是怎么落地的有了这个抽象再看readFile就更容易理解了。Claude Code 里的FileReadTool并不是fs.readFile的薄封装而是一个完整工具exportconstFileReadToolbuildTool({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){constfilePathresolveFilePath(input.file_path);constcontentawaitreadFile(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()。它先组出一套“理论上可用”的内建工具集合exportfunctiongetAllBaseTools():Tools{return[BashTool,FileReadTool,FileEditTool,WebFetchTool,...extraTools];}但真正给当前会话用的不是这份静态列表而是getTools(permissionContext)再过滤一遍exportconstgetTools(permissionContext:ToolPermissionContext):Tools{if(isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)){return[BashTool,FileReadTool,FileEditTool];}letallowedToolsfilterToolsByDenyRules(getAllBaseTools(),permissionContext,);returnallowedTools.filter(tooltool.isEnabled());};这个注册链路至少做了三件事。第一区分“实现了”与“暴露了”。工具写在代码里不代表本轮就该给模型看见。第二把环境和模式带进来。simple模式下系统会主动退化成极小工具集而不是把所有能力都开放给模型。第三把 deny rules 和isEnabled()作为注册阶段的一部分而不是等模型调用时才拒绝。这样做的意义很大因为它减少了模型的决策噪音也缩小了高风险能力的暴露面。这也是为什么 Claude Code 的工具系统更像“能力管理系统”而不是一个函数目录。注册层要解决的不是“还有哪些函数没挂上”而是“当前这轮对话里哪些能力应该被模型看见”。所以这一章想回答的问题其实很简单一个工具就算已经写好了为什么这一轮会话里仍然可能不该暴露给模型。Claude Code 的做法是把“实现”和“暴露”明确分成两层先有能力再决定此刻要不要公开。4. 工具的生命周期当模型真的产出一个tool_use之后Claude Code 也不是立刻tool.call()。它走的是一条明确分层的生命周期。用源码里的runToolUse和checkPermissionsAndCallTool来压缩大致是下面这条链模型产出 tool_use按 name 找到 Tool按输入结构先做基础解析validateInputPreToolUse hooks权限决策tool.call整理成标准化结果PostToolUse hooks写回会话继续推理如果只看核心代码味道是这样的// 先按输入结构做基础解析constparsedInputparseInputBySchema(tool.inputSchema,input)// 再做更细的参数校验constisValidCallawaittool.validateInput?.(parsedInput.data,toolUseContext)letprocessedInputisValidCall.updatedInput??parsedInput.datalethookPermissionResult// 运行前置 hooks// 1. 可能补充消息// 2. 可能追加额外上下文// 3. 可能改写输入// 4. 也可能直接阻断执行forawait(constresultofrunPreToolUseHooks(toolUseContext,tool,processedInput,toolUseID,messageId,requestId,mcpServerType,mcpServerBaseUrl,)){// 根据 hook 返回的类型更新输入、记录消息或中止执行}// 综合 hook 和权限系统的结果决定这次调用能不能继续constpermissionDecisionawaitresolveHookPermissionDecision(...)// 真正执行工具constresultawaittool.call(processedInput,context,canUseTool,assistantMessage)// 把工具内部结果整理成会话里统一的返回格式consttoolResultBlockformatToolResult(result.data,toolUseID)// 运行后置 hooks做补充处理forawait(consthookResultofrunPostToolUseHooks(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里的核心判断非常直接privatecanExecuteTool(isConcurrencySafe:boolean):boolean{constexecutingToolsthis.tools.filter(tt.statusexecuting)return(executingTools.length0||(isConcurrencySafeexecutingTools.every(tt.isConcurrencySafe)))}它真正关心的是这次调用在语义上是否并发安全。再结合工具定义里的声明你就能看懂这套策略FileReadTool明确声明isConcurrencySafe() { return true }所以多个读取类工具可以并发。没有显式声明并发安全的工具默认按不安全处理。只要队列里出现非并发安全工具它就要求独占执行。这背后的价值不是“更保守”而是让并发策略与工具语义绑定而不是与技术实现绑定。因为 agent 的工具不是纯函数。它们操作的是文件系统、终端、外部服务和会话状态。只要涉及副作用并发问题就不是吞吐问题而是一致性问题。Claude Code 在执行器里还做了两件很实用的事即使并发执行结果也会按工具出现顺序缓冲和回放避免会话里的结果顺序被打乱。如果某个并发中的工具出错兄弟工具可以被取消或生成合成错误结果避免系统在半失效状态下继续推进。结语回头看文章开头那几个典型问题其实都能在这条主线上找到位置。工具为什么不该只是函数表对应的是统一 Tool 合同工具为什么不能全量暴露对应的是注册层工具为什么不能拿到名字就直接执行对应的是完整生命周期工具为什么不能盲目并发对应的是语义驱动的并发策略。Claude Code 的工具系统值得借鉴不是因为它工具多而是因为它把工具放回了 runtime 的中心位置。对刚从“能调 LLM”迈向“能做 agent”的开发者来说这个转变尤其关键当你开始把工具当成合同、能力入口、执行对象和结果回流节点来设计时你才真正进入 agent runtime 的实现阶段。