AB
AiBoss
Tutorials

Claude

Tutorials

用 Claude API 的 Tool Runner 构建 TypeScript 工具调用代理

面向需要让 Claude 调用自有 API 的开发者:用 @anthropic-ai/sdk 的 betaZodTool 定义工具、用 toolRunner 自动跑完「调用—执行—回传」循环,并以一个服务监控代理为例,讲清工具定义、安全护栏、并行调用、流式消费与 pause_turn 等易踩的坑。

如果你想让 Claude 调用自己写的几个接口——查库存、读监控、触发重启——而不需要 Claude Code 那一整套文件读写与 Bash 能力,那么 Claude 的 API SDK 里有一个更轻的选择:client.beta.messages.toolRunner。它只负责一件事:把「模型发起工具调用 → 你的代码执行 → 结果回传给模型 → 模型继续判断」这个循环替你转起来。工具本身由你用 Zod 定义,执行逻辑也由你写,SDK 不接管任何内置工具。

这篇教程面向已经能用 Claude API 发一次普通请求、并且熟悉 TypeScript 与 Zod 的读者。读完你应该能独立写出一个可运行的小型运维代理:它先并行查询多个服务的状态,再根据状态决定是否执行有副作用的操作,最后用一句话汇报结果。

准备工作

先分清两个容易混淆的东西

搜索「Claude 代理实现」时,很容易先撞上 @anthropic-ai/claude-agent-sdk。那是把 Claude Code 做成库的封装,自带文件读写、Bash 等一整套工具与运行框架。它适合「让模型在代码仓库里干活」的场景。

而本文要用的 @anthropic-ai/sdk 是普通的 API SDK,其中的 toolRunner 只是一个薄层:它不提供任何内置工具,只代管你自己定义的工具的循环执行、并行调用与停止判定。两者的名字很像,能力范围完全不同。判断标准很简单:

  • 需要模型读写文件、跑命令、在项目里自主探索 → 用 Claude Agent SDK。
  • 只需要模型调用你定义的两三个接口,不需要审批流、不需要特殊传输层 → 用 API SDK 的 Tool Runner。

还有第三条路:完全手写循环。用 client.messages.create 自己写 while,从响应里挑出 tool_use 块,执行后把 tool_result 塞回下一轮请求。控制权百分之百在自己手里,代价是代码量明显增加,并行调用、停止条件都要自己处理。工具数量少、逻辑简单时,Tool Runner 能省下这部分样板代码。

环境与依赖

需要 Node.js 环境、一个可用的 Anthropic API Key,以及两个 npm 包:

npm install @anthropic-ai/sdk zod

SDK 通过环境变量读取密钥,因此运行前需要设置 ANTHROPIC_API_KEY。不要把密钥硬编码进源码,也不要提交到版本库。

需要留意的是,toolRunner 目前位于 client.beta.messages 命名空间下,属于 beta 能力。beta 接口的签名、导入路径和可用性都可能随版本变化,实际开发前请以官方文档与当前安装版本的类型定义为准,不要照抄旧示例里的路径。

操作步骤

第一步:明确工具边界与业务规则

示例做一个「服务监控代理」:给定服务名,查询它的运行状态与延迟;如果状态不佳,允许重启;如果服务本来健康,则拒绝重启。

这里有两个工具:

  • get_service_status:只读,返回指定服务的状态与延迟。
  • restart_service:有副作用,仅在服务处于 degraded 或 unhealthy 时允许执行。

关键设计点在于:「健康服务不许重启」这条规则必须写在工具的执行函数里,而不是只写在提示词里。 提示词是给模型的建议,模型可能读漏、可能判断偏差;而执行函数是真正的副作用入口,把校验放在这里,无论模型怎么想,危险操作都会被拦下。

第二步:用 betaZodTool 定义工具

