从 LCEL 到 LangGraph:实现一个可循环决策的 Agent

上一篇已经完成了一个基于 LCEL(LangChain Expression Language)的自动化网页研究总结案例。整个案例讲了如何把用户问题自动转换成结构化研究结果,流程并不复杂。

1
用户问题 → 选择研究助手 → 生成搜索请求 → 执行网页搜索 → 抓取网页内容 → 总结信息 → 生成研究报告

从工程角度看,这已经是一个完整闭环。系统能够自动完成角色选择、搜索、抓取、总结和报告生成,适合大量路径明确的信息收集任务。

但它缺少一个关键能力:判断中间结果是否真的有效。比如搜索结果只是背景介绍、网页抓取质量很差、内容明显偏离问题时,流程仍会继续总结并生成报告。系统完成了流程,却不一定完成了研究。

问题不在模型能力,而在流程设计。LCEL 更适合表达稳定、确定、顺序执行的结构。

1
输入 → 处理 → 输出

它擅长把多个步骤组织成清晰的数据流,但默认假设每一步都会产生可继续使用的结果。现实中的研究过程通常不是这样,真实的信息收集更像一种持续推进过程。

1
提出问题 → 收集信息 → 判断结果质量 → 调整方向 → 继续收集 → 形成结论

过程中存在状态积累、结果评估、路径调整以及重复尝试。因此,本篇不再继续扩展搜索能力或堆叠 Prompt,而是把上一篇基于 LCEL 的网页研究总结器,升级为一个具备状态管理、条件判断和循环执行能力的 LangGraph Agent。

从这一章开始,关注点将逐渐从 Prompt、Chain 和模型调用,转向状态、节点、路径以及决策流程,系统不再只是把任务执行完,而开始判断任务是否已经做得足够好。

1. 自动执行不等于真正具备研究能力

上一篇的案例已经具备完整自动化能力:输入问题之后,系统能够拆解任务、生成搜索请求、抓取网页信息,并输出结构化报告。

这种结构适合用 LCEL 表达,因为 LCEL 擅长把多个能力模块连接成稳定的数据流。问题在于,它很难表达“什么时候应该停下来重新思考”。

1.1 从执行流程到研究过程

自动执行和研究过程看起来相似,但目标并不相同。自动执行强调,

1
步骤完成 → 输出结果

研究过程强调:

1
获得结果 → 判断结果是否有效 → 决定下一步

举一个典型例子,问题,”当前英伟达股票还值得买入吗?”,按照上一篇系统的流程,可能生成几组搜索词,并得到类似结果,

1
2
3
GPU 发展历史
英伟达公司介绍
图形处理器百科

这些内容本身没有错误,系统也能继续总结并生成报告。但真正的问题是,它们可能没有回答原问题。问题关注的是:

1
2
3
4
当前风险
AI 芯片需求
估值水平
投资逻辑

而不是历史背景。系统完成了执行,却没有完成判断。完成流程 Execution,不等于完成研究 Decision。研究能力真正困难的部分,不是获得信息,而是决定哪些信息值得继续使用。

1.2 Workflow 的能力边界

很多早期 AI 应用,本质上都属于 Workflow,结构通常类似:

1
输入 → 固定步骤 → 固定输出 

例如客服问答:

1
识别问题 → 检索知识 → 生成回答

例如内容生成:

1
输入主题 → Prompt → 生成文章

工作流可以很复杂,甚至包含几十个步骤,但共同特点是流程结构提前确定。系统负责控制,模型负责执行。这种模式最大的优势是可预测,因此工程实现通常比较稳定。

但能力边界也很明显,当系统需要面对下面这些情况时,线性流程开始吃力:

  • 搜索结果明显偏题
  • 中间结果质量不足
  • 信息之间出现冲突
  • 当前结论缺少依据
  • 需要重新尝试另一种路径

传统 Workflow 很难自然表达:

1
执行结果 → 判断 → 调整策略 → 重新执行

一旦强行加入这些能力,就会出现大量 ifwhile、状态变量和异常处理。流程控制逐渐侵入业务代码,复杂度迅速上升。

1.3 真正缺失的是决策能力

很多系统升级时,第一反应是增加工具、模型或搜索源。但问题往往不在能力不足,而在系统不会决策。

对于研究系统来说,真正需要回答的问题通常只有几个:

1
2
3
4
信息是否相关
资料是否充分
是否继续搜索
是否已经可以输出

只有能够回答这些问题,系统才开始具备研究特征。但判断能力不等于事实能力,即使系统会评估搜索结果、重新搜索、控制流程,也不能天然保证结果真实。模型仍然可能生成:

  • 看起来可信的数据
  • 看起来存在的来源
  • 看起来完整的逻辑链

因此研究型 Agent 的目标不是生成更多内容,而是降低无效内容进入最终结论的概率。这也是下面引入 Agent 与 LangGraph 的原因:系统不再只关心执行流程,而开始关注目标如何持续推进。

2. Agent 与 Agentic Workflow:从执行系统到决策系统

理解了 Workflow 的边界之后,下一步的问题是,如果系统已经具备搜索、抓取、总结和生成能力,为什么还需要 Agent?

答案不在工具数量,而在流程是否允许系统根据结果调整行为。Agent 不是“会自己思考的大模型”,更接近一种围绕目标持续推进任务的执行系统。区别不在模型,而在控制方式。

2.1 Agent 真正解决的问题是什么

传统 Workflow 更像流水线,流程提前确定:

1
输入 → 步骤A → 步骤B → 输出

系统默认每一步都会成功,只负责执行。而 Agent 更像一个不断推进目标的循环:

1
目标 → 行动 → 观察结果 → 判断 → 继续行动

这里出现了一个新的环节:观察。系统不再认为执行结束就意味着任务结束,而是会根据执行结果决定后续动作。继续用研究任务举例,问题:

1
某家公司未来三年是否值得长期投资

普通 Workflow:

1
搜索 → 总结 → 生成报告

Agent:

1
搜索 → 发现结果不足 → 重新搜索 → 发现观点冲突 → 继续补充 → 形成结论

执行路径开始变化,研究过程开始形成闭环。

2.2 Agent 的核心不是自由,而是受控决策

很多关于 Agent 的描述容易让人误以为系统拥有完全自主能力。实际上,工程里的 Agent 很少允许模型无限决策。真正可靠的结构通常是:

1
模型参与判断 → 系统控制边界 → 工具完成执行

模型负责判断结果是否相关、是否需要继续、下一步方向是什么;系统负责限制允许走哪些路径、最多执行多少轮、什么时候停止。例如搜索系统中,模型可以判断当前信息不足,但系统规定最多重新搜索三次,达到上限后必须结束流程。

这种设计的意义不是限制模型,而是避免系统陷入无限循环或持续生成低价值内容。因此 Agent 的价值不是自由,而是有边界的动态决策

2.3 状态开始成为整个系统的新中心

一旦允许系统动态调整流程,就必须回答:当前执行到了哪里,已经获得了什么信息,下一步依据什么继续。这些内容不能散落在函数调用之间,而要沉淀为状态。

