教程Claude Code系列第 8 / 8 篇

Claude Agent SDK 的 TypeScript 版:把同一个订单助手再写一遍,和 Python 版逐项对照

这个系列前面都用 Python。本站把同一个订单助手用 TypeScript 重写并实测:自定义工具、出错处理、审批回调和 hooks、会话、逐字输出、结构化结果。多数只是换个名字,不同的有几处:参数用 Zod 写;没有保持会话的客户端对象;审批回调更省事;env 是整个替换,漏写之后 Bash 里连 ls 都找不到。

发布 · 约 12 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(13 节)
  1. 安装
  2. 名字怎么对应
  3. 一、自定义工具:参数用 Zod 写
  4. 二、跑起来
  5. 三、出错:也是“先给结果,再抛异常”
  6. 四、审批回调和 hooks:TypeScript 这边更省事
  7. 五、会话:没有那个“客户端”对象
  8. 六、逐字输出
  9. 七、结构化结果:Zod 转成 JSON Schema
  10. 八、env:整个替换,不是合并
  11. 哪些完全一样
  12. 这一趟花了多少
  13. 这次实测没有覆盖到的

这篇教程解决一个问题:这个系列前七篇用的都是 Python。你的项目是 Node.js 或 TypeScript 的,那些内容哪些能直接搬、哪些要改?

前面每一篇都写了“TypeScript 没有实测”。这一篇把账补上:同一个订单助手,用 TypeScript 重写,把前几篇的主要实验重跑一遍。

实测环境
日期 2026-10-13
版本 @anthropic-ai/claude-agent-sdk 0.3.296,zod 4.6.7,tsx 4.23.15
系统 Ubuntu 24.04,Node.js 22.23.2
模型 Sonnet 5.5
连接方式 通过酷站AI 中转站,不是直连官方

安装

npm init -y
npm pkg set type=module
npm install @anthropic-ai/claude-agent-sdk zod
npm install --save-dev tsx
  • type=module 让脚本里可以直接在顶层用 await。
  • tsx 用来直接运行 TypeScript 文件,不用先编译:npx tsx agent.mts。
  • 文件用 .mts 结尾最省事,不管项目是不是 ES 模块都能跑。

装完看一眼 node_modules/@anthropic-ai/,除了 SDK 本身还有带平台名的包。那是自带的 Claude Code 可执行文件,通过 npm 的可选依赖装进来的,所以不用另外安装 Claude Code。这里有两件事要知道:

  • 跳过可选依赖就没有这个文件。 文档说,这时 SDK 会报 Native CLI binary for <platform>-<arch> not found,要用 pathToClaudeCodeExecutable 选项指向一份单独安装的 claude。镜像构建脚本里常见的 --omit=optional 就属于这种情况。
  • 体积不小。 本站用 npm 10.9.8 装出来有两个平台包:claude-agent-sdk-linux-x64(246 MB)和 claude-agent-sdk-linux-x64-musl(240 MB),node_modules 一共 553 MB。两个包对应两种 C 运行库,一台机器只用得上一个。文档说 SDK 会自己挑对的那个启动;要给容器镜像省地方,可以把用不上的那个目录删掉。

名字怎么对应

选项名是驼峰写法,其余几乎一一对应:

Python TypeScript
allowed_tools、disallowed_tools allowedTools、disallowedTools
permission_mode permissionMode
system_prompt systemPrompt
mcp_servers mcpServers
max_turns、max_budget_usd maxTurns、maxBudgetUsd
setting_sources settingSources
can_use_tool canUseTool
include_partial_messages includePartialMessages
output_format outputFormat
continue_conversation continue
fork_session forkSession
resume、tools、hooks、env、cwd、model 一样

有一处反直觉:结果消息上的字段名没有改成驼峰。 本站把一条结果消息的键全打印了出来,里面是 total_cost_usd、num_turns、session_id、is_error、duration_ms,和 Python 里一模一样。唯一的例外是按模型拆分的用量,TypeScript 里叫 modelUsage,Python 里叫 model_usage。

