Claude Agent SDK 的 TypeScript 版:把同一个订单助手再写一遍,和 Python 版逐项对照
这个系列前面都用 Python。本站把同一个订单助手用 TypeScript 重写并实测:自定义工具、出错处理、审批回调和 hooks、会话、逐字输出、结构化结果。多数只是换个名字,不同的有几处:参数用 Zod 写;没有保持会话的客户端对象;审批回调更省事;env 是整个替换,漏写之后 Bash 里连 ls 都找不到。
本站已实测,测试日期:2026-10-13。
本页目录(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 的性能和内存。
- 每种情况只跑了一次。