跳到主要内容

01 · 核心引擎 deepResearch — 广度×深度的递归搜索树

这章讲全项目的心脏 deepResearchsrc/deep-research.ts:176-294):它怎么把一句话题目展开成一棵搜索树,两个旋钮怎么控制树形,要点怎么滚雪球,出错时怎么优雅认输。

3.1 它要解决的小问题

人做深度调研有个天然的"树形":一个大问题拆成几个子问题(广度),每个子问题查完又冒出更细的新问题,顺着继续查(深度)。难点是怎么把这个树形自动展开、还不能无限查下去。deepResearch 用一个自递归函数干净地表达了这棵树。

3.2 思路 / 直觉(先于代码)

核心直觉就三条:

  • 一次调用 = 树上一个节点:这个节点负责"想 breadth 个搜索词、并发搜、提炼要点"。
  • 往下长 = 递归调用自己:把提炼出的"研究目标 + 后续问题"当成新题目,再调 deepResearch
  • 保证收敛:每往下一层,breadth 减半、depth 减一。depth 减到 0 就停——这是递归出口。

所以树是越往下越窄的:比如 breadth=4、depth=2,第一层叉 4 支,每支往下只叉 ceil(4/2)=2 支,第二层到 depth=0 停。

题目 (breadth=4, depth=2)
┌────┬────┬────┬────┐ ← 第1层:4 个搜索词并发
q1 q2 q3 q4
│ (每个 qi 搜完、提炼,然后往下长)
┌───┴───┐
q1a q1b ← 第2层:breadth 减半=2,depth 减到 0 → 停

怎么读:横向的分叉数 = 当前 breadth;纵向层数 = 初始 depth。命中 depth=0 即为叶子,不再往下。

3.3 原理演示(简化,非源码)

先用一段极简 JS 把"递归树"的骨架演出来,抹掉并发和错误处理:

// 示意,非源码:deepResearch 的递归骨架
async function research(query, breadth, depth, learnings = []) {
// ① 让 LLM 想出 breadth 个搜索词
const queries = await genQueries(query, breadth, learnings);

// 对每个搜索词分别深挖
const results = await Promise.all(queries.map(async (q) => {
const pages = await search(q.query); // ② 真去搜
const found = await extract(q.query, pages); // ③ LLM 提炼要点+后续问题
const all = [...learnings, ...found.learnings]; // 要点滚雪球

if (depth - 1 > 0) { // ④ 还没到底
const next = q.researchGoal + found.questions.join('\n');
return research(next, Math.ceil(breadth / 2), depth - 1, all); // 递归、旋钮衰减
}
return { learnings: all }; // 到底,收工
}));

return { learnings: [...new Set(results.flatMap(r => r.learnings))] }; // 去重合并
}

重点看两处:Math.ceil(breadth/2)depth-1 保证树收敛;结尾的 new Set(...) 把各分支重复的要点去掉。

3.4 真实实现(对照源码看)

旋钮衰减 + 递归src/deep-research.ts:230-263)。真源码里 newBreadthnewDepth 就是那两个衰减量,processSerpResultnumFollowUpQuestions 直接用 newBreadth,让"后续问题数"跟着下一层的宽度走:

const newBreadth = Math.ceil(breadth / 2);
const newDepth = depth - 1;
// ...
if (newDepth > 0) {
const nextQuery = `
Previous research goal: ${serpQuery.researchGoal}
Follow-up research directions: ${newLearnings.followUpQuestions.map(q => `\n${q}`).join('')}
`.trim();
return deepResearch({ query: nextQuery, breadth: newBreadth, depth: newDepth, learnings: allLearnings, visitedUrls: allUrls, onProgress });
}

注意"下一层的题目" nextQuery上一层的研究目标 + 提炼出的后续问题拼起来的——这就是"顺着发现往下钻"的具体落地(serpQuery.researchGoal 由 LLM 在想词时一并生成,见第 02 章)。

并发扇出src/deep-research.ts:216-288)。同一层的 breadth 个搜索词不是串行,而是用 p-limit 控并发的 Promise.all

const limit = pLimit(ConcurrencyLimit);
const results = await Promise.all(
serpQueries.map(serpQuery => limit(async () => { /* 搜索+提炼+递归 */ })),
);

ConcurrencyLimit 默认 2(src/deep-research.ts:30,读 FIRECRAWL_CONCURRENCY)。这是为免费版 Firecrawl 的限流留的旋钮——付费/自托管可调大。

要点滚雪球 + 最终去重。累积发生在两处:递归时把 allLearnings 当参数传下去(:238:260),叶子返回后再在顶层用 Set 去重(src/deep-research.ts:290-293):

return {
learnings: [...new Set(results.flatMap(r => r.learnings))],
visitedUrls: [...new Set(results.flatMap(r => r.visitedUrls))],
};

3.5 关键细节 / 坑

① 错误被静默吞掉,一支挂了不拖垮整棵树。 每个分支包在 try/catch 里,出错就返回空 learnings(src/deep-research.ts:275-285):

} catch (e: any) {
if (e.message && e.message.includes('Timeout')) {
log(`Timeout error running query: ${serpQuery.query}: `, e);
} else {
log(`Error running query: ${serpQuery.query}: `, e);
}
return { learnings: [], visitedUrls: [] };
}

好处:单个搜索超时/失败不会让 Promise.all 整体 reject。代价:失败是静默降级,最终报告可能悄悄少了一整支的发现,用户看不出来。

② learnings 累积会有重复膨胀。 递归下传时是 [...learnings, ...newLearnings.learnings] 直接拼接,只有最顶层才去重。同一条要点会随着深度一路复制到每个子调用的 prompt 里,深度大时 prompt 会变长——这也是为什么需要 trimPrompt(见第 03 章)。

③ 进度上报是尽力而为。 reportProgresssrc/deep-research.ts:200-203)用 Object.assign 就地改一个共享 progress 对象再回调 onProgress。并发多支同时改同一个对象,currentDepth/currentBreadth 这些字段只是"最近一次"的快照,不精确——它只为 UI 显示大概进度,不是账本。

④ depth=1 时递归其实只跑一层。 出口判断是 newDepth > 0depth-1 > 0,所以 depth=1 传进来,第一层搜完 newDepth=0 就直接返回,不再往下——符合"深度 1 = 只查一轮"的直觉。

下一章:这四个 LLM 调用点(想词/提炼/写报告等)内部的 prompt 与 schema 怎么设计 → 02-prompts-and-structured-output.md