读消息的方式不同。Python 里用 isinstance 判断类型,内容在 message.content;TypeScript 里看 message.type,助手消息的内容多包了一层,在 message.message.content:

for await (const message of query({ prompt, options })) {
  if (message.type === "system" && message.subtype === "init") {
    // 启动信息:message.model、message.tools、message.permissionMode
  } else if (message.type === "assistant") {
    for (const block of message.message.content) {
      if (block.type === "tool_use") console.log("调用", block.name, block.input);
      else if (block.type === "text") console.log(block.text);
    }
  } else if (message.type === "result") {
    console.log(message.subtype, message.num_turns, message.total_cost_usd);
  }
}

一、自定义工具:参数用 Zod 写

这是写法差别最大的地方。Python 里参数是一个字典({"customer": str}),TypeScript 里用 Zod 描述:

import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const text = (data: unknown, isError = false) =>
  ({ content: [{ type: "text" as const, text: JSON.stringify(data) }], isError });

const findOrders = tool(
  "find_orders",
  "按客户姓名查这位客户的全部订单。",
  { customer: z.string().describe("客户姓名") },       // .describe() 里的话模型看得到
  async (args) => text(ORDERS.filter((o) => o.customer === args.customer)),
);

const refundOrder = tool(
  "refund_order",
  "给一张订单办理退款。只有已付款或已发货的订单能退。",
  { order_id: z.string(), amount_cents: z.number().int().describe("退款金额,单位是分") },
  async (args) => {
    const o = ORDERS.find((x) => x.id === args.order_id);
    if (!o) return text({ error: `没有订单 ${args.order_id}` }, true);     // isError: true
    if (!["paid", "shipped"].includes(o.status)) return text({ error: `订单状态是 ${o.status},不能退款` }, true);
    o.status = "refunded";
    return text({ ok: true, order_id: o.id, refunded_cents: args.amount_cents });
  },
);

export const shop = createSdkMcpServer({ name: "shop", version: "1.0.0", tools: [findOrders, getOrder, refundOrder] });

用 Zod 的好处是处理函数里的 args 自动带上类型,args.amount_cents 是 number,写错字段名编译器会报。

其余和第二篇一样:工具名在模型那边是 mcp__shop__find_orders;出错时返回 isError: true,不要让异常直接抛出去;业务规则写在处理函数里。

二、跑起来

import { query, type Options } from "@anthropic-ai/claude-agent-sdk";
import { shop } from "./shop.mts";

const options: Options = {
  model: "claude-sonnet-5-5",
  mcpServers: { shop },
  tools: [],                                                       // 去掉全部内置工具
  systemPrompt: "你是网店的客服助手。用中文回答,两三句话说清楚。",
  allowedTools: ["mcp__shop__find_orders", "mcp__shop__get_order"],
  permissionMode: "dontAsk",
};
 1.3s init:model=claude-sonnet-5-5 permissionMode=dontAsk tools=3 个
 4.6s [调用] mcp__shop__find_orders {"customer":"张伟"}
 7.8s [调用] mcp__shop__get_order {"order_id":"A1002"}
11.9s [回答] 张伟最近的一张订单是 A1002,下单日期是 2026-10-08,金额 459.00 元。目前状态是已发货,物流单号是 YT9876543210。……
12.0s [结束] subtype=success is_error=false num_turns=3 total_cost_usd=0.0117

和 Python 版的行为一样:1.3 秒子进程就绪,两次工具调用,三轮。

三、出错:也是“先给结果,再抛异常”

部署那篇讲过 Python 版出错时结果消息和异常都会来。TypeScript 版一样:

--- maxTurns: 1
 4.0s [结束] subtype=error_max_turns is_error=true num_turns=2 total_cost_usd=0.0033
 4.6s [抛出] Error: Claude Code returned an error result: Reached maximum number of turns (1)