每个工具由三部分构成:name、description、inputSchema(Zod 对象)以及 run(异步执行函数)。SDK 会从 Zod schema 自动生成给模型看的 JSON Schema,所以字段上的 .describe() 会直接成为模型理解参数含义的提示。参数名越含糊,描述就该写得越具体。

import Anthropic from "@anthropic-ai/sdk";
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod";
import { z } from "zod";

const client = new Anthropic();

// 演示用的内存状态表,生产环境替换为数据库或监控 API
const SERVICES = new Map([
  ["api-gateway", { status: "degraded", latencyMs: 820 }],
  ["billing-worker", { status: "healthy", latencyMs: 45 }],
]);

const getServiceStatus = betaZodTool({
  name: "get_service_status",
  description: "获取指定服务的运行状态与延迟",
  inputSchema: z.object({
    serviceName: z.string().describe("例如 api-gateway 或 billing-worker"),
  }),
  run: async (input) => {
    const service = SERVICES.get(input.serviceName);
    if (!service) {
      return JSON.stringify({ error: `unknown service: ${input.serviceName}` });
    }
    return JSON.stringify(service);
  },
});

const restartService = betaZodTool({
  name: "restart_service",
  description: "重启服务。仅当服务处于 degraded 或 unhealthy 时允许执行",
  inputSchema: z.object({
    serviceName: z.string(),
    reason: z.string().describe("判断需要重启的理由"),
  }),
  run: async (input) => {
    const service = SERVICES.get(input.serviceName);
    if (!service) {
      return JSON.stringify({ ok: false, error: `unknown service: ${input.serviceName}` });
    }
    if (service.status === "healthy") {
      // 不允许模型擅自重启健康服务
      return JSON.stringify({ ok: false, error: "service is healthy; refusing restart" });
    }
    SERVICES.set(input.serviceName, { status: "healthy", latencyMs: 40 });
    return JSON.stringify({ ok: true, restarted: input.serviceName, reason: input.reason });
  },
});

注意 run 的返回值形态:它返回的是字符串,这段字符串会作为工具结果原样交给模型阅读。所以「拒绝重启」不是抛异常,而是返回一段带 ok: false 的 JSON 文本,让模型读到原因后自行决定下一步。这是 Tool Runner 的设计取向——失败也作为信息返回,而不是中断循环。

第三步:把工具交给 toolRunner

把工具数组和消息数组传进去,await 一次就能拿到最终消息。循环、并行调用、何时停止,都由 SDK 处理。

async function main() {
  const finalMessage = await client.beta.messages.toolRunner({
    model: "claude-opus-5",
    max_tokens: 4096,
    tools: [getServiceStatus, restartService],
    messages: [
      {
        role: "user",
        content:
          "检查 api-gateway 和 billing-worker 的状态,必要时重启。最后用一句话汇报结果。",
      },
    ],
  });

  for (const block of finalMessage.content) {
    if (block.type === "text") {
      console.log(block.text);
    }
  }
}

main();

返回的 finalMessage 结构与普通 Messages API 响应一致,遍历 content 取出 type === "text" 的块即可。模型 ID 请按你账号当前可用的模型填写,示例中的名称仅对应素材写作时的状态。

第四步:观察中间过程

如果只想拿到最终答案,上面的写法就够了。但调试时通常需要看到每一次工具调用与结果。toolRunner 返回的对象支持 for await 消费:非流式模式下,每一轮迭代会给出一个完整的消息对象。

const runner = client.beta.messages.toolRunner({
  model: "claude-opus-5",
  max_tokens: 4096,
  tools: [getServiceStatus, restartService],
  messages: [{ role: "user", content: "看一下 api-gateway 的状态" }],
});

for await (const message of runner) {
  console.log(message.stop_reason, message.content);
}

这样就能逐轮打印 stop_reason 与内容块,看清模型先调了哪个工具、拿到了什么结果、又做了什么决定。排查「模型为什么没调用某个工具」这类问题时,这一步几乎是必需的。