1
读取状态 → 执行动作 → 更新状态 → 决定下一步

例如一个研究任务,初始状态:

1
问题

搜索完成:

1
问题 + 搜索结果

总结之后:

1
问题 + 搜索结果 + 摘要

评估之后:

1
问题 + 摘要 + 相关性结果

状态不断累积,系统不再依赖某个函数返回值,而是依赖整个运行过程。这里出现了一个后面实现的核心对象:

1
ResearchState

它不只是参数集合,而是整个 Agent 在执行过程中持续维护的状态上下文

2.4 判断能力并不等于真实性保证

到这里容易产生另一个误解,既然系统已经开始评估结果、重新搜索、动态调整,是不是意味着结果更可信。

答案是否定的,评估能力解决的是流程问题,不是事实问题。例如搜索后得到多篇内容,系统可以判断:

1
2
3
4
相关
不相关
信息充分
信息不足

但无法判断:

1
2
3
4
内容是否真实
来源是否可靠
数据是否过期
引用是否准确

模型很擅长组织语言,也很容易生成逻辑连贯的解释。因此研究型 Agent 的目标不是保证真实性,而是提高过程质量,减少无效信息进入最终结论。即使最终生成的内容结构完整、来源丰富,也仍然需要保持一个基本原则,看起来真实,并不等于真实存在。

到这里,关注点开始从“先执行谁、后执行谁”,转向“当前状态是什么、下一步去哪、什么时候结束”。

小结

Workflow 和 Agent 的差别,不在模型能力,也不在工具数量,而在流程是否允许根据结果持续调整。Workflow 更关注:

1
执行 → 输出

Agent 更关注:

1
目标 → 行动 → 判断 → 继续推进

为了实现这种能力,系统要围绕状态组织流程。下面进入 LangGraph:它解决的是状态如何组织、节点如何连接,以及流程如何形成可控循环。

3. LangGraph:从组织调用变成组织状态

理解了 Agent 的目标之后,真正的问题变成:如何把搜索、抓取、总结和报告生成这些能力,组织成一个能够持续运行、允许回退、支持判断的系统。

继续沿用 Chain 也能实现,但需要不断增加条件判断、循环和状态变量。逻辑增长后,系统会很快变得难以维护。

LangGraph 解决的问题,不是模型能力,而是流程组织方式,它把关注点从函数调用,转移到状态流转。核心思想可以概括成一句话,让状态在节点之间流动,而不是让函数彼此调用。

3.1 LangGraph 不是替代 LangChain,而是改变控制方式

第一次接触 LangGraph 时,容易误以为原来的 Prompt、模型调用和工具能力都会消失。实际上,这些能力都会保留。例如这个网页研究系统中:

1
2
3
4
5
6
Prompt
LLM
网页搜索
网页抓取
内容总结
报告生成

这些模块继续存在,变化的是组织方式。以前关注:

1
2
先调用谁
后调用谁

现在关注:

1
2
3
当前状态是什么
下一步应该去哪
什么时候结束

控制权从代码顺序转移到状态推进,流程变成:

1
状态 → 节点执行 → 状态更新 → 路径判断 → 继续执行

系统不再依赖某个函数返回值,而依赖完整上下文。

3.2 Node、Edge、State:Graph 的三个基本概念

LangGraph 的抽象并不复杂,整个系统可以拆成三个核心组成部分。

3.2.1 Node:动作

Node 表示执行动作,每个节点只负责完成一件事情,例如当前研究系统可以拆成:

1
2
3
4
5
6
选择研究助手
生成搜索请求
执行搜索
网页总结
评估结果
生成报告

节点的职责通常很简单:

1
读取状态 → 处理 → 返回状态更新

节点本身不决定流程,只完成动作。因此节点可以独立维护和测试。例如替换搜索引擎时,只需要修改搜索节点。

3.2.2 Edge:路径

Edge 表示节点之间如何连接,最简单情况:

1
A → B → C

这仍然属于固定流程,真正开始体现 Graph 价值的是条件路径:

1
2
3
        ↗ B
A → 判断
↘ C

不同状态走向不同节点,流程不再提前完全确定。以研究任务为例,搜索完成后,如果结果足够相关,就生成报告;如果结果不够,就重新生成搜索策略并继续搜索。

3.2.3 State:运行现场

State 是整个系统最重要的一层,它记录当前执行过程中的全部上下文,例如研究系统最终会保存:

1
2
3
4
5
6
7
问题
助手信息
搜索请求
搜索结果
网页摘要
评估结果
最终报告

传统 Chain 更像:

1
输出 → 输入

Graph 更像:

1
读取状态 → 处理 → 更新状态

状态不会随着某一步结束而消失,而会持续生长。后面的代码实现里,这部分对应 ResearchState,也就是整个 Agent 的核心对象。

3.3 从概念迁移到系统迁移

理解 Node、Edge 和 State 之后,就可以在上一篇能力基础上做升级。上一篇已经完成:

1
2
3
4
搜索
抓取
总结
报告

这些能力不需要大的调整,真正变化的是组织方式,改成这样:

1
状态 → 节点 → 状态更新 → 条件路由

这意味着原来的函数需要调整成节点。例如:

1
2
3
4
5
6
7
8
9
10
11
select_assistant()

generate_search_queries()

perform_web_searches()

summarize_search_results()

evaluate_search_relevance()

write_research_report()

这些函数不再直接互相调用,只处理状态。真正决定执行顺序的是 Graph。

3.4 Graph 的价值不是分支,而是持续推进目标

很多介绍 LangGraph 时,会强调支持:

1
2
3
循环
条件判断
多分支

这些都成立,但更核心的是:LangGraph 让流程围绕目标持续推进。以前:

1
步骤完成 → 结束

现在:

1
获得结果 → 判断是否满足目标 → 决定是否继续

系统不再默认“搜索完成就可以写报告”,而是判断当前资料是否足以支持结论。如果不能,就继续收集,直到满足条件或达到退出边界。

到这里,迁移思路已经讲完了。下面开始把上一篇的网页研究总结器一步一步改造成可执行的 LangGraph Agent。

4. 将网页研究总结器拆成 Agent

完成概念迁移之后,接下来要回到原来的网页研究总结器,上一篇系统已经具备完整能力。能够选择助手、生成搜索词、执行网页搜索、抓取网页内容、总结信息并生成报告。

所以升级为 LangGraph Agent,并不是把原来的代码全部推倒重写,而是重新拆分和组织,核心问题变成:

1
2
3
哪些能力应该变成节点
哪些信息应该进入状态
哪些地方需要条件判断

原来的系统更像一条流水线,现在要把它改造成一个可判断、可回退、可循环的状态图。

4.1 从连续步骤拆成独立节点

上一篇的流程可以概括为:

1
研究问题 → 搜索 → 总结 → 报告

这个结构足够清晰,但过于线性,每一步默认都会产生有效结果。升级后,需要把流程拆得更明确:

1
选择研究助手 → 生成搜索请求 → 执行网页搜索 → 抓取并总结网页 → 评估结果相关性 → 生成研究报告

