1 调一下,就出结果?
上一篇结尾撂下一句:模型吐出一句 tool_use 之后,工具到底怎么一步步跑起来?这一篇就拆它。
你看着 Claude Code 调一下工具(读个文件、跑条命令),唰一下结果就回来了。
这一瞬,你会以为模型一喊「跑」,工具就立刻动手。其实没有。从模型吐出那句 tool_use,到工具真正动手,中间隔着一条你看不见的管线:八道关,挨个查——工具对不对、参数填对没、该不该放行、安不安全。任何一道过不去,就拦下。
2 demo 一行能跑,Claude Code 为什么还要八道关
最简单的 demo 里,执行工具就一行:
result = tool.call(input)
拿到模型吐的 tool_use,直接调工具的 call,拿结果,完事。没有检查、没有拦不拦——一调就跑。
Claude Code 不是这样。那句 tool_use 被交给一个叫 runToolUse 的函数(src/services/tools/toolExecution.ts),它不急着调 call,而是先把这次调用送上一条管线——一道道关,过完才轮到 call 动手。
顺带分清一层:这条管线,和上一篇(流式篇)拆的
StreamingToolExecutor不是一回事。那个管「哪些工具能一起跑、谁先谁后、谁出错了要把兄弟召回」——是调度;这个管「调度把一个tool_use放进来之后,它自己要过几道关」。一个管怎么排,一个管怎么过。
八道关,按真实顺序,长这样(后文挨个拆):
- 工具查找 — 你叫的这个工具,存在吗?
- 参数校验(Zod) — 填的参数,类型对吗?
- 业务校验(validateInput) — 参数值合理吗?(路径存不存在之类)
- 参数归整 — 把参数里该补的补上、该剥的剥掉
- PreToolUse Hook — 用户挂的小脚本,工具跑前先过它
- 权限决策 — 让不让跑?(Hook、规则、人,三方说了算)
call执行 — 真正动手干活- PostToolUse Hook — 跑完之后,再过一道用户脚本
前六道都在 call 之前——工具真正开始执行,要先过六道关。
3 第一关到第四关:这次调用,本身能用吗
前四道关,处理的其实是同一件事:模型递来的这次调用,本身是个能用的工具吗?
第一关,工具查找。 模型吐了一句 tool_use,说「用 X 工具」。第一件事:这个 X,在工具清单里吗?Claude Code 的工具不少,模型偶尔会记错名字——吐一个根本不存在的工具,或者拼错了。查不到,直接拦下:
// src/services/tools/toolExecution.ts
content: `<tool_use_error>Error: No such tool available: ${toolName}</tool_use_error>`,
is_error: true,
注意它怎么处理「查不到」:不是抛个异常让循环崩,而是合成一份出错的回执(标着 is_error: true),内容就是「没有这个工具」。这份回执塞回给模型——模型读到「No such tool」,就知道自己叫错了,下一轮换个对的。
这是这条管线里「拦下」的第一次亮相,看清它的处理方式:拦下,不崩,把原因写进回执,塞回去。
第二关,参数校验。 工具找着了,接着看参数:模型填的类型对不对?这一步用一个叫 Zod 的现成校验库来查(TypeScript 圈里专门核对参数的工具)。有意思的是源码里这句注释:
// Validate input types with zod (surprisingly, the model is not great at generating valid input)
「出乎意料,模型并不太擅长生成合法的输入」——连 Claude Code 自己都在代码里认了这事:模型吐的参数,经常是错的。该给数组它给字符串、该给数字它给文字、漏了必填字段。这道关兜的就是它——参数不对,拦下;回执里写清「哪个字段、怎么错了」,塞回去。
第三关,业务校验。 Zod 只查参数的类型——该是数字的是不是数字、该是字符串的是不是字符串。可类型对了,值未必合理。比如 Read 一个文件,路径是个合法字符串(Zod 查类型,过了),可这路径根本不存在——Zod 管不到这层。于是每个工具还能再挂一个自己的 validateInput:Read 查路径存不存在,Glob 查你给的目录是不是个目录。查不过,拦下。
为什么分两层、不合一起?类型校验是通用的,所有工具共享一套;值校验是每个工具自己的。通用的复用、特有的各管各,比塞进一个又杂又大的校验器干净。
第四关,参数归整。 前三道都是「查」——查到不对就拦。第四道不一样:它不查、也不拦,只把参数收拾干净。比如模型有时会偷偷在参数里塞一个只该由系统注入的内部标记(想绕过检查),这道就把它剥掉;有些字段模型没给、但后面的权限检查需要,这道就给补上。收拾完,给后面几道一份干净的输入。
这四道过的都是「调用本身能不能用」。可一件合格的事,就该做吗?读一个不存在的文件,第三关拦了;那读一个存在、却不该读的文件呢?——这就不是「能不能用」了,是「让不让跑」。进第二幕。
4 第五、六关:让不让跑
「让不让跑」不是一个简单的是非题——是三方说了算:你挂的 Hook 脚本、你配的权限规则、必要时还有你本人。
先看第五关,PreToolUse Hook。工具查过了、参数也对了,该跑了吧?还不一定——工具真正动手之前,还插着一道:用户自己挂的小脚本。
Hook 是什么?用户写的小脚本,挂在工具跑前(PreToolUse)、跑后(PostToolUse)这些节点上。工具走到这些节点,Harness 先跑一遍用户挂的 Hook——等于在自动流程里,给用户留了插手的口子。
这道 Hook 能改模型的参数、往上下文塞东西、甚至直接说「这个不准跑」。最有意思的是——它还能对「让不让跑」表态,返回 allow / deny / ask。这就把它和第六关缠到了一起。
第六关,权限决策。 三方怎么过招,一张图看清:
图上有三个绕不开的点,各停一下。
① 为什么 Hook 跑在权限前面? 你大概觉得,权限最基础,该先查「让不让」再跑 Hook。源码偏不。因为 Hook 想要能影响权限——权限先查、先定死了,Hook 再跑也只能干看着。把 Hook 放前面,你就能拿它自动放行:写个 Hook,认出某种安全的命令,直接 allow,省得每次弹窗问你。Hook 想插嘴「让不让」,就得让它先发言。
② 为什么 Hook 说了 allow,还可能被拦? 看图上 allow 那条线——它还得撞一道 deny / ask 规则的检查。源码注释挑得明明白白:
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
「Hook 说 allow,只是跳过了弹窗那一步;可你配的 deny / ask 规则,照查不误。」为什么?因为 Hook 是脚本——你写的、或第三方给的,可能过时、可能有 bug。而你在设置里写的 deny 是硬红线:「这个目录绝对不准动」「这条命令永不放行」。让一个可能出问题的脚本,绕过你亲手写的红线,那就是安全漏洞。所以设计上:Hook 说 allow,可以省去弹窗的麻烦,但红线照查;一旦撞上 deny,Hook 也保不住,照拦。
③ Hook 和规则都没态度,就问你。 这是图上的 ask 分支——遇到拿不准的操作(比如工具要改一个新文件),Harness 把控制权交出来,弹个确认:「允许修改 src/foo.ts 吗?」你点允许,它才动;你拒绝,它就拦下。
而无论第六关最后是谁拦的——Hook、规则、还是你亲手——只要拦下,回执里就写上「为什么被拒」,塞回模型。模型读到「Permission denied」,知道这次没被允许,换个做法再来。
过完这道,工具才真正拿到动手的许可。
5 第七、八关:干活
第七关,call 执行。 六道关过了,工具终于碰到你的硬盘。读文件的读文件、跑命令的跑命令、改代码的改代码——这一道是真正干活的,前面六道都是替它把关。
第八关,PostToolUse Hook。 call 跑完了,还有最后一道用户脚本。这里有个设计值得停下问一句:为什么 Hook 要分 Pre 和 Post 两道,一前一后包着 call?而不是 call 跑完只回调一次?
因为「跑前」和「跑后」能拦的事不一样。PreToolUse 在工具动手前跑,能挡住一个还没发生的危险——比如「这条 rm -rf 不准跑」,拦下了,命令根本没执行。要是只有事后一道,等回调跑起来,命令早执行完了——都删完了再拦,拦个寂寞。
所以这两道是「跑前挡 + 跑后查」:Pre 在前面挡,能拦住还没发生的危险;Post 在后面查,却只能在跑完后追加信息、或改改某些工具的输出,撤销不了已经发生的事。分前后两道,才守得住「跑前能挡」这一关。
call 的结果,是工具自己返回的原始对象。可模型不收散乱的原始数据——它要一份规规矩矩的回执。最后一步是把结果归整:每个工具自己有个 mapToolResult,把返回的东西拼成模型要的格式(比如 Glob 把文件路径一行一个拼好),装成回执,塞回对话。
顺带:结果要是太大(比如读了个超长文件),这一步会卡一道尺寸——只给模型一段预览,全文落盘存着,免得撑爆上下文。
到这儿,一个 tool_use 从进管线到出回执,八道关全过完了。顺利的话,模型收到一份干净的结果回执。
6 那要是出错了呢
工具真跑起来,也可能出错。call 执行时抛了个异常——读文件撞上权限错、跑命令崩了、网络请求超时——这些不是前面几道能预判的,是真跑起来才冒出来的事。
出错了怎么办?
demo 那一行 result = tool.call(input),一个未捕获的异常,能把整个循环带崩——一串报错甩出来,啥都没了。
Claude Code 不一样。整条管线最外面,套着 catch。call 抛出来的任何异常,都被接住;接住之后,做的还是那件你熟悉的事:不崩,合成一份出错回执(is_error: true),把错误信息格式化好,塞回给模型。
这里值得停一下问:为什么宁可费劲合成一份回执,也不让它崩? 因为崩了,这一轮当场就死,模型根本看不见「这个工具刚失败过」——下一轮它没准又调同一个工具、撞同一堵墙,一个出错掉的工具,能把整个循环拖进死循环。合成回执,就是给模型留一句话:「这个失败了,原因是 X」——它读到了,才换得了路。
7 全景图
把八道关摊开,长这样:
图上有一条线,值得专门点出来。
你会发现:无论一个 tool_use 最后是顺利跑完(走绿线,带回一份正常回执),还是在哪一道被拦下(走红线,带回一份出错回执)——它都带回了一份回执。没有哪条路,是「崩了、什么都没带回来」的。
对比一下,这条管线兜住了什么:
| 出错的情况 | demo(一行 call)会怎样 | Claude Code(八道关)怎么收 |
|---|---|---|
| 模型吐了不存在的工具 | ReferenceError,循环崩 | 合成「No such tool」回执,模型换工具 |
| 模型填错参数 | call 内部崩或静默错 | Zod 拦下,合成参数错回执,模型改参数 |
危险命令(rm -rf) | 直接跑了,不可逆 | call 前过权限 + Hook,拒了合成回执 |
| 工具执行抛异常 | 整个循环崩,报错甩一脸 | catch 兜住,合成出错回执 |
8 回到那个「一下」
回到开头那个「调一下,就出结果」的瞬间。
现在你知道了:那「一下」,一点也不轻。模型吐出一句 tool_use 之后,要先过一条八道关的管线,任何一道觉得不对,都能把它拦下。
可最妙的不是这八道关本身,而是它们拦下之后怎么收场:每一次拦下,都没让循环崩,而是换成一句模型读得懂的回执,塞回对话。这才是这条管线真正的用意:不是确保每个工具都成功(模型一定会犯错,工具一定会失败,这拦不住),而是把每一次失败,都变成模型下一轮做对的契机。
工具这三部曲走到这儿,「工具系统」这个组件算补齐了:入门①说工具是什么(模型的手和脚)、入门②说工具的边界比你想的大得多(万物皆工具)、这一篇说这些工具到底怎么一步步跑起来——过八道关,每一种失败都换成回执。
可这留下了一个新问题。工具跑完了,结果也回灌了——可这些结果,加上之前每一轮的对话历史、加上项目里的 CLAUDE.md,全都要塞进模型这一轮的上下文窗口。一次又一次调工具、塞结果,窗口怎么装得下?装不下的时候,又怎么办?
那是下一个组件的事了——上下文工程。