丹尼拉(Dayfing)
返回文章列表
4,213 字19 分钟

AI SDK 6 与 TypeScript:工具循环、结构化输出和人工审批

AI SDK 6 为 TypeScript 应用 建立 了模 型决 策 与应 用权 限 之间 的清 晰边 界。模型 可以 选择 一个 有类 型 的工 具,接收 工具 结果,并继 续 对话。你的 代码 仍然 负责 验证 输入、执行 权限 检查、决定 某个 副作 用 是否 需要 人工 参与,以及 记录 发生 的事 情。这种 分离 是生 产级 代理 的有 用设 计原 则。本文 使用 AI SDK 6 的稳 定 API,而不 是 AI SDK 7 才引 入 的较 新审 批 API。请安 装 ai@6 和兼 容 6.x 的提 供商 包,例如 @ai-sdk/openai@3,并在 lockfile 中锁 定版 本。

你正 在构 建的 循环

一次 模型 调用 可以 返回 文本,也可 以返 回 工具 调用。工具 循环 会在 工具 执行 完成 后 再次 调用 模型,让模 型解 释 结果 并决 定 是否 需要 另一 个工 具。每一 次模 型生 成 都是 一个 步骤。当模 型不 再请 求工 具、被调 用工 具没 有 execute、需要 审批,或者 stopWhen 条件 满足 时,循环 结束。结果 会暴 露 最终 文本、工具 调用 和结 果、响应 消息、用量,以及 steps。因此 整个 循环 可以 被检 查和 测试,而不 是一 个不 可见 的黑 盒。

ToolLoopAgent 把这 种行 为 封装 为可 复用 对象。构造 函数 需要 一个 LanguageModel,还可 以接 收 instructionstoolsstopWhenoutputprepareStepmaxRetries、超时 和回 调。在 AI SDK 6 中,默认 停止 条件 是 stepCountIs(20)。对于 有边 界 的工 作流,应该 设置 更小 的上 限。这个 上限 控制 成本 和可 用性,但不 能代 替权 限检 查。

类型 安全 的 ToolLoopAgent

tool 辅助 函数 会根 据 inputSchema 推断 execute 参数 的类 型。这个 schema 会发 送给 提供 商,也会 用于 验证 模型 传入 的参 数。但这 并不 意味 着模 型 已经 可信。一个 结构 合法 的值 仍然 可能 包含 其他 用户 的账 号、被禁 止 的路 径、危险 的 URL 或不 允许 的金 额。

npm install [email protected] @ai-sdk/[email protected] @ai-sdk/[email protected] zod
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs, tool } from 'ai';
import { z } from 'zod';

const getWeather = tool({
  description: 'Get the current weather for a city',
  inputSchema: z.object({
    city: z.string().min(1).max(80),
  }),
  execute: async ({ city }) => ({
    city,
    temperatureC: 18,
    condition: 'cloudy',
  }),
});

const agent = new ToolLoopAgent({
  model: openai('gpt-4o-mini'),
  instructions: 'Use getWeather for current weather. Never invent a tool result.',
  tools: { getWeather },
  stopWhen: stepCountIs(4),
  maxRetries: 2,
});

const result = await agent.generate({
  prompt: 'What is the weather in Kyiv?',
});

console.log(result.text);
console.log(result.steps.length);

一个 工具 应该 只执 行 一个 操作,并返 回 小型、可序 列化 的结 果。描述 中 要明 确 单位、权限、数据 新鲜 度 和失 败行 为。只有 在提 供商 支持 严格 工具 调用 且 schema 兼容 时,才使 用 strict: true。严格 模式 可以 提升 可靠 性,但不 是授 权机 制。toolChoice: 'auto' 让模 型决 定,required 强制 使用 一个 可用 工具,none 禁用 工具,{ type: 'tool', toolName: 'getWeather' } 选择 指定 工具。activeTools 可以 限制 某一 步 暴露 的工 具。

显式 调用 与循 环上 限

当你 只需 要一 次调 用,或者 要直 接控 制 消息 历史 时,使用 generateText。如果 工具 结果 需要 返回 给模 型,就添 加 stopWhen。没有 这个 选项 时,generateText 只生 成一 次,然后 返回 工具 调用,不会 自动 继续。

import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';

const lookupOrder = tool({
  description: 'Look up an order by its public order number',
  inputSchema: z.object({ orderNumber: z.string().regex(/^ORD-[0-9]{6}$/) }),
  execute: async ({ orderNumber }) => ({
    orderNumber,
    status: 'shipped',
  }),
});

