Vercel AI SDK 的工具审批(Approval)流程详解

概述

在 AI 应用中,当 AI 助手需要调用本地文件系统或其他敏感工具时,出于安全考虑,需要在执行前获得用户的明确批准。Vercel AI SDK(文中使用 v6 版本,这狗东西 API 一直变来变去的)提供了工具审批(Tool Approval)机制,允许开发者在 AI 执行工具前拦截并请求用户确认。

注意这个参数主要是为了配合stopWhen(也就是工具自动调用),也就是:

1
2
3
4
5
6
const result = streamText({
model: newapi.chat(config.model.model),
messages,
tools,
stopWhen: stepCountIs(5), // 这里 自动工具调用
});

同时也注意这东西的调用方式灵活的很,毕竟他不是应用级别的封装,不用这套自己也可以搓一个出来。

同时在 AI SDK UI 中的调用流程是完全不一样的,这里只针对 Core 的 API。(但是其实也八九不离十了)

Tool 配置:开启审批

需要在定义 tool 时设置 needsApproval: true 来启用审批机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// src/mini-cc-cli/tools/index.ts
import { tool, type ToolSet } from "ai";
import { z } from "zod";
import { readFile } from "fs/promises";

// Read Tool 定义
export const readTool = tool({
description: "Read the contents of a file at the specified path",
inputSchema: z.object({
path: z.string().describe("The absolute or relative file path to read"),
}),
needsApproval: true, // 🔑 关键:启用审批
execute: async ({ path }) => {
try {
const content = await readFile(path, "utf-8");
return {
success: true,
path,
content,
size: content.length,
lines: content.split("\n").length,
};
} catch (error) {
return {
success: false,
path,
error: error instanceof Error ? error.message : String(error),
};
}
},
});

// Read Image Tool 定义
export const readImageTool = tool({
description: "Read an image file and return it as base64 encoded string.",
inputSchema: z.object({
path: z.string().describe("The path to the image file"),
}),
needsApproval: true, // 🔑 同样启用审批
execute: async ({ path }) => {
try {
const buffer = await readFile(path);
const base64 = buffer.toString("base64");
const ext = path.split(".").pop()?.toLowerCase() || "png";

return {
success: true,
path,
mimeType: `image/${ext}`,
base64,
size: buffer.length,
};
} catch (error) {
return {
success: false,
path,
error: error instanceof Error ? error.message : String(error),
};
}
},
});

// 导出工具集
export const tools = {
read: readTool,
readImage: readImageTool,
} satisfies ToolSet;

核心流程:两次 streamText 调用

然后需要通过两次streamText调用实现了审批机制:

  1. 第一次调用:AI 分析用户请求,生成工具调用请求(但是不执行,其实你可以把 approve 当成另一种 stopWhen 的条件,遇到需要 approve 的请求就 stop)
  2. 用户审批:应用拦截请求,询问用户是否批准,这个需要自己实现,比如让用户选择 Yes/No
  3. 第二次调用:将审批结果发送给 AI,执行工具或处理拒绝

核心数据结构

Tool Approval Request(工具审批请求)

第一次 streamText 调用后,如果 AI 决定调用某个需要审批的工具,SDK 会返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'call_TbD9PcJ587DGoevvmhXCMYhl', // <- 注意这个id
toolName: 'read',
input: { path: '.gitignore' },
providerExecuted: undefined,
providerOptions: undefined
},
{
type: 'tool-approval-request',
approvalId: 'aitxt-fPnw2HDSNhqLvhxz3f253A9l',
toolCallId: 'call_TbD9PcJ587DGoevvmhXCMYhl' // <- 注意这个id 这两成对出现
}
]
},

Tool Approval Response(工具审批响应)

用户做出决定后,应用需要构造审批响应:

1
2
3
4
5
6
7
8
9
10
11
{
role: 'tool',
content: [
{
type: 'tool-approval-response',
approvalId: 'aitxt-fPnw2HDSNhqLvhxz3f253A9l', // 这个对应哪个request 一个请求里可能会包含多个approve请求
approved: true,
reason: 'User approved'
}
]
}

