要是你已然可以去调用大模型,而且还做过最为基础的那种“让模型挑选工具并且发起相应调用”的小型实验,紧接着很容易遭遇到一种落差:
模型已然能够“调用工具”,然而系统却依旧不太好似那种真正负责将任务予以交付的 agent。
有可能它会去查找资料,会去阅读文件,也会去运行某些命令,然而一旦任务稍微复杂那么一点儿,就会出现如下这些问题:
这段话表明,实际存在困难的关键之处,向来都不是只是单纯地为模型连接几个函数,而是在于究竟该如何将工具予以转化,使其成为一套能够实现可调度、可进行约束的执行系统。
这篇文章既不讲述工具列表,也不制作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;
prompt(): Promise;
validateInput(input): ValidationResult;
checkPermissions(input, context): PermissionResult;
isReadOnly(input): boolean;
isConcurrencySafe(input): boolean;
call(input, context): Promise;
// 概念层:把工具内部结果整理成“可回写”的标准结果
formatResult?(result): ToolResult;
// 实现层:把结果映射成真正写入消息流的 tool_result block 参数
mapToolResultToToolResultBlockParam(
result,
toolUseId,
): ToolResultBlockParam;
};
这里进行一层补充说明,以此来防止将两个处于不同层次的接口视作冲突,上面所提到的formatResult是为了有助于理解而被抽象出来的“结果格式化”能力,当落实到Claude Code的实际实现过程中,更为常见的是更为具体的mapToolResultToToolResultBlockParam(result, toolUseId)。它携带的 toolUseId 比 formatResult 多一个,返回的并非泛化的 ToolResult,而是 tool_result block 参数,此参数能够直接写回会话消息流。下面当进入结果回写时,我们按照这个更贴近实现的名字统一展开。
第一眼瞅见这段接口,极易被字段数量给吓到。更为得当的读法并非逐个去背字段,而是先将它拆分成3组:
2.1 模型接口:告诉模型“这个工具叫什么、怎么用”
这一组字段决定的是,模型眼里看到的工具协议是什么:
它们能被你理解成是“给模型看的那个侧面”,名字、描述以及输入结构让人搞不清楚,模型连怎样发起一回稳定调用都没办法去做到。
2.2 runtime 控制:告诉系统“这次调用该怎么被约束”
这一组字段决定的是,runtime 要怎么管理这次调用:
这儿的关键并非是“可不可以进行调用”,而是在于“系统应不应该予以放行”,以及“应当怎样去调度”,还有“能否实现并发”。
2.3 执行与结果回写:工具做完以后,结果怎么回到会话里
这组需要处理的问题相当具体,工具运行 end 之后,结果不可以仅仅停留在程序范围之内,还必须返回至会话之中,变成模型下一轮切实能够看到的上下文。
对应到代码里,通常会分成两步:
于Claude Code之中,工具执行完毕并非终点,对于agent来讲,结果还需被稳定地写回到会话,如此后续的推理方可接上。
这也是为什么这里要把“执行”和“结果回写”分开看。
如果用伪代码表示,大概是这样:
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() 径直返回最终会写回到会话里的结果呢?是由于这两层所处理的是两类不一样的问题。
分开之后,工具的内部能够留存自身最为自然的数据结构,然而整个系统在写回到会话之际,依旧能够维持统一的格式。
若缺失这一步骤,极易出现一种颇为典型的情形,程序中分明已获取到结果,然而模型下一轮却仿若未曾看见,原因在于结果未被整理成其真正能够继续读取的会话内容。
所以,这一组能力,实际上仅仅是在处理两件事情,其一为,工具究竟该如何切实地执行,其二是,在执行完毕之后,结果要怎样回归到会话当中,进而成为后续对话仍能够持续运用的上下文。
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当中所处理的内容远远不止是文本读取这一方面,在源码里面能够看到这样一些逻辑。
所以,从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去进行压缩,大体上是如下这般的链条:
如果只看核心代码,味道是这样的:
// 先按输入结构做基础解析
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能否一同运行,抑或是必须排队执行。
也就是说,这里有两层分工:
真正对并发能否成立起到决定作用的,并非模型对于并发有没有意愿,而是这般工具在语义层面上是不是准许并发去施行。
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))
)
}
它真正关心的是:这次调用在语义上是否并发安全。
再结合工具定义里的声明,你就能看懂这套策略:
这背后所蕴含的价值,并非是“更趋于保守”这种情况,而是要达成这样一种结果,即让并发策略与工具语义形成绑定关系,并非是与技术实现建立绑定关系。
由于 agent 的工具并非纯函数,它们对文件系统、终端、外部服务以及会话状态进行操作,只要牵涉到副作用,那么并发问题并非吞吐问题,而是一致性问题。
Claude Code 在执行器里还做了两件很实用的事:
结语
回首去看,文章起始部分的那几个具有典型特征的问题,实际上都能够在这条主要的线索之上寻觅到所处的位置。
工具为何不该仅仅是函数表,与之对应的是统一 Tool 合同;工具为何不能进行全量暴露,与之对应的是注册层;工具为何不能在拿到名字后就直接被执行,与之对应的是完整生命周期域在;工具如果不能盲目并发,与之对应的是语义驱动的并发策略。
Cleaude Code的工具系统是值得去进行借鉴的,并非是因为其工具数量众多,而是由于它将工具放置到了runtime的核心位置。对于刚刚从“能调LLM”朝着“能做agent”迈进的开发者而言,这样的转变是尤为关键的,当你开始把工具当作合同、能力入口、执行对象以及结果回流节点来开展设计的时候,你才算是真正进入到了agent runtime的实现阶段。