const result = await generateText({
  model: openai('gpt-4o-mini'),
  tools: { lookupOrder },
  stopWhen: stepCountIs(3),
  prompt: 'Check order ORD-104209 and explain its status.',
});

console.log(result.text);
console.log(result.steps.flatMap(step => step.toolCalls));

内置 条件 包括 stepCountIs(count)hasToolCall(toolName)isLoopFinished()。传入 数组 时,只要 任一 条件 满足,循环 就会 停止。isLoopFinished() 没有 最大 步数,因此 只能 与 外部 预算、超时、取消 信号 和提 供商 配额 一起 使用。自定 义 StopCondition 会收 到 { steps },可以 根据 业务 状态 或测 量 的 token 预算 停止。条件 会在 最后 一步 含有 工具 结果 时被 检查。如果 工具 调用 和结 构化 输出 同时 存在,要为 输出 生成 额外 保留 一步。

maxRetries 会重 试 失败 的模 型调 用,但不 会让 execute 自动 具备 幂等 性。发送 邮件、扣款 或创 建记 录 的工 具,必须 带有 幂等 键,并自 行检 测重 复请 求。对于 远程 MCP 客户 端,重试 默认 关闭,需要 在 createMCPClient 中配 置 maxRetries。只重 试 网络 错误 和速 率限 制。不要 盲目 重放 不具 幂等 性 的 tools/call 请求。

TypeScript 可以 信任 的结 构化 输出

AI SDK 6 已将 generateObjectstreamObject 标记 为弃 用,建议 改用 带有 output 选项 的 generateTextstreamTextOutput.object 接受 Zod、Valibot 或 JSON Schema。完整 响应 会先 被解 析并 根据 schema 验证,之后 result.output 才会 解析 完成。部分 对象 适合 界面 逐步 展示,但不 能作 为最 终验 证结 果。

import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';

const reportSchema = z.object({
  sentiment: z.enum(['positive', 'neutral', 'negative']),
  score: z.number().min(0).max(1),
  keyPoints: z.array(z.string().min(1)).max(8),
});

const { output } = await generateText({
  model: openai('gpt-4o-mini'),
  output: Output.object({
    schema: reportSchema,
    name: 'review_report',
    description: 'A concise, evidence-based review report',
  }),
  prompt: 'Analyze: The battery lasts all day, but the charger is bulky.',
});

console.log(output.sentiment, output.score, output.keyPoints);

验证 有两 个层 次。提供 商可 能会 强制 响应 格式,AI SDK 则会 解析 返回 的 JSON,并用 schema 验证。应用 仍然 必须 检查 业务 规则,例如 标识 符 是否 属于 当前 租户,或者 某个 分数 是否 有权 触发 退款。让 schema 保持 封闭 和狭 窄。限制 字符 串、数组 和数 字,并使 用枚 举。遇到 未知 命令 时应 拒绝,而不 是把 任意 JSON 传给 特权 适配 器。

使用 工具 时,模型 可以 先查 询,再生 成报 告。设置 stopWhen: stepCountIs(4) 或其 他明 确预 算,因为 结构 化输 出步 骤 属于 同一 个多 步骤 流程。解析 失败 时,捕获 SDK 错误,保留 关联 ID 和提 供商 元数 据,并返 回 可安 全重 试 的响 应。不要 让第 二个 模型 为格 式错 误 的输 出做 授权 决定。

文本 和事 件的 流式 传输

streamText 为文 本和 结构 化输 出提 供 异步 流。ToolLoopAgent.stream 会在 准备 调用 后返 回 StreamTextResult,所以 在读 取 textStream 前先 等待 它。文本 流包 含生 成 的文 本,完整 结果 和回 调则 暴露 工具 调用 和结 果。

import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent } from 'ai';

const agent = new ToolLoopAgent({
  model: openai('gpt-4o-mini'),
  instructions: 'Answer clearly and briefly.',
});

const stream = await agent.stream({
  prompt: 'Explain why typed tool inputs matter.',
});

for await (const chunk of stream.textStream) {
  process.stdout.write(chunk);
}

对于 结构 化流,使用 带 Output.objectstreamText,并消 费 partialOutputStreamonStepFinish 可用 于持 久化 已完 成步 骤、记录 用量 和展 示审 计事 件。流也 可能 以 tool-approval-requesttool-errortool-output-denied 结束,而不 只是 文本。只渲 染文 本 的客 户端,可能 会隐 藏 等待 人工 处理 的动 作。

