1. 从一次竞品调研说起:AI Research Agent 到底解决什么问题
你可能遇到过这种场景:老板丢来一句“把市面上主流 AI 编程助手做个对比,明天给我”,然后你打开十几个网页,复制粘贴、整理表格、反复核对,一整天就没了。AI Research Agent 要做的,就是把这条链路自动化——你只提一个问题,Agent 自己去检索、阅读、归纳、追问,最后吐出一份带引用来源的结构化报告。
它和普通聊天机器人的区别在于:聊天机器人是“你问一句它答一句”,而 Research Agent 是“你给一个目标,它自己规划步骤并执行”。核心能力拆成四块就是 Planning(拆任务)、Tool(调搜索/爬虫)、Memory(记住读过什么)、RAG(把长文切块检索后再归纳)。Openclaw 这类项目的思路之所以值得借鉴,是因为它把这几块用很轻的方式串了起来,没有过度工程化,一个前端工程师花两三天就能跑通最小闭环。
适合谁跟做:有基础 JavaScript/TypeScript 能力、想理解 Agent 编排本质、又不想一上来就啃重型框架的人。我试过用 LangChain 全家桶起步,结果光理解抽象层就耗掉半天,后来换成“手写 Planner + 工具函数”的 Openclaw 式思路,反而更快看到结果。下面这套流程,从提问到结构化报告,每一步都能复制。
整个系统的数据流是这样的:用户问题进入 Planner,Planner 产出一个步骤列表(搜索→阅读→归纳→成文),Executor 按步骤调用工具,Search Tool 拿回候选链接,Web Reader 抓正文,LLM 做分段摘要,最后 Report Generator 汇总成报告。中间所有抓取内容进向量库做 RAG 检索,多轮追问时从 Memory 里捞上下文。听起来模块多,但每个模块代码量都不大,关键是接口对齐。
2. TaoToken 前置:给 Agent 接一个稳定的模型入口
Agent 跑起来最怕的不是逻辑写错,而是模型调用不稳定——搜索到一半 401,摘要生成到一半超时,整个流程就断了。所以在写 Planner 之前,先把模型入口配好。这里用 TaoToken 作为统一入口,它兼容 OpenAI 风格的接口,改一个 Base URL 就能切换模型,对 Research Agent 这种需要频繁调 LLM 的场景很友好。
你需要准备三样东西:Base URL、API Key、Model ID。Base URL 填https://taotoken.net/api,API Key 在控制台的 API Keys 页面生成,Model ID 按你实际要用的模型填(比如做归纳用推理能力强的,做摘要用速度快的)。这三件套在后面的 JSON 配置、环境变量、代码里会反复出现,先记牢。
为什么 Agent 场景特别需要统一入口?因为 Research Agent 一次任务里会调用 LLM 很多次:Planner 拆步骤一次、每个网页分段摘要 N 次、最终成文一次、多轮追问再来几次。如果每次都要换不同的 SDK 和鉴权方式,代码会变得很脏。统一成 OpenAI 兼容格式后,你只需要维护一个 client 实例,换模型只改 Model ID 字符串。
配置方式有两种,选一种就行。第一种是环境变量,适合本地开发:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_MODEL="你的模型ID"第二种是写进项目的 settings 文件,适合团队协作时统一。以 Node 项目为例,建一个config/agent.settings.json:
{ "llm": { "baseURL": "https://taotoken.net/api", "apiKey": "sk-你的key", "model": "你的模型ID", "temperature": 0.3, "maxTokens": 2048 }, "agent": { "maxSteps": 5, "timeoutMs": 60000, "topK": 5 } }注意temperature设低一点(0.2~0.4),研究类任务要的是稳定归纳而不是发散创作。maxSteps和timeoutMs是防止 Agent 无限循环的保险丝,后面排障会讲。API Key 不要硬编码进提交到仓库的文件,用.env或密钥管理,这里为了演示直观才写进 JSON。
配好之后,写一个最小验证脚本,确认模型入口通了再往下做:
import OpenAI from "openai"; import settings from "./config/agent.settings.json" assert { type: "json" }; const client = new OpenAI({ baseURL: settings.llm.baseURL, apiKey: settings.llm.apiKey, }); const res = await client.chat.completions.create({ model: settings.llm.model, messages: [{ role: "user", content: "回复两个字:就绪" }], }); console.log(res.choices[0].message.content);跑出“就绪”两个字,说明 Base URL、Key、Model ID 三件套都对。这一步别跳过,后面所有报错排查都以这个脚本为基准。
3. 可复制配置:Planner、工具与 RAG 检索的完整片段
这一节是核心,把 Agent 的骨架搭出来。我按 Openclaw 的思路拆成四个文件:planner.ts负责拆任务,tools/search.ts和tools/crawler.ts负责取数据,memory/vectorStore.ts负责 RAG 检索,executor.ts负责串流程。先看 Planner 的提示词模板,这是整个 Agent 的大脑。
// backend/agent/prompts.ts export const PLANNER_PROMPT = ` 你是一个研究规划器。用户会给你一个研究问题,你需要输出一个 JSON 步骤数组。 可用步骤类型只有四种: - search: 生成搜索关键词 - read: 阅读指定 URL 的正文 - summarize: 对已读内容做归纳 - report: 生成最终报告 规则: 1. 最多 5 步,不要输出解释性文字,只输出 JSON。 2. 第一步必须是 search。 3. 最后一步必须是 report。 4. 每一步带一个 "input" 字段说明具体做什么。 输出格式示例: [ {"step": "search", "input": "AI Agent 发展趋势 2025"}, {"step": "read", "input": "从搜索结果中选前3条"}, {"step": "summarize", "input": "归纳核心观点"}, {"step": "report", "input": "生成结构化报告"} ] `;Planner 的输出必须是严格 JSON,否则 Executor 解析会崩。实测下来,把“只输出 JSON”写进提示词还不够,最好在代码里加一层容错:用正则把第一个[到最后一个]之间的内容抠出来再JSON.parse。这样即使模型多说了两句废话,也不影响流程。
接下来是 Search Tool。这里用 Serper 做演示,你也可以换成任何搜索 API,接口结构类似:
// backend/tools/search.ts export async function search(query: string) { const res = await fetch("https://google.serper.dev/search", { method: "POST", headers: { "X-API-KEY": process.env.SERPER_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ q: query, num: 8 }), }); const data = await res.json(); return data.organic.slice(0, 5).map((item: any) => ({ title: item.title, link: item.link, snippet: item.snippet, })); }Web Reader 用 Cheerio 抓正文。这里有个坑:直接$("body").text()会把导航栏、页脚、广告全抓进来,噪声极大。更好的做法是优先取article、main标签,取不到再退回 body:
// backend/tools/crawler.ts import * as cheerio from "cheerio"; export async function readWeb(url: string) { const html = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; ResearchAgent/1.0)" }, }).then((r) => r.text()); const $ = cheerio.load(html); $("script, style, nav, footer, aside").remove(); const main = $("article").text() || $("main").text() || $("body").text(); return main.replace(/\s+/g, " ").trim().slice(0, 12000); }RAG 检索这块,Openclaw 式思路不追求上重型向量库,先用内存数组 + 余弦相似度就能跑通。把每个网页按 800 字切块,调 embedding 接口存起来,追问时按相似度取 topK:
// backend/memory/vectorStore.ts type Chunk = { text: string; source: string; embedding: number[] }; const store: Chunk[] = []; export function chunkText(text: string, size = 800): string[] { const chunks: string[] = []; for (let i = 0; i < text.length; i += size) { chunks.push(text.slice(i, i + size)); } return chunks; } export function cosine(a: number[], b: number[]) { let dot = 0, na = 0, nb = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; } return dot / (Math.sqrt(na) * Math.sqrt(nb)); } export function retrieve(queryEmb: number[], topK = 5) { return store .map((c) => ({ ...c, score: cosine(queryEmb, c.embedding) })) .sort((a, b) => b.score - a.score) .slice(0, topK); }Executor 把上面这些串起来,核心是一个 for 循环加 switch:
// backend/agent/executor.ts export async function execute(question: string) { const plan = await planSteps(question); const context: string[] = []; for (const step of plan) { if (step.step === "search") { const results = await search(step.input); context.push(...results.map((r) => `${r.title} | ${r.link}`)); } else if (step.step === "read") { const urls = extractUrls(context); for (const url of urls.slice(0, 3)) { const text = await readWeb(url); await indexChunks(text, url); } } else if (step.step === "summarize") { const summary = await summarizeWithLLM(context.join("\n")); context.push(summary); } else if (step.step === "report") { return await generateReport(question, context); } } }indexChunks负责调 embedding 接口并写入 store,summarizeWithLLM和generateReport都是标准的 chat.completions 调用,用第 2 节配好的 client 即可。报告生成的提示词要强制引用来源,这是防幻觉的关键:
export const REPORT_PROMPT = ` 基于以下资料生成研究报告,必须包含:标题、摘要、核心观点、趋势分析、参考来源。 要求:每个核心观点后标注来源编号 [1][2],只允许使用资料中出现的信息,不得编造。 资料: {context} `;4. 验证请求:从提问到结构化报告的完整跑通
配置写完,跑一次完整验证。用一个真实问题:“对比主流 AI 编程助手的核心差异”。启动流程后,观察控制台输出,正常应该看到这样的执行轨迹:
[Planner] 生成 4 步计划 [Search] 关键词: AI 编程助手 对比 2025 [Search] 返回 5 条结果 [Read] 抓取 3 个网页,共 28400 字 [Index] 切分 36 个 chunk,写入向量库 [Summarize] 归纳出 6 个核心观点 [Report] 生成报告,引用来源 5 条最终报告的结构化输出大致长这样:
# AI 编程助手核心差异对比 ## 摘要 主流工具在补全准确率、上下文长度、Agent 能力三个维度分化明显... ## 核心观点 1. 补全类工具侧重低延迟,Agent 类工具侧重多步任务 [1][3] 2. 上下文窗口从 128K 向 200K 演进 [2] 3. 本地部署与云端调用的取舍取决于数据合规要求 [4] ## 趋势分析 多 Agent 协作与工具调用标准化是下一阶段重点 [5] ## 参考来源 [1] https://... [2] https://...验证成功的三个标志:一是报告里每个观点都有来源编号,二是来源链接能点开且内容相关,三是整个流程耗时在 60 秒内。如果报告里出现没有编号的“裸观点”,说明提示词约束不够,回去把REPORT_PROMPT里的“不得编造”再强调一遍,并在代码里做后处理——扫描报告中的句子,没有[n]标记的段落打回重写。
多轮追问的验证也要做。在报告生成后,追加一句“第二个观点有数据支撑吗”,Agent 应该走 RAG 检索,从向量库里捞出相关 chunk,再调 LLM 回答。这一步验证的是 Memory 模块是否真的在工作。如果追问时 Agent 答非所问,多半是 embedding 没存进去,或者检索时 query 没做 embedding,检查retrieve调用前有没有先算 query 向量。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
跑 Agent 流程时,报错集中在几个地方,我按出现频率排一下。
401 Unauthorized:最常见。先确认TAOTOKEN_API_KEY有没有正确加载,Node 里process.env读不到多半是没装dotenv或没在入口import "dotenv/config"。其次检查 Base URL 有没有多写斜杠,https://taotoken.net/api后面不要再加/v1,OpenAI SDK 会自己拼路径。如果用的是 settings.json 方式,确认 JSON 里没有尾逗号,JSON 解析失败会静默变成 undefined。
local proxy failed / connection refused:这个报错通常不是模型入口的问题,而是你的爬虫在抓某个网页时被目标站拒绝,或者本地网络策略拦截。排查方法:把readWeb单独拿出来,用固定 URL 测一次,看是 fetch 阶段挂还是 cheerio 解析阶段挂。如果是 fetch 挂,加超时和重试:
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10000); const html = await fetch(url, { signal: controller.signal }).then(r => r.text()); clearTimeout(timer);reading choices 报错(Cannot read properties of undefined (reading 'choices')):说明 LLM 返回体结构不对,res.choices是 undefined。三种可能:一是 API 返回了错误对象(比如额度不足、模型名写错),你没检查res.error就直接取 choices;二是流式和非流式混用;三是 Model ID 填错,接口返回了非预期结构。加一层防御:
const res = await client.chat.completions.create({...}); if (!res.choices || !res.choices[0]) { console.error("LLM 返回异常:", JSON.stringify(res)); throw new Error("模型调用失败,检查 Model ID 与额度"); }OAuth / 鉴权相关报错:如果你用的是 Claude Code 或 Codex 这类带 OAuth 的工具链,报错信息里出现 token 过期,注意区分“工具自身的登录态”和“模型 API 的 Key”。Research Agent 走的是 API Key 鉴权,不涉及 OAuth 流程。如果你在 CC Switch 或 Cline MCP 里配置,三件套要写全:Base URL 填https://taotoken.net/api,Key 填生成的 sk- 开头字符串,Model ID 填具体模型名,缺一个都会鉴权失败。
Agent 无限循环:表现为控制台一直刷[Search]。这是 Planner 没约束好,或者 Executor 的maxSteps没生效。在 Executor 循环里加计数器,超过阈值强制跳到 report 步骤。同时给整个 execute 包一层Promise.race做超时:
await Promise.race([ execute(question), new Promise((_, reject) => setTimeout(() => reject(new Error("Agent 超时")), 60000)), ]);报告全是幻觉:来源编号对不上,或者引用了不存在的链接。根因是 summarize 阶段把多个网页内容混在一起喂给 LLM,模型分不清哪句来自哪个源。解决方法是分段摘要时保留 source 元数据,最终成文时按 source 分组传入,并在提示词里明确“来源 [n] 对应 URL 列表如下”。
6. 语义一致 CTA:把这条研究流接到你的工作里
跑通最小闭环后,你会发现这套结构的扩展性比想象中好。想加深度研究模式,就在 Planner 里允许search → read → search again的循环;想加多 Agent 协作,就把 summarize 拆成独立的 Researcher Agent 和 Writer Agent,用消息队列串起来;想加前端可视化,把 Executor 每步的日志通过 SSE 推给 Next.js 页面,就能看到“正在搜索”“正在阅读”的实时状态。
模型入口这块,如果你要长期跑研究类 Agent,调用量会比普通对话大不少,建议在控制台里把用量监控开起来,按任务维度打标签,方便定位是哪个环节在烧 token。需要生成新的 API Key 或查看额度,去控制台的 API Keys 页面操作;想先验证模型对话效果再接入代码,可以用模型对话页面直接测提示词;如果打算把这条流做成长期跑的编码/研究 Agent,Coding Plan 里有更完整的额度方案。
接入文档里有 OpenAI 兼容接口的完整参数说明,包括流式、函数调用、embedding 的用法,写 RAG 检索那部分时对照着看能少踩坑。整套流程的代码结构不复杂,难的是把每个环节的边界条件处理好——超时、重试、幻觉约束、循环上限,这些才是 Research Agent 从 demo 到能用的分水岭。