Skip to content

第 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':发出审批请求并等待显式响应。

自动批准/拒绝时可用对象形式附带原因:

ts
toolApproval: {
  deleteFile: {
    type: 'denied',
    reason: 'Deleting files is disabled in this workspace',
  },
}

审批函数也可以返回 undefined,效果等同于 'not-applicable'

为单个工具要求审批

每个工具有简单策略时用 per-tool 映射:

ts
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 审批函数——它接收类型化的输入以及 toolCallIdmessagestoolContextruntimeContext

ts
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):

ts
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(完整调用,含 toolNametoolCallIdinput 与是否 dynamic)、tools(全部可用工具)、toolsContextmessages(产生该调用的步骤所发送的消息)、runtimeContext

按请求配置审批

toolApproval 是 agent 设置,因此也能从 prepareCall 返回——适合策略取决于 call options、租户策略或用户权限的场景:

ts
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 手动审批的处理流程

手动审批需要两次调用:

  1. toolApproval 调用 agent.generate()agent.stream()
  2. 从结果或 UI stream 中读取 tool-approval-request
  3. 向用户或你的审批系统征求决定;
  4. tool-approval-response 加入 messages;
  5. 用更新后的 messages 再次调用 agent。
ts
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 响应:

tsx
'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 把审批与服务端密码学绑定:

ts
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 与输入参数,任一被改动即失效。

部署要点:

  1. 生成高熵随机字符串(至少 32 字节):openssl rand -base64 32
  2. 存为所有服务端实例可访问的环境变量;
  3. 通过 experimental_toolApprovalSecret 传给 generateTextstreamText

行为:无有效签名的审批请求会被拒(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 定义工具的 inputSchemadescription(模型经训练会用这些工具,效果通常更好),你只需提供 execute。Anthropic Memory Tool 给 Claude 一个管理 /memories 目录的结构化接口:

ts
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',
});

工具接收结构化命令(viewcreatestr_replaceinsertdeleterename),路径限定在 /memories 下。适合已用 Claude 且想以最小成本获得记忆的场景,代价是 provider 锁定。

Memory Providers

这类 provider 内置记忆能力,通过 AI SDK 标准接口暴露——存储、检索与注入透明完成,你自己不用定义任何工具:

Letta:在 Letta 平台创建 agent 并在那里配置记忆(core/archival/recall);

Mem0:在任何受支持 LLM provider 之上加记忆层,自动提取/存储/检索对话记忆:

ts
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',
});

也可显式管理记忆:

ts
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:

ts
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)等选择。

自定义记忆工具

灵活性最高但工作量最大:自己定义带 inputSchemaexecute 的普通工具,把存储后端(文件系统、数据库等)接到语义检索上——第 20 章实战中的 RAG 检索正是这一思路的基础形态。

19.4 子代理(Subagents)

子代理是父代理可以调用的 agent:父代理通过工具委派工作,子代理自主执行后返回结果。

工作方式五步:

  1. 定义子代理——有自己的模型、instructions 和工具;
  2. 创建调用它的工具——供主代理使用;
  3. 子代理独立运行——拥有自己的上下文窗口;
  4. 返回结果——可选地流式展示进度到 UI;
  5. 控制模型所见——用 toModelOutput 做摘要。

何时使用子代理

子代理会增加延迟与复杂度,收益大于代价时才用:

适用场景避免场景
任务需要探索大量 token任务简单聚焦
需要并行化独立研究顺序处理足够
上下文会超出模型限制上下文可控
想按能力隔离工具访问所有工具可安全共存

核心价值在于上下文卸载:让专用子代理消耗几十万 token 读文件、搜代码库,只返回约一千 token 的聚焦摘要,主代理上下文保持干净连贯。其次是并行化(多个子代理同时研究不同领域)与专业化编排(探索/编码/集成各自独立工具集)。

不带流式的基础模式

最简单的子代理模式不需要特殊机制——主代理拥有一个在其 execute 中调用另一个 agent 的工具:

ts
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」报错,可用:

ts
import { convertToModelMessages } from 'ai';

const modelMessages = await convertToModelMessages(messages, {
  ignoreIncompleteToolCalls: true,
});

流式子代理进度

想在子代理工作时展示增量进度,需使用 preliminary tool results:把 execute 从普通函数改为异步生成器(async function*),每个 yield 都向前端发送一个初步结果。注意每次 yield 整体替换上一份输出(而非追加),所以要累积构建完整消息——readUIMessageStream 正是为此而生,它逐块读取并把至今收到的所有 parts 组装成不断增长的 UIMessage

ts
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:

ts
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 能提取有用摘要,子代理必须产出一份——给它写明确的总结指令:

ts
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 的 statepreliminary 标志。tool part 状态表:

状态说明
input-streaming正在生成工具输入
input-available工具就绪待执行
output-available工具产生输出(检查 preliminary
output-error工具执行失败

检测流式还是完成:

tsx
const hasOutput = part.state === 'output-available';
const isStreaming = hasOutput && part.preliminary === true;
const isComplete = hasOutput && !part.preliminary;

类型方面,用 InferAgentUIMessage 导出主代理的消息类型供 UI 使用:

ts
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>;

渲染主代理消息与子代理流式输出:

tsx
'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 传入:
ts
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 摘要,让用户看全程、模型只见精华。

🛠️ 动手实践

  1. 给一个文件管理 agent 配置分级审批策略:读文件自动放行、删除文件要求 user-approval、金额类操作按输入数额决定(低于阈值自动批、超过则人工审)。
  2. 为审批 UI 补齐安全加固:生成 TOOL_APPROVAL_SECRET 环境变量,启用 HMAC 校验,并验证篡改 approvalId 后的请求会被拒绝。
  3. 构建两级研究系统:主代理持有 research 工具(内部委托研究子代理),子代理流式产出 preliminary results,前端渲染「研究中…」占位符,最终用 toModelOutput 只把结论喂回主代理。