对副 作用 进行 人工 审批

在 AI SDK 6 中,可以 在工 具上 设置 needsApproval,值为 true,或者 设置 基于 已验 证输 入 的异 步谓 词。第一 次调 用 generateTextstreamText 会返 回 tool-approval-request 部分。服务 器不 会暂 停等 待浏 览器。保存 响应 消息,向用 户显 示 精确 的工 具名 和参 数,把 tool-approval-response 加入 新的 tool 消息,再次 调用 模型。

下面 的完 整 Node 示例 使用 终端 问题 作为 人工 边界。Web 应用 应该 把 messagesapprovalIdtoolCallId、已认 证审 核人 和过 期时 间保 存在 服务 器端 存储 中。

import { createInterface } from 'node:readline/promises';
import { openai } from '@ai-sdk/openai';
import {
  generateText,
  tool,
  type ModelMessage,
  type ToolApprovalResponse,
} from 'ai';
import { z } from 'zod';

const records = new Map([
  ['draft-17', { ownerId: 'user-7', text: 'Quarterly notes' }],
]);

const deleteDraft = tool({
  description: 'Delete one draft owned by the authenticated user',
  inputSchema: z.object({ draftId: z.string().regex(/^draft-[0-9]+$/) }),
  needsApproval: true,
  execute: async ({ draftId }) => {
    if (!records.delete(draftId)) {
      throw new Error('Draft was not found');
    }
    return { draftId, deleted: true };
  },
});

async function requestHumanApproval(toolName: string, input: unknown) {
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
  const answer = await terminal.question(`Approve ${toolName} ${JSON.stringify(input)}? [y/N] `);
  terminal.close();
  return answer.trim().toLowerCase() === 'y';
}

const messages: ModelMessage[] = [
  { role: 'user', content: 'Delete draft-17.' },
];

const first = await generateText({
  model: openai('gpt-4o-mini'),
  system: 'If an action is denied, do not retry it.',
  tools: { deleteDraft },
  messages,
});

messages.push(...first.response.messages);
const approvals: ToolApprovalResponse[] = [];

for (const part of first.content) {
  if (part.type === 'tool-approval-request') {
    approvals.push({
      type: 'tool-approval-response',
      approvalId: part.approvalId,
      approved: await requestHumanApproval(part.toolCall.toolName, part.toolCall.input),
      reason: 'Decision made by the authenticated reviewer',
    });
  }
}

if (approvals.length > 0) {
  messages.push({ role: 'tool', content: approvals });
  const final = await generateText({
    model: openai('gpt-4o-mini'),
    system: 'If an action is denied, do not retry it.',
    tools: { deleteDraft },
    messages,
  });
  console.log(final.text);
} else {
  console.log(first.text);
}

审批 应该 变成 清晰 的工 具结 果,且不 能自 动重 试。审批 不是 授权 的替 代品。在执 行副 作用 之前,再次 检查 用户、租户、目标、策略 和资 源版 本。把保 存 的审 批绑 定到 approvalIdtoolCallId、工具 名和 已验 证输 入 的哈 希。设置 过期 时间,只允 许使 用一 次,并拒 绝已 经改 变 的输 入。对于 高影 响动 作,要显 示目 标、身份、参数、离开 系统 的数 据和 预期 副作 用,而不 是只 显示 模型 摘要。

危险 工具 和安 全边 界

不要 向模 型暴 露通 用 shell、无限 制 HTTP 客户 端、任意 文件 路径 或直 接数 据库 连接。应优 先提 供 deleteDraftcreateCalendarEventlookupOrder 这样 的窄 操作。把授 权和 允许 列表 放进 execute 或策 略服 务。检查 URL 的协 议、主机、端口、解析 地址 和重 定向。把路 径解 析限 制在 允许 的根 目录,并处 理符 号链 接。在数 据库 查询 中检 查租 户所 有权,不要 只在 prompt 中声 明。

把工 具描 述、检索 文档、记忆 和工 具结 果都 视为 不可 信内 容。提示 词注 入可 能让 模型 泄露 秘密、调用 无关 工具,或把 数据 发送 到攻 击者 控制 的地 址。模型 不是 安全 边界。提示 词注 入与 MCP 安全介绍 最小 权限、来源、出站 网络、隔离、限额 和审 批控 制。生产 AI 代理 架构则讨 论如 何分 离规 划器、执行 器、策略 和存 储。

