Skip to content

limit-coding/deep-research-agent

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Mini DeepResearch Agent

后端工程师面试准备项目。核心目标不是"能跑的 demo",而是每个设计决策都能讲清楚 why—— 但既然顺手搭了个前端,也放一个真能跑的版本上去:research.learnpath.tech (公开 demo,限流:单 IP 10 分钟 3 次 / 每日总额度 30 次)。

核心流程

query
  │
  ▼
[decompose]  拆成 ≤4 个独立子问题
  │
  ▼
[search] ◄────────────────┐  用 Tavily 查子问题 / 反思后的 follow-up 问题
  │                        │
  ▼                        │
[synthesize] 综合全部来源写报告草稿       │
  │                        │
  ▼                        │
[reflect] 自我批评:信息够吗?引用对得上吗?
  │                        │
  ├─ 不充分 且 iteration < MAX_RESEARCH_ITERATIONS ─┘
  │
  └─ 充分 / 达到上限
       │
       ▼
   [output] 整理最终报告 + 来源列表 + 反思记录

5 个节点对应 deep_research_agent/nodes.py 里的 5 个函数,图的装配在 graph.py

目录结构

deep_research_agent/
  state.py   # ResearchState:节点间传递的共享状态
  llm.py     # LLM provider 切换(Anthropic/OpenAI/DeepSeek)
  tools.py   # 搜索工具的唯一出口(Tavily)
  nodes.py   # 5 个节点的业务逻辑 + 2 个 structured-output schema
  graph.py   # 节点装配 + 反思循环的路由逻辑
main.py      # CLI 入口,按需接入 Langfuse tracing
api/         # FastAPI 后端:把图包成 SSE 流式接口 + 限流,供 frontend/ 用(见下方"跑 demo")
frontend/    # React + Vite 前端:输入框 + 进度条 + 报告渲染
deploy/      # 部署到 VPS 用的 nginx/systemd 参考配置
eval/        # 评测数据集 + LLM-as-judge 评测脚本(见下方"跑评测")
tests/       # mock 掉 LLM/搜索后验证图的连线逻辑,不需要真实 key
reference/   # clone 的 langchain-ai/deep_research_from_scratch,只读,不提交进 git

用到的开源工具是什么

不熟悉这套生态的话,下面是个速查表,重点是"在这个项目里它具体管什么",不是泛泛的官方介绍:

  • uv:Rust 写的 Python 包/项目管理器,比 pip install + venv 快很多,现在社区迁移很快。这里用它替代 pip:uv sync 一条命令建虚拟环境 + 装好 pyproject.toml 里声明的全部依赖,uv.lock 锁版本号(类似前端的 package-lock.json,保证别人 clone 下来装的版本跟你完全一致)。
  • LangChain / langchain-core:给"调用 LLM、消息格式、工具调用"定义了一套统一接口,换模型厂商不用改业务代码。llm.py 里的 ChatAnthropic/ChatOpenAInodes.py 里的 HumanMessage,都是它定义的抽象。
  • LangGraph:LangChain 团队做的"状态图"编排框架,比单纯一条 prompt 链更适合做有分支、循环的 agent 流程。我们的反思重试循环就是靠它的 add_conditional_edges 实现的,graph.py 是直接用它的地方。
  • Pydantic:数据校验库,写一个 class 就能描述"这个数据应该长什么样"。nodes.py 里的 SubQuestions/Critique 用它定义 schema,配合 LangChain 的 with_structured_output,强迫 LLM 输出符合这个结构的数据,而不是一段随意文本再自己写正则去解析。
  • Tavily:专门为 LLM agent 设计的搜索 API——返回的是适合喂给模型的摘要和正文,不是给人看的搜索结果网页,区别于自己爬 Google 或者调免费但不稳定的 DuckDuckGo。tools.py 是唯一调它的地方。
  • Langfuse:LLM 应用的可观测性 + 评测平台,开源、可自部署也有免费云版。这个项目用到它两个能力:tracing(main.py 里的 CallbackHandler,记录每次跑 agent 时每个节点/每次 LLM 调用的输入输出和耗时,方便排查"为什么这次结果不对")和 Dataset/Evaluation(还没做的下一步,存评测题 + LLM-as-judge 自动打分)。同类工具里 LangSmith 是 LangChain 官方产品但闭源,选 Langfuse 是 CLAUDE.md 里定好的技术选型。
  • pytest:Python 最主流的测试框架。tests/test_graph.py 里用的 @patch 其实来自标准库 unittest.mock,不是 pytest 自带的,pytest 只是负责发现和运行这些测试函数。
  • gh CLI:GitHub 官方命令行工具。这次创建仓库、推送代码全程没碰浏览器,等价于网页上点 "New repository" 再 git remote addgit push,命令行一步到位。