表面上步骤变多了,但其实思路反而更清楚,因为每个节点只负责一件事。

  • 选择助手节点只负责判断当前问题适合哪个研究角色。
  • 生成搜索请求节点只负责把问题转换成搜索方向。
  • 搜索节点只负责拿到网页链接。
  • 总结节点只负责从网页文本中提炼信息。
  • 评估节点只负责判断当前信息是否足以继续。
  • 报告节点只负责基于已有资料组织最终结果。

节点拆开之后,复杂度不会集中在一个大函数里,后续调试、替换和扩展都会更方便。

4.2 从函数传参变成共享状态

节点拆开之后,马上会遇到一个问题,中间结果放在哪里。如果继续沿用普通函数调用,流程很容易变成:

1
函数A返回结果 → 传给函数B → 函数B再返回结果 → 传给函数C

步骤少时没有问题,一旦加入评估、重试和循环,参数传递会越来越混乱。LangGraph 的做法是维护统一状态,在这个系统中,状态大致包含:

  • 系统开始时,状态中只有用户问题。
  • 选择助手之后,状态增加 assistant_info
  • 生成搜索词之后,状态增加 search_queries
  • 搜索完成之后,状态增加 search_results
  • 总结完成之后,状态增加 research_summary
  • 评估完成之后,状态增加 relevance_evaluationshould_regenerate_queries
  • 最终生成报告之后,状态增加 final_report

这些字段描述了 Agent 当前的运行状态,整个过程中状态在不断完善,这也是 Agent 和普通 Chain 最大的差别之一。

4.3 引入评估节点,让流程形成闭环

原来的系统是:

1
搜索 → 总结 → 报告

升级之后,中间多了一个关键节点,评估,新的结构变成:

1
搜索 → 总结 → 评估 → 继续或输出

这个节点决定了系统是否真正具备 Agentic Workflow 的特征。

例如问题是,某项技术未来五年的发展趋势如何? 如果第一次搜索结果主要是百科介绍、历史背景和基础定义,评估节点就应该判断,当前资料不足以支撑趋势分析。随后系统可以重新生成更具体的搜索词,例如:

1
2
3
4
5
行业报告
市场规模
技术路线
企业案例
政策变化

然后再进入下一轮搜索,这样流程就从一次性执行,变成了:

1
执行 → 观察 → 判断 → 调整 → 继续执行

这就是 Agent 的基本形态。

4.4 循环必须有退出边界

只要系统允许重新搜索,就必须设置边界,否则流程可能一直停留在:

1
搜索结果不理想 → 重新生成搜索词 → 继续搜索 → 仍然不理想 → 继续循环

这种循环看似智能,但它可能浪费成本,也可能不断生成看似合理但价值不高的内容,因此代码中需要设计一个最大尝试次数,例如最多三轮,达到上限之后,即使结果不够理想,也要进入报告生成阶段,但最终报告应该保留限制说明。例如:

1
2
3
当前资料有限
部分结论需要谨慎看待
搜索结果未能充分覆盖问题

这才是更负责任的 Agent 设计。系统不能因为多执行了几轮,就假装已经掌握充分证据,尤其在投资、医疗、法律、政策等场景中,更需要明确边界。循环和评估只能提升过程质量,不能替代事实核验。

4.5 从架构设计到代码实现

到这里,网页研究总结器的 Agent 化设计已经清楚,原来的能力不变,改变的是组织方式:

1
线性链式调用 → 状态驱动图结构

接下来进入代码实现时,需要依次解决四个问题。

  • 定义统一状态对象。
  • 把每个能力封装成节点函数。
  • 用条件路由决定是否重新搜索。
  • 编译并运行完整 Graph。

后面的实现会围绕这条主线展开。

1
ResearchState → Node → Edge → Conditional Edge → Compiled Graph

5. 定义统一状态对象 ResearchState

第一步先定义状态。LangGraph 的核心是“节点读取状态、处理状态、返回状态更新”。在这个网页研究 Agent 中,所有节点都围绕同一个状态对象运行:

1
2
3
4
5
6
7
8
9
10
11
12
class ResearchState(TypedDict):  
user_question: str
assistant_info: Optional[AssistantInfo]
search_queries: Optional[List[SearchQuery]]
search_results: Optional[List[SearchResult]]
search_summaries: Optional[List[SearchSummary]]
research_summary: Optional[str]
relevance_evaluation: Optional[Dict[str, Any]]
should_regenerate_queries: Optional[bool]
iteration_count: Optional[int]
used_fallback_search: Optional[bool]
final_report: Optional[str]

这段代码定义了 Agent 的运行状态。

5.1 State 不是参数容器,而是运行状态

在普通函数式流程里,中间结果通常通过参数一级一级传递,例如:

1
搜索词 → 搜索结果 → 网页摘要 → 最终报告

这种方式适合短流程。但一旦系统需要循环、回退和条件判断,单纯依靠函数传参就会变得混乱。LangGraph 的做法是把中间结果放进统一状态,节点只关心两件事:

  • 从状态中读取自己需要的信息。
  • 把新结果写回状态。

例如选择助手节点读取:

1
user_question = state["user_question"]

然后返回:

1
2
3
return {
"assistant_info": assistant_info
}

这里并没有直接调用下一个函数,只是更新状态。后续由 Graph 决定下一步去哪。

5.2 ResearchState 中的核心字段

ResearchState 中的字段可以分成几类。

  • 第一类是原始输入。
1
user_question: str

它保存最初的问题,后续所有搜索、总结、评估和报告生成都围绕它展开。

  • 第二类是研究策略。
1
2
assistant_info: Optional[AssistantInfo]
search_queries: Optional[List[SearchQuery]]

assistant_info 保存选择出来的研究助手类型和研究指令,search_queries 保存当前轮生成的搜索请求。

  • 第三类是外部信息。
1
2
3
search_results: Optional[List[SearchResult]]
search_summaries: Optional[List[SearchSummary]]
research_summary: Optional[str]

search_results 保存网页搜索得到的链接,search_summaries 保存单个网页的总结,research_summary 则是所有网页摘要合并后的研究上下文。

  • 第四类是流程控制。
1
2
3
4
relevance_evaluation: Optional[Dict[str, Any]]
should_regenerate_queries: Optional[bool]
iteration_count: Optional[int]
used_fallback_search: Optional[bool]

这几个字段决定系统是否继续搜索。relevance_evaluation 保存相关性评估结果,should_regenerate_queries 表示是否需要重新生成搜索词,iteration_count 用来限制循环次数,used_fallback_search 记录是否触发备用搜索,避免后续把降级结果误认为正常研究结果。

  • 第五类是最终输出。
1
final_report: Optional[str]

它保存最后生成的研究报告。

这些字段组合起来,构成完整的研究过程。

5.3 状态如何随着流程逐步生长

初始状态中,大部分字段都是空的。

1
2
3
4
5
6
7
8
9
10
11
12
13
initial_state = {
"user_question": question,
"assistant_info": None,
"search_queries": None,
"search_results": None,
"search_summaries": None,
"research_summary": None,
"used_fallback_search": False,
"relevance_evaluation": None,
"should_regenerate_queries": None,
"iteration_count": 0,
"final_report": None
}

