第 19 章 · 审批、记忆与子代理
本章目标:
- 掌握
toolApproval三种配置形态:每工具映射、按输入决策的函数、全局策略函数- 理解审批状态机(
not-applicable/approved/denied/user-approval)与手动审批的两段式调用流程- 在
useChat中用addToolApprovalResponse渲染批准/拒绝按钮,并了解experimental_toolApprovalSecret的安全加固- 对比三种记忆方案:Provider-Defined Tools、Memory Providers、自定义工具
- 学会子代理(Subagents)模式:上下文卸载、流式进度、
toModelOutput摘要与注意事项
19.1 工具审批(Tool Approvals)
默认情况下,带 execute 函数的工具在被模型调用时自动执行。使用 ToolLoopAgent 上的 toolApproval 可以在选定工具执行前进行审查、批准或拒绝。
toolApproval 适用于可能修改数据、花钱、执行代码、发送消息、访问私有数据等敏感操作的工具。
📌
toolApproval只作用于由 AI SDK 执行的工具;provider 执行的工具在 provider 侧运行,不走 AI SDK 审批。
四种审批状态
每条审批规则返回以下状态之一(字符串或带 type 字段的对象):
'not-applicable':正常执行工具,不带审批元数据(默认)。'approved':记录一次自动批准,然后执行工具。'denied':记录一次自动拒绝并返回拒绝后的工具输出。'user-approval':发出审批请求并等待显式响应。
自动批准/拒绝时可用对象形式附带原因:
toolApproval: {
deleteFile: {
type: 'denied',
reason: 'Deleting files is disabled in this workspace',
},
}审批函数也可以返回 undefined,效果等同于 'not-applicable'。
为单个工具要求审批
每个工具有简单策略时用 per-tool 映射:
import { ToolLoopAgent, tool, createGateway } from 'ai';
import { z } from 'zod';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
const agent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
tools: {
runCommand: tool({
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => runCommand(command),
}),
},
toolApproval: {
runCommand: 'user-approval',
},
});当 runCommand 被调用时,agent 会返回一个 tool-approval-request 而不是执行工具。
基于工具输入决策
决策取决于解析后的工具输入时,用 per-tool 审批函数——它接收类型化的输入以及 toolCallId、messages、toolContext 和 runtimeContext:
import { ToolLoopAgent, tool, createGateway } from 'ai';
import { z } from 'zod';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
const agent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
tools: {
processPayment: tool({
inputSchema: z.object({
amount: z.number(),
recipient: z.string(),
}),
execute: async ({ amount, recipient }) =>
processPayment({ amount, recipient }),
}),
},
toolApproval: {
processPayment: async ({ amount }, { runtimeContext }) => {
if (runtimeContext.role !== 'admin') {
return { type: 'denied', reason: 'Only admins can send payments' };
}
return amount > 1000 ? 'user-approval' : undefined;
},
},
});这个例子里:非 admin 自动被拒;大额支付需要人工审批;admin 的小额支付正常执行。
所有工具共用一条策略
当审批依赖完整 tool call、跨工具共享状态或整个工具集时,直接把函数作为 toolApproval 传入(即 GenericToolApprovalFunction):
const agent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
tools: {
readFile: tool({
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => readFile(path),
}),
deleteFile: tool({
inputSchema: z.object({ path: z.string() }),
execute: async ({ path }) => deleteFile(path),
}),
},
toolApproval: ({ toolCall }) => {
if (toolCall.dynamic) {
return 'user-approval';
}
if (toolCall.toolName === 'deleteFile') {
return 'user-approval';
}
return undefined;
},
});该通用函数接收:toolCall(完整调用,含 toolName、toolCallId、input 与是否 dynamic)、tools(全部可用工具)、toolsContext、messages(产生该调用的步骤所发送的消息)、runtimeContext。
按请求配置审批
toolApproval 是 agent 设置,因此也能从 prepareCall 返回——适合策略取决于 call options、租户策略或用户权限的场景:
import { ToolLoopAgent, tool, createGateway } from 'ai';
import { z } from 'zod';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
const agent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
callOptionsSchema: z.object({
canRunCommands: z.boolean(),
}),
prepareCall: ({ options, ...settings }) => ({
...settings,
toolApproval: {
runCommand: options.canRunCommands
? 'user-approval'
: { type: 'denied', reason: 'Command access is disabled' },
},
}),
tools: {
runCommand: tool({
inputSchema: z.object({ command: z.string() }),
execute: async ({ command }) => runCommand(command),
}),
},
});19.2 手动审批的处理流程
手动审批需要两次调用:
- 带
toolApproval调用agent.generate()或agent.stream(); - 从结果或 UI stream 中读取
tool-approval-request; - 向用户或你的审批系统征求决定;
- 把
tool-approval-response加入 messages; - 用更新后的 messages 再次调用 agent。
import { type ModelMessage, type ToolApprovalResponse } from 'ai';
const messages: ModelMessage[] = [{ role: 'user', content: 'Delete temp.txt' }];
const result = await agent.generate({ messages });
messages.push(...result.responseMessages);
const approvalResponses: ToolApprovalResponse[] = [];
for (const part of result.content) {
if (part.type === 'tool-approval-request' && !part.isAutomatic) {
approvalResponses.push({
type: 'tool-approval-response',
approvalId: part.approvalId,
approved: true,
reason: 'User confirmed the file can be deleted',
});
}
}
messages.push({
role: 'tool',
content: approvalResponses,
});
const finalResult = await agent.generate({ messages });批准后工具在第二次调用中执行;拒绝则模型收到拒绝信息,可以不依赖该工具结果继续回答。
💡 当工具执行被拒时,建议在 instructions 中加一句「当工具执行未获批准时不要重试」,避免对同一动作反复发起审批请求。
在 useChat 中处理审批
把 agent 流式输出到聊天 UI 时,审批请求表现为 state: 'approval-requested' 的 tool parts。用 addToolApprovalResponse 响应:
'use client';
import { useChat } from '@ai-sdk/react';
import { lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
export default function Chat() {
const { messages, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
return messages.map(message =>
message.parts.map(part => {
if (part.type !== 'tool-runCommand') {
return null;
}
if (part.state === 'approval-requested' && !part.approval.isAutomatic) {
return (
<div key={part.toolCallId}>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
})
}
>
Deny
</button>
</div>
);
}
}),
);
}只有手动审批需要调用 addToolApprovalResponse——自动批准/拒绝已在流中携带审批状态。
安全考量
标准 useChat 模式下,服务端每轮都从客户端发来的 messages 重建对话——消息历史本质上是客户端可控输入。从历史重建的审批会在执行前重新校验(输入对照 schema、重评审批策略),但若无额外保护,恶意客户端可伪造「看起来合法」的审批绕过人机环节。
对执行敏感操作的工具,应使用 experimental_toolApprovalSecret 把审批与服务端密码学绑定:
const result = await streamText({
model: gateway('openai/gpt-5'),
tools: { deleteFile, runQuery },
toolApproval: { deleteFile: 'user-approval', runQuery: 'user-approval' },
experimental_toolApprovalSecret: process.env.TOOL_APPROVAL_SECRET,
messages,
});提供 secret 后,服务端签发时对每个审批请求做 HMAC 签名、回放时验证签名;伪造或篡改的审批会在工具执行前被拒。签名绑定了确切的工具名、tool call ID 与输入参数,任一被改动即失效。
部署要点:
- 生成高熵随机字符串(至少 32 字节):
openssl rand -base64 32; - 存为所有服务端实例可访问的环境变量;
- 通过
experimental_toolApprovalSecret传给generateText或streamText。
行为:无有效签名的审批请求会被拒(fail-closed);未配置 secret 时保持向后兼容;secret 永远不会发给客户端或进入流。
19.3 Agent 记忆(Memory)
Memory 让 agent 保存信息并在之后回忆。没有记忆,每次对话都从零开始;有记忆,agent 能随时间积累上下文、回忆过往交互并适应用户。
AI SDK 提供三条路线,各有取舍:
| 方案 | 成本 | 灵活性 | Provider 锁定 |
|---|---|---|---|
| Provider-Defined Tools | 低 | 中 | 是 |
| Memory Providers | 低 | 低 | 取决于 memory provider |
| 自定义工具 | 高 | 高 | 无 |
Anthropic Memory Tool(Provider-Defined)
provider 定义工具的 inputSchema 与 description(模型经训练会用这些工具,效果通常更好),你只需提供 execute。Anthropic Memory Tool 给 Claude 一个管理 /memories 目录的结构化接口:
import { ToolLoopAgent } from 'ai';
const memory = anthropic.tools.memory_20250818({
execute: async action => {
// `action` contains `command`, `path`, and other fields
// depending on the command (view, create, str_replace,
// insert, delete, rename).
// Implement your storage backend here.
// Return the result as a string.
},
});
const agent = new ToolLoopAgent({
model: 'anthropic/claude-haiku-4.5',
tools: { memory },
});
const result = await agent.generate({
prompt: 'Remember that my favorite editor is Neovim',
});工具接收结构化命令(view、create、str_replace、insert、delete、rename),路径限定在 /memories 下。适合已用 Claude 且想以最小成本获得记忆的场景,代价是 provider 锁定。
Memory Providers
这类 provider 内置记忆能力,通过 AI SDK 标准接口暴露——存储、检索与注入透明完成,你自己不用定义任何工具:
Letta:在 Letta 平台创建 agent 并在那里配置记忆(core/archival/recall);
Mem0:在任何受支持 LLM provider 之上加记忆层,自动提取/存储/检索对话记忆:
import { createMem0 } from '@mem0/vercel-ai-provider';
import { ToolLoopAgent } from 'ai';
const mem0 = createMem0({
provider: 'openai',
mem0ApiKey: process.env.MEM0_API_KEY,
apiKey: process.env.OPENAI_API_KEY,
});
const agent = new ToolLoopAgent({
model: mem0('gpt-4.1', { user_id: 'user-123' }),
});
const { text } = await agent.generate({
prompt: 'Remember that my favorite editor is Neovim',
});也可显式管理记忆:
import { addMemories, retrieveMemories } from '@mem0/vercel-ai-provider';
await addMemories(messages, { user_id: 'user-123' });
const context = await retrieveMemories(prompt, { user_id: 'user-123' });Supermemory:提供 addMemory / searchMemories 工具集,兼容任意 AI SDK provider:
import { supermemoryTools } from '@supermemory/tools/ai-sdk';
import { ToolLoopAgent, createGateway } from 'ai';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
const agent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
tools: supermemoryTools(process.env.SUPERMEMORY_API_KEY!),
});
const result = await agent.generate({
prompt: 'Remember that my favorite editor is Neovim',
});此外还有 Hindsight(retain/recall/reflect 等五个工具,可自托管)和 MongoDB Atlas 向量检索记忆(Session/Semantic/Procedural/Episodic/Scratchpad 五层结构,面向 AI SDK v6 API)等选择。
自定义记忆工具
灵活性最高但工作量最大:自己定义带 inputSchema 与 execute 的普通工具,把存储后端(文件系统、数据库等)接到语义检索上——第 20 章实战中的 RAG 检索正是这一思路的基础形态。
19.4 子代理(Subagents)
子代理是父代理可以调用的 agent:父代理通过工具委派工作,子代理自主执行后返回结果。
工作方式五步:
- 定义子代理——有自己的模型、instructions 和工具;
- 创建调用它的工具——供主代理使用;
- 子代理独立运行——拥有自己的上下文窗口;
- 返回结果——可选地流式展示进度到 UI;
- 控制模型所见——用
toModelOutput做摘要。
何时使用子代理
子代理会增加延迟与复杂度,收益大于代价时才用:
| 适用场景 | 避免场景 |
|---|---|
| 任务需要探索大量 token | 任务简单聚焦 |
| 需要并行化独立研究 | 顺序处理足够 |
| 上下文会超出模型限制 | 上下文可控 |
| 想按能力隔离工具访问 | 所有工具可安全共存 |
核心价值在于上下文卸载:让专用子代理消耗几十万 token 读文件、搜代码库,只返回约一千 token 的聚焦摘要,主代理上下文保持干净连贯。其次是并行化(多个子代理同时研究不同领域)与专业化编排(探索/编码/集成各自独立工具集)。
不带流式的基础模式
最简单的子代理模式不需要特殊机制——主代理拥有一个在其 execute 中调用另一个 agent 的工具:
import { ToolLoopAgent, tool, createGateway } from 'ai';
import { z } from 'zod';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
// Define a subagent for research tasks
const researchSubagent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
instructions: `You are a research agent.
Summarize your findings in your final response.`,
tools: {
read: readFileTool, // defined elsewhere
search: searchTool, // defined elsewhere
},
});
// Create a tool that delegates to the subagent
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async ({ task }, { abortSignal }) => {
const result = await researchSubagent.generate({
prompt: task,
abortSignal,
});
return result.text;
},
});
// Main agent uses the research tool
const mainAgent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
instructions: 'You are a helpful assistant that can delegate research tasks.',
tools: {
research: researchTool,
},
});不需要在 UI 展示子代理进度时这样即可。工具调用会阻塞直到子代理完成,再返回最终文本。
取消传播:用户取消请求时 abortSignal 会传播给子代理,务必透传以保证清理。若 signal 被 abort,子代理停止执行并抛出 AbortError,主代理的工具执行失败从而停止主循环。为避免后续消息中出现「未完成 tool call」报错,可用:
import { convertToModelMessages } from 'ai';
const modelMessages = await convertToModelMessages(messages, {
ignoreIncompleteToolCalls: true,
});流式子代理进度
想在子代理工作时展示增量进度,需使用 preliminary tool results:把 execute 从普通函数改为异步生成器(async function*),每个 yield 都向前端发送一个初步结果。注意每次 yield 整体替换上一份输出(而非追加),所以要累积构建完整消息——readUIMessageStream 正是为此而生,它逐块读取并把至今收到的所有 parts 组装成不断增长的 UIMessage:
import { readUIMessageStream, toUIMessageStream, tool } from 'ai';
import { z } from 'zod';
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async function* ({ task }, { abortSignal }) {
// Start the subagent with streaming
const result = await researchSubagent.stream({
prompt: task,
abortSignal,
});
// Each iteration yields a complete, accumulated UIMessage
for await (const message of readUIMessageStream({
stream: toUIMessageStream({ stream: result.stream }),
})) {
yield message;
}
},
});每次 yield 的 message 都是包含子代理至今全部 parts(文本、tool calls、tool results)的完整 UIMessage,前端只需用最新消息替换显示。
toModelOutput:控制模型所见
这是子代理用于上下文管理的关键。完整的 UIMessage(含子代理全部工作)会存入消息历史并在 UI 显示,但可以用 toModelOutput 控制主代理模型实际看到的 tokens:
const researchTool = tool({
description: 'Research a topic or question in depth.',
inputSchema: z.object({
task: z.string().describe('The research task to complete'),
}),
execute: async function* ({ task }, { abortSignal }) {
const result = await researchSubagent.stream({
prompt: task,
abortSignal,
});
for await (const message of readUIMessageStream({
stream: toUIMessageStream({ stream: result.stream }),
})) {
yield message;
}
},
toModelOutput: ({ output: message }) => {
// Extract just the final text as a summary
const lastTextPart = message?.parts.findLast(p => p.type === 'text');
return {
type: 'text',
value: lastTextPart?.text ?? 'Task completed.',
};
},
});如此一来:用户看到子代理的完整执行过程(每次工具调用、每个中间步骤);模型只看到最终摘要文本。子代理可能消耗 10 万 token 探索推理,主代理却只消费那份摘要。
要让 toModelOutput 能提取有用摘要,子代理必须产出一份——给它写明确的总结指令:
const researchSubagent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
instructions: `You are a research agent. Complete the task autonomously.
IMPORTANT: When you have finished, write a clear summary of your findings as your final response.
This summary will be returned to the main agent, so include all relevant information.`,
tools: {
read: readFileTool,
search: searchTool,
},
});否则子代理可能只回一句「Done」,toModelOutput 就无物可提取了。
在 UI 中渲染子代理进度
配合 useChat 时,检查 tool part 的 state 与 preliminary 标志。tool part 状态表:
| 状态 | 说明 |
|---|---|
input-streaming | 正在生成工具输入 |
input-available | 工具就绪待执行 |
output-available | 工具产生输出(检查 preliminary) |
output-error | 工具执行失败 |
检测流式还是完成:
const hasOutput = part.state === 'output-available';
const isStreaming = hasOutput && part.preliminary === true;
const isComplete = hasOutput && !part.preliminary;类型方面,用 InferAgentUIMessage 导出主代理的消息类型供 UI 使用:
import { ToolLoopAgent, InferAgentUIMessage } from 'ai';
export const mainAgent = new ToolLoopAgent({
// ... configuration with researchTool
});
// Export the main agent message type for the chat UI
export type MainAgentMessage = InferAgentUIMessage<typeof mainAgent>;渲染主代理消息与子代理流式输出:
'use client';
import { useChat } from '@ai-sdk/react';
import type { MainAgentMessage } from '@/lib/agents';
export function Chat() {
const { messages } = useChat<MainAgentMessage>();
return (
<div>
{messages.map(message =>
message.parts.map((part, i) => {
switch (part.type) {
case 'text':
return <p key={i}>{part.text}</p>;
case 'tool-research':
return (
<div>
{part.state !== 'input-streaming' && (
<div>Research: {part.input.task}</div>
)}
{part.state === 'output-available' && (
<div>
{part.output.parts.map((nestedPart, i) => {
switch (nestedPart.type) {
case 'text':
return <p key={i}>{nestedPart.text}</p>;
default:
return null;
}
})}
</div>
)}
</div>
);
default:
return null;
}
}),
)}
</div>
);
}注意事项
- 子代理不能用工具审批:
toolApproval(及废弃的needsApproval)不适用于子代理内的工具,所有工具必须无人工确认地自动执行; - 子代理上下文隔离:每次调用都以全新上下文窗口开始——这正是它能重度探索而不污染主对话的原因。若确实需要历史,可在工具 execute 中拿到
messages传入:
execute: async ({ task }, { abortSignal, messages }) => {
const result = await researchSubagent.generate({
messages: [
...messages, // The main agent's conversation history
{ role: 'user', content: task }, // The specific task for this invocation
],
abortSignal,
});
return result.text;
},慎用——传全量历史会削弱上下文隔离的收益;
- 流式增加复杂度:基础模式更易实现调试,只在确需 UI 实时进度时才引入流式。
本章小结
toolApproval有四种状态:not-applicable(默认直接执行)、approved(自动批)、denied(自动拒)、user-approval(挂起等待人工);- 配置三形态:per-tool 字符串映射、基于输入的 per-tool 函数、接收完整 toolCall 的全局策略函数;还能从
prepareCall按 call options 动态下发; - 手动审批是两段式调用:读到
tool-approval-request→ 收集tool-approval-response加入 messages → 再次 generate;UI 侧用addToolApprovalResponse+sendAutomaticallyWhen自动续跑; - 敏感工具建议配
experimental_toolApprovalSecret做 HMAC 签名校验(fail-closed)防伪造审批; - 记忆三方案:Provider-Defined Tools(省力但锁定厂商)、Memory Providers(Letta/Mem0/Supermemory 等)、自定义工具(最灵活);子代理模式实现上下文卸载与并行研究,配合 preliminary results 流式进度 +
toModelOutput摘要,让用户看全程、模型只见精华。
🛠️ 动手实践
- 给一个文件管理 agent 配置分级审批策略:读文件自动放行、删除文件要求
user-approval、金额类操作按输入数额决定(低于阈值自动批、超过则人工审)。 - 为审批 UI 补齐安全加固:生成
TOOL_APPROVAL_SECRET环境变量,启用 HMAC 校验,并验证篡改 approvalId 后的请求会被拒绝。 - 构建两级研究系统:主代理持有
research工具(内部委托研究子代理),子代理流式产出 preliminary results,前端渲染「研究中…」占位符,最终用toModelOutput只把结论喂回主代理。