--- maxBudgetUsd: 0.005
 8.7s [结束] subtype=error_max_budget_usd is_error=true num_turns=2 total_cost_usd=0.0082
 9.3s [抛出] Error: Claude Code returned an error result: Reached maximum budget ($0.005)

所以循环要包在 try 里,并且先把结果消息留住,再处理异常。预算同样不是硬顶:设的是 0.005 美元,停下来时已经花了 0.0082。

抛出来的是普通的 Error,Python 那边是 ResultError。文档说 TypeScript 的 SDK 里有名字的错误类只有 AbortError 一个(你主动中止时用),其余失败都是普通的 Error。所以要判断出了什么错,看结果消息的 subtype,不要去匹配异常里的那句英文。

四、审批回调和 hooks:TypeScript 这边更省事

第三篇里,Python 的审批回调有两处额外的要求:提示要用流式的方式传入,还要挂一个占位的 hook。TypeScript 不需要这两样,提示是普通字符串,直接写回调就行:

const options: Options = {
  ...base,
  permissionMode: "default",
  canUseTool: async (toolName, input: any) => {
    if (toolName === "mcp__shop__refund_order" && input.amount_cents <= 10000)
      return { behavior: "allow", updatedInput: input };
    return { behavior: "deny", message: "超过 100 元的退款要主管批准,这次没有批准。" };
  },
  hooks: {
    PreToolUse: [{
      matcher: "mcp__shop__refund_order",
      hooks: [async (input: any) => {
        if (input.tool_input?.order_id === "A1002")
          return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny",
                                         permissionDecisionReason: "订单 A1002 有纠纷,已冻结,任何人不能操作。" } };
        return {};
      }],
    }],
    PostToolUse: [{ hooks: [async (input: any) => { audit.push(`${input.tool_name} ${JSON.stringify(input.tool_input)}`); return {}; }] }],
  },
};

写法上的对应:

Python TypeScript
放行 PermissionResultAllow(updated_input=…) { behavior: "allow", updatedInput: … }
拒绝 PermissionResultDeny(message=…) { behavior: "deny", message: … }
注册 hook HookMatcher(matcher=…, hooks=[fn]) { matcher: …, hooks: [fn] }

实测两种情况。小额退款,回调放行:

 4.4s [调用] mcp__shop__get_order {"order_id":"A1003"}
 7.0s [调用] mcp__shop__refund_order {"order_id":"A1003","amount_cents":8800}
 7.1s 〔审批回调〕mcp__shop__refund_order {"order_id":"A1003","amount_cents":8800}
 9.8s [回答] 订单 A1003(客户李娜)已全额退款,金额 88.00 元。……

冻结的订单,hook 拦下:

19.6s [调用] mcp__shop__refund_order {"order_id":"A1002","amount_cents":45900}
19.6s 〔hook〕A1002 在冻结名单里,拒绝
23.4s [回答] 订单 A1002 这次没能退款。系统提示该订单有纠纷,已被冻结,任何人都不能操作。……

第二种情况里审批回调没有被调用:hook 排在前面,它拒绝了,后面就不用问了。跑完后审计记录里有三条,只有真正执行了的调用;被拦下的那次退款不在里面。

“预先批准的工具不经过回调”这一条两边一样。TypeScript 版把警告打成了 Node 的进程警告:

[CLAUDE_SDK_CAN_USE_TOOL_SHADOWED] Warning: canUseTool will not be invoked for:
mcp__shop__find_orders, mcp__shop__get_order. Bare allowedTools entries auto-approve the whole
tool before the callback is consulted. To gate every tool call, use a PreToolUse hook; …

五、会话:没有那个“客户端”对象

Python 里在一个进程里连着聊用的是 ClaudeSDKClient。TypeScript 没有对应的东西。 每次都是一个新的 query(),靠选项把它们连起来:

