在 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 计费下它们是零成本。
同一个工具在其他框架里的写法:LangChain、CrewAI、n8n,以及裸 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 是 API Pick 的 CEO,专注于为 AI Agent 与 LLM 工作流构建可用于生产的 API。