永远 不要 把 API 密钥 放入 prompt 或工 具结 果。对日 志和 遥测 中的 token 做脱 敏。输入 可能 包含 个人 数据 时,不要 记录 完整 参数。让请 求 ID 贯穿 模型 调用 和工 具执 行。在加 入下 一个 prompt 前限 制结 果大 小。为读 取、草稿 和提 交使 用不 同凭 据。浏览 器或 文件 代理 应运 行在 隔离 worker 中,不带 无关 密钥,并默 认拒 绝网 络。

保持 类型 安全 的 MCP

@ai-sdk/mcp 会把 MCP 服务 器工 具适 配为 AI SDK 工具。AI SDK 6 建议 生产 环境 使用 HTTP,把 stdio 限定 给本 地服 务器。对于 不受 你控 制 的服 务器 或敏 感工 具,应显 式定 义 schema。这样 可以 保持 工具 集合 狭窄,并让 TypeScript 提供 有用 的输 入类 型。如果 部署 策略 不允 许重 定向,设置 redirect: 'error'。验证 OAuth 授权 服务 器来 源,并在 finallyonFinish 中关 闭客 户端。

import { openai } from '@ai-sdk/openai';
import { createMCPClient } from '@ai-sdk/mcp';
import { ToolLoopAgent, stepCountIs } from 'ai';
import { z } from 'zod';

const mcpUrl = process.env.MCP_URL;
if (!mcpUrl) throw new Error('MCP_URL is required');

const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url: mcpUrl,
    headers: process.env.MCP_TOKEN
      ? { Authorization: `Bearer ${process.env.MCP_TOKEN}` }
      : undefined,
    redirect: 'error',
  },
  maxRetries: 2,
});

try {
  const tools = await mcpClient.tools({
    schemas: {
      'get-customer-note': {
        inputSchema: z.object({ customerId: z.string().uuid() }),
      },
    },
  });
  const agent = new ToolLoopAgent({
    model: openai('gpt-4o-mini'),
    instructions: 'Use only the customer note tool for this request.',
    tools,
    stopWhen: stepCountIs(3),
  });
  const result = await agent.generate({ prompt: 'Read the requested customer note.' });
  console.log(result.text);
} finally {
  await mcpClient.close();
}

通过 mcpClient.tools() 自动 发现 很方 便,但会 暴露 服务 器广 告的 每一 个工 具,也不 会提 供编 译期 输入 类型。显式 schema 只加 载指 定工 具。只对 临时 网络 错误 重试。MCP 应用 错误,以及 带 isError: true 的成 功响 应,都应 该传 给模 型,而不 能重 放副 作用。OAuth 仍然 需要 受众 检查、PKCE、短期 token、精确 redirect URI,以及 已发 现授 权服 务器 的允 许列 表。带 OAuth 的 TypeScript MCP 服务 器介绍 服务 器端 实现。

错误、测试 与运 行维 护

在生 成外 层建 立边 界,区分 无效 输入、提供 商失 败、超时、取消、格式 错误 和工 具失 败。AI SDK 会把 execute 抛出 的异 常转 换为 tool-error 部分,使多 步骤 模型 能够 看到 失败。让工 具返 回经 过清 理 的错 误消 息,或者 在交 给模 型前 转换 结果。不要 泄露 stack trace、密钥、SQL、本地 路径 或上 游响 应正 文。用 abortSignaltimeout 限制 每一 次请 求。记录 finishReasonusagetotalUsage、步骤 数量 和工 具策 略决 定。

AI SDK 6 在 ai/test 中提 供确 定性 mock。MockLanguageModelV3 可以 在第 一次 生成 返回 工具 调用,在第 二次 生成 返回 文本。下面 的测 试证 明循 环只 执行 一次 工具,并在 不联 系真 实提 供商 的情 况下 从工 具结 果得 到最 终响 应。

import assert from 'node:assert/strict';
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';

const usage = {
  inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 1, text: 1, reasoning: undefined },
};

let calls = 0;
let executions = 0;
const model = new MockLanguageModelV3({
  doGenerate: async () => {
    calls += 1;
    if (calls === 1) {
      return {
        content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'add', input: '{"a":2,"b":3}' }],
        finishReason: { unified: 'tool-calls', raw: undefined },
        usage,
        warnings: [],
      };
    }
    return {
      content: [{ type: 'text', text: 'The result is 5.' }],
      finishReason: { unified: 'stop', raw: undefined },
      usage,
      warnings: [],
    };
  },
});