代码流程

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// src/mini-cc-cli/index.ts
async function handleGeneration(
config: ReturnType<typeof loadConfig>,
messages: ModelMessage[]
): Promise<void> {
// ========== 阶段一:第一次 streamText 调用 ==========
const result = streamText({
model: newapi.chat(config.model.model),
messages,
tools,
stopWhen: stepCountIs(5),
});

let hasTextOutput = false;

// 流式输出文本(如果有)
for await (const chunk of result.textStream) {
if (!hasTextOutput) {
process.stdout.write(chalk.blue("\nAssistant: "));
hasTextOutput = true;
}
process.stdout.write(chalk.blue(chunk));
}

if (hasTextOutput) {
console.log("\n");
}

// 等待响应完成
const response = await result.response;
const content = await result.content;

// 将第一次调用的响应加入消息历史
messages.push(...response.messages);

// ========== 阶段二:检查并处理审批请求 ==========
const approvals: ToolApprovalResponse[] = [];

for (const part of content) {
if (part.type === "tool-approval-request") {
console.log(chalk.yellow("\n⏸️ Tool execution requires approval\n"));

// 请求用户审批(可以根据配置自动approve或者拒绝或者请求用户)
const decision = await requestApproval(config, {
approvalId: part.approvalId,
toolName: part.toolCall.toolName,
input: part.toolCall.input,
});

// 构造审批响应对象
const approval: ToolApprovalResponse = {
type: "tool-approval-response",
approvalId: part.approvalId,
approved: decision.approved,
};

if (decision.reason) {
approval.reason = decision.reason;
}

approvals.push(approval);
}
}

// ========== 阶段三:第二次 streamText 调用(如果有审批) ==========
if (approvals.length > 0) {
// 🔑 关键:将审批响应作为 tool 角色的消息加入历史
messages.push({ role: "tool", content: approvals });

console.log(chalk.gray("🔄 Processing approval responses...\n"));

// 第二次调用:AI 会根据审批结果执行或放弃工具调用
const finalResult = streamText({
model: newapi.chat(config.model.model),
messages,
tools,
stopWhen: stepCountIs(5),
});

let hasFinalText = false;

// 流式输出最终文本
for await (const chunk of finalResult.textStream) {
if (!hasFinalText) {
process.stdout.write(chalk.blue("Assistant: "));
hasFinalText = true;
}
process.stdout.write(chalk.blue(chunk));
}

if (hasFinalText) {
console.log("\n");
}

// 等待最终响应
const finalResponse = await finalResult.response;

// 🔑 关键:将最终响应加入消息历史
messages.push(...finalResponse.messages);
}
}

三阶段详解

阶段一:初始生成(第一次 streamText)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const result = streamText({
model: newapi.chat(config.model.model),
messages,
tools,
stopWhen: stepCountIs(5),
});

// 流式输出文本
for await (const chunk of result.textStream) {
process.stdout.write(chalk.blue(chunk));
}

// 等待完整响应
const response = await result.response;
const content = await result.content;

// 🔑 加入消息历史
messages.push(...response.messages);

发生了什么:

  1. AI 接收用户消息和对话历史
  2. AI 分析是否需要调用工具
  3. 如果需要且 needsApproval: true,SDK 不会执行工具
  4. 而是在 content 中返回 tool-approval-request 对象
  5. response.messages 包含了这次交互的完整消息(包括审批请求)

关键点:

  • result.textStream 可能为空,也可能包含 AI 的思考过程
  • result.response 是异步的,需要 await 等待完成
  • response.messages 必须加入 messages 数组,保持对话连续性(不然就丢失上下文了)

阶段二:收集审批决策

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const approvals: ToolApprovalResponse[] = [];

for (const part of content) {
if (part.type === "tool-approval-request") {
// 请求用户审批
const decision = await requestApproval(config, {
approvalId: part.approvalId,
toolName: part.toolCall.toolName,
input: part.toolCall.input,
});

// 构造审批响应
const approval: ToolApprovalResponse = {
type: "tool-approval-response",
approvalId: part.approvalId,
approved: decision.approved,
};

if (decision.reason) {
approval.reason = decision.reason;
}

approvals.push(approval);
}
}

发生了什么:

  1. 遍历 content 数组,查找所有 tool-approval-request
  2. 对每个请求调用 requestApproval 获取用户决策
  3. 构造标准的 ToolApprovalResponse 对象
  4. 收集所有审批响应到 approvals 数组

关键点:

  • 可能有多个工具调用需要审批(批量审批)
  • approvalId 用于匹配请求和响应
  • requestApproval (这个是自己实现的,不是 AI SDK 里的东西)的实现可以是交互式的、基于规则的或自动化的

阶段三:执行审批后的操作(第二次 streamText)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (approvals.length > 0) {
// 🔑 将审批响应作为 tool 角色的消息加入历史
messages.push({ role: "tool", content: approvals });

// 第二次调用
const finalResult = streamText({
model: newapi.chat(config.model.model),
messages,
tools,
stopWhen: stepCountIs(5),
});

// 流式输出
for await (const chunk of finalResult.textStream) {
process.stdout.write(chalk.blue(chunk));
}

// 等待完成
const finalResponse = await finalResult.response;

// 🔑 加入消息历史
messages.push(...finalResponse.messages);
}

发生了什么:

  1. 将审批响应以 { role: "tool", content: approvals } 形式加入消息历史
  2. 第二次调用 streamText,AI 接收到审批结果
  3. 如果 approved: true
    • AI 会执行工具(调用 execute 函数)
    • 获取工具返回结果
    • 基于结果生成回复
  4. 如果 approved: false
    • AI 不会执行工具
    • 根据 reason 和 system message 的指示
    • 解释原因并寻求替代方案

注意事项

  • role: "tool" 告诉 SDK 这是审批响应消息
  • 第二次调用的 messages 包含了审批响应,AI 才能知道是否执行
  • finalResponse.messages 包含工具执行结果和 AI 的最终回复

System Message 的作用

为了确保 AI 正确处理拒绝情况,需要在 system message 中明确指示,不过这个只是一个加强罢了,不会真的有 LLM 傻到一直请求吧(啊嘞??):