怎么跑

1. 装依赖

cd /Users/limit/deep-research-agent
uv sync --extra dev

uv sync 会在项目目录下建一个 .venv/,把 pyproject.toml 里声明的依赖都装进去。之后有两种方式执行命令:

  • 每次命令前加 uv run,比如 uv run pytest——不用管虚拟环境有没有激活,uv 自动用对的环境。
  • 或者先 source .venv/bin/activate 激活一次,之后这个终端窗口里直接 pytestpython main.py 就行,不用每次加前缀。退出用 deactivate。两种等价,看个人习惯。

2. 配置 API key

cp .env.example .env

打开 .env 填入真实值,每个变量的作用:

变量 作用 去哪儿拿
LLM_PROVIDER anthropicopenai,决定 llm.py 用哪家 不用申请,直接选
ANTHROPIC_API_KEY provider 选 anthropic 时必填 https://console.anthropic.com
OPENAI_API_KEY provider 选 openai 时必填 https://platform.openai.com/account/api-keys
TAVILY_API_KEY 搜索功能必填,不填 search 节点会一直返回空结果 https://app.tavily.com(免费额度每月 1000 次)
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY 选填,不填就是不接 tracing,main.py 里会自动跳过 https://cloud.langfuse.com
MAX_RESEARCH_ITERATIONS 反思循环最多重新搜索几轮,默认 2 直接改数字

.env 已经在 .gitignore 里,不会被提交。

3. 先跑测试,确认骨架没问题(不需要任何 key)

uv run pytest -q

这一步把 get_llm/web_search 都 mock 掉了,只验证图的节点连线和状态流转对不对,几秒钟出结果,跟有没有配 key、key 是否有效完全无关。如果这一步过不了,先别管 key 的事,是代码本身有问题。

4. 真实跑一次

uv run python main.py "LangGraph 和 LangChain 的关系是什么?"

会依次打印每个节点的执行(如果之后接了 LangSmith/终端日志),最后输出大致是这样的结构:

========================================

<综合报告正文,每条结论后面带 [1] [2] 这样的编号>