系统开始运行时,只有问题是真正有值的。随后每个节点补充一部分状态。选择助手后:

1
2
user_question
assistant_info

生成搜索词后:

1
2
3
user_question
assistant_info
search_queries

完成搜索和总结后:

1
2
3
4
5
6
user_question
assistant_info
search_queries
search_results
search_summaries
research_summary

完成评估后:

1
2
3
4
relevance_evaluation
should_regenerate_queries
iteration_count
used_fallback_search

最终报告生成后:

1
final_report

状态不是一次性写完,而是在流程中不断补全。Agent 因此可以基于完整上下文做判断,而不是只依赖某一步的返回值。

5.4 TypedDict 的作用

这里使用 TypedDict 定义状态结构,可以让代码结构更清晰。例如:

1
2
3
4
5
6
7
8
9
10
11
# 每个搜索请求都包含搜索语句和原始问题。
class SearchQuery(TypedDict):
search_query: str
user_question: str

# 每条搜索结果不仅包含 URL,还保留它来自哪个搜索词,以及是否为降级结果。
class SearchResult(TypedDict):
result_url: str
search_query: str
user_question: str
is_fallback: Optional[bool]

这种设计便于调试。最终报告出问题时,可以顺着状态回看:

1
2
3
哪个搜索词产生了这个链接
哪个链接生成了这段摘要
摘要是否进入了最终报告

状态结构越清楚,运行过程越容易追踪。

小结

ResearchState 是整个 LangGraph Agent 的基础。它不是参数集合,而是系统执行过程中的共享上下文:

1
2
Chain 关注输入输出
Graph 关注状态流转

后续所有节点都遵循同一种模式:

1
读取 ResearchState → 完成一个动作 → 返回状态更新

有了统一状态之后,下一步就可以把原来的能力拆成节点。

6. 实现节点

有了 ResearchState 之后,下一步就是把原来的能力拆成节点。在 LangGraph 中,节点本质上就是普通函数,它不需要知道整个流程怎么走,也不需要直接调用下一个函数,只需要完成自己的职责,然后返回需要更新的状态字段。

第一个节点是助手选择节点:

1
def select_assistant(state: Dict[str, Any]) -> Dict[str, Any]:

它负责根据用户问题选择合适的研究助手,并把结果写入 assistant_info

6.1 select_assistant 的基本结构

这个节点首先从状态中读取原始问题:

1
user_question = state["user_question"]

然后把问题填充进助手选择 Prompt:

1
2
3
prompt = ASSISTANT_SELECTION_PROMPT_TEMPLATE.format(
user_question=user_question
)

接着调用模型:

1
2
3
llm = get_llm()
response = llm.invoke(prompt)
response_text = response.content

模型返回的内容不是直接写入状态,而是先解析成 JSON,因为后续节点需要结构化使用助手信息,然后节点返回:

1
2
3
return {
"assistant_info": assistant_info
}

最终期望得到的结果类似:

1
2
3
4
5
6
7
8
{  
'assistant_info':
{
'assistant_type': '金融分析助手',
'assistant_instructions': '你是一名资深金融分析 AI 助手。你的核心目标是...',
'user_question': '当前英伟达股票还值得买入吗?'
}
}

这一步完成后,状态中就多了研究角色和研究指令,后续生成搜索词时,就可以基于这个角色决定搜索方向。

6.2 为什么助手信息要写回状态

助手选择并不是为了让系统看起来更复杂,它的作用是给后续研究过程提供策略约束。同样是一个问题,不同领域需要关注的信息完全不同。例如:

1
苹果未来三年值得投资吗

金融分析助手会关注:

1
2
3
4
5
财报
估值
收入结构
竞争格局
风险因素

旅游向导助手显然不适合处理这个问题,再比如:

1
东京三日游怎么安排

旅游向导助手会关注:

1
2
3
4
5
景点
交通
路线
时间安排
文化体验

金融分析助手也不合适。所以 assistant_info 的核心作用,是把用户问题转成更明确的研究视角。在状态中保存这个字段后,后续节点不需要重新判断任务类型,只需要读取:

1
2
assistant_info = state["assistant_info"]
assistant_instructions = assistant_info["assistant_instructions"]

然后继续执行,这就是状态复用的价值。

6.3 JSON 解析失败时为什么需要兜底

模型输出虽然可以通过 Prompt 约束,但不能保证每次都严格返回合法 JSON,因此代码中做了异常处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
try:  
# 提取响应中的 JSON 部分
json_start = response_text.find('{')
json_end = response_text.rfind('}') + 1
json_str = response_text[json_start:json_end]

# 解析 JSON
assistant_info = json.loads(json_str)

# 返回更新后的状态对象
return {
"assistant_info": assistant_info
}

except Exception as e:
# 如果解析失败,默认通用研究助手
default_assistant = {
"assistant_type": "通用研究助手",
"assistant_instructions": (
"你是一名具备通用研究能力的 AI 助手。"
"你的目标是基于已有信息,围绕目标问题输出全面、深入、客观、结构清晰的研究报告。"
"优先关注事实依据、逻辑完整性和信息相关性,避免主观推断。"
),
"user_question": user_question
}
return {
"assistant_info": default_assistant
}

这段兜底逻辑很关键,如果没有兜底,一旦 JSON 解析失败,整个 Graph 就会中断,而有了默认助手之后,即使模型没有按格式返回,系统也能继续往下执行。这体现了 Agent 工程中很重要的一点,模型输出不稳定,流程设计必须有容错。 节点不是只处理理想情况,它还要处理模型格式错误、字段缺失、内容异常等问题。

1
读取问题 → 构造 Prompt → 调用模型 → 解析助手信息 → 写回状态

这个节点只负责把 assistant_info 补充进 ResearchState。下一步,系统会根据研究角色和用户问题生成搜索查询。

7. 实现搜索

助手选择完成后,状态中已经有了两个关键信息:

1
2
user_question
assistant_info

接下来要做的,就是把用户问题转换成可以用于网页搜索的查询语句。这一部分对应两个节点:

1
2
generate_search_queries()
perform_web_searches()

前者负责生成搜索词,后者负责真正执行搜索,它们共同完成从“研究问题”到“网页链接”的转换。

7.1 生成搜索查询

generate_search_queries 首先从状态中读取助手信息和用户问题:

1
2
3
assistant_info = state["assistant_info"]
user_question = state["user_question"]
assistant_instructions = assistant_info["assistant_instructions"]

第一次运行时,系统会直接使用 WEB_SEARCH_PROMPT_TEMPLATE 生成搜索请求:

1
2
3
4
5
prompt = WEB_SEARCH_PROMPT_TEMPLATE.format(
assistant_instructions=assistant_instructions,
user_question=user_question,
num_search_queries=NUM_SEARCH_QUERIES
)

