Skip to content
目标
完成这一章,你会拿到一段可以直接在命令行里跑起来的 TypeScript 程序。它会在你输入问题后自动判断是否需要上网搜索,如果需要,就调用搜索引擎、取回网页摘要,再把信息整合成最终回答;如果不需要,就凭模型掌握的知识直接回答。程序还会记住前后对话,允许你基于上一轮的答案继续追问。
运行效果大致如下:
> 最近有什么值得关注的 AI 发布会?
Agent 正在搜索:2025年6月 AI 发布会 ...
[搜索结果摘要]
Agent:最近一周值得关注的发布会有...
> 详细介绍第一个
Agent:关于 OpenAI 的最新发布...联网搜索能力拆解
模型本身没有“打开浏览器”的能力。它能联网,是因为背后的程序在模型回答之前主动去调了一个搜索接口,把返回的网页内容塞进对话上下文里,让模型基于这些外部信息生成回答。这是检索增强生成(RAG)的一种具体应用形式,区别在于这里的“检索”动作由 Agent 自主决定触发,而不是硬编码在流程中。
整个过程可以拆成三步:
- 查询构造——模型根据用户问题和系统提示,判断“我的训练数据可能不够新”,于是生成一个适合搜索引擎的关键词串。
- 外部检索——程序替模型请求搜索服务,拿到一批网页的标题、链接和文字片段。
- 整合回答——模型把检索到的片段连同原问题一起消化,输出一段有引用依据的自然语言答案。
限制也很直接。第一,搜索结果再丰富,也只能塞进有限的上下文窗口里,多轮对话会持续消耗窗口空间,可能导致关键信息被截断。第二,搜索本身依赖外部服务,如果没配置好 API 密钥或者当天调用量超出配额,搜索能力就静默失效,模型拿不到结果只能胡诌。第三,模型本身对搜索结果的真实度没有校验能力——来源标题可能点进去是 404,但模型看不出,这一点在依赖网页摘要回答时容易被忽略。
选择与接入 Search API
首先要解决一个工程问题:用什么搜索服务。这里选 SerpAPI 作为后端,它的免费计划每天有 100 次调用,接口返回结构化 JSON,不需要自己解析 Google 页面。
接入需要三步:
- 在 SerpAPI 官网注册账号,拿到
API Key。 - 在系统环境变量里设置
SERPAPI_API_KEY,程序从process.env读取。 - 使用 HTTP GET 请求
https://serpapi.com/search,传入q(关键词)、api_key和engine=google。
一个原始请求大概是这样的:
ts
const response = await fetch(
`https://serpapi.com/search?q=${encodeURIComponent("深度学习 2025")}&api_key=${process.env.SERPAPI_API_KEY}&engine=google`
);
const data = await response.json();响应里的关键字段是 organic_results,它是一个数组,每个元素包含 title、link 和 snippet。后续的工具封装就基于这个结构。
注意:免费配额(100 次/月)在频繁测试时很容易打满。碰到返回
status: "error"先别怀疑代码,查一下 SerpAPI Dashboard 里的用量。
把搜索封装成工具
Agent 执行循环认识一个"工具"的方式是通过 JSON Schema 描述,外加一个可以实际执行的函数。下面用 Function Calling 格式定义搜索工具的 schema,再实现对应的调用逻辑。
工具 schema 与后端调用
工具描述需要让模型准确理解"什么时候该用我"。description 不能太宽,否则模型会滥用;也不能太窄,否则该搜的时候不搜。对于搜索工具,直接点出"实时信息、最新事件、数据查询"这几类场景即可。
ts
const webSearchTool = {
type: "function" as const,
function: {
name: "web_search",
description:
"当用户的问题涉及实时信息、最新事件、新闻、价格、天气,或者任何你认为训练数据可能不足以覆盖的内容时,调用搜索引擎获取最新网页摘要。",
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "用于搜索引擎的关键词字符串,应简洁、直击问题核心。"
}
},
required: ["query"]
}
}
};对应的后端调用函数很简单——把 query 发给 SerpAPI,从响应的 organic_results 里取前 5 条,拼成一段文本回传模型。这里没有返回原始 JSON,因为纯文本更容易被模型消化,而且对 token 的消耗更可控。
ts
async function executeWebSearch(args: { query: string }): Promise<string> {
const apiKey = process.env.SERPAPI_API_KEY;
if (!apiKey) {
return "错误:未配置 SERPAPI_API_KEY 环境变量,搜索不可用。";
}
const params = new URLSearchParams({
q: args.query,
api_key: apiKey,
engine: "google"
});
const resp = await fetch(`https://serpapi.com/search?${params.toString()}`);
if (!resp.ok) {
return `搜索请求失败,HTTP ${resp.status}`;
}
const data = await resp.json();
const results = data.organic_results?.slice(0, 5) ?? [];
if (results.length === 0) {
return "搜索结果为空,尝试更换关键词。";
}
// 格式化为模型易读的摘要块
return results
.map(
(r: any, i: number) =>
`[${i + 1}] ${r.title}\n链接: ${r.link}\n摘要: ${r.snippet}`
)
.join("\n\n");
}结果解析与格式化回传
上面函数的返回值就是最终回传给模型的"工具结果"。这里做了三个处理:
- 截断——只取前 5 条,防止一次搜索就吃光上下文窗口。
- 结构化摘要——每条结果用序号、标题、链接、摘要的固定格式输出。模型在看到这种格式后被引导出带编号的引用,回答质量会稳定不少。
- 错误转文本——各种异常(缺 API Key、网络错误、空结果)全部转成自然语言反馈,让模型自己去处理,而不是在代码里抛异常终止循环。实际运行中,模型收到"搜索结果为空"后会主动换关键词再搜。
将搜索工具挂入执行循环
在系列第四章里封装了一个可复用的 AgentLoop,它负责:接收用户输入 → 调用 LLM → 检查是否有 tool_calls → 执行工具 → 把结果塞回消息数组 → 再次调用 LLM,直到模型不再要求工具调用或达到最大轮次。现在只差把搜索工具注册进去。
注册需要两样东西:工具的 schema 数组,以及一个根据 tool_call 名称分发实际执行函数的映射。下面的代码基于之前封装的 AgentLoop 类(只要它的构造函数接受 tools 和 toolExecutor 即可)。
ts
import { AgentLoop } from "./agent-loop"; // 前文封装
const loop = new AgentLoop({
model: "gpt-4o-mini", // 可用便宜模型
tools: [webSearchTool],
toolExecutor: async (toolName: string, args: any) => {
if (toolName === "web_search") {
return executeWebSearch(args);
}
return `错误:未知工具 ${toolName}`;
},
systemPrompt:
"你是一个助手,能使用 web_search 获取最新信息。当需要实时数据时主动搜索。如果不需要,直接回答。",
maxIterations: 5 // 防止搜索-无结果-再搜索的循环
});模型自主决策:搜索、查询与终止
工具注册进去后,决策权完全在模型——它根据 system prompt、用户问题以及之前的工具结果,自主决定三点:
- 是否搜索——被问"1+1等于几"时,模型不会调搜索;被问"今天比特币价格"时,它会在第一轮就生成
web_search调用。 - 搜索什么——
query内容是模型即时生成的,可能比用户原始句子更精简。例如用户问"最近北京那边的气温如何?",模型可能直接生成query: "北京 2025年6月 温度"。 - 何时停止——拿到搜索结果后,模型判断信息已经足够,就不会再发起第二轮工具调用,直接整合生成最终回答。如果它觉得不够,可能再搜一次。循环的
maxIterations就是兜底手段。
实际运行时的回合示意:
Round 1:
assistant: tool_call { web_search, query: "比特币美元价格 2025-06-22" }
tool result: [摘要1...]
Round 2:
assistant: 根据搜索,当前比特币价格约为...命令行界面:读取问题、展示搜索过程、输出总结
终端交互用 Node.js 的 readline 模块就能搭出来。界面需要清楚地展示 Agent 在干什么,所以会在每一轮工具调用时打印"正在搜索 → 关键词",并把返回的摘要块也输出到控制台,方便观察模型到底拿到了什么信息。
ts
import readline from "readline";
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function chat() {
rl.question("\n> 你:", async (userInput) => {
if (userInput.toLowerCase() === "exit") {
rl.close();
return;
}
console.log("Agent 思考中...");
const reply = await loop.run(userInput);
console.log(`\nAgent:${reply}\n`);
chat(); // 继续下一轮
});
}
console.log('输入 "exit" 退出。');
chat();为了让搜索过程可见,可以在 AgentLoop 的执行循环里加几行日志。比如在检测到 tool_call 时:
ts
if (toolCalls.length > 0) {
for (const call of toolCalls) {
console.log(`\n🔍 正在搜索:${call.function.arguments.query}`);
}
// 执行工具...
console.log("搜索结果摘要:", toolResult.slice(0, 200) + "...");
}这部分输出不属于最终回答,只是给人看的调试信息,不会污染模型的消息历史。
让 Agent 记住上下文:挂接记忆模块与多轮追问
模型 API 本身无状态,对话历史必须在每次请求时完整传过去。在第五篇里,已经有了一个管理 messages 数组的 ConversationMemory,它负责存储用户和助手的消息,并在需要时做 Token 截断或摘要压缩。这里直接把它挂到命令行交互里,不需要重新实现那些细节。
每次用户输入一条新消息,就把这条消息附加到 memory 里,然后以整个记忆数组作为上下文传给 AgentLoop。Agent 执行完后,再把助手的回答也存入记忆,这样下一轮追问时模型就能"记得"之前聊过什么。
ts
import { ConversationMemory } from "./memory"; // 前文封装
const memory = new ConversationMemory({ maxTokens: 4000 });
async function chat() {
rl.question("\n> 你:", async (input) => {
if (input === "exit") { rl.close(); return; }
memory.addUserMessage(input);
// 用记忆里全部消息去驱动本轮对话
const reply = await loop.runWithHistory(memory.getMessages());
memory.addAssistantMessage(reply);
console.log(`\nAgent:${reply}\n`);
chat();
});
}这里 AgentLoop 的 runWithHistory 方法其实就是把 memory.getMessages() 作为初始的 messages 数组,再拼接 system prompt 后发起调用。因为模型能从历史里看到上一轮它自己给出的回答以及用户的新问题,所以像"详细介绍第一个"这种指代就能正确处理。
多轮追问的典型流程:
> 最近有什么值得关注的 AI 发布会?
Agent 搜索 "AI 发布会 2025年6月" → 给出列表
> 第一个发言人在哪里可以看回放?
Agent 搜索 "OpenAI 发布会回放" → 回答链接完整流程串联与运行演示
把上面的组件拼在一起,就是一个可用的命令行搜索 Agent。下面是完整的 agent-search.ts 脚本,你可以直接保存运行(需要先准备好 Node.js 环境和 SerpAPI 的 Key)。
ts
// agent-search.ts
import readline from "readline";
import { AgentLoop } from "./agent-loop";
import { ConversationMemory } from "./memory";
// ========== 搜索工具定义 ==========
const webSearchTool = {
type: "function" as const,
function: {
name: "web_search",
description: "获取实时网络信息,当问题涉及近期事件、新闻、数据时使用。",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "搜索关键词" }
},
required: ["query"]
}
}
};
async function executeWebSearch(args: { query: string }): Promise<string> {
const apiKey = process.env.SERPAPI_API_KEY;
if (!apiKey) return "错误:未配置 SERPAPI_API_KEY";
const params = new URLSearchParams({ q: args.query, api_key: apiKey, engine: "google" });
const resp = await fetch(`https://serpapi.com/search?${params}`);
const data = await resp.json();
const results = data.organic_results?.slice(0, 5) ?? [];
if (results.length === 0) return "搜索结果为空";
return results
.map((r: any, i: number) => `[${i + 1}] ${r.title}\n链接: ${r.link}\n摘要: ${r.snippet}`)
.join("\n\n");
}
// ========== 执行循环初始化 ==========
const loop = new AgentLoop({
model: "gpt-4o-mini",
tools: [webSearchTool],
toolExecutor: async (name, args) => {
if (name === "web_search") return executeWebSearch(args);
return `未知工具:${name}`;
},
systemPrompt: "你是搜索助手,需要最新信息时使用 web_search。",
maxIterations: 3,
verbose: true // 打印搜索过程
});
const memory = new ConversationMemory({ maxTokens: 4000 });
// ========== 命令行交互 ==========
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log('搜索 Agent 已启动。输入 "exit" 退出。\n');
async function chat() {
rl.question("> 你:", async (line) => {
if (line.trim().toLowerCase() === "exit") { rl.close(); return; }
memory.addUserMessage(line);
const reply = await loop.runWithHistory(memory.getMessages());
memory.addAssistantMessage(reply);
console.log(`\nAgent:${reply}\n`);
chat();
});
}
chat();启动命令与一次完整的搜索-总结流程
确保依赖已安装(openai、dotenv 等),然后在项目根目录下运行:
bash
$ SERPAPI_API_KEY=你的key npx tsx agent-search.ts运行效果:
搜索 Agent 已启动。输入 "exit" 退出。
> 你:最近 24 小时内科技圈有什么大新闻?
Agent 思考中...
🔍 正在搜索:科技新闻 2025-06-22
搜索结果摘要: [1] 苹果发布新芯片... [2] 特斯拉自动驾驶更新...
Agent:最近 24 小时内科技领域的重点动态包括:
1. 苹果发布了下一代 M4 芯片,性能提升明显...
2. 特斯拉推送了自动驾驶软件的新版本...
3. ...
> 你:第一个新闻的芯片用在什么设备上?
Agent 思考中...
🔍 正在搜索:苹果 M4 芯片 设备型号
Agent:根据搜索,苹果 M4 芯片将首先搭载在...注意点与改进方向
配额耗尽
SerpAPI 免费计划每月 100 次,测试几次就会用完。碰到 You have exceeded your plan’s monthly quota 时,可以在 SerpAPI 后台升级计划,或换用 Bing Search API、自建搜索代理。代码中可以通过增加缓存(同一 query 短时间内不重复请求)来减少调用次数。
搜索结果截断
每次只取前 5 条摘要,是权衡 token 消耗的结果。如果模型回答时频繁遗漏关键信息,可以适当增加到 8 条,但要同时关注 ConversationMemory 的 token 上限。更稳妥的做法是让模型在看到结果后自行判断:如果认为信息不足,再搜一次。
模型不触发搜索
检查两点:一是工具描述里的 description 是否明确要求"实时信息用搜索";二是 system prompt 是否给出了类似"当你不确定时优先搜索"的指令。模型对提示词很敏感,调整这两处通常能解决触发不足的问题。
Agent 陷入搜索循环
maxIterations 设得偏大时,模型可能因拿到的摘要不理想而反复搜。建议把上限压在 3-5 之间,并在 system prompt 中加入"如果连续两次搜索都没有得到相关信息,就告诉用户你无法回答"这类约束。
多轮对话下上下文溢出
虽然 ConversationMemory 做了 token 管理,但搜索摘要会额外占用大量 token。如果 Agent 在第十轮对话时突然"失忆",几乎可以确定是上下文满了。这时要么调整记忆窗口,要么引入更激进的摘要压缩,把历史对话和搜索结果分别处理。
