跳到主要内容

用 CloudBase 云函数代理 Tavily AI 搜索

一句话定义:用 @tavily/core 在 CloudBase 云函数里调 Tavily Search API,拿到带 answer / results[] / score 的 LLM-friendly 搜索结果,前端通过 HTTP 调用,Tavily key 留在云端不暴露。

预计耗时:25 分钟 | 难度:进阶

适用场景

  • 适用:给 LLM agent 加实时联网检索能力,结果直接喂给模型做 search-augmented generation
  • 适用:不想让前端 / 小程序直接持有 Tavily API key,需要一层后端代理
  • 适用:跟 add-rag-with-pgvector-cloudbase 互补——RAG 答私域,Tavily 答外部公开网页
  • 不适用:纯私域知识检索(用 RAG + 向量库更合适,Tavily 不会爬你的内网)
  • 不适用:用户问 LLM 自身参数化记忆里就有的内容(比如经典算法解释),多此一举

环境要求

依赖版本
@tavily/corelatest(npm view @tavily/core version 查最新版)
Node.js(云函数运行时)≥ 18
@cloudbase/clilatest
云函数类型Web 云函数(HTTP 触发) —— 前端要直接 fetch
公网出口云函数默认能访问公网;配过 VPC 的环境需要确认 NAT 已挂

需要准备:

  • 一个 Tavily API key(tavily.com 注册后免费额度可以跑通,超过免费额度按调用计费,单价以官方 pricing 页面 为准)
  • 一个用于校验前端调用方的 token(最简单就是 32 字节的随机串,下文叫 PROXY_ACCESS_TOKEN

关于包名:Tavily 的官方 JS SDK npm 包名叫 @tavily/core,而不是 GitHub 仓库名 tavily-js,装错包是首踩坑。

第一步:申请 Tavily key + 在 CloudBase 配环境变量

  1. tavily.com 注册,控制台「API Keys」生成一个 key,形如 tvly-xxxxxxxxxxxxxxxxxxxx
  2. 本地生成一个 proxy access token:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
  1. 暂存这两个值,下面部署完函数后到控制台「云函数 → 环境变量」里填入:
    • TAVILY_API_KEY = 刚拿到的 Tavily key
    • PROXY_ACCESS_TOKEN = 刚生成的随机串

第二步:写云函数(完整 search + 参数校验 + 结果裁剪)

mkdir tavily-search && cd tavily-search
npm init -y
npm install --save express @tavily/core

package.jsonmain 改成 index.js,加 start 脚本:

{
"name": "tavily-search",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.18.2",
"@tavily/core": "^0.5.0"
}
}

index.js

const express = require('express');
const { tavily } = require('@tavily/core');

const app = express();
app.use(express.json({ limit: '1mb' }));

const TAVILY_API_KEY = process.env.TAVILY_API_KEY;
const PROXY_ACCESS_TOKEN = process.env.PROXY_ACCESS_TOKEN;

if (!TAVILY_API_KEY || !PROXY_ACCESS_TOKEN) {
console.error('Missing TAVILY_API_KEY or PROXY_ACCESS_TOKEN env');
process.exit(1);
}

const client = tavily({ apiKey: TAVILY_API_KEY });

// 鉴权:校验 Authorization: Bearer <PROXY_ACCESS_TOKEN>
function requireAuth(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (token !== PROXY_ACCESS_TOKEN) {
return res.status(401).json({ error: 'unauthorized' });
}
next();
}

// 允许从前端透传的搜索参数白名单
// 不在这里就丢掉,防止前端乱传放大成本
const ALLOWED_OPTIONS = new Set([
'searchDepth', // "basic" | "advanced"
'topic', // "general" | "news" | "finance"
'timeRange', // "day"|"week"|"month"|"year"|"d"|"w"|"m"|"y"
'startDate', // YYYY-MM-DD
'endDate',
'maxResults', // 0-20
'chunksPerSource', // advanced 才生效
'includeImages',
'includeAnswer', // false | "basic" | "advanced"
'includeRawContent', // false | "markdown" | "text"
'includeDomains', // max 300
'excludeDomains', // max 150
'country',
'timeout', // 秒
'exactMatch',
'includeFavicon',
]);

function pickOptions(input = {}) {
const out = {};
for (const [k, v] of Object.entries(input)) {
if (ALLOWED_OPTIONS.has(k)) out[k] = v;
}
// 强制兜底,防止 maxResults 被前端开到很大
if (typeof out.maxResults !== 'number' || out.maxResults < 1) out.maxResults = 5;
if (out.maxResults > 10) out.maxResults = 10;
return out;
}

app.post('/search', requireAuth, async (req, res) => {
const { query, options } = req.body || {};

if (typeof query !== 'string' || !query.trim()) {
return res.status(400).json({ error: 'invalid_query', message: 'body.query must be a non-empty string' });
}

const safeOptions = pickOptions(options);

let response;
try {
response = await client.search(query, safeOptions);
} catch (err) {
console.error('tavily search failed', err);
// Tavily SDK 的错误一般是 HTTP error,把状态码透出去方便前端判断
const status = err.status || err.response?.status || 502;
return res.status(status).json({
error: 'tavily_error',
message: err.message || 'tavily request failed',
});
}

// 裁剪一下返回体:LLM 一般只关心 query / answer / results 几个字段
// 原样把 rawContent 直接吐回去会很大,默认丢掉,前端要的话自己再开 includeRawContent
const slim = {
query: response.query,
answer: response.answer,
results: (response.results || []).map((r) => ({
title: r.title,
url: r.url,
content: r.content,
score: r.score,
publishedDate: r.publishedDate,
})),
responseTime: response.responseTime,
requestId: response.requestId,
};

res.json(slim);
});

app.get('/health', (_req, res) => res.json({ ok: true }));