这里的关键点不在 Prompt 本身,而在搜索词的定位。用户问题通常不能直接作为搜索词使用,直接搜索这句话,结果可能比较泛,更合理的搜索方向应该覆盖多个方面。也就是说,搜索词生成节点的职责,是把一个自然语言问题拆成多个信息入口。代码中还是默认生成 3 条搜索查询:

1
NUM_SEARCH_QUERIES = 3

每条查询都保留原始问题:

1
2
3
4
{
"search_query": "英伟达 股票 业绩 数据中心 AI 芯片 增长 最新分析",
"user_question": "当前英伟达股票还值得买入吗?"
}

这样后续总结网页时,仍然知道这条搜索词服务于哪个问题。

7.2 多轮搜索如何调整策略

这个节点真正体现 Agent 特征的地方,是它会根据 iteration_count 采用不同策略。

第一次搜索:

1
if iteration_count == 0:

使用正常搜索 Prompt。如果采用第二次搜索:

1
elif iteration_count == 1:

会参考上一轮搜索词和相关性评估结果,生成更具体、更有针对性的查询。代码中会读取:

1
2
previous_queries = state.get("search_queries", [])
relevance_evaluation = state.get("relevance_evaluation", None)

如果上一轮结果相关性不足,系统会把这些信息放进新 Prompt,这样模型生成新搜索词时,就不是盲目重试,而是基于失败原因调整方向,第三轮或之后走 else逻辑,系统会要求从完全不同角度搜索。例如:

1
2
3
4
5
拆成更小的问题
使用专业术语
寻找专家观点
寻找案例
补充历史背景

这个设计解决了一个常见问题,如果只是简单重新搜索,模型很可能生成和上一轮差不多的关键词。看起来重新尝试了,实际上信息来源没有明显变化。因此代码中特别强调:

1
2
3
不要重复上一轮搜索词
不要只是换一种说法
换一个角度重新搜索

这就是循环系统中很重要的一点,重新执行不等于重复执行。

7.3 搜索结果如何写回状态

模型返回搜索词后,代码会尝试解析 JSON 数组:

1
2
3
4
json_start = response_text.find('[')
json_end = response_text.rfind(']') + 1
json_str = response_text[json_start:json_end]
search_queries = json.loads(json_str)

解析成功后写回状态:

1
2
3
4
5
return {
"search_queries": search_queries,
"relevance_evaluation": None,
"should_regenerate_queries": None
}

这里有一个细节,生成新搜索词时,会清空上一轮的评估结果和重新生成标记。因为从这一刻开始,系统已经进入新一轮搜索。旧的评估结果只用于生成新策略,不应该继续影响后续判断。如果解析失败,则使用默认搜索词兜底:

1
2
3
4
5
6
default_queries = [
{
"search_query": f"{user_question} iteration {iteration_count + 1}",
"user_question": user_question
}
]

这同样是为了保证 Graph 不会因为模型格式问题直接中断。最终期望得到的结果类似:

1
2
3
4
5
6
7
8
9
{  
'search_queries': [
{'search_query': '英伟达 股票 业绩 数据中心 AI 芯片 增长 最新分析', 'user_question': '当前英伟达股票还值得买入吗?'},
{'search_query': '英伟达 估值 市盈率 收入增速 毛利率 机构观点','user_question': '当前英伟达股票还值得买入吗?'},
{'search_query': '英伟达 竞争风险 AI 资本开支 半导体周期 出口管制', 'user_question': '当前英伟达股票还值得买入吗?'}
],
'relevance_evaluation': None,
'should_regenerate_queries': None
}

7.4 执行网页搜索

有了 search_queries 之后,下面进入 perform_web_searches,这个节点读取搜索词:

1
search_queries = state["search_queries"]

然后逐条调用搜索工具:

1
2
3
4
urls = web_search(
web_query=search_query,
num_results=NUM_SEARCH_RESULTS_PER_QUERY
)

每条搜索词默认返回 3 个结果:

1
NUM_SEARCH_RESULTS_PER_QUERY = 3

最终每个 URL 会被整理成结构化对象:

1
2
3
4
5
6
search_results.append({
"result_url": url,
"search_query": search_query,
"user_question": user_question,
"is_fallback": is_fallback
})

这里保留了三个关键上下文。

  • 链接来自哪个搜索词。
  • 链接服务于哪个用户问题。
  • 这个链接是否来自降级搜索。

这样后续如果最终报告质量不好,可以回溯到具体搜索来源,最后节点返回:

1
2
3
4
return {
"search_results": search_results,
"used_fallback_search": fallback_used
}

状态中开始出现真正的外部信息来源。最终期望得到的结果类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
'search_results': [
{
'result_url': 'https://www.nvidia.com/en-us/about-nvidia/investor-relations/',
'search_query': '英伟达 股票 业绩 数据中心 AI 芯片 增长 最新分析',
'user_question': '当前英伟达股票还值得买入吗?',
'is_fallback': False
},
{
'result_url': 'https://finance.yahoo.com/quote/NVDA/',
'search_query': '英伟达 股票 业绩 数据中心 AI 芯片 增长 最新分析',
'user_question': '当前英伟达股票还值得买入吗?',
'is_fallback': False
}, ...
],
'used_fallback_search': True}

7.5 搜索失败后的降级策略

网页搜索不一定稳定,搜索引擎可能限流,请求可能失败,某些查询可能没有结果,所以 web_search 中设计了重试和降级机制。首先通过 _min_request_interval 控制请求间隔,避免频繁请求触发限制,然后通过 max_retries 控制重试次数,如果 DuckDuckGo 搜索失败,系统会进入:

1
fallback_search()

降级搜索会返回一些通用来源,例如 Wikipedia、Britannica、JSTOR 等,这能保证流程继续运行。但也必须承认它的局限。降级结果不一定真正匹配原问题。因此代码中会记录:

1
"is_fallback": True

并在后续总结和报告中保留提示,这是一种重要的习惯。系统可以降级,但不能假装降级结果和正常搜索结果一样可靠。

搜索系统完成了两个动作:把自然语言问题转换成搜索查询,再把搜索查询转换成网页链接。对应的状态变化是:

1
assistant_info → search_queries → search_results

到这里只是拿到了链接,还不能直接支撑最终报告。下一步需要抓取网页内容,并压缩成可用的研究摘要。

8. 实现网页总结与信息聚合

搜索节点完成后,状态中已经有了 search_queries,这些结果只是网页链接,还不能直接用于生成报告。原因很简单,网页内容通常很长,结构也不稳定,有些页面可能包含导航、广告、评论、推荐内容,直接把整页文本交给模型,很容易产生噪声。所以接下来需要做两件事,抓取网页正文和把网页正文压缩成和问题相关的摘要。这一部分对应两个函数:

1
2
web_scrape()
summarize_search_results()

前者负责获取网页文本,后者负责逐条总结并汇总成研究上下文。

8.1 网页抓取:从 URL 到原始文本

web_scrape 的职责很明确。输入一个 URL,返回页面文本。代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def web_scrape(url: str) -> str:  
try:
response = requests.get(url)

if response.status_code == 200:
soup = BeautifulSoup(response.text, "html.parser")
page_text = soup.get_text(strip=True)