1
2
3
4
5
6
7
const createSystemMessage = (): ModelMessage => ({
role: "system",
content:
"You are a helpful AI assistant with access to file system tools. " +
"When a tool execution is not approved, do not retry it. " +
"Explain what you were trying to do and ask for alternative approaches.",
});
  • do not retry it:避免 AI 重复尝试被拒绝的操作
  • Explain what you were trying to do:要求 AI 解释意图,增强透明度
  • ask for alternative approaches:引导 AI 寻求其他解决方案

这确保了拒绝审批后的优雅降级,而非死循环或错误终止。

完整流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
用户输入:"read the file /etc/passwd"

messages = [
{ role: "system", content: "..." },
{ role: "user", content: "read the file /etc/passwd" }
]

┌─────────────────────────────────────────────
│ 第一次 streamText 调用
├─────────────────────────────────────────────
│ AI 分析:需要调用 read tool
│ 发现:needsApproval = true
│ 返回:tool-approval-request(不执行)
└─────────────────────────────────────────────

messages.push(...response.messages)
messages = [
{ role: "system", content: "..." },
{ role: "user", content: "read the file /etc/passwd" },
{ role: "assistant", content: [
{
type: 'tool-call',
toolCallId: 'call_TbD9PcJ587DGoevvmhXCMYhl',
toolName: 'read',
input: { path: '/etc/passwd' },
providerExecuted: undefined,
providerOptions: undefined
},
{
type: 'tool-approval-request',
approvalId: 'aitxt-fPnw2HDSNhqLvhxz3f253A9l',
toolCallId: 'call_TbD9PcJ587DGoevvmhXCMYhl'
}
]
}
]

┌─────────────────────────────────────────────────
│ 用户审批阶段
├─────────────────────────────────────────────────
│ requestApproval() 被调用
│ 显示工具信息和参数
│ 用户决定:approved = false
│ 原因:Security concern
└─────────────────────────────────────────────────

构造审批响应:
approval = {
type: "tool-approval-response",
approvalId: "aitxt-fPnw2HDSNhqLvhxz3f253A9l",
approved: false,
reason: "Security concern"
}

messages.push({ role: "tool", content: [approval] })
messages = [
{ role: "system", content: "..." },
{ role: "user", content: "read the file /etc/passwd" },
{ role: "assistant", content: [
{ type: "tool-approval-request", approvalId: "aitxt-fPnw2HDSNhqLvhxz3f253A9l", ... }
]
},
{ role: "tool", content: [
{ type: "tool-approval-response", approvalId: "aitxt-fPnw2HDSNhqLvhxz3f253A9l", approved: false, ... }
]
}
]

┌─────────────────────────────────────────────────
│ 第二次 streamText 调用
├─────────────────────────────────────────────────
│ AI 接收审批结果:approved = false
│ 不执行工具
│ 根据 system message 指示:
│ - 解释意图:"我想读取该文件以..."
│ - 提供替代方案:"或者你可以手动提供内容"
└─────────────────────────────────────────────────

messages.push(...finalResponse.messages)

对话继续...

消息历史的完整性

整个流程中,messages 数组始终保持完整的对话历史:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始状态
messages = [
{ role: "system", content: "..." },
{ role: "user", content: "read the file" },
];

// 第一次 streamText 后
messages.push(...response.messages);
// 新增:assistant 的 tool-approval-request

// 审批后
messages.push({ role: "tool", content: approvals });
// 新增:tool 的 approval-response

// 第二次 streamText 后
messages.push(...finalResponse.messages);
// 新增:assistant 的最终回复(可能包含 tool-result 和文本)

为什么重要:

  • AI 能理解完整的上下文
  • 审批决策被记录,AI 可以引用
  • 后续对话可以基于历史信息
  • 便于日志记录和调试

要点总结

  1. Tool 配置

    • 设置 needsApproval: true 启用审批
    • 定义清晰的 inputSchema 便于展示参数
  2. 两次调用机制

    • 第一次:生成工具调用请求(不执行)
    • 第二次:根据审批结果执行或放弃
  3. 消息历史管理

    • 每次调用后都要 messages.push(...response.messages)
    • 审批响应必须以 { role: "tool", content: approvals } 形式加入
  4. 审批决策

    • approvalId 用于匹配请求和响应
    • approved: false 时 AI 不会执行工具
    • reason 帮助 AI 理解拒绝原因
  5. 优雅降级

    • System message 指导 AI 如何处理拒绝
    • AI 应解释意图并寻求替代方案

总结

Vercel AI SDK 的自动工具调用(stopWhen)额外通过审批流程通过 两次 streamText 调用 巧妙地实现了安全控制:

  • 第一次调用:AI 提出工具调用请求,但不执行
  • 审批环节:应用拦截并请求用户确认 image.png
  • 第二次调用:AI 根据审批结果执行或放弃

另外这个结构是 AI SDK 内部自己维持的,外部的模型请求最后会抹掉这个工具调用 approve 的过程:

这个可以通过打印 steps,查看完整的请求:

1
2
3
const steps = await result.steps;
console.log("\n\nsteps:");
console.dir(steps, { depth: null });