后端工程师面试准备项目。核心目标不是"能跑的 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/ChatOpenAI、nodes.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 add再git push,命令行一步到位。
cd /Users/limit/deep-research-agent
uv sync --extra devuv sync 会在项目目录下建一个 .venv/,把 pyproject.toml 里声明的依赖都装进去。之后有两种方式执行命令:
- 每次命令前加
uv run,比如uv run pytest——不用管虚拟环境有没有激活,uv自动用对的环境。 - 或者先
source .venv/bin/activate激活一次,之后这个终端窗口里直接pytest、python main.py就行,不用每次加前缀。退出用deactivate。两种等价,看个人习惯。
cp .env.example .env打开 .env 填入真实值,每个变量的作用:
| 变量 | 作用 | 去哪儿拿 |
|---|---|---|
LLM_PROVIDER |
anthropic 或 openai,决定 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 里,不会被提交。
uv run pytest -q这一步把 get_llm/web_search 都 mock 掉了,只验证图的节点连线和状态流转对不对,几秒钟出结果,跟有没有配 key、key 是否有效完全无关。如果这一步过不了,先别管 key 的事,是代码本身有问题。
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.py里logger.warning的输出),通常是 key 无效或者网络问题,不会让程序崩,但报告质量会很差。- 跑得很慢:每多一轮反思循环就要多跑一遍 search + synthesize + reflect,三次 LLM 调用,正常现象,调小
MAX_RESEARCH_ITERATIONS能加快但报告质量可能下降。
线上版本在 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_KEY、LLM_PROVIDER 对应的 LLM key,以及 LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY(评测结果要写进 Langfuse Dataset,这三类 key 缺一不可,跟单纯跑 main.py 不一样——那个 Langfuse key 是选填的)。
做了什么:
- 把
eval/dataset.py里 15 道后端/分布式系统方向的问题 + 标准答案要点,灌进 Langfuse 上名为deepresearch-eval-v1的 Dataset(重复跑不会产生重复数据,create_dataset/create_dataset_item都按 name/id upsert)。 - 对每道题真实跑一次完整的 Agent 图(
research_task),拿到final_report和这一题用到的全部search_results。 - 四个 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已经覆盖的"是否切题"重叠。
- 跑完在终端打印每题的输入/输出/打分,同时整个实验在 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_results 用 Annotated[list[SearchResult], operator.add]?
LangGraph 默认每个节点返回的状态字段会覆盖上一次的值。但反思循环会让 search 节点被进入第二次,如果直接覆盖,第一轮搜到的来源就丢了——而 synthesize/output 需要看到全部历史来源才能做完整的引用检查。operator.add 这个 reducer 让多次返回的列表自动拼接而不是替换。
Q: 为什么 decompose/reflect 用 with_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.CallbackHandler(main.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 兼容,直接复用 ChatOpenAI 换 base_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_report 是 output_node 专门为 CLI 拼好的一整段字符串(报告正文 + 文本化的来源列表 + 反思记录
粘在一起),这对终端打印是对的格式,但前端要把来源渲染成卡片(标题/域名/摘要分开排版)、把反思记录
当成 markdown 渲染——从一个拼好的字符串里再正则解析出这几块,比直接从 state 里拿原始字段更脆弱。
state 在 graph.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_scratch的summarize_webpage_content),现在直接用 Tavily 返回的 snippet - 引用可信度打分(参考
tarun7r/deep-research-agent的思路),现在只检查"引用编号对不对得上",没有单独评估来源本身的可信度 - LangGraph checkpointer,支持长任务断点续跑/人工介入