return page_text
else:
return f"获取网页失败:状态码 {response.status_code}"
except Exception as e:
print(f"获取网页失败:{e}")
return f"获取网页失败:{e}"

它使用 requests 请求页面,再用 BeautifulSoup 提取文本,这是一种比较基础的网页抓取方式。优点是简单直接,适合做最小可运行版本,但也有局限,有些页面依赖 JavaScript 渲染,有些页面有反爬限制,有些页面正文和导航混在一起,所以这里抓到的文本不一定干净,也不一定完整。

因此后面总结节点还需要做一次筛选。如果抓取失败,函数不会直接抛出异常,而是返回错误信息,后续节点会根据这个结果决定是否跳过,这保证了单个网页失败不会拖垮整个 Graph。

8.2 逐条总结搜索结果

summarize_search_results 会读取状态中的搜索结果:

1
search_results = state["search_results"]

然后逐个处理 URL,核心流程可以概括为:

1
读取搜索结果 → 抓取网页文本 → 过滤无效内容 → 调用模型总结 → 保存摘要 → 合并为 research_summary

代码中会先抓取网页内容:

1
search_result_text = web_scrape(url=result_url)[:RESULT_TEXT_MAX_CHARACTERS]

这里限制最大字符数:

1
RESULT_TEXT_MAX_CHARACTERS = 10000

这是为了避免网页文本过长,导致模型上下文压力过大。随后会跳过抓取失败或者内容太短的页面:

1
2
if search_result_text.startswith("Failed to") or len(search_result_text) < 50:
continue

这一步非常必要,因为不是所有链接都值得进入总结阶段,无效网页如果继续进入模型,很容易污染后面的研究摘要。

8.3 为什么要先总结再生成报告

抓到网页正文后,并不是直接生成最终报告,而是先做单页摘要,普通页面内容通常比较杂,例如一篇财经新闻里可能同时包含:

1
2
3
4
5
6
市场背景
价格变化
专家观点
历史数据
无关广告
相关推荐

如果直接把所有内容拼在一起生成报告,模型很容易被噪声带偏。所以先用 SUMMARY_PROMPT_TEMPLATE 对每个网页单独总结:

1
2
3
4
prompt = SUMMARY_PROMPT_TEMPLATE.format(
search_result_text=search_result_text,
search_query=search_query
)

Prompt 的目标不是自由发挥,而是基于网页内容回答当前搜索问题,如果网页内容无法回答问题,就做简洁总结,这样可以把原始网页压缩成更稳定的研究材料。最终每条摘要会保存成:

1
2
3
4
5
6
summary = {
"summary": f"Source Url: {result_url}\nSummary: {text_summary}",
"result_url": result_url,
"user_question": user_question,
"is_fallback": is_fallback
}

这里保留了来源 URL,后面生成报告时,可以把来源一起带入上下文,这比只保存摘要文本更可靠,也方便排查结果来源。

8.4 fallback 来源需要单独说明

在搜索阶段,代码会为每条搜索结果记录一个 is_fallback 标记。当搜索结果前两条里出现 wikipedia.orgbaike.baidu.comzhihu.com 这类通用资料来源时,系统会认为这次搜索触发了降级路径:

1
"is_fallback": True

如果完全没有搜索结果,系统也会使用一个默认 fallback 来源,这类来源可以让流程继续运行,但它们不一定真正回答用户的原始问题,所以在 summarize_search_results 中,如果某条结果是 fallback 来源,系统会使用单独的摘要 Prompt,明确提醒模型:

1
这是降级资料源,内容可能无法直接对应问题。

摘要生成后,代码还会追加说明:

1
[说明:这部分信息来自降级资料源,可能无法直接对应原问题。]

如果本轮搜索中使用过 fallback,最终合并出的 research_summary 也会追加整体说明:

1
[说明:由于主搜索引擎不可用,部分或全部信息来自降级资料源,相关性可能低于正常搜索结果。]

这样做的目的,是把“背景资料”和“直接证据”区分开。如果正常搜索失败,系统可能降级到 Wikipedia 的页面。这个页面可以解释相关概念,但无法形成相关判断。

因此系统不会把这类内容混进正常资料里,而是保留来源不确定性和相关性风险。这是一种更负责任的 Agent 设计:流程可以继续,但证据质量必须被标记出来。

8.5 research_summary 是报告生成的核心上下文

所有单页摘要完成后,系统会把它们合并成一个大的研究摘要:

1
research_summary = "\n\n".join([s["summary"] for s in summaries])

并写回状态:

1
2
3
4
5
return {
"search_summaries": summaries,
"research_summary": research_summary,
"used_fallback_search": used_fallback_search
}

到这里,状态中已经有了完整的研究上下文

1
search_results → search_summaries → research_summary

后续报告生成并不直接依赖网页原文,而是依赖 research_summary。这样做有两个好处,首选减少噪声,其次是降低上下文长度压力。

最终期望得到的结果类似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{  
'search_summaries': [
{
'summary': '来源 URL:...\n摘要:...',
'result_url': 'https://www.nvidia.com/en-us/about-nvidia/investor-relations/',
'user_question': '当前英伟达股票还值得买入吗?',
'is_fallback': False
},
{
'summary': '来源 URL:...\n摘要:...\n[说明:这部分信息来自降级资料源,可能无法直接对应原问题。]',
'result_url': 'https://en.wikipedia.org/wiki/Nvidia',
'user_question': '当前英伟达股票还值得买入吗?',
'is_fallback': True
}
],
'research_summary': '...',
'used_fallback_search': True
}

但这里也要注意,摘要本身仍然是模型生成内容,可能遗漏信息,也可能错误概括网页内容。摘要不是事实本身,而是对原始资料的一次压缩。

网页总结节点完成了从外部链接到研究材料的转换。它把分散的网页内容整理成统一的 research_summary,为后续评估和报告生成提供基础,这一阶段的核心变化是:

1
URL → 网页文本 → 单页摘要 → 研究上下文

到这里,系统已经有了可用于写报告的材料。但材料是否相关、是否足够,仍然需要评估。

9. 引入评估节点形成循环

完成网页总结后,状态中已经有了 research_summary,按照普通 Workflow 的做法,这一步之后就可以直接开始报告生成。但 Agent 化改造的关键,恰恰是不是写报告,而是先判断当前材料是否值得继续使用,这一部分对应函数:

1
evaluate_search_relevance()

它负责评估搜索摘要与原始问题的相关性,并决定是否重新生成搜索查询。

9.1 为什么需要相关性评估

搜索和总结只能说明系统已经拿到一些内容,但这些内容是否能回答问题,还需要单独判断。例如问题是:

1
当前英伟达股票还值得买入吗?

如果摘要主要来自百科页面,内容集中在:

1
2
3
英伟达公司历史
GPU 技术发展
图形渲染应用

这些信息并非完全无用,但不足以回答“是否值得买入”。真正相关的信息更可能是:

1
2
3
4
5
6
数据中心收入增速
AI 芯片需求持续性
毛利率和业绩指引
估值水平
竞争格局
出口管制和供应链风险