const first = await run("张伟有哪些订单?");                                 // 记下 session_id
await run("金额大的那张,物流单号是多少?", { resume: first.sessionId });     // 按 ID 接着聊
await run("这位客户两张订单加起来多少钱?不用再查。", { continue: true });     // 接最近的一次
await run("把刚才查到的两张订单用英文一句话概括。不要再查。",
          { resume: first.sessionId, forkSession: true });                   // 分一支

这里的 run 是本站自己写的小函数:把多出来的选项并进去,调一次 query(),读完消息流,把结果消息上的 session_id 带回来。

 8.0s [结束] … session=b0439638
--- resume
12.8s [调用] mcp__shop__get_order {"order_id":"A1002"}
16.3s [回答] 金额较大的订单是 A1002(459.00 元),物流单号是 YT9876543210。
16.4s [结束] … session=b0439638
--- continue: true
21.5s [回答] 两张订单合计 588.00 元,也就是 129.00 元加 459.00 元。
21.5s [结束] … session=b0439638
--- forkSession
25.7s [回答] Zhang Wei has two orders: A1001 (¥129.00, delivered …) and A1002 (¥459.00, shipped …).
25.8s [结束] … session=5cb4263d

第二问没说是谁的订单,它直接查了 A1002;第三问没有调用工具;分支拿到了新的会话 ID。和第四篇里 Python 版的表现一致。

continue: true 接的同样是“这个目录里最近的一次”,第四篇讲过它可能接错。多个用户或多条对话共用一个目录时,老老实实存 ID、用 resume。

每次 query() 都要重新起一个子进程。文档提到 TypeScript 有 streamInput() 可以往一个已经开着的会话里追加消息,还有 startup() 和 prewarm() 预热子进程。这几个本站没有试。

六、逐字输出

打开 includePartialMessages,消息流里多出 type 为 stream_event 的消息,原始的流式事件在 message.event 里:

if (message.type === "stream_event") {
  const ev: any = message.event;
  if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta")
    process.stdout.write(ev.delta.text);                              // 一段一段地打出来
  if (ev.type === "content_block_start" && ev.content_block?.type === "tool_use")
    console.log(`(正在调用 ${ev.content_block.name}…)`);            // 工具开始的信号
}
 3.7s (正在调用 mcp__shop__find_orders…)
 4.3s [调用] mcp__shop__find_orders {"customer":"张伟"}
张伟最近的一张订单是 A1002,下单日期 2026-10-08,金额 459.00 元。订单状态是已发货,还在配送途中。……

工具开始的信号比完整的工具调用消息早到了 0.6 秒。文字逐段打出之后,完整的助手消息照样会再来一遍,只显示其中一种。

七、结构化结果:Zod 转成 JSON Schema

Python 用 Pydantic,TypeScript 用 Zod,思路相同:定义一个结构,转成 JSON Schema 交给 SDK,拿到结果再用同一个结构校验。

const Summary = z.object({
  customer: z.string(),
  order_count: z.number().int(),
  total_cents: z.number().int(),
  orders: z.array(z.object({ id: z.string(), status: z.string(), amount_cents: z.number().int() })),
  needs_attention: z.boolean(),
  note: z.string(),
});

const schema = z.toJSONSchema(Summary, { target: "draft-7" });       // 注意 target

// options: { outputFormat: { type: "json_schema", schema } }
const parsed = Summary.safeParse(result.structured_output);

target: "draft-7" 不能省。 SDK 按 JSON Schema draft-07 校验你给的结构,而 Zod 默认生成的是更新的版本,文档说那样会被拒绝。

