注册即送 100 个免费额度,无需绑卡查看定价

[ blog · tutorial ]8 min read

在 Vercel AI SDK 里加一个网页搜索工具

Sarah Choy发布于 2026年9月23日约 8 分钟阅读
在 Vercel AI SDK 里加一个网页搜索工具

AI SDK 大约十五行就能给你一个带类型的工具——然后两件事会咬你:模型回了空文本,因为循环停在了工具调用那一步;以及 key 进了客户端 bundle。两个都是一行就能修的。

一句话总结

  • AI SDK 5 的工具用 inputSchema(一个 Zod schema)声明参数;v4 里这个字段叫 parameters。
  • 不设 stopWhen,请求会以 finishReason: 'tool-calls' 结束、text 为空——stopWhen: stepCountIs(5) 才让模型有机会用文字作答。
  • 工具的 execute 必须留在服务端:Route Handler 或 server action,绝不放进客户端组件。
  • POST https://www.apipick.com/api/search/web,带 x-api-key 头:每次 15 额度,仅 HTTP 200 扣费。
  • 每条结果只返回三个字段。多出来的每个字段,在循环后面每一步都会被重新发给模型。

一个带类型的搜索工具长什么样?

AI SDK 的 tool() 接收一段描述、一个 Zod schema 和一个 execute 函数。schema 会被喂给模型,并在你的代码跑起来之前校验模型给的参数,所以畸形的工具调用根本到不了 fetch 那一层。

// lib/tools.ts —— 仅服务端
import { tool } from "ai";
import { z } from "zod";

export const webSearch = tool({
  description:
    "搜索实时网络。用于新闻、价格,以及任何在训练截止之后发布的内容。" +
    "查询词用大白话写。",
  inputSchema: z.object({
    query: z.string().describe("大白话的搜索词"),
    countryCode: z
      .string()
      .length(2)
      .optional()
      .describe("用于本地化结果的 ISO 国家码,例如 US 或 GB"),
  }),
  execute: async ({ query, countryCode }) => {
    const res = await fetch("https://www.apipick.com/api/search/web", {
      method: "POST",
      headers: {
        "x-api-key": process.env.APIPICK_KEY!,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        query,
        max_num_results: 5,
        ...(countryCode ? { country_code: countryCode } : {}),
      }),
    });
    if (!res.ok) return { error: `search failed: ${res.status}` };
    const data = await res.json();
    return data.results.map((r: any) => ({
      title: r.title,
      url: r.url,
      snippet: r.snippet,
    }));
  },
});

模型为什么什么都没回?

这是 AI SDK 工具最常见的「bug」,而它并不是 bug。generateText 默认只发一次模型请求。模型调用了你的工具,请求以 finishReason: 'tool-calls' 结束,text 是空的——因为模型根本没拿到写正文的第二个回合。

import { generateText, stepCountIs } from "ai";
import { webSearch } from "@/lib/tools";

const { text, steps } = await generateText({
  model: "anthropic/claude-sonnet-5",
  tools: { webSearch },
  stopWhen: stepCountIs(5),        // <- 修的就是这一行
  system:
    "事实性断言必须落在搜索结果上,并给出 URL。" +
    "如果搜索没返回有用的东西,就直说,不要猜。",
  prompt: "今年欧盟 AI 法案的时间表有什么变化?",
});

stopWhen 把一次请求变成工具调用循环,同时给它封了个顶。AI SDK 4 里对应的旋钮叫 maxSteps

怎么在 Next.js 路由里流式返回?

工具不变,换成 streamText,放在 Route Handler 里,key 就不会离开服务端。

// app/api/chat/route.ts
import { streamText, stepCountIs, convertToModelMessages } from "ai";
import { webSearch } from "@/lib/tools";

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: "anthropic/claude-sonnet-5",
    messages: convertToModelMessages(messages),
    tools: { webSearch },
    stopWhen: stepCountIs(5),
    system: "每一个事实性断言都要给出 URL。",
  });

  return result.toUIMessageStreamResponse();
}