所以评估节点要判断的不是内容有没有信息量,而是内容是否服务于原始问题。这一步让系统从“拿到资料”升级为“判断资料是否可用”。

9.2 evaluate_search_relevance 的执行流程

函数首先读取当前状态:

1
2
3
4
search_summaries = state.get("search_summaries", [])
user_question = state["user_question"]
research_summary = state.get("research_summary", "")
used_fallback_search = state.get("used_fallback_search", False)

如果没有摘要,说明搜索或抓取阶段没有得到可用结果,此时不应该继续写报告,而应该重新生成搜索词:

1
2
if not search_summaries or not research_summary:
return {"should_regenerate_queries": True}

如果有摘要,就交给模型做相关性评估,Prompt 会要求模型围绕原始问题判断每条摘要是否相关,并返回结构化结果:

1
2
3
4
5
6
{
"relevance_percentage": 70,
"explanation": "大部分摘要与原始问题相关,但缺少最新市场数据。",
"relevant_count": 7,
"total_count": 10
}

代码随后解析 JSON:

1
2
evaluation = json.loads(json_str)
relevance_percentage = evaluation.get("relevance_percentage", 0)

并用一个简单阈值判断是否需要重新搜索:

1
should_regenerate = relevance_percentage < 50

如果相关性低于 50%,系统会认为当前材料不足,需要回到搜索查询生成节点。如果相关性达到要求,就进入报告生成节点。

9.3 should_regenerate_queries 如何控制后续路径

评估节点最终返回两个字段:

1
2
3
4
return {
"relevance_evaluation": evaluation,
"should_regenerate_queries": should_regenerate
}

其中 relevance_evaluation 保存评估详情,方便后续生成新搜索词时参考,should_regenerate_queries 是流程控制标记,它不会直接调用搜索节点,它只是写入状态。

真正根据这个字段决定下一步去哪的,是后面的条件路由,这正是 LangGraph 的核心设计,节点只负责更新状态。Graph 负责根据状态决定路径。流程可以概括为:

1
总结完成 → 评估相关性 → 写入 should_regenerate_queries → 条件路由读取状态 → 决定重新搜索或生成报告

9.4 评估节点的边界

相关性评估很重要,但不能过度理解。它判断的是:

1
当前摘要是否有助于回答问题

它不能完全判断:

1
2
3
4
网页内容是否真实
数据是否最新
来源是否权威
结论是否可靠

尤其是模型参与评估时,本身也可能误判,如果摘要写得很顺,模型可能认为它相关。如果来源本身过时,模型未必能自动发现。

因此评估节点的价值在于提升流程质量,而不是保证事实真实性。它能减少明显偏题内容进入报告,但不能替代来源核验和事实验证。

对于严肃场景,尤其是投资、医疗、法律、政策等方向,最终报告中仍然需要明确资料范围和不确定性。

评估节点是 Agent 化改造的关键节点。它让系统从自动执行,升级为能够判断和回退。状态变化是:

1
research_summary → relevance_evaluation → should_regenerate_queries

接下来需要把这个评估接入 StateGraph,通过条件路由控制系统是重新搜索,还是进入最终报告生成。

10. 用 StateGraph 组织完整 Agent

前面已经完成了状态设计和各个节点的拆分,现在还差最后一步,把这些节点真正组织成一张可运行的图,这一部分对应函数:

1
def create_research_graph() -> StateGraph:

它负责创建完整的 LangGraph 研究流程。

10.1 创建 Graph 并注册节点

首先创建一个 StateGraph,这里传入的 ResearchState,就是前面定义的统一状态对象。接着把各个函数注册成节点:

1
2
3
4
5
6
7
8
graph = StateGraph(ResearchState)

graph.add_node("select_assistant", select_assistant)
graph.add_node("generate_search_queries", generate_search_queries)
graph.add_node("perform_web_searches", perform_web_searches)
graph.add_node("summarize_search_results", summarize_search_results)
graph.add_node("evaluate_search_relevance", evaluate_search_relevance)
graph.add_node("write_research_report", write_research_report)

每个节点都有一个名字,Graph 后续通过这些名字组织执行路径。这里可以看到,节点本身并不复杂,都是前面已经写好的普通函数。LangGraph 是把这些函数连接成一个有状态、有路径、有判断能力的执行流程。

10.2 添加固定路径

注册节点之后,需要先添加基础执行路径:

1
2
3
4
graph.add_edge("select_assistant", "generate_search_queries")
graph.add_edge("generate_search_queries", "perform_web_searches")
graph.add_edge("perform_web_searches", "summarize_search_results")
graph.add_edge("summarize_search_results", "evaluate_search_relevance")

这条路径和之前的 LCEL 流程很像:

1
选择助手 → 生成搜索词 → 执行搜索 → 网页总结 → 相关性评估

这部分是稳定路径,无论后续是否循环,第一次执行都要走完这几个节点。固定路径的价值在于保证系统不会一开始就乱跳。Agent 并不是完全自由运行,而是在明确边界内执行。

10.3 添加条件路由

真正让系统具备 Agent 特征的是条件路由,代码中通过 add_conditional_edges 添加:

1
2
3
4
5
6
7
8
graph.add_conditional_edges(
"evaluate_search_relevance",
route_based_on_relevance,
{
"generate_search_queries": "generate_search_queries",
"write_research_report": "write_research_report"
}
)

意思是,当 evaluate_search_relevance 执行完之后,不直接进入固定下一个节点,而是先调用:

1
route_based_on_relevance()

这个路由函数会读取当前状态,并返回下一步节点名称。如果返回generate_search_queries,流程就回到搜索词生成节点,开始新一轮搜索。如果返回write_research_report,流程就进入报告生成节点。这样一来,流程从线性结构变成了条件结构:

1
2
3
    评估结果
↙ ↘
重新搜索 生成报告

这就是 Agentic Workflow 的核心。

10.4 条件路由如何控制循环

路由函数的核心逻辑是:

1
2
3
iteration_count = state.get("iteration_count", 0)
new_iteration_count = iteration_count + 1
state["iteration_count"] = new_iteration_count

每完成一次评估,就增加一次迭代次数,然后先判断是否达到最大轮数:

1
2
if new_iteration_count >= 3:
return "write_research_report"

如果达到 3 次,就不再继续搜索,直接进入报告生成。如果还没达到上限,再判断是否需要重新生成搜索词:

1
2
3
4
if state.get("should_regenerate_queries", False):
return "generate_search_queries"
else:
return "write_research_report"

完整逻辑可以概括为:

1
评估完成 → 迭代次数 + 1 → 是否达到最大次数 → 是:生成报告 → 否:检查是否需要重新搜索 → 需要:回到搜索词生成 → 不需要:生成报告

这里有一个要点,最大循环次数优先级高于相关性判断。也就是说,即使系统认为结果仍不够好,只要已经达到上限,也必须结束。这可以避免无限循环。

10.5 报告节点与结束节点

当流程进入报告生成节点后,会执行:

1
write_research_report()