## 参考来源
[1] 标题 - 摘要 (https://...)
[2] 标题 - 摘要 (https://...)

## 反思记录(共 1 轮)
<reflect 节点给出的自我批评文字>

如果配了 Langfuse key,这次运行会在 https://cloud.langfuse.com 的项目里出现一条完整 trace,能展开看每个节点的输入输出和耗时。

故障排查

  • tavily.errors.MissingAPIKeyError.env 里没填 TAVILY_API_KEY,或者命令没有从这个目录下执行(.env 只在 cwd 下会被自动读取)。
  • openai.AuthenticationError / anthropic.AuthenticationError:key 填错了或者是失效的占位 key,去对应平台后台确认。
  • final_report 里"参考来源"是空的:说明 web_search 全部失败了(看 tools.pylogger.warning 的输出),通常是 key 无效或者网络问题,不会让程序崩,但报告质量会很差。
  • 跑得很慢:每多一轮反思循环就要多跑一遍 search + synthesize + reflect,三次 LLM 调用,正常现象,调小 MAX_RESEARCH_ITERATIONS 能加快但报告质量可能下降。

跑交互式 demo(本地)

线上版本在 research.learnpath.tech;本地跑的话要同时起后端和前端:

# 后端:把图包成 SSE 接口
uv sync --extra api
uv run uvicorn api.main:app --port 8000

# 前端:另开一个终端
cd frontend
npm install
npm run dev   # http://localhost:5173,vite.config.js 已经把 /api 转发到本地 8000 端口

打开 http://localhost:5173,输入问题或点示例问题,能看到 5 个节点按 SSE 事件逐个点亮、 反思循环触发时显示第几轮、最终报告 + 来源卡片 + 反思记录分块渲染。api/rate_limit.py 里的 限流默认值(单 IP 10 分钟 3 次、每日总额度 30 次)本地也生效,调试嫌烦可以调环境变量 DEMO_PER_IP_MAX/DEMO_DAILY_MAX 调大。部署到服务器的步骤和 nginx/systemd 配置在 deploy/README.md

跑评测

uv run python -m eval.run_eval

这一步需要 TAVILY_API_KEYLLM_PROVIDER 对应的 LLM key,以及 LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY(评测结果要写进 Langfuse Dataset,这三类 key 缺一不可,跟单纯跑 main.py 不一样——那个 Langfuse key 是选填的)。

做了什么:

  1. eval/dataset.py 里 15 道后端/分布式系统方向的问题 + 标准答案要点,灌进 Langfuse 上名为 deepresearch-eval-v1 的 Dataset(重复跑不会产生重复数据,create_dataset/create_dataset_item 都按 name/id upsert)。
  2. 对每道题真实跑一次完整的 Agent 图(research_task),拿到 final_report 和这一题用到的全部 search_results
  3. 四个 evaluator 各自打分,维度命名参考了 DeepResearch Bench(arXiv:2506.11763)评测深度研究类 agent 的两套框架——RACE(报告质量)和 FACT(引用质量):
    • accuracy_evaluator(0~1,LLM judge):报告结论是否覆盖标准答案要点、有没有事实错误。
    • citation_support_evaluator(0~1,LLM judge):报告里 [编号] 引用的结论,是否真的能在对应来源原文里找到支持——对应 FACT 的 Citation Accuracy。
    • citation_count_evaluator(整数,纯计算不调用 LLM):报告正文里实际用到的不同来源编号数——对应 FACT 的 effective citation count,防止"只引用 1 条来源但 citation_support 也能打高分"这种漏洞。
    • report_quality_evaluator(0~1 x 3,一次 LLM 调用拆出 3 个字段):Comprehensiveness(覆盖面)/ Depth(分析深度)/ Readability(结构表达)——对应 RACE 的同名维度。RACE 还有第四个维度 Instruction Following,这里没单独评,因为它和 accuracy_evaluator 已经覆盖的"是否切题"重叠。
  4. 跑完在终端打印每题的输入/输出/打分,同时整个实验在 Langfuse 后台(Datasets → deepresearch-eval-v1 → Runs)可视化查看。

关键设计决策(面试官问到怎么答)

Q: 为什么用显式的 5 节点图,不用 LangGraph 的 ReAct 模式让 LLM 自己决定何时搜索?

对照过 langchain-ai/deep_research_from_scratch 的 notebook 2:它是 llm_call → tool_node → llm_call 的真 ReAct 循环,模型自己决定调 tavily_search 还是 think_tool(一个不做任何事、纯粹强迫模型把反思写成结构化输出的“假工具”)。这个模式更灵活,但控制权完全在 LLM 手里——每轮搜几次、什么时候停,都不可预测。

我们要的是确定性的拆解→搜索→综合→反思流程:方便单独评测每一步(Langfuse trace 里按节点名对齐)、方便预估搜索调用次数上限(成本可控)、也方便后续给"引用可信度"单独打分。代价是更死板,遇到需要灵活调整策略的复杂问题不如 ReAct 灵活——这是有意识的取舍,不是没想到。

Q: 为什么 search_resultsAnnotated[list[SearchResult], operator.add]

LangGraph 默认每个节点返回的状态字段会覆盖上一次的值。但反思循环会让 search 节点被进入第二次,如果直接覆盖,第一轮搜到的来源就丢了——而 synthesize/output 需要看到全部历史来源才能做完整的引用检查。operator.add 这个 reducer 让多次返回的列表自动拼接而不是替换。

Q: 为什么 decompose/reflectwith_structured_output 而不是让模型输出文本再手写解析?

手写 parse "模型输出的 JSON 字符串"很脆弱(模型可能加 markdown 代码块、可能漏字段)。with_structured_output 基于 tool-calling,让模型直接产出符合 Pydantic schema 的结构化对象,schema 本身就是接口文档,少一层解析失败的风险。

Q: 反思循环怎么保证不会死循环?

两层保护:业务层在 reflect_node 里维护 iteration 计数,route_after_reflect 检查 iteration < MAX_RESEARCH_ITERATIONS(默认 2,.env 可调);框架层 LangGraph 本身也有 recursion_limit(默认 25)作为兜底。只信任 LLM 的自我判断("我觉得信息还不够")是不够的——这正是 Reflexion 类方法最容易踩的坑。

Q: web_search 为什么自己包一层,不直接在 node 里调 TavilyClient

把搜索源换成别的(比如免 key 的 DuckDuckGo,或者以后要支持的 MCP 工具)只需要改 tools.py 这一个函数的实现和返回的字段名,nodes.py 完全不用动。同时这里也是唯一一处吞掉网络异常返回空列表的地方——一次搜索失败不该让整条图崩掉,而是交给 reflect 节点去判断"这个子问题信息不足,要不要重查"。

Q: 为什么 LLM 默认选 Anthropic Claude Haiku,而不是 GPT-4o-mini?

两个都支持,由 LLM_PROVIDER 环境变量切换,llm.py 是唯一改动点。先用便宜模型控制迭代成本,等流程跑顺、评测指标稳定了再考虑要不要换更大的模型。temperature=0 是为了同一道评测题多次跑结果可比,不是为了所谓"更准确"。

Q: 反思节点为什么说借鉴了 Karpathy autoresearch?

那个项目里每轮迭代后会有一个 keep/revert 的自我评估步骤决定要不要采纳这次改动,而不是无条件相信模型的最新输出。我们的 reflect_node 是同一个思路在"研究报告"场景下的应用:每轮综合后先自我批评,批评不通过才追加搜索重新综合。项目本身完全是 DeepResearch Agent,没有借用它的训练循环或其他部分。

Q: 为什么"准确性"和"引用是否支持结论"拆成两个 evaluator,而不是让 judge 一次性打一个综合分?

这两件事经常脱节:报告可以"结论对但引用乱标",也可以"引用都对得上但漏了关键信息"。拆开打分能在 Langfuse 的 Run 视图里分别看每一项的平均分,定位问题更精确——如果只看一个混合分,分数低了不知道是哪类问题。CLAUDE.md 里把这两项分开列也是这个原因。

Q: 后来加的 citation_count_evaluator / report_quality_evaluator 是怎么来的,为什么不是一开始就有?

最初只有 accuracy_evaluator + citation_support_evaluator,是先把"评测脚本本身能跑通、能写进 Langfuse Dataset"这件事跑顺。后来去查了深度研究 agent 这个细分方向学界怎么定义"报告好不好",找到 DeepResearch Bench 这篇专门做这个的论文,发现它的 FACT 框架除了 Citation Accuracy 还有一个 effective citation count——这正好填了我们原来的一个漏洞:citation_support_evaluator 只检查"已经标的引用对不对",一份只引用 1 条来源、剩下检索到的资料完全没用上的报告,在这一项上也能拿高分。citation_count 不调用 LLM、纯数 [编号] 出现次数,零成本补上这个盲区。report_quality_evaluator 同理对应 RACE 框架的 Comprehensiveness/Depth/Readability,三项揉进一次结构化输出而不是拆三次 judge 调用,是评测成本的取舍(15 题只多 1 次 LLM 调用,不是 3 次)。这两个不是论文要求的"标准答法",是先验证简单版本能跑通、再用学界的分类去检查"还漏了什么"补上来的。

Q: 评测脚本里 judge 直接调 get_llm(),跟被评测的 agent 用同一个模型,这样不会自己给自己打高分吗?

是一个已知的取舍,不是没意识到。更严谨的做法是用更强的模型(比如 Claude Sonnet)专门当 judge,跟被测的便宜模型分开,避免"用同一个模型的偏好评价自己"这种系统性偏差。现在保持简单是因为评测脚本本身的正确性(数据集结构、两个分数怎么定义、怎么跟 Langfuse Dataset 接起来)是这一步要先验证的事,换 judge 模型只是把 get_llm() 换成单独写死的 ChatAnthropic(model="..."),后续指标不稳定再做。

Q: 为什么 eval/run_eval.py 给每个 dataset item 显式传 id(用 q01/q02...而不是让 Langfuse 自动生成)?

create_dataset_item 文档写明"传自己的 id 可以用来去重"。评测脚本会被反复跑(改完 prompt 想看分数有没有提升),如果每次都让后端生成随机 id,15 道题跑 10 次就堆出 150 条重复数据;用固定 id 让这一步天然幂等,重复跑只是更新同一批条目,不污染 Dataset。

Q: pyproject.toml 里为什么除了 langchain-core 还要加裸的 langchain

这是搭评测脚本时顺手发现的一个真实 bug:langfuse.langchain.CallbackHandlermain.py 接 tracing 用的那个)内部会 import langchain 去判断版本号选哪套兼容逻辑,但项目最初只声明了 langchain-core/langchain-anthropic/langchain-openai,没有 langchain 本身——本地没配 Langfuse key 时这行代码根本不会跑到,测试也测不出来,配了 key 才会在运行时炸 ModuleNotFoundError。补上 langchain>=1.0 依赖后验证过 import 和现有测试都正常。

Q: 为什么加了一个 DeepSeek provider,CLAUDE.md 里定的不是 Claude Haiku / GPT-4o-mini 吗?

技术选型本身没变——llm.py 默认还是 anthropic,DeepSeek 只是多加的一个分支。加它纯粹是实际可用性问题: 手头能拿到的真实 key 是 DeepSeek 的,不加这个分支整条链路就没法用真实 key 验证。实现上没引入新依赖, DeepSeek 的 API 跟 OpenAI 兼容,直接复用 ChatOpenAIbase_url 就行。过程中还发现一个真问题: with_structured_output 默认走 OpenAI 新版 json_schema 严格模式,DeepSeek 不支持会直接 400—— 所以加了 get_structured_llm() 统一指定 method="function_calling",这是 Anthropic/OpenAI/DeepSeek 三家都支持的最大公约数,顺带也让 decompose/reflect/judge 三处调用都收口到一个函数。

Q: SSE 流式接口为什么前端用手写 fetch + ReadableStream 解析,不用浏览器原生 EventSource

试过,但 EventSource 在收到非 200 响应(比如限流的 429)时读不到响应体和状态码,只会抛一个不带 任何信息的 error 事件,而且默认行为是自动重连——对一个"故意返回 429 让你别再重试"的场景完全不可控, 重连只会把同一个 IP 再撞一次限流。手写 fetch 能先检查 response.ok,把 429 的 JSON detail 读出来 展示给用户,而不是一个无法解释的失败状态。

Q: /api/research 的 SSE 端点为什么是普通 def(同步生成器),不是 async def

graph.stream() 内部跑的 LLM 调用和 Tavily 搜索都是同步阻塞的,不是 await 出来的协程。如果把端点 声明成 async def 但函数体全是阻塞代码,会直接卡住事件循环,拖慢这个进程里所有其他请求(哪怕只有 这一个 demo 用户在用)。FastAPI 对同步 def 生成器会自动放到线程池里跑,这是 FastAPI skill 里"不确定 就用 def"的标准做法,不是漏写 async

Q: API 的 done 事件为什么发 draft_report/sources/critique 三个分开的字段,不是直接发 state["final_report"]

final_reportoutput_node 专门为 CLI 拼好的一整段字符串(报告正文 + 文本化的来源列表 + 反思记录 粘在一起),这对终端打印是对的格式,但前端要把来源渲染成卡片(标题/域名/摘要分开排版)、把反思记录 当成 markdown 渲染——从一个拼好的字符串里再正则解析出这几块,比直接从 state 里拿原始字段更脆弱。 stategraph.stream() 执行过程中本来就分别持有这几个字段,发分开的字段是更直接的做法。

跟参考仓库的对比笔记

读 + 实跑了 langchain-ai/deep_research_from_scratch(clone 在 reference/,只读,不提交):

  • notebook 1(scoping):纯结构化输出做澄清 + 生成 research brief,用 Command 做条件路由,这个我们目前的骨架里没有"反问用户澄清"这一步——属于明确的范围裁剪,不是疏漏(CLAUDE.md 里没要求多轮对话澄清)。
  • notebook 2(research agent):ReAct 循环 + think_tool 强制结构化反思,跟我们 reflect_node 的思路殊途同归,只是它把反思塞进了循环内部的每一步,我们是综合之后单独一步。
  • notebook 4/5(supervisor):用 asyncio.gather 并行跑多个子 agent 分头研究不同子主题,再汇总——这是我们 decompose 出多个子问题后值得借鉴的下一步优化方向(目前 search_node 是顺序查询)。

当前限制 / 下一步

  • 评测数据集(15 题 + 标准答案)存进 Langfuse Dataset,四个 evaluator 打分:准确性、引用是否真支持结论、引用丰富度、报告质量(维度参考 DeepResearch Bench 的 RACE/FACT 框架,见上方"跑评测";judge 跟被测 agent 用同一模型,是已知的简化,见 FAQ)
  • 可交互 demo:FastAPI(SSE 流式 + 限流)+ React 前端,部署在 research.learnpath.tech(见上方"跑交互式 demo",部署配置在 deploy/
  • search_node 改成并发查询(参考 supervisor 模式的 asyncio.gather),现在是顺序循环
  • 长网页内容摘要压缩(参考 deep_research_from_scratchsummarize_webpage_content),现在直接用 Tavily 返回的 snippet
  • 引用可信度打分(参考 tarun7r/deep-research-agent 的思路),现在只检查"引用编号对不对得上",没有单独评估来源本身的可信度
  • LangGraph checkpointer,支持长任务断点续跑/人工介入

About

Mini DeepResearch Agent: LangGraph + Langfuse tracing/evaluation(面试准备项目)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors