数据截至 (上游 commit fd323b521a51)
node-DeepResearch — 架构与原理
30 秒导读: 给它一个问题,它就不停地搜网页、读网页、推理,直到找到能通过"严格评审"的答案,或者把 token 预算烧光为止。它刻意不写长报告——只追一件事:把这个问题的正确答案找出来。
本页是这个项目的总入口(Layer 0 + Layer 1)。同组暂时只有本章 index.md;后续深入章节会以相对链接从这里链出。
1. 这是什么(零基础也能懂)
-
一句话定义: node-DeepResearch 是 Jina AI 开源的一个 "深度搜索" agent——它把"搜索 → 读网页 → 推理"三个动作放进一个循环里反复跑,像一个不知疲倦的研究助理,直到答出问题。
-
解决什么问题 / 给谁用: 普通搜索引擎给你一堆链接,聊天机器人凭记忆张口就来(可能编造)。当你的问题需要边查边想、查好几轮才能答——比如"readerlm-v2 的上下文长度是多少?""谁会是 Jina AI 最大的竞争对手?"——你希望有个东西自己去把网翻一遍再给你一个带引用的确切答案。这就是它的活。
-
它跟 OpenAI/Perplexity 的 "Deep Research" 有什么不同: 官方 README 特意强调,它只优化"找到对的答案",不生成长篇文章(README:8)。要长报告请用别的;要"深挖一个具体答案",用它。
-
它能做什么:
- 接一个自然语言问题,自动决定要不要搜、搜什么、读哪些 URL。
- 把中间发现攒成"知识",逐步逼近答案。
- 用一个"专门挑刺的评审"反复检验答案,不合格就打回重来。
- 输出带 footnote 引用(
[^1])的 Markdown 答案。 - 兼容 OpenAI 的
/v1/chat/completions接口,能被任何 OpenAI 客户端直接调用。
-
用起来什么样: 命令行一句话就跑(README:73-80):
export GEMINI_API_KEY=... # 推理用的 LLMexport JINA_API_KEY=jina_... # 搜索/读网页用的 Jina Readernpm run dev "what is the context length of readerlm-v2?"它会打印一步步的推理过程(
search/visit/reflect/answer),最后给出答案 + 访问过的 URL。入口就是agent.ts的main()(src/agent.ts:1194),它调getResponse()这一个核心函数。 -
一句话直觉/ 类比: 把它想成一个只会一件事的死循环——"我现在信息够不够答?不够就再搜/再读,够了就答;答得不好就总结教训、换个角度重来。" 循环的每一步,由 LLM 从菜单里挑一个动作;循环的刹车,是 token 预算。
本节不含底层代码细节。记住一件事就够:它是一个"搜-读-想"的循环,烧 token 直到答出来。
2. 顶层全景(它大概怎么转)
2.1 主循环:一张图看懂
整个项目的价值都在一个函数里:getResponse()(src/agent.ts:419)。它是一个 while 循环,条件是"已用 token < 常规预算"(src/agent.ts:518)。
怎么读这张图: 从上往下是一次 while 迭代;LLM 在中间"选动作"这一步做决定,选完跳到对应分支执行,然后回到循环顶。只有"答案通过评审"或"预算耗尽"才跳出循环。
┌──────────────────────────────┐
问题 question ──▶│ gaps 队列(待答问题,含原问题)│
└──────────────┬───────────────┘
│ 每轮取一个当前问题
▼
┌───────────────────────────────────────┐
┌───────▶│ while: 已用token < 预算(85%)? │──否──▶ Beast Mode(强制出答案)
│ └───────────────────┬───────────────────┘
│ │是
│ ▼
│ ┌───────────────────────────────────────┐
│ │ ① 排序 URL + ② 拼 system prompt(哪些 │
│ │ 动作此刻可用)+ ③ LLM 选 1 个动作 │
│ └───────────────────┬───────────────────┘
│ │
│ ┌───────┬─────────────┼─────────────┬──────────┐
│ ▼ ▼ ▼ ▼ ▼
│ search visit reflect answer coding
│ (搜关键词)(读URL) (拆子问题) (给答案) (跑JS)
│ │ │ │ │ │
│ 存URL/ 存网页 子问题入 评审:过? 存结果
│ 知识片段 正文为知识 gaps队列 └过→结束 为知识
│ │ │ │ └不过→存教训、重 来
└──────┴───────┴─────────────┴─────────────┴──────────┘
(每个动作做完,回到循环顶)
2.2 五个动作 = agent 的"技能菜单"
每一轮,LLM 只能从下面五个动作里选一个。动作类型定义在 src/types.ts:4(BaseAction)和它的各个子类型:
| 动作 | 干什么 | 产出 | 类型定义 |
|---|---|---|---|
search | 生成关键词、调搜索引擎 | 新 URL + 搜索片段(存进知识) | SearchAction src/types.ts:15 |
visit | 读取选中 URL 的全文 | 网页正文(存进知识) | VisitAction src/types.ts:64 |
reflect | 把难题拆成子问题 | 新子问题(压进 gaps 队列) | ReflectAction src/types.ts:59 |
answer | 尝试给出答案 | 答案 + 引用(送去评审) | AnswerAction src/types.ts:39 |
coding | 用 JS 处理数据(计数/过滤/正则) | 计算结果(存进知识) | CodingAction src/types.ts:70 |
2.3 核心部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 主循环 | 编排整个搜-读-想过程 | getResponse src/agent.ts:419 |
| Prompt 组装 | 按"当前允许哪些动作"动态拼 system prompt | getPrompt src/agent.ts:110 |
| 动作 Schema | 用 Zod 定义 LLM 必须返回的结构 | getAgentSchema src/utils/schemas.ts:268 |
| 安全生成器 | 调 LLM 拿结构化输出,失败会降级/修复 | ObjectGeneratorSafe src/utils/safe-generator.ts:34 |
| 答案评审 | 从多个维度"挑刺",判定答案过不过 | evaluateAnswer src/tools/evaluator.ts:622 |
| URL 排序 | 给候选 URL 打分排序(选谁去读) | rankURLs src/utils/url-tools.ts:250 |
| Token 预算 | 累计用量,是循环的刹车 | TokenTracker src/utils/token-tracker.ts |
2.4 主线走一遍(高层,不进代码)
以 "what is the context length of readerlm-v2?" 为例:
- 进循环,当前问题 = 原问题;先跑一次"问题评估"决定该用哪些评审标准(见 §3.3)。
- LLM 觉得信息不够 → 选
search→ 生成关键词、调 Jina 搜索 → 一批 URL 和摘要进"知识库"。 - 下一轮 LLM 选
visit→ 读排名靠前的 URL 全文 → 正文进知识库。 - 又一轮 LLM 觉得够了 → 选
answer→ 送评审。评审通过 → 跳出循环。 - 循环外做"精修":重写 Markdown、抽取引用、配图,产出最终
mdAnswer(src/agent.ts:1082-1112)。
如果一直答不好、把预算烧到 85% 还没结束,循环退出后进 Beast Mode(§3.5),用剩下的预算强制吐一个答案——"任何答案都好过没答案"(src/agent.ts:1042)。
3. 核心原理(逐个机制,由浅入深)
3.1 机制一:动作不是硬编码的,是"当前允许什么"动态拼出来的
-
要解决的小问题: 怎么防止 agent 犯傻——比如刚搜完就急着用半生不熟的摘要作答,或者手头一个 URL 都没有还要
visit? -
思路/直觉: 不靠 prompt 里写"请不要……",而是从根上不给它这个选项。每一轮根据当前状态算出一组
allowXxx布尔量,只有被允许的动作才会:(a) 出现在 system prompt 的动作清单里,(b) 出现在 LLM 必须匹配的 Zod schema 里。LLM 从物理上就选不了没被允许的动作。 -
门控规则(部分) 都在循环体里:
规则 代码 没有候选 URL 就不许 visitallowRead = allowRead && weightedURLs.length > 0src/agent.ts:566URL 已经超过 50 个就停止 searchallowSearch = allowSearch && weightedURLs.length < 50src/agent.ts:568检测到需要时效性,第 1 步禁止直接作答 src/agent.ts:546-550刚搜完立刻禁 answer,防止用生片段抢答allowAnswer = falsesrc/agent.ts:930刚 reflect完就禁下一步再reflectallowReflect = falsesrc/agent.ts:803 -
真实实现: prompt 侧,
getPrompt用一串if (allowXxx)往actionSections里推对应片段(src/agent.ts:167、184、213、222)。schema 侧,getAgentSchema用同样的if (allowXxx)往actionSchemas里加字段,最后把动作名做成z.enum(Object.keys(actionSchemas))(src/utils/schemas.ts:331)——没加进去的动作, 枚举里根本没有。 -
关键细节: 每轮开头会把所有
allow*重置为true(src/agent.ts:605-609),再按当轮状态重新收紧。所以门控是"每一步现算"的,不是一次性设定。
3.2 机制二:gaps 队列 + 知识库 = agent 的"工作记忆"
-
要解决的小问题: 一个难问题往往要先答几个小问题才能答。怎么组织这些子任务和一路攒下来的发现?
-
思路/直觉: 两个数据结构:
gaps:一个待答问题队列,初始只装原问题(src/agent.ts:476)。reflect会往里塞子问题,答对一个子问题就把它移除(src/agent.ts:771)。allKnowledge:一个 KnowledgeItem 列表(src/types.ts:50),把搜索摘要、网页正文、已答子问题、代码结果……全部记成"问答对",每轮都拼进给 LLM 的消息里。
-
轮换机制: 当前问题不是死盯原问题,而是轮流处理队列里的问题:
// 示意,非源码:用步数对队列长度取模来轮换问题const currentQuestion = gaps[totalStep % gaps.length];真实实现:
const currentQuestion = gaps[totalStep % gaps.length](src/agent.ts:526)。这让 agent 在原问题和各子问题间来回切,避免在某一个上死磕。 -
知识怎么喂回 LLM:
BuildMsgsFromKnowledge(src/agent.ts:59)把每条知识展开成一对user/assistant消息,composeMsgs(src/agent.ts:85)把知识放最前面,再接真实对话和当前问题。等于每轮都把"我已经知道的一切"重新讲给模型听。
3.3 机制三:答案要过"多维评审",不是模型说了算
-
要解决的小问题: LLM 很容易给一个听起来很确定、其实没答到点的答案。怎么挡住?
-
思路/直觉: 引入一个专门唱反调的评审。system prompt 直接把它设定成"冷酷、挑剔、训练来拒绝答案"的角色,要求它"找出任何弱点""先全力反驳,再支持,最后给改进计划"(
src/tools/evaluator.ts:16-21)。 -
评审维度不是固定的,是按问题定制的。 第 1 步先跑
evaluateQuestion(src/tools/evaluator.ts:560),让模型判断这个问题需要哪些检查,再加一条强制的strict:// 示意,非源码:第 1 步为原问题挑选评审维度const metrics = await evaluateQuestion(question); // 可能返回 definitive / freshness / plurality / completenessmetrics.push({ type: 'strict', numEvalsRequired: maxBadAttempts });真实实现在
src/agent.ts:531-541。六种评审类型定义在EvaluationType(src/types.ts:77):类型 检查什么 definitive答案够不够确定,有没有"我不确定/我做不到"这类话 freshness时效性:答案日期 vs 允许的最大陈旧天数 plurality数量:问"列举 5 个"时是否真给够了 completeness完整性:问题要求的各方面是否都覆盖 attribution归因:答案是否有来源支撑 strict最严格,产出 improvement_plan(怎么改进) -
不过怎么办: 每个维度带一个
numEvalsRequired计数,失败就减一(src/agent.ts:688-693)。strict失败时把improvement_plan收进finalAnswerPIP,下一轮作为"评审意见"拼进 prompt 逼模型改(src/agent.ts:695-697、92-102)。同时调analyzeSteps做错误复盘,把"为什么这个答案不好"写成一条知识(src/agent.ts:718-742)。所有维度都耗尽 → 放弃,转 Beast Mode(src/agent.ts:699-703)。 -
一个例外: 如果是第 1 步就直接给答案、且没开
noDirectAnswer,直接认定为trivialQuestion跳过所有评审(src/agent.ts:616-622)——像 "1+1=" 这种没必要走全套。
3.4 机制四:该读哪个 URL?——多信号打分排序
-
要解决的小问题: 搜索会攒下几十上百个 URL,读全文很贵。怎么挑最该读的几个?
-
思路/直觉: 给每个 URL 算一个
finalScore,由几路信号相加、再夹在[0, 5]区间(rankURLs,src/utils/url-tools.ts:250)。直觉 是:被反复搜到的域名/路径更可能相关,和问题语义更近的更该读。信号 含义 代码 freqBoost这个 URL 被搜到的次数(权重) src/utils/url-tools.ts:330hostnameBoost同域名出现得多就加分(+手动 boost 名单) src/utils/url-tools.ts:315pathBoost路径前缀频率,越深的路径按 decayFactor衰减src/utils/url-tools.ts:318-328jinaRerankBoost用 Jina rerank 模型算 URL 摘要和问题的语义相关度 src/utils/url-tools.ts:286-298 -
一个巧思(多样性): 排完序后调
keepKPerHostname(weightedURLs, 2)(src/agent.ts:563),每个域名最多留 2 条,防止结果被某一个站霸屏。 -
关键细节(有意思的坑): rerank 是异步 fire-and-forget 的——
rerankDocuments(...).then(...)没有await(src/utils/url-tools.ts:286)。也就是说本轮的 rerank 分数可能下一轮才生效。这是拿"每步不阻塞"换"分数稍微滞后"的取舍。
3.5 机制五:Beast Mode —— 预算烧光时的兜底
-
要解决的小问题: 万一循环跑到预算耗尽都没产出通过评 审的答案,总不能什么都不给用户吧?
-
思路/直觉: 预留 15% 预算(常规预算 =
tokenBudget * 0.85,src/agent.ts:499)——注意源码此处注释写的是 "reserve the 10% final budget",与代码里的* 0.85(即预留 15%)不符,以代码为准。循环退出后如果还没有isFinal答案,就进 Beast Mode:关掉除answer外的所有动作,把攒下的全部知识塞进去,强制模型给一个答案。 -
它靠一段夸张的 prompt 施压: Beast Mode 的动作段是全大写的"火力全开"文案——"摧毁一切犹豫,任何回答都胜过沉默"(
src/agent.ts:197-211)。目的就是压掉模型"我无法确定"的倾向。 -
真实实现:
if (!(thisStep as AnswerAction).isFinal)分支(src/agent.ts:1036),这里getPrompt(...)的一堆allow*全传false、最后一个beastMode参数传true(src/agent.ts:1045-1057),再用agentBeastMode这个模型档强制生成一次答案。
4. 巧妙之处(可借鉴的技术)
-
结构化输出的"防翻车"三级降级。 调 LLM 要 JSON 很容易翻车。
ObjectGeneratorSafe(src/utils/safe-generator.ts:34)的策略:先正常生成;失败就用"去掉所有字段描述的精简 schema"重试(createDistilledSchema,src/utils/safe-generator.ts:45);再 不行用Hjson/jsonrepair硬修模型吐出的坏 JSON(src/utils/safe-generator.ts:11-18)。这是让"LLM 必须返回合法结构"在生产里能站住的关键。 -
门控写两遍,prompt 和 schema 一致。 §3.1 说的
allow*同时驱动 prompt 文案(getPrompt)和 Zod 枚举(getAgentSchema)。好处:模型看到的"能做什么"和它被允许返回的"能做什么"永远对齐,不会出现 prompt 说能搜、schema 里却没有 search 字段的错位。 -
"reflect 去重"防止原地打转。
reflect产出的子问题会先经dedupQueries(src/tools/jina-dedup.ts:9)和已问过的问题比对去重(src/agent.ts:774),避免 agent 把同一个子问题换个说法反复问。 -
可选的"团队并行"研究模式。 当
teamSize > 1,researchPlan(src/tools/research-planner.ts:67)把问题拆成若干正交子课题,然后递归调用getResponse(每个子课题teamSize=1)并行跑,最后聚合答案(src/agent.ts:824-862)。等于把单 agent 变成"主管 + 若干独立研究员"。 -
答案后处理流水线。 通过评审后,答案还要过一长串修复函数:
finalizeAnswer→ 修脚注 → 修代码块缩进 → 修坏链接 → HTML 表转 Markdown → 最终修复(src/agent.ts:1083-1096),再由buildReferences(src/tools/build-ref.ts:10)从正文抽取带exactQuote的引用。把"模型给的原始文本"打磨成"能直接展示的成品"。