实测:

 4.1s [调用] mcp__shop__find_orders {"customer":"张伟"}
 7.9s [调用] StructuredOutput {"customer":"张伟","order_count":2,"total_cents":58800,…
structured_output: {"customer":"张伟","order_count":2,"total_cents":58800,"orders":[…],"needs_attention":true,"note":"…"}
Zod 校验: 通过,合计 588.00 元

和 Python 版一样,结果是通过一次名叫 StructuredOutput 的工具调用交上来的,数据在结果消息的 structured_output 上。

八、env:整个替换,不是合并

这是两边行为真正不同的一处:

  • Python 的 env 是叠加在继承来的环境变量上的。
  • TypeScript 的 env 是整个替换。只写 env: { DEMO_FLAG: "1" },你的进程里原有的环境变量就一个也不传给子进程。

本站把两种写法各跑了一次,让智能体用 Bash 工具打印环境变量的名字,再跑几条普通命令。

只写 env: { DEMO_FLAG: "1" }:

[工具结果] AI_AGENT ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL CLAUDECODE … DEMO_FLAG GIT_EDITOR … PATH PWD SHELL SHLVL
PATH=
      变量个数: 22

[工具结果] /bin/bash: line 1: ls: No such file or directory
/bin/bash: line 1: git: No such file or directory
/bin/bash: line 1: node: No such file or directory
HOME=[] USER=[]

写成 env: { ...process.env, DEMO_FLAG: "1" }:

      变量个数: 50

[工具结果] envtest.mts
……(目录里的其他文件)
shop.mts
git version 2.43.0
v22.23.2
HOME=[/home/ccdemo] USER=[ccdemo]

三点观察:

  • 智能体本身照样能启动、能回答。 第一种写法跑了三次,都没有报错,结果都是 subtype=success。这台测试机的接入配置写在用户设置文件里,不靠环境变量,所以登录没受影响。剩下的 22 个变量里,有传进去的 DEMO_FLAG,有设置文件里的两个 ANTHROPIC_ 变量,其余是 Claude Code 和 shell 自己设的。
  • 坏的是 Bash 工具。 PATH 是空的,ls、git、node 全都找不到,HOME 和 USER 也是空的。
  • 它不会替你发现。 模型看到命令失败后只是在回答里提了一句“PATH 很可能没有设置好”,结果消息照样是成功。

前面几节的例子没有受影响,是因为订单助手用 tools: [] 去掉了内置工具,根本不跑 Bash。你的智能体只要用到 Bash,就要这样写:

env: { ...process.env, CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: "5" }

如果你的密钥和接口地址是用环境变量给的(服务器上通常如此),漏掉 ...process.env 时它们也传不进去。这种情况本站没有试,文档的说法是继承来的变量都会丢。

哪些完全一样

不用重新学的部分:

  • 底下跑的是同一个 Claude Code 可执行文件,消息的种类、先后顺序、字段含义都一样。
  • allowedTools 不是白名单、tools: [] 才是去掉内置工具,这些规则一样。
  • 花费的算法、出错时的行为、预算会超一点,一样。
  • 中转站那边的限制也一样。本站把模型写成 haiku,TypeScript 版得到的是和 Python 版相同的那句“模型不存在或你没有权限”。

这一趟花了多少

约 0.15 美元。一共 18 次运行,把每条结果消息上的 total_cost_usd 加起来得到这个数,单次最多 0.0143 美元。用的是 Sonnet 5.5,订单助手只有三个自定义工具,上下文很小。这是 SDK 按官方价格估算的数字,不是中转站实际扣的金额。

这次实测没有覆盖到的

  • 子代理、skills、插件、工具搜索、文件检查点、任务清单没有用 TypeScript 重测,只在前两篇里用 Python 测过。
  • streamInput()、startup()、prewarm() 没有试,长驻服务里保持会话的写法没有验证。
  • 用环境变量给密钥时漏写 ...process.env 会怎样没有试。 测试机的密钥在设置文件里。
  • 只用 tsx 在 Node.js 22 上跑过。 先编译再运行、Bun、Deno 都没有试。
  • 没有比较两种 SDK 的性能和内存。
  • 每种情况只跑了一次。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章