跳到主要内容

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=... # 推理用的 LLM
    export JINA_API_KEY=jina_... # 搜索/读网页用的 Jina Reader
    npm run dev "what is the context length of readerlm-v2?"

    它会打印一步步的推理过程(search / visit / reflect / answer),最后给出答案 + 访问过的 URL。入口就是 agent.tsmain()(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 promptgetPrompt 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?" 为例:

  1. 进循环,当前问题 = 原问题;先跑一次"问题评估"决定该用哪些评审标准(见 §3.3)。
  2. LLM 觉得信息不够 → 选 search → 生成关键词、调 Jina 搜索 → 一批 URL 和摘要进"知识库"。
  3. 下一轮 LLM 选 visit → 读排名靠前的 URL 全文 → 正文进知识库。
  4. 又一轮 LLM 觉得够了 → 选 answer → 送评审。评审通过 → 跳出循环
  5. 循环外做"精修":重写 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 > 0 src/agent.ts:566
    URL 已经超过 50 个就停止 searchallowSearch = allowSearch && weightedURLs.length < 50 src/agent.ts:568
    检测到需要时效性,第 1 步禁止直接作答src/agent.ts:546-550
    刚搜完立刻禁 answer,防止用生片段抢答allowAnswer = false src/agent.ts:930
    reflect 完就禁下一步再 reflectallowReflect = false src/agent.ts:803
  • 真实实现: prompt 侧,getPrompt 用一串 if (allowXxx)actionSections 里推对应片段(src/agent.ts:167184213222)。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 / completeness
    metrics.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-69792-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:330
    hostnameBoost同域名出现得多就加分(+手动 boost 名单)src/utils/url-tools.ts:315
    pathBoost路径前缀频率,越深的路径按 decayFactor 衰减src/utils/url-tools.ts:318-328
    jinaRerankBoost用 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 的引用。把"模型给的原始文本"打磨成"能直接展示的成品"。


5. 边界与局限(诚实)

  • 不产出长报告。 README:8 明说不优化长文;它的目标函数是"答对",不是"写得全"。要长报告用别的工具。

  • 强依赖外部 API。 推理要 Gemini/OpenAI(或本地兼容 LLM),搜索/读网页要 Jina(默认 provider,config.json:13)。Jina key 401 时会直接抛错终止(src/agent.ts:333-335)。没有这些 key,config.ts:128-130 启动即抛错。

  • 烧钱/烧时。 每步之间还硬 sleep STEP_SLEEP 秒防限流(默认 1 秒,config.json:15;src/agent.ts:3381033)。复杂问题几十步很常见(README 里有 42 步的例子),token 和时间都不便宜。

  • 答案质量受评审模型影响。 整个"够不够好"的判断押在评审 LLM 上;评审偏松/偏严,直接影响 agent 何时停。Beast Mode 更是"确定性优先"——被 prompt 逼着不许说不确定,极端情况下可能给出自信但未必对的答案。

  • 本地状态副作用。 非服务器场景下,每步会把 prompt/context/knowledge 等写到当前目录的一堆 json 文件(storeContext,src/agent.ts:1173-1186),便于调试但会在工作目录留文件。


6. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移。下表是读源码的跳转起点。

主题文件符号
主循环 / 编排src/agent.tsgetResponse
CLI 入口src/agent.tsmain
动态 prompt 组装src/agent.tsgetPrompt
知识 → 消息src/agent.tsBuildMsgsFromKnowledge / composeMsgs
执行搜索查询src/agent.tsexecuteSearchQueries
动作类型定义src/types.tsBaseAction / SearchAction / AnswerAction / ReflectAction / VisitAction / CodingAction
知识条目类型src/types.tsKnowledgeItem
动作 Zod schemasrc/utils/schemas.tsgetAgentSchema
评审 schemasrc/utils/schemas.tsgetEvaluatorSchema
安全结构化生成src/utils/safe-generator.tsObjectGeneratorSafe / createDistilledSchema
答案评审src/tools/evaluator.tsevaluateAnswer / evaluateQuestion
错误复盘src/tools/error-analyzer.tsanalyzeSteps
URL 打分排序src/utils/url-tools.tsrankURLs / addToAllURLs / keepKPerHostname
读取 URL 全文src/utils/url-tools.tsprocessURLs
查询重写src/tools/query-rewriter.tsrewriteQuery
查询/问题去重src/tools/jina-dedup.tsdedupQueries
团队并行研究src/tools/research-planner.tsresearchPlan
引用抽取src/tools/build-ref.tsbuildReferences
答案定稿src/tools/finalizer.tsfinalizeAnswer
代码沙箱动作src/tools/code-sandbox.tsCodeSandbox
LLM/搜索 provider 配置src/config.tsgetModel / getToolConfig
预算/用量追踪src/utils/token-tracker.tsTokenTracker
OpenAI 兼容路由(路由实体)src/app.ts:387app.post('/v1/chat/completions')
HTTP 服务启动入口src/server.tsstartServer(仅 import applisten,路由不在此)