它读取状态中的:

1
2
research_summary = state["research_summary"]
user_question = state["user_question"]

然后使用报告 Prompt 生成最终结果:

1
2
3
4
prompt = RESEARCH_REPORT_PROMPT_TEMPLATE.format(
research_summary=research_summary,
user_question=user_question
)

最后写回状态:

1
return {"final_report": report}

Graph 中通过下面这行把报告节点连接到结束节点:

1
graph.add_edge("write_research_report", END)

这表示报告生成完成后,整个流程结束,最后设置入口节点:

1
graph.set_entry_point("select_assistant")

这样整个 Agent 的执行路径就完整了。

StateGraph 把前面所有节点组织成完整流程,包含两类路径。

第一类是固定路径:

1
选择助手 → 生成搜索词 → 执行搜索 → 网页总结 → 评估结果

第二类是条件路径:

1
2
3
     评估结果
↙ ↘
重新搜索 生成报告

这一步完成了从 Chain 到 Graph 的落地。下一步只剩下编译 Graph、初始化状态,并真正运行研究流程。

11. 编译并运行 Graph

完整 Graph 定义完成后,最后一步是运行。这一部分对应函数:

1
def run_research(question: str) -> str:

它负责接收用户问题,创建 Graph,初始化状态,并返回最终研究报告。

11.1 run_research 的整体结构

run_research 的流程很清晰:

1
创建 Graph → 编译 Graph → 初始化状态 → 执行 Graph → 返回最终报告

对应代码如下:

1
2
3
4
research_graph = create_research_graph()
app = research_graph.compile()
result = app.invoke(initial_state)
return result["final_report"]

这里的 compile() 可以理解为把前面定义好的节点、边和条件路由整理成一个可执行对象。真正启动流程的是:

1
app.invoke(initial_state)

从这一刻开始,Graph 会按照入口节点运行,并在每个节点执行后更新状态。

11.2 initial_state 如何初始化

运行 Graph 前,需要先准备初始状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
initial_state = {
"user_question": question,
"assistant_info": None,
"search_queries": None,
"search_results": None,
"search_summaries": None,
"research_summary": None,
"final_report": None,
"used_fallback_search": False,
"relevance_evaluation": None,
"should_regenerate_queries": None,
"iteration_count": 0
}

这里除了 user_questioniteration_count,大部分字段一开始都是空。这就是状态驱动的设计,系统不是一次性准备好所有数据,而是在运行过程中逐步补全。初始状态只需要说明:

1
2
问题是什么
当前是第几轮

其余内容交给节点逐步生成。

11.3 一次完整执行过程

假设输入问题是:

1
question = "当前英伟达股票还值得买入吗?"

完整运行过程大致如下:

1
2
3
4
select_assistant → generate_search_queries → perform_web_searches → summarize_search_results → 
↗ 重新搜索 → END
evaluate_search_relevance → 条件判断
↘ write_research_report

如果第一次搜索结果相关性足够,流程会直接进入报告生成,如果相关性不足,系统会回到 generate_search_queries,带着上一轮搜索词和评估结果重新生成搜索方向。

如果连续多轮仍然不理想,达到最大迭代次数后,也会进入报告生成。这时最终报告应该基于已有资料输出,并在内容中保留信息不足的限制说明。

11.4 运行入口的作用

代码最后提供了一个测试入口:

1
2
3
4
if __name__ == "__main__":
question = "当前英伟达股票还值得买入吗?"
report = run_research(question)
print(report)

这让整个 Agent 可以直接作为脚本运行。从外部看,只需要传入一个问题:

1
run_research(question)

内部会自动完成,选择角色、生成搜索词、搜索网页、抓取总结、相关性评估、必要时重试、生成报告一系列环节,调用方式保持简单,但内部流程已经从线性 Chain 变成了可循环的 Graph。

run_research 是整个系统的入口。它隐藏 Graph 的内部复杂度,对外只暴露一个简单接口。

1
输入问题 → 输出报告

这也是架构升级的意义:外部调用不一定变复杂,内部流程变得更可控、更可追踪。

12. 总结

到这里,基于 LangGraph 的网页研究 Agent 已经完成。从外部看,系统仍然是接收一个问题,然后输出一份研究报告;但内部结构已经从线性流水线:

1
输入问题 → 选择助手 → 生成搜索词 → 搜索网页 → 总结内容 → 生成报告

升级成了状态驱动流程:

1
状态 → 节点 → 状态更新 → 条件判断 → 继续执行或结束

12.1 从 Chain 到 Graph 的核心变化

这次升级不是为了多调用几次模型,而是为了改变流程控制方式。核心变化有三点:

  • 统一状态:所有中间结果都沉淀在 ResearchState 中。
  • 独立节点:每个节点只做一件事。
  • 条件路由:评估后根据 should_regenerate_queries 决定重新搜索还是输出报告。

于是系统不再只是跑流程,而是先判断当前结果是否足够,再决定是否继续。

12.2 评估和循环让系统更接近研究过程

真实研究很少一次完成,通常是:

1
收集资料 → 判断质量 → 发现不足 → 调整方向 → 继续收集 → 形成结论

本章通过 evaluate_search_relevanceroute_based_on_relevance 表达这个过程:结果不足就重新生成搜索词,结果够用就生成报告,多轮尝试仍不理想则在达到最大次数后结束。

这种设计让系统具备基本的自我调整能力,但相关性评估不是事实验证。它只能判断材料是否贴近问题,不能保证网页内容真实、数据最新、来源可靠。

12.3 LangGraph 的价值在于让复杂流程可控

Agent 不是让模型随意决定一切,更合理的工程结构是:

  • 模型参与判断
  • 系统控制边界
  • 工具负责执行
  • 状态记录过程

LangGraph 的价值就是把这些边界显式写出来:哪些节点可以运行,哪些路径可以跳转,什么时候重新搜索,什么时候生成报告,最多循环几次。

12.4 最终的系统结构

最终系统可以概括为:

---
title: LangGraph 网页研究 Agent 执行流程
---
flowchart LR
    A["ResearchState
统一状态对象"] --> B["select_assistant
选择研究助手"] B --> C["generate_search_queries
生成搜索请求"] C --> D["perform_web_searches
执行网页搜索"] D --> E["summarize_search_results
抓取并总结网页"] E --> F["evaluate_search_relevance
评估搜索相关性"] F -->|结果不足
继续搜索| C F -->|结果可用
生成报告| G["write_research_report
生成研究报告"] G --> H["END
流程结束"]

12.5 下一步可以继续优化的方向

当前实现已经完成最小闭环,但仍然可以继续优化:搜索结果去重、来源质量评分、更稳定的正文提取、逐条相关性评分、引用校验,以及在最终报告中区分确定信息、推测信息和资料不足部分。

从 LCEL 到 LangGraph 的迁移,真正改变的不是某一段代码,而是组织方式:从线性调用到状态驱动,从固定流程到条件路由,从一次执行到可循环决策。

13.备注

完整代码:https://github.com/keychankc/AI_agent_code/tree/main/web-research-lg