一个完整示例

把上面的片段拼起来,就是一个可以跑通的最小代理。完整流程如下:

  1. 安装 @anthropic-ai/sdk 与 zod,设置 ANTHROPIC_API_KEY。
  2. 创建 agent.ts,写入客户端初始化、状态表、两个 betaZodTool 定义。
  3. 在 main() 中调用 client.beta.messages.toolRunner,传入两个工具与一条用户消息。
  4. 遍历 finalMessage.content,打印文本块。
  5. 用 npx tsc --noEmit --strict agent.ts 先做类型检查,确认没有类型错误。
  6. 运行脚本,观察输出。

在初始状态下(api-gateway 为 degraded,billing-worker 为 healthy),一次典型运行会呈现这样的顺序:

  • 模型对 api-gateway 与 billing-worker 并行发起两次 get_service_status 调用。Tool Runner 默认支持并行工具调用,不需要额外配置。
  • 读到 api-gateway 处于 degraded 后,模型调用 restart_service,并附上它判断的理由。
  • 执行函数把该服务状态改为 healthy,返回成功 JSON。
  • billing-worker 状态健康,模型跳过重启,直接给出文本汇报。

如果模型误判、对健康服务发起了重启,执行函数会返回 ok: false 与拒绝原因,模型读到后会在最终汇报里体现这一点,而实际状态不会被改动。这就是把安全阀放在工具层的价值。

想验证拒绝路径,可以临时把 billing-worker 的初始状态设为 healthy 并在提示词里要求「两个都重启」,观察工具返回的拒绝信息如何影响模型的后续表述。

注意事项

pause_turn 不会被自动恢复

这是最容易在后期踩到的坑。toolRunner 不会自动处理 stop_reason === "pause_turn"。当你混入 Web 搜索这类长时间运行的服务端工具时,循环可能在中途暂停,需要你自己检测该状态,并用 runner.pushMessages(...) 把消息推回去继续。只用自定义工具时一般不会触发,但一旦后续要加服务端工具,就必须补上这段恢复逻辑。

run 的返回值是给模型看的文本,不是成功标志

run 返回的字符串会被模型当作工具结果阅读,它不承担「成功 / 失败」的语义。返回 JSON.stringify({ ok: false, ... }) 时,模型是把它当作文本信息来理解并决定下一步的。真正需要中断执行、标记为错误的场景(例如参数解析失败),属于手动循环里 is_error: true 的范畴;在 Tool Runner 里,更自然的做法是把失败也组织成可读的文本返回。

业务规则不要只写在提示词里

凡是会产生真实副作用的操作——重启、扣款、删除、发消息——校验都必须落在执行函数内部。提示词可以被忽略,代码不会。示例中 restart_service 对 healthy 状态的拒绝,就是这一原则的最小体现。

模型选择与成本

监控类代理的调用频次往往很高,每次都上最强的模型成本会迅速累积。比较务实的做法是先用较小的模型跑通流程、观察判断质量,只在确实需要更强推理的环节切换到更大的模型。具体有哪些模型、各自的价格与配额,请以 Anthropic 官网当前公布的信息为准,本文不给出固定数字,因为价格与可用模型会随时间调整。

beta 命名空间与版本漂移

client.beta.messages.toolRunner 与 @anthropic-ai/sdk/helpers/beta/zod 都带 beta 标记。升级 SDK 后如果出现导入失败或类型不匹配,先检查这两处的路径与签名是否变化,再对照当前版本的类型定义调整代码。示例中的模型名、包版本同样只是写作时的状态,不要当作长期有效的常量。

先做类型检查再运行

涉及 API Key 的脚本在 CI 或本地跑一次都要花钱,先用 tsc --noEmit --strict 把类型问题挡在运行之前,是成本最低的验证方式。类型通过不代表逻辑正确,但能排除掉相当一部分低级错误。