const PORT = process.env.PORT || 9000;
app.listen(PORT, () => {
console.log(`tavily-search listening on ${PORT}`);
});

几个有意思的点:

  • searchDepth: "basic" 单次返回够 LLM 用,速度快、单价低;searchDepth: "advanced" 会做多次抓取并按 chunk 切片,质量更高但耗时和单价都涨——按场景在前端二选一,函数侧不要写死
  • includeAnswer: "basic" 让 Tavily 直接返回一句总结,省掉前端再调一次 LLM 做 summary 的成本;要更长的总结用 "advanced"
  • 过滤白名单很重要:Tavily 部分参数(includeImages / includeRawContent)会显著放大返回体和单价,不该让前端随便开
  • 裁剪 rawContent 字段是性价比最高的优化——很多 results 的 raw HTML 加起来能到上百 KB,浏览器解析会卡

第三步:部署到 CloudBase

tcb login
tcb fn deploy tavily-search --httpFn -e your-env-id

部署完到控制台「云函数 → tavily-search」做两件事:

  1. 「环境变量」里加 TAVILY_API_KEYPROXY_ACCESS_TOKEN(值就是第一步生成的两个)
  2. 「触发方式」确认 HTTP 访问服务已启用,记下访问 URL,类似 https://your-env.service.tcloudbase.com/tavily-search

改了环境变量要等下一次冷启动才生效;想立即生效可以在控制台对函数做一次「重新部署」,或者把内存 / 超时随便改一下保存触发实例重启。

第四步:前端 / agent 端调用 + 把结果喂给 LLM

最简单的 fetch 调用:

const resp = await fetch('https://your-env.service.tcloudbase.com/tavily-search/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer YOUR_PROXY_ACCESS_TOKEN',
},
body: JSON.stringify({
query: '2026 年 4 月美联储议息会议结果',
options: {
searchDepth: 'basic',
topic: 'news',
timeRange: 'week',
maxResults: 5,
includeAnswer: 'basic',
},
}),
});
const data = await resp.json();
console.log(data.answer);
console.log(data.results.map((r) => `${r.title}${r.url}`));

接到 LLM 上下文里时,建议把 results 拼成有出处编号的 prompt 片段,让模型在回答里引用:

const sources = data.results
.map((r, i) => `[${i + 1}] ${r.title}\n${r.content}\n来源: ${r.url}`)
.join('\n\n');

const userPrompt = `
基于以下检索结果回答用户问题,引用时用 [1] [2] 这种编号:

${sources}

用户问题: 2026 年 4 月美联储利率决议结果
`;

完整的 search-augmented chatbot 串起来需要再接一个 LLM 代理,可参考 connect-openai-api-cloud-function

运行验证

curl -X POST 'https://your-env.service.tcloudbase.com/tavily-search/search' \
-H 'Authorization: Bearer YOUR_PROXY_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"query": "CloudBase 是什么",
"options": { "searchDepth": "basic", "maxResults": 3, "includeAnswer": "basic" }
}'

预期返回结构:

{
"query": "CloudBase 是什么",
"answer": "CloudBase 是腾讯云推出的云开发平台...",
"results": [
{ "title": "...", "url": "https://...", "content": "...", "score": 0.91, "publishedDate": null }
],
"responseTime": 1.09,
"requestId": "uuid"
}

responseTime 一般在 0.5-3 秒之间;searchDepth: "advanced" 会到 5-15 秒,函数超时记得调到 30 秒以上。

@tavily/core 同一个 client 对象上还有几个方法,用法跟 search 类似:

  • client.extract(["url1", "url2"], { ... }) — 抓指定 URL 的正文(可选 extractDepth: "advanced" 做更彻底的解析)
  • client.crawl("base-url", { maxDepth, maxBreadth }) — 整站爬取,返回多页内容
  • client.map(...) — 只生成站点 URL 结构图,不取正文

需要这些能力直接在云函数里加新的 route 复用同一个 client 实例就行,不需要再 tavily() 一次。

常见错误

现象原因修复
Cannot find module 'tavily-js'装错了包名卸掉重装 npm i @tavily/coretavily-js 是 GitHub 仓库名不是 npm 包名
401 Unauthorized from TavilyTAVILY_API_KEY 没设 / 写错 / key 已 revoke控制台「环境变量」核对;去 Tavily Dashboard 看 key 是否还 active
429 Too Many Requests超过当前套餐的并发或月度调用上限降并发 / 升 plan / 在云函数侧加节流;具体限额看 Tavily 控制台
Invalid value for parameter searchDepth传了 "deep""full" 等不存在的值searchDepth 只接受 "basic""advanced";老版本 Python SDK 用的是 search_depth,JS SDK 是驼峰 searchDepth
topic: "news" 返回结果不带 publishedDate个别站点没有发布时间字段不是 bug,前端要按 r.publishedDate 是否存在做兜底;想强制要时间就在 prompt 里只用有 publishedDate 的 results
getaddrinfo ENOTFOUND api.tavily.com云函数没公网出口控制台 →「网络配置」开启公网访问;配过 VPC 的需要 NAT 网关
502 tavily_error 偶发,重试就好上游网络抖动或 Tavily 后端短暂 5xx在调用方做最多 2 次指数退避重试;不要无限重试,会把单价撑爆

错误码完整列表:https://docs.cloudbase.net/error-code/

相关文档

下一步

search 代理跑起来后下一步是接进 LLM 形成完整 search-augmented 链路:前端先调 tavily-search,把 results[] 拼进 messages 调 connect-openai-api-cloud-function 里搭的 LLM 代理函数,模型基于检索结果作答;要做成 Perplexity 那种"边搜边答"的 SSE 流式响应,套一层 add-vercel-ai-sdk-streaming-chatbot 即可。