Next.js + Vercel AI SDK 实战(二):实现 Inline Tool Calling 与动态 UI 渲染
💡 本教程技术要点
从 OpenAI Function Calling 协议的 JSON 帧格式讲起,完整解析 Tool Call 的六步生命周期。使用真实天气 API 实战演示工具定义、LLM 自主选择、前端动态 UI 组件渲染,并深入 Zod Schema 描述质量、执行超时、幻觉工具调用和 maxSteps 递归控制等生产陷阱。
🎯 问题引入:当 AI 只会"说"不会"做"
想象你正在开发一个企业内部的 BI 分析平台。产品经理提了一个需求:用户对着 AI 聊天框说"帮我看看特斯拉最近的股价趋势",期望看到的是一张可交互的折线图,而不是一段干巴巴的 Markdown 表格。用户问"北京今天天气怎么样",想看到的是一个精美的天气卡片组件,而不是"北京今天晴,气温 32°C"这样的纯文本。
这就是纯文本 AI 的根本局限——LLM 本身只能输出文本。它无法调用 API、无法查询数据库、无法渲染 UI 组件。但现实业务场景中,用户期望 AI 像一个真正的助手那样"做事",而不仅仅是"说话"。
Tool Calling(工具调用)正是解决这个问题的关键机制。它让 LLM 在对话过程中主动决定"我需要调用某个工具来获取信息",后端执行工具逻辑,再把结果注入回对话上下文,最终由前端根据工具类型渲染对应的 UI 组件。这一篇,我们就来完整实现这个链路。
🧠 原理解析:Tool Calling 到底是怎么运作的
OpenAI Function Calling 协议格式
Tool Calling 的本质是一个结构化的协议约定。当你向 OpenAI API 发送请求时,除了 messages 数组,还可以附带一个 tools 数组,告诉模型"你有哪些工具可以使用"。每个工具的定义是一个标准的 JSON Schema:
{
"tools": [
{
"type": "function",
"function": {
"name": "getWeather",
"description": "获取指定城市的实时天气信息,包括温度、湿度、天气状况",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如 Beijing、Shanghai"
},
"units": {
"type": "string",
"enum": ["metric", "imperial"],
"description": "温度单位,metric 为摄氏度,imperial 为华氏度"
}
},
"required": ["city"]
}
}
}
],
"tool_choice": "auto"
}
关键在于模型的响应。当模型判断需要调用工具时,它不会返回普通文本,而是返回一个特殊的结构——finish_reason 变为 "tool_calls" 而非 "stop":
{
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "getWeather",
"arguments": "{\"city\": \"Beijing\", \"units\": \"metric\"}"
}
}]
}
}]
}
注意 arguments 是一个JSON 字符串,不是对象。这意味着 LLM 实际上是在"生成"一段 JSON 文本,而不是真正在调用函数。这也是为什么参数有时会出现幻觉——模型本质上还是在做 token 预测。
完整的 Tool Call 生命周期
理解整个流程至关重要。Tool Calling 不是一次请求就完成的,而是一个多轮对话的过程:
┌─────────────────────────────────────────────────────────────┐
│ Tool Call 完整生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户输入: "北京今天天气怎么样?" │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 第一次 LLM 请求 │ messages + tools 定义 │
│ │ (推理阶段) │ │
│ └──────────┬──────────┘ │
│ │ finish_reason: "tool_calls" │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 后端执行工具 │ 调用 OpenWeatherMap API │
│ │ getWeather(Beijing) │ │
│ └──────────┬──────────┘ │
│ │ 返回: { temp: 32, humidity: 45, ... } │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ 第二次 LLM 请求 │ 原始 messages + tool_call │
│ │ (总结阶段) │ + tool 执行结果 │
│ └──────────┬──────────┘ │
│ │ finish_reason: "stop" │
│ ▼ │
│ 最终响应: "北京今天晴,气温 32°C,湿度 45%,适合户外活动。" │
│ + 前端渲染 WeatherCard 组件 │
│ │
└─────────────────────────────────────────────────────────────┘
Vercel AI SDK 的 maxSteps 机制
在原生 OpenAI API 中,你需要手动处理这个多轮对话循环——判断 finish_reason、拼接 tool 结果消息、再次发送请求。Vercel AI SDK 通过 maxSteps 参数把这一切自动化了。
maxSteps 表示 SDK 最多自动执行多少轮 tool call 循环。设为 3 意味着:LLM 最多可以连续调用 3 次工具,每次工具的结果会自动注入回上下文,直到模型返回 finish_reason: "stop" 或达到步数上限。这个机制让复杂的多工具编排变得极其简洁。
💻 动手实现:从零构建 Tool Calling 全链路
Step 1:后端定义工具——用 Zod Schema 声明参数
Vercel AI SDK 使用 Zod 来定义工具参数的类型和校验规则。Zod Schema 会被自动转换为 OpenAI 需要的 JSON Schema 格式。这比手写 JSON Schema 安全得多,因为你能在编译时就捕获类型错误。
我们以 OpenWeatherMap 免费 API 为例,构建一个真实的天气查询工具:
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText, tool } from "ai";
import { z } from "zod";
// 真实的天气 API 调用,不是 mock 数据
async function fetchWeather(city: string, units: string = "metric") {
const apiKey = process.env.OPENWEATHERMAP_API_KEY;
if (!apiKey) {
throw new Error("OPENWEATHERMAP_API_KEY is not configured");
}
const url = new URL("https://api.openweathermap.org/data/2.5/weather");
url.searchParams.set("q", city);
url.searchParams.set("units", units);
url.searchParams.set("appid", apiKey);
// 设置超时,避免外部 API 无响应导致请求挂起
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url.toString(), { signal: controller.signal });
if (!res.ok) {
throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return {
city: data.name,
temperature: data.main.temp,
feelsLike: data.main.feels_like,
humidity: data.main.humidity,
description: data.weather[0]?.description ?? "unknown",
windSpeed: data.wind.speed,
};
} finally {
clearTimeout(timeout);
}
}
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
// maxSteps: 允许 SDK 自动处理多轮 tool call 循环
// 设为 3 意味着最多自动执行 3 轮工具调用
maxSteps: 3,
tools: {
getWeather: tool({
// description 是 LLM 决定是否调用这个工具的关键依据
description:
"获取指定城市的实时天气数据,包括温度、体感温度、湿度、风速。" +
"当用户询问天气、温度、是否需要带伞等问题时使用此工具。",
parameters: z.object({
city: z
.string()
.describe("城市英文名称,如 Beijing, Shanghai, Tokyo"),
units: z
.enum(["metric", "imperial"])
.default("metric")
.describe("温度单位,metric=摄氏度,imperial=华氏度"),
}),
execute: async ({ city, units }) => {
return await fetchWeather(city, units);
},
}),
},
});
return result.toDataStreamResponse();
}
注意 description 字段的写法:它不仅描述了工具的功能,还明确告诉 LLM 什么场景下应该使用这个工具("当用户询问天气、温度、是否需要带伞时")。这直接影响模型的工具选择准确率,后面的"生产陷阱"部分会详细展开。
Step 2:多工具定义——让 LLM 自主选择
真实场景中一个 AI 助手往往拥有多个工具。LLM 会根据用户意图自主决定调用哪个工具,甚至在一次回答中调用多个工具。我们增加一个计算器工具:
// 在 tools 对象中新增 calculate 工具
tools: {
getWeather: tool({
description: "获取指定城市的实时天气数据...",
parameters: z.object({ /* 同上 */ }),
execute: async ({ city, units }) => fetchWeather(city, units),
}),
calculate: tool({
description:
"执行数学计算。支持基础四则运算和常见数学函数。" +
"当用户需要计算汇率换算、百分比、面积等数值问题时使用。",
parameters: z.object({
expression: z
.string()
.describe("数学表达式,如 '(100 * 1.08) + 50' 或 'sqrt(144)'"),
}),
execute: async ({ expression }) => {
// 使用安全的表达式解析,绝不使用 eval()
// 生产环境建议使用 mathjs 库
const { evaluate } = await import("mathjs");
try {
const result = evaluate(expression);
return {
expression,
result: Number(result),
formatted: String(result),
};
} catch {
return { expression, error: "无法计算该表达式" };
}
},
}),
},
当用户问"北京今天多少度?如果比东京高 5 度,东京大概多少度?",LLM 可能会先调用 getWeather("Beijing"),得到结果后再调用 calculate 做减法。maxSteps: 3 允许这种多步推理自动完成。
Step 3:前端拦截工具调用状态,渲染动态 UI
这是最核心的一步。Vercel AI SDK 的 useChat Hook 返回的 messages 中,每条消息包含一个 parts 数组。当 LLM 触发 tool call 时,parts 中会出现 type: "tool-invocation" 的元素,我们据此渲染对应的 UI 组件。
// components/ChatMessages.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { WeatherCard } from "./WeatherCard";
import { CalculatorResult } from "./CalculatorResult";
export function ChatMessages() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({ maxSteps: 3 });
return (
<div className="flex flex-col gap-4 p-4">
{messages.map((message) => (
<div key={message.id} className="flex flex-col gap-2">
<span className="text-sm text-gray-500">
{message.role === "user" ? "你" : "AI"}
</span>
{/* 遍历 message.parts 而非直接使用 message.content */}
{message.parts.map((part, i) => {
// 普通文本部分
if (part.type === "text") {
return <p key={i}>{part.text}</p>;
}
// 工具调用部分——根据工具名称渲染不同组件
if (part.type === "tool-invocation") {
const { toolInvocation } = part;
// 工具正在执行中,显示 loading
if (toolInvocation.state === "call") {
return (
<div key={i} className="animate-pulse text-gray-400">
正在查询 {toolInvocation.toolName}...
</div>
);
}
// 工具执行完成,根据工具名称渲染对应组件
if (toolInvocation.state === "result") {
switch (toolInvocation.toolName) {
case "getWeather":
return (
<WeatherCard
key={i}
data={toolInvocation.result}
/>
);
case "calculate":
return (
<CalculatorResult
key={i}
data={toolInvocation.result}
/>
);
default:
return (
<pre key={i}>
{JSON.stringify(toolInvocation.result, null, 2)}
</pre>
);
}
}
}
return null;
})}
</div>
))}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="问我天气或计算问题..."
className="flex-1 border rounded px-3 py-2"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading}
className="bg-blue-600 text-white px-4 py-2 rounded"
>
发送
</button>
</form>
</div>
);
}
这里有一个设计决策值得注意:我们使用 message.parts 而非 message.content 来渲染消息。parts 是一个有序数组,它精确地反映了 AI 回复中"文本"和"工具调用"的交错顺序。比如 AI 可能先说一段话,然后调用工具,最后再总结——parts 能完美保留这种交错结构。
Step 4:实现具体的 UI 组件
天气卡片组件需要处理正常数据和错误两种情况:
// components/WeatherCard.tsx
interface WeatherData {
city: string;
temperature: number;
feelsLike: number;
humidity: number;
description: string;
windSpeed: number;
error?: string;
}
export function WeatherCard({ data }: { data: WeatherData }) {
// 工具执行可能返回错误,需要优雅降级
if (data.error) {
return (
<div className="border border-red-200 bg-red-50 rounded-lg p-4">
<p className="text-red-600">天气查询失败:{data.error}</p>
</div>
);
}
return (
<div className="border rounded-lg p-4 bg-gradient-to-br from-blue-50 to-sky-100 max-w-sm">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">{data.city}</h3>
<span className="text-3xl font-bold">{data.temperature}°C</span>
</div>
<p className="text-gray-600 mt-1">{data.description}</p>
<div className="grid grid-cols-3 gap-2 mt-3 text-sm text-gray-500">
<div>体感 {data.feelsLike}°C</div>
<div>湿度 {data.humidity}%</div>
<div>风速 {data.windSpeed}m/s</div>
</div>
</div>
);
}
计算器组件类似,但要额外处理表达式解析失败的情况:
// components/CalculatorResult.tsx
interface CalcData {
expression: string;
result?: number;
formatted?: string;
error?: string;
}
export function CalculatorResult({ data }: { data: CalcData }) {
if (data.error) {
return (
<div className="border border-amber-200 bg-amber-50 rounded p-3">
<p className="text-amber-700">计算失败:{data.error}</p>
<code className="text-sm">{data.expression}</code>
</div>
);
}
return (
<div className="inline-flex items-center gap-2 border rounded-lg px-4 py-2 bg-gray-50">
<code className="text-gray-600">{data.expression}</code>
<span className="text-gray-400">=</span>
<span className="text-xl font-bold text-green-700">
{data.formatted}
</span>
</div>
);
}
⚠️ 生产陷阱:Tool Calling 的五个深坑
陷阱一:Zod describe() 的描述质量直接决定工具调用准确率
LLM 选择调用哪个工具,完全依赖 description 字段和参数的 describe()。描述写得不好,模型就会频繁选错工具或传错参数。
// ❌ 差的描述——模型不知道什么时候该用
const badTool = tool({
description: "获取天气",
parameters: z.object({
city: z.string(),
}),
// ...
});
// ✅ 好的描述——明确功能、场景、参数格式
const goodTool = tool({
description:
"获取指定城市的实时天气信息,包括温度、湿度、天气状况、风速。" +
"当用户询问某个城市的天气、温度、是否下雨、是否需要带伞等问题时调用。" +
"不适用于天气预报(未来天气)查询。",
parameters: z.object({
city: z
.string()
.describe("城市的英文名称,如 Beijing, New York, Tokyo。不接受中文。"),
}),
// ...
});
经验法则:description 应该同时回答"这个工具做什么"和"什么时候不该用它"。
陷阱二:外部 API 超时导致流式响应挂起
Tool 的 execute 函数如果调用了外部 API,而该 API 响应缓慢或超时,整个流式响应会卡住。用户只看到 loading 状态,毫无反馈。解决方案是使用 AbortController 设置硬超时:
execute: async ({ city }) => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(apiUrl, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// 超时时返回错误信息,而不是让整个请求崩溃
// LLM 会基于这个错误信息生成用户友好的回复
return { error: "天气服务响应超时,请稍后重试" };
}
return { error: "天气查询失败" };
} finally {
clearTimeout(timeout);
}
},
关键点:不要让 execute 抛出异常。返回一个包含 error 字段的对象,让 LLM 基于错误信息生成用户友好的回复,比直接报错优雅得多。
陷阱三:LLM 幻觉——调用不存在的工具
模型偶尔会"发明"你没定义的工具名称,尤其在工具列表较长时。toolChoice 参数可以控制模型的工具调用行为:
"auto"(默认):模型自己决定是否调用工具——最灵活,但偶尔会出现幻觉"required":强制模型必须调用一个工具——适合明确知道需要工具的场景"none":禁止调用工具——适合纯对话场景{ type: "tool", toolName: "getWeather" }:强制调用指定工具
Vercel AI SDK 已经在 SDK 层做了校验——如果模型返回的工具名称不在你定义的 tools 列表中,SDK 会自动忽略该调用。但理解这个机制有助于你设计更健壮的系统。
陷阱四:maxSteps 导致无限循环
如果工具返回的结果让 LLM 认为"信息不够,需要再查一次",模型会持续调用工具直到达到 maxSteps 上限。这不仅浪费 token,还会让用户等待时间倍增。
// ❌ 危险:maxSteps 设太大,失控时成本爆炸
const result = streamText({
model: openai("gpt-4o-mini"),
maxSteps: 10, // 最多 10 轮工具调用,每轮都消耗 token
tools: { /* ... */ },
});
// ✅ 安全:根据实际业务场景设置合理上限
const result = streamText({
model: openai("gpt-4o-mini"),
maxSteps: 3, // 绝大多数场景 2-3 轮足够
tools: { /* ... */ },
});
实践建议:先设为 2 观察日志,只有在确实需要多步推理时才逐步增加。同时在生产环境中监控每次请求的实际步数,异常高的步数往往意味着工具定义有问题。
陷阱五:Token 成本的隐性膨胀
每一轮 tool call 都会把之前所有的消息(包括工具定义、工具调用参数、工具返回结果)全部重新发送给 LLM。假设工具定义占 500 tokens,工具返回结果占 300 tokens,3 轮调用下来,光这些"协议开销"就额外消耗了 2400+ tokens。
优化策略:精简工具返回的数据结构,只返回 LLM 需要的字段;对大量数据做摘要后再返回;在 description 中清晰说明工具能力边界,避免无效调用。
🔗 扩展阅读
主流模型 Tool Calling 能力对比
| 特性 | OpenAI Function Calling | Anthropic Tool Use | Google Gemini |
|---|---|---|---|
| 参数定义格式 | JSON Schema | JSON Schema | OpenAPI 子集 |
| 并行工具调用 | ✅ 支持 | ✅ 支持 | ✅ 支持 |
| Streaming Tool Calls | ✅ 逐 token 流式 | ✅ content_block 流式 | ✅ 支持 |
| 强制指定工具 | tool_choice 参数 | tool_choice 参数 | tool_config 参数 |
| Vercel AI SDK 适配 | ✅ 原生支持 | ✅ 原生支持 | ✅ 原生支持 |
Vercel AI SDK 的最大优势在于统一抽象——你只需要写一份 tool 定义,切换底层模型时只需改一行 model: anthropic("claude-sonnet-4-20250514"),工具定义和前端渲染代码完全不需要改动。
MCP:工具集成的未来标准
Model Context Protocol(MCP)是 Anthropic 提出的开放协议,目标是让 AI 应用能像 USB 一样即插即用地连接各种工具和数据源。与本文介绍的"在代码中手动定义工具"不同,MCP 允许工具以独立服务的形式存在,AI 应用通过标准协议自动发现和调用这些工具。Vercel AI SDK 已经提供了实验性的 MCP 客户端支持。
下一步:Multi-Agent Handoff
当工具数量超过 10 个时,单个 LLM 的工具选择准确率会明显下降。更好的架构是 Multi-Agent Handoff——一个路由 Agent 负责理解用户意图,然后将请求"移交"给专门的子 Agent(天气 Agent、计算 Agent、数据分析 Agent)。每个子 Agent 只携带自己领域的 2-3 个工具,选择准确率大幅提升。这将是我们下一篇的主题。
// 典型实现逻辑 / Code outline
// 如需获取该场景下完整可运行的代码库与技术顾问指导,请联系我们
console.log("Loading module: $全栈开发...");
console.log("Configuring agent pipeline: $Next.js + Vercel AI SDK 实战(二):实现 Inline Tool Calling 与动态 UI 渲染...");
console.log("Dependencies active. Pipeline initializing...");
// TODO: Custom code hooks for wolaizuo solutions. * 本文为“我来做”动手开发实战教程。如果您不想亲自编写代码,或者需要更深入的企业系统(ERP/CRM)对接与私有化部署,欢迎点击下方按钮预约我们的免费诊断服务。