客户端用 useChat,工具调用和结果会作为消息流的一部分传下来,所以你不用额外接线就能渲染一个「搜索中…」的状态。

为什么结果只留三个字段?

因为在多步循环里,工具结果在之后的每一步都会被重新发送。它属于模型每一轮都要重读的消息历史。一个带着 score、source_type、时间戳和 metadata 的结果对象不是只付一次钱——第 2 步、第 3 步、第 4 步都还要再付一遍。

条数用 max_num_results(1–5)在源头限制,而不是取回一大堆再在 TypeScript 里切。切片省 token;不取既省 token 也省时间。

怎么加上读页面的能力?

给模型第二个工具做后续阅读,并在 schema 里限死 URL 数量,让它没法一次要十二个页面。

export const readPages = tool({
  description:
    "取回指定 URL 的干净正文。在 webSearch 之后、摘要不足以回答时调用。",
  inputSchema: z.object({
    urls: z.array(z.string().url()).min(1).max(3),
  }),
  execute: async ({ urls }) => {
    const res = await fetch("https://www.apipick.com/api/extract", {
      method: "POST",
      headers: {
        "x-api-key": process.env.APIPICK_KEY!,
        "content-type": "application/json",
      },
      body: JSON.stringify({ urls }),
    });
    if (!res.ok) return { error: `extract failed: ${res.status}` };
    const data = await res.json();
    return data.results
      .filter((r: any) => r.status === "ok")
      .map((r: any) => ({ url: r.url, content: r.content.slice(0, 6000) }));
  },
});

schema 里的 .max(3)execute 运行之前就会生效,这比在描述里客气地请求模型要硬得多。

上线前该检查什么?

  • 设了 stopWhen不设的话,工具能跑,答案是空的。
  • key 在服务端从 process.env 读。定义在客户端组件里的工具,就是一次等着发生的凭据泄露。
  • 错误用 return,不要 throw。返回 { error } 能让模型自己恢复、换个说法;throw 会直接终止这一轮。
  • 仅成功计费。工具循环里 schema 校验失败和重试很常见;在 HTTP 200 计费下它们是零成本。

同一个工具在其他框架里的写法:LangChainCrewAIn8n,以及裸 OpenAI / Claude。先拿一把免费 key:100 额度,不用卡。

常见问题

到底是 inputSchema 还是 parameters?

AI SDK 5 是 inputSchema,v4 是 parameters。如果你从旧文章里抄了一段、而模型从不调用你的工具,通常就是这个原因——未知的键会被忽略而不是报错,于是工具以「无参数」注册,模型也就想不出该怎么调它。

为什么模型调完工具之后返回空文本?

因为请求在工具调用那一步就结束了。generateText 和 streamText 默认只发一次模型请求;一旦产生了工具调用,这次运行就以 finishReason: 'tool-calls' 收尾,没有正文。加上 stopWhen: stepCountIs(5) 之后,单次请求变成一个循环,模型才有机会读到工具结果并写出答案。

工具的 execute 应该在哪里运行?

永远在服务端。把工具放进 Route Handler(app/api/chat/route.ts)或 server action,让 API key 留在服务端的 process.env 里。定义在客户端组件里的工具,要么读不到 key,要么更糟——把 key 打进 bundle 发给了浏览器。

工具该返回多少内容?

每条结果三个字段:title、url、snippet。在多步循环里,每个工具结果都属于消息历史,后面每一步都会被重新发送,所以一份过宽的结果不是一次性 token 成本——它在之后的每一步都要再付一遍。条数用 max_num_results(取值 1–5,默认 5)在源头限制,而不是取回来再在 TypeScript 里切。

每次调用多少钱?

每次搜索 15 额度,1 美元 = 1,000 额度,约 0.015 美元。额度只在 HTTP 200 时扣除,所以畸形请求、超时,或者 schema 校验失败后的重试,都是零成本。

本文涉及的 API

Sarah Choy
作者
Sarah Choy
CEO, API Pick

Sarah Choy 是 API Pick 的 CEO,专注于为 AI Agent 与 LLM 工作流构建可用于生产的 API。