第 6 课:实战:给 Agent 接上三个工具
学习目标:
- 写出一个完整的工具执行循环,让 Agent 真正跑起来
- 用一张表同时注册工具的接口定义和实现,避免两边对不上
- 给循环装上安全阀,并能从日志里判断"工具接错了"
前置要求:读完第 1-5 课,能读懂基本的 JavaScript / Node.js | 上一课 第 5 课 <<
先看效果:一次完整的运行
这是本课最后要跑出来的东西。终端里输入一句话,Agent 自己决定调用哪些工具、调几次:
三轮,三个工具,每一轮的参数都建立在上一轮结果之上:先搜出 lodash 出现在哪些文件里,再读 package.json 确认版本号,最后拿着这个名字去问 GitHub。这不是写死的脚本,是模型自己决定"接下来调哪个工具、传什么参数"。
这一课把它从零搭出来:三个工具、一张注册表、一个执行循环、几道安全阀。
这背后发生了什么:一次一次的 API 往返
上面看到的每一"轮",背后都是一次完整的 HTTP 请求。第 2 课讲过单次工具调用的往返长什么样;这里只是把它接成了循环——模型返回 stop_reason: "tool_use",你的代码执行工具、把结果拼回对话,再发一次请求,直到模型不再要求调用工具为止1。
三轮工具调用的背后其实是四次 messages.create:前三次模型都在要工具,第四次它拿到了 GitHub 返回的数据,觉得信息够了,直接给出文字回答,循环结束。这个"要不要继续要工具"的判断完全在模型那边,你的代码只负责执行、回传。
第一步:给工具写契约
第 4 课讲过工具接口的三个核心字段:name、description、input_schema2。这里直接落成代码。三个工具分别对应第 3 课讲过的五类工具里的三类:搜索、读、调用——写和执行这两类留给你在练习里自己接。
github_repo_info 带了 github_ 前缀——官方建议工具涉及外部服务时用服务名做命名空间前缀,模型选错工具的概率会低很多3。search_files、read_file 操作本地文件系统,不存在"哪个服务"的歧义,不需要前缀。
三段 description 都写了"找不到时返回什么样的文字",不是废话。第 4 课提过好的描述要消除输入输出的歧义4;这里的歧义不在参数上,而在"工具没找到东西时该怎么表达"——这个坑会在"安全阀"一节炸出来。
第二步:把契约和实现注册在同一张表里
一个容易踩的坑:schema 列表和执行时用的 handler 查找表如果分开写成两份,迟早会对不上。你把 search_files 改名成 find_in_files,却忘了同步改 handler 表里的 key,模型照着新 schema 发起调用,handler 表里查不到,直接抛错。
解决办法是只维护一张表,name、description、input_schema、真正执行的函数全挤在同一个对象里,API 需要的 schema 列表和执行时需要的 handler 查找表都从这张表派生:
toolSchemas 和 toolHandlers 永远同步,因为它们是同一份数据算出来的两个视图,不是两份手写的数据。改一个工具的名字、加一个参数,只需要改 TOOLS 这一处。
第三步:实现三个工具,带上边界
searchFiles 自己写目录遍历,不借 shell 的 grep——避免把用户输入拼进命令行触发命令注入。命中数量封顶,避免一次搜索把几千行塞进上下文:
readFile 只做一件事:确认目标路径没有跑出项目根目录。第 5 课讲过的边界思路在这里就是一行带分隔符的前缀检查。注意不是裸的 startsWith(PROJECT_ROOT):假如项目根目录是 /Users/me/proj,模型传一个 ../proj-backup/x 进来,resolve 之后得到 /Users/me/proj-backup/x,裸前缀匹配照样通过——拼上 path.sep 之后,边界才真正落在目录分隔符上:
githubRepoInfo 是唯一会把数据发到项目之外的工具——本地文件内容经模型提炼成 owner、repo 两个字符串,再发到公网。这正好是"读了私有数据 + 对外通信"两个高风险条件凑在一起的场景5,所以加一条明确的权限规则:参数必须匹配 GitHub 合法命名格式,不许是别的:
GITHUB_TOKEN 从环境变量读,不出现在代码里;不设置也能跑,只是匿名请求的速率限制更低。这跟第 5 课讲的权限规则是一个思路的两种写法:那一课讲 Claude Code 配置文件里 allow/deny/ask 那种声明式规则6,这里是写进工具代码里的命令式版本——核心都是"给高风险操作画一条不能越过的线"7。
第四步:写执行循环
有了 toolSchemas 和 toolHandlers,循环本身并不复杂。核心逻辑就四步:发请求、看 stop_reason、不是 tool_use 就返回文字、是就执行每一个工具调用块并把结果拼回去1:
这里有个容易漏的细节:for (const block of response.content) 遍历的是这一轮返回的所有内容块,不是只取第一个。模型经常一次并行请求两三个工具,每一个都要执行、生成对应的 tool_result,tool_use_id 一一对应,一个都不能少8。Level 2 练习会让你亲手踩一次漏处理的坑。
安全阀,以及怎么看出工具接错了
上面这版循环能跑,但少了两道保险。加上它们:
保险一:工具执行失败要喂回去,不能让循环崩掉。 把裸调用包一层 try/catch,失败也生成一个 tool_result,只是标上 is_error: true——模型看到这个标记,通常会调整参数重试,而不是重复同一个错误98:
保险二:同一个工具、同一组参数,连续调三次就该停了。 这不是靠猜,是靠记录最近几次调用的签名:
加上 MAX_TURNS 这道总闸,三道安全阀分工不同:MAX_TURNS 防"模型换着花样一直要工具,永远不停";重复调用检测防"模型卡在同一个参数上原地打转";工具内部的路径和格式校验(第三步写的那些)防"模型编了个越权参数,工具还老老实实执行了"。三层缺一,循环就有失控或越权的风险7。
怎么从日志里看出"工具接错了"? 两个最常见的信号:
- 模型反复调同一个工具,参数只在小范围内变化(大小写、加减一个词)。十有八九不是模型笨,是
tool_result 内容太模糊——"没找到"返回空字符串,模型分不清"确实没有"和"工具坏了",只能靠猜再试一次。
- 模型把参数猜着填,比如给
read_file 传了一个不存在的路径。往回查通常有两种原因:description 没交代清楚参数该从哪来(呼应第 4 课),或前一步工具返回的内容里没给出精确路径,模型只能拍脑袋编一个。
小结
- 工具的 schema 和 handler 放在同一张表(
TOOLS)里注册,toolSchemas、toolHandlers 都从这张表派生,改一处不会漏改另一处
- 执行循环的核心是:发请求 → 看
stop_reason 是不是 tool_use → 是就遍历每一个工具调用块、执行、拼回 tool_result → 不是就返回文字,循环结束
- 一轮里可能有多个并行的工具调用,每一个
tool_use 都要有唯一对应的 tool_result,漏一个下一轮请求就会报错
- 三道安全阀各管一层:
MAX_TURNS 防止模型无限索要工具,重复调用检测防止模型在同一组参数上打转,工具内部的路径和格式校验防止参数越权
tool_result 的内容要把"没找到"和"出错了"说清楚,含糊的空返回是模型反复重试、日志看起来像是"接错了"的头号原因
你已经走完这门课的六课,从"Agent 为什么需要工具"讲到自己写出一个能跑的工具执行循环。接下来最值得做的,不是再读一课,而是挑一个你项目里真实要做的小任务,拆成两三个工具,把这套循环骨架搬过去改一改——跑起来一次,比再读十遍解释都管用。调试时拿不准具体字段,回 sources.md 查 S4、S5 两篇官方文档,那是这套多轮循环最原始的规范文本。