我来做
全栈开发 中级

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 个工具,选择准确率大幅提升。这将是我们下一篇的主题。

💻 核心参考代码 (Reference Implementation)
// 典型实现逻辑 / 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)对接与私有化部署,欢迎点击下方按钮预约我们的免费诊断服务。

联系我们代为开发
返回教程列表