const add = tool({
  description: 'Add two numbers',
  inputSchema: z.object({ a: z.number(), b: z.number() }),
  execute: async ({ a, b }) => {
    executions += 1;
    return a + b;
  },
});

const result = await generateText({
  model,
  tools: { add },
  stopWhen: stepCountIs(2),
  prompt: 'Add 2 and 3.',
});

assert.equal(result.text, 'The result is 5.');
assert.equal(calls, 2);
assert.equal(executions, 1);

为被 拒绝 schema、租户 不匹 配、路径 遍历、SSRF 地址、重复 幂等 键、过期 审批、拒绝 流程、MCP 工具 允许 列表 和输 出验 证增 加测 试。测试 流中 的工 具和 审批 部分,不要 只比 较可 见文 本。用要 求模 型忽 略任 务或 泄露 上下 文 的文 档运 行对 抗用 例。AI 代理 评估介绍 回归 数据 集、工具 调用 断言,以及 成本 和延 迟测 量。

从 AI SDK 5 迁移 的注 意事 项

ToolLoopAgent 替换 Experimental_Agent。它的 system 设置 改名 为 instructions。默认 停止 条件 从 stepCountIs(1) 变为 stepCountIs(20),因此 升级 后可 能产 生更 多模 型调 用。请设 置明 确上 限。用带 Output.objectOutput.array 或其 他输 出策 略 的 generateTextstreamText 替换 generateObjectstreamObject。流式 结果 使用 partialOutputStream

CoreMessage 改为 ModelMessage,AI SDK 6 中的 convertToModelMessages 变为 异步 函数。ToolCallOptions 改为 ToolExecutionOptions。将 V2 mock 换成 MockLanguageModelV3ai/test 中其 他 V3 mock。聊天 模型 上的 提供 商选 项 structuredOutputs 被移 除,改用 strictJsonSchema。实现 toModelOutput 时,按 v6 签名 解构 { output } 参数。运行 v6 codemod 后,仍要 手动 检查 提供 商适 配器、消息 转换、审批 重放 和流 式测 试。codemod 可以 重命 名符 号,但无 法判 断新 的默 认值 是否 适合 你的 工作 流。

确切 API 取决 于锁 定的 patch 版本 和提 供商。升级 前请 阅读 ToolLoopAgent 参考工具 调用 指南结构 化数 据指 南MCP 指南测试 指南AI SDK 5 到 6 迁移 指南。如果 提供 商警 告某 个选 项被 忽略,应把 它视 为测 试失 败。

上线前检查清单

上线前 为每个 工具 写清楚 负责人、输入、输出、权限、超时、重试 和审计 字段。确认 模型 只能 看到 当前 工作流 需要 的工具。确认 每个 租户 的身份 都会 在工具 执行时 再次 验证。确认 所有 写操作 都有 明确 的审批 路径、过期时间、单次 使用限制 和幂等键。确认 拒绝 后模型 不会 反复 请求 同一动作。

为每次 运行 保存 请求 ID、模型 ID、工具 名称、参数摘要、策略 结果、审批 人员、步骤 数量、令牌 用量 和最终 状态。敏感 字段 使用 脱敏 值,原始 密钥 不得 进入 日志。为 provider error、schema error、tool error、timeout、abort 和 MCP error 准备 不同 的重试 策略。把 仅适合 重试 的网络 故障 和不应 重放 的业务 失败 分开。

为提示词 注入准备 回归 文档,包括 网页、邮件、PDF、数据库 记录、MCP 工具描述 和工具 结果。检查 模型 是否 会将 数据 当作 指令。检查 是否 会越过 租户 边界。检查 是否 会把 私密 内容 发送 到外部 地址。检查 审批 界面 展示 的参数 与实际 执行 的参数 完全一致。

部署 MCP 前 固定 服务器 版本,审核 工具 schema,限制 网络 出口,验证 OAuth 受众,并拒绝 未知 的重定向。部署 代理 前 测试 stream、resume、cancel 和 disconnect。部署 新模型 前 对比工具 调用率、步骤 分布、错误 比例、延迟、成本 和拒绝率。把这些 指标 与旧版本 的基线 比较,而不是 只看最终 文本 是否自然。

来源

更多文章