从 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 | GPU 发展历史 |
这些内容本身没有错误,系统也能继续总结并生成报告。但真正的问题是,它们可能没有回答原问题。问题关注的是:
1 | 当前风险 |
而不是历史背景。系统完成了执行,却没有完成判断。完成流程 Execution,不等于完成研究 Decision。研究能力真正困难的部分,不是获得信息,而是决定哪些信息值得继续使用。
1.2 Workflow 的能力边界
很多早期 AI 应用,本质上都属于 Workflow,结构通常类似:
1 | 输入 → 固定步骤 → 固定输出 |
例如客服问答:
1 | 识别问题 → 检索知识 → 生成回答 |
例如内容生成:
1 | 输入主题 → Prompt → 生成文章 |
工作流可以很复杂,甚至包含几十个步骤,但共同特点是流程结构提前确定。系统负责控制,模型负责执行。这种模式最大的优势是可预测,因此工程实现通常比较稳定。
但能力边界也很明显,当系统需要面对下面这些情况时,线性流程开始吃力:
- 搜索结果明显偏题
- 中间结果质量不足
- 信息之间出现冲突
- 当前结论缺少依据
- 需要重新尝试另一种路径
传统 Workflow 很难自然表达:
1 | 执行结果 → 判断 → 调整策略 → 重新执行 |
一旦强行加入这些能力,就会出现大量 if、while、状态变量和异常处理。流程控制逐渐侵入业务代码,复杂度迅速上升。
1.3 真正缺失的是决策能力
很多系统升级时,第一反应是增加工具、模型或搜索源。但问题往往不在能力不足,而在系统不会决策。
对于研究系统来说,真正需要回答的问题通常只有几个:
1 | 信息是否相关 |
只有能够回答这些问题,系统才开始具备研究特征。但判断能力不等于事实能力,即使系统会评估搜索结果、重新搜索、控制流程,也不能天然保证结果真实。模型仍然可能生成:
- 看起来可信的数据
- 看起来存在的来源
- 看起来完整的逻辑链
因此研究型 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 | 相关 |
但无法判断:
1 | 内容是否真实 |
模型很擅长组织语言,也很容易生成逻辑连贯的解释。因此研究型 Agent 的目标不是保证真实性,而是提高过程质量,减少无效信息进入最终结论。即使最终生成的内容结构完整、来源丰富,也仍然需要保持一个基本原则,看起来真实,并不等于真实存在。
到这里,关注点开始从“先执行谁、后执行谁”,转向“当前状态是什么、下一步去哪、什么时候结束”。
小结
Workflow 和 Agent 的差别,不在模型能力,也不在工具数量,而在流程是否允许根据结果持续调整。Workflow 更关注:
1 | 执行 → 输出 |
Agent 更关注:
1 | 目标 → 行动 → 判断 → 继续推进 |
为了实现这种能力,系统要围绕状态组织流程。下面进入 LangGraph:它解决的是状态如何组织、节点如何连接,以及流程如何形成可控循环。
3. LangGraph:从组织调用变成组织状态
理解了 Agent 的目标之后,真正的问题变成:如何把搜索、抓取、总结和报告生成这些能力,组织成一个能够持续运行、允许回退、支持判断的系统。
继续沿用 Chain 也能实现,但需要不断增加条件判断、循环和状态变量。逻辑增长后,系统会很快变得难以维护。
LangGraph 解决的问题,不是模型能力,而是流程组织方式,它把关注点从函数调用,转移到状态流转。核心思想可以概括成一句话,让状态在节点之间流动,而不是让函数彼此调用。
3.1 LangGraph 不是替代 LangChain,而是改变控制方式
第一次接触 LangGraph 时,容易误以为原来的 Prompt、模型调用和工具能力都会消失。实际上,这些能力都会保留。例如这个网页研究系统中:
1 | Prompt |
这些模块继续存在,变化的是组织方式。以前关注:
1 | 先调用谁 |
现在关注:
1 | 当前状态是什么 |
控制权从代码顺序转移到状态推进,流程变成:
1 | 状态 → 节点执行 → 状态更新 → 路径判断 → 继续执行 |
系统不再依赖某个函数返回值,而依赖完整上下文。
3.2 Node、Edge、State:Graph 的三个基本概念
LangGraph 的抽象并不复杂,整个系统可以拆成三个核心组成部分。
3.2.1 Node:动作
Node 表示执行动作,每个节点只负责完成一件事情,例如当前研究系统可以拆成:
1 | 选择研究助手 |
节点的职责通常很简单:
1 | 读取状态 → 处理 → 返回状态更新 |
节点本身不决定流程,只完成动作。因此节点可以独立维护和测试。例如替换搜索引擎时,只需要修改搜索节点。
3.2.2 Edge:路径
Edge 表示节点之间如何连接,最简单情况:
1 | A → B → C |
这仍然属于固定流程,真正开始体现 Graph 价值的是条件路径:
1 | ↗ B |
不同状态走向不同节点,流程不再提前完全确定。以研究任务为例,搜索完成后,如果结果足够相关,就生成报告;如果结果不够,就重新生成搜索策略并继续搜索。
3.2.3 State:运行现场
State 是整个系统最重要的一层,它记录当前执行过程中的全部上下文,例如研究系统最终会保存:
1 | 问题 |
传统 Chain 更像:
1 | 输出 → 输入 |
Graph 更像:
1 | 读取状态 → 处理 → 更新状态 |
状态不会随着某一步结束而消失,而会持续生长。后面的代码实现里,这部分对应 ResearchState,也就是整个 Agent 的核心对象。
3.3 从概念迁移到系统迁移
理解 Node、Edge 和 State 之后,就可以在上一篇能力基础上做升级。上一篇已经完成:
1 | 搜索 |
这些能力不需要大的调整,真正变化的是组织方式,改成这样:
1 | 状态 → 节点 → 状态更新 → 条件路由 |
这意味着原来的函数需要调整成节点。例如:
1 | select_assistant() |
这些函数不再直接互相调用,只处理状态。真正决定执行顺序的是 Graph。
3.4 Graph 的价值不是分支,而是持续推进目标
很多介绍 LangGraph 时,会强调支持:
1 | 循环 |
这些都成立,但更核心的是:LangGraph 让流程围绕目标持续推进。以前:
1 | 步骤完成 → 结束 |
现在:
1 | 获得结果 → 判断是否满足目标 → 决定是否继续 |
系统不再默认“搜索完成就可以写报告”,而是判断当前资料是否足以支持结论。如果不能,就继续收集,直到满足条件或达到退出边界。
到这里,迁移思路已经讲完了。下面开始把上一篇的网页研究总结器一步一步改造成可执行的 LangGraph Agent。
4. 将网页研究总结器拆成 Agent
完成概念迁移之后,接下来要回到原来的网页研究总结器,上一篇系统已经具备完整能力。能够选择助手、生成搜索词、执行网页搜索、抓取网页内容、总结信息并生成报告。
所以升级为 LangGraph Agent,并不是把原来的代码全部推倒重写,而是重新拆分和组织,核心问题变成:
1 | 哪些能力应该变成节点 |
原来的系统更像一条流水线,现在要把它改造成一个可判断、可回退、可循环的状态图。
4.1 从连续步骤拆成独立节点
上一篇的流程可以概括为:
1 | 研究问题 → 搜索 → 总结 → 报告 |
这个结构足够清晰,但过于线性,每一步默认都会产生有效结果。升级后,需要把流程拆得更明确:
1 | 选择研究助手 → 生成搜索请求 → 执行网页搜索 → 抓取并总结网页 → 评估结果相关性 → 生成研究报告 |
表面上步骤变多了,但其实思路反而更清楚,因为每个节点只负责一件事。
- 选择助手节点只负责判断当前问题适合哪个研究角色。
- 生成搜索请求节点只负责把问题转换成搜索方向。
- 搜索节点只负责拿到网页链接。
- 总结节点只负责从网页文本中提炼信息。
- 评估节点只负责判断当前信息是否足以继续。
- 报告节点只负责基于已有资料组织最终结果。
节点拆开之后,复杂度不会集中在一个大函数里,后续调试、替换和扩展都会更方便。
4.2 从函数传参变成共享状态
节点拆开之后,马上会遇到一个问题,中间结果放在哪里。如果继续沿用普通函数调用,流程很容易变成:
1 | 函数A返回结果 → 传给函数B → 函数B再返回结果 → 传给函数C |
步骤少时没有问题,一旦加入评估、重试和循环,参数传递会越来越混乱。LangGraph 的做法是维护统一状态,在这个系统中,状态大致包含:
- 系统开始时,状态中只有用户问题。
- 选择助手之后,状态增加
assistant_info。 - 生成搜索词之后,状态增加
search_queries。 - 搜索完成之后,状态增加
search_results。 - 总结完成之后,状态增加
research_summary。 - 评估完成之后,状态增加
relevance_evaluation和should_regenerate_queries。 - 最终生成报告之后,状态增加
final_report。
这些字段描述了 Agent 当前的运行状态,整个过程中状态在不断完善,这也是 Agent 和普通 Chain 最大的差别之一。
4.3 引入评估节点,让流程形成闭环
原来的系统是:
1 | 搜索 → 总结 → 报告 |
升级之后,中间多了一个关键节点,评估,新的结构变成:
1 | 搜索 → 总结 → 评估 → 继续或输出 |
这个节点决定了系统是否真正具备 Agentic Workflow 的特征。
例如问题是,某项技术未来五年的发展趋势如何? 如果第一次搜索结果主要是百科介绍、历史背景和基础定义,评估节点就应该判断,当前资料不足以支撑趋势分析。随后系统可以重新生成更具体的搜索词,例如:
1 | 行业报告 |
然后再进入下一轮搜索,这样流程就从一次性执行,变成了:
1 | 执行 → 观察 → 判断 → 调整 → 继续执行 |
这就是 Agent 的基本形态。
4.4 循环必须有退出边界
只要系统允许重新搜索,就必须设置边界,否则流程可能一直停留在:
1 | 搜索结果不理想 → 重新生成搜索词 → 继续搜索 → 仍然不理想 → 继续循环 |
这种循环看似智能,但它可能浪费成本,也可能不断生成看似合理但价值不高的内容,因此代码中需要设计一个最大尝试次数,例如最多三轮,达到上限之后,即使结果不够理想,也要进入报告生成阶段,但最终报告应该保留限制说明。例如:
1 | 当前资料有限 |
这才是更负责任的 Agent 设计。系统不能因为多执行了几轮,就假装已经掌握充分证据,尤其在投资、医疗、法律、政策等场景中,更需要明确边界。循环和评估只能提升过程质量,不能替代事实核验。
4.5 从架构设计到代码实现
到这里,网页研究总结器的 Agent 化设计已经清楚,原来的能力不变,改变的是组织方式:
1 | 线性链式调用 → 状态驱动图结构 |
接下来进入代码实现时,需要依次解决四个问题。
- 定义统一状态对象。
- 把每个能力封装成节点函数。
- 用条件路由决定是否重新搜索。
- 编译并运行完整 Graph。
后面的实现会围绕这条主线展开。
1 | ResearchState → Node → Edge → Conditional Edge → Compiled Graph |
5. 定义统一状态对象 ResearchState
第一步先定义状态。LangGraph 的核心是“节点读取状态、处理状态、返回状态更新”。在这个网页研究 Agent 中,所有节点都围绕同一个状态对象运行:
1 | class ResearchState(TypedDict): |
这段代码定义了 Agent 的运行状态。
5.1 State 不是参数容器,而是运行状态
在普通函数式流程里,中间结果通常通过参数一级一级传递,例如:
1 | 搜索词 → 搜索结果 → 网页摘要 → 最终报告 |
这种方式适合短流程。但一旦系统需要循环、回退和条件判断,单纯依靠函数传参就会变得混乱。LangGraph 的做法是把中间结果放进统一状态,节点只关心两件事:
- 从状态中读取自己需要的信息。
- 把新结果写回状态。
例如选择助手节点读取:
1 | user_question = state["user_question"] |
然后返回:
1 | return { |
这里并没有直接调用下一个函数,只是更新状态。后续由 Graph 决定下一步去哪。
5.2 ResearchState 中的核心字段
ResearchState 中的字段可以分成几类。
- 第一类是原始输入。
1 | user_question: str |
它保存最初的问题,后续所有搜索、总结、评估和报告生成都围绕它展开。
- 第二类是研究策略。
1 | assistant_info: Optional[AssistantInfo] |
assistant_info 保存选择出来的研究助手类型和研究指令,search_queries 保存当前轮生成的搜索请求。
- 第三类是外部信息。
1 | search_results: Optional[List[SearchResult]] |
search_results 保存网页搜索得到的链接,search_summaries 保存单个网页的总结,research_summary 则是所有网页摘要合并后的研究上下文。
- 第四类是流程控制。
1 | relevance_evaluation: Optional[Dict[str, Any]] |
这几个字段决定系统是否继续搜索。relevance_evaluation 保存相关性评估结果,should_regenerate_queries 表示是否需要重新生成搜索词,iteration_count 用来限制循环次数,used_fallback_search 记录是否触发备用搜索,避免后续把降级结果误认为正常研究结果。
- 第五类是最终输出。
1 | final_report: Optional[str] |
它保存最后生成的研究报告。
这些字段组合起来,构成完整的研究过程。
5.3 状态如何随着流程逐步生长
初始状态中,大部分字段都是空的。
1 | initial_state = { |
系统开始运行时,只有问题是真正有值的。随后每个节点补充一部分状态。选择助手后:
1 | user_question |
生成搜索词后:
1 | user_question |
完成搜索和总结后:
1 | user_question |
完成评估后:
1 | relevance_evaluation |
最终报告生成后:
1 | final_report |
状态不是一次性写完,而是在流程中不断补全。Agent 因此可以基于完整上下文做判断,而不是只依赖某一步的返回值。
5.4 TypedDict 的作用
这里使用 TypedDict 定义状态结构,可以让代码结构更清晰。例如:
1 | # 每个搜索请求都包含搜索语句和原始问题。 |
这种设计便于调试。最终报告出问题时,可以顺着状态回看:
1 | 哪个搜索词产生了这个链接 |
状态结构越清楚,运行过程越容易追踪。
小结
ResearchState 是整个 LangGraph Agent 的基础。它不是参数集合,而是系统执行过程中的共享上下文:
1 | Chain 关注输入输出 |
后续所有节点都遵循同一种模式:
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 | prompt = ASSISTANT_SELECTION_PROMPT_TEMPLATE.format( |
接着调用模型:
1 | llm = get_llm() |
模型返回的内容不是直接写入状态,而是先解析成 JSON,因为后续节点需要结构化使用助手信息,然后节点返回:
1 | return { |
最终期望得到的结果类似:
1 | { |
这一步完成后,状态中就多了研究角色和研究指令,后续生成搜索词时,就可以基于这个角色决定搜索方向。
6.2 为什么助手信息要写回状态
助手选择并不是为了让系统看起来更复杂,它的作用是给后续研究过程提供策略约束。同样是一个问题,不同领域需要关注的信息完全不同。例如:
1 | 苹果未来三年值得投资吗 |
金融分析助手会关注:
1 | 财报 |
旅游向导助手显然不适合处理这个问题,再比如:
1 | 东京三日游怎么安排 |
旅游向导助手会关注:
1 | 景点 |
金融分析助手也不合适。所以 assistant_info 的核心作用,是把用户问题转成更明确的研究视角。在状态中保存这个字段后,后续节点不需要重新判断任务类型,只需要读取:
1 | assistant_info = state["assistant_info"] |
然后继续执行,这就是状态复用的价值。
6.3 JSON 解析失败时为什么需要兜底
模型输出虽然可以通过 Prompt 约束,但不能保证每次都严格返回合法 JSON,因此代码中做了异常处理:
1 | try: |
这段兜底逻辑很关键,如果没有兜底,一旦 JSON 解析失败,整个 Graph 就会中断,而有了默认助手之后,即使模型没有按格式返回,系统也能继续往下执行。这体现了 Agent 工程中很重要的一点,模型输出不稳定,流程设计必须有容错。 节点不是只处理理想情况,它还要处理模型格式错误、字段缺失、内容异常等问题。
1 | 读取问题 → 构造 Prompt → 调用模型 → 解析助手信息 → 写回状态 |
这个节点只负责把 assistant_info 补充进 ResearchState。下一步,系统会根据研究角色和用户问题生成搜索查询。
7. 实现搜索
助手选择完成后,状态中已经有了两个关键信息:
1 | user_question |
接下来要做的,就是把用户问题转换成可以用于网页搜索的查询语句。这一部分对应两个节点:
1 | generate_search_queries() |
前者负责生成搜索词,后者负责真正执行搜索,它们共同完成从“研究问题”到“网页链接”的转换。
7.1 生成搜索查询
generate_search_queries 首先从状态中读取助手信息和用户问题:
1 | assistant_info = state["assistant_info"] |
第一次运行时,系统会直接使用 WEB_SEARCH_PROMPT_TEMPLATE 生成搜索请求:
1 | prompt = WEB_SEARCH_PROMPT_TEMPLATE.format( |
这里的关键点不在 Prompt 本身,而在搜索词的定位。用户问题通常不能直接作为搜索词使用,直接搜索这句话,结果可能比较泛,更合理的搜索方向应该覆盖多个方面。也就是说,搜索词生成节点的职责,是把一个自然语言问题拆成多个信息入口。代码中还是默认生成 3 条搜索查询:
1 | NUM_SEARCH_QUERIES = 3 |
每条查询都保留原始问题:
1 | { |
这样后续总结网页时,仍然知道这条搜索词服务于哪个问题。
7.2 多轮搜索如何调整策略
这个节点真正体现 Agent 特征的地方,是它会根据 iteration_count 采用不同策略。
第一次搜索:
1 | if iteration_count == 0: |
使用正常搜索 Prompt。如果采用第二次搜索:
1 | elif iteration_count == 1: |
会参考上一轮搜索词和相关性评估结果,生成更具体、更有针对性的查询。代码中会读取:
1 | previous_queries = state.get("search_queries", []) |
如果上一轮结果相关性不足,系统会把这些信息放进新 Prompt,这样模型生成新搜索词时,就不是盲目重试,而是基于失败原因调整方向,第三轮或之后走 else逻辑,系统会要求从完全不同角度搜索。例如:
1 | 拆成更小的问题 |
这个设计解决了一个常见问题,如果只是简单重新搜索,模型很可能生成和上一轮差不多的关键词。看起来重新尝试了,实际上信息来源没有明显变化。因此代码中特别强调:
1 | 不要重复上一轮搜索词 |
这就是循环系统中很重要的一点,重新执行不等于重复执行。
7.3 搜索结果如何写回状态
模型返回搜索词后,代码会尝试解析 JSON 数组:
1 | json_start = response_text.find('[') |
解析成功后写回状态:
1 | return { |
这里有一个细节,生成新搜索词时,会清空上一轮的评估结果和重新生成标记。因为从这一刻开始,系统已经进入新一轮搜索。旧的评估结果只用于生成新策略,不应该继续影响后续判断。如果解析失败,则使用默认搜索词兜底:
1 | default_queries = [ |
这同样是为了保证 Graph 不会因为模型格式问题直接中断。最终期望得到的结果类似:
1 | { |
7.4 执行网页搜索
有了 search_queries 之后,下面进入 perform_web_searches,这个节点读取搜索词:
1 | search_queries = state["search_queries"] |
然后逐条调用搜索工具:
1 | urls = web_search( |
每条搜索词默认返回 3 个结果:
1 | NUM_SEARCH_RESULTS_PER_QUERY = 3 |
最终每个 URL 会被整理成结构化对象:
1 | search_results.append({ |
这里保留了三个关键上下文。
- 链接来自哪个搜索词。
- 链接服务于哪个用户问题。
- 这个链接是否来自降级搜索。
这样后续如果最终报告质量不好,可以回溯到具体搜索来源,最后节点返回:
1 | return { |
状态中开始出现真正的外部信息来源。最终期望得到的结果类似:
1 | { |
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 | web_scrape() |
前者负责获取网页文本,后者负责逐条总结并汇总成研究上下文。
8.1 网页抓取:从 URL 到原始文本
web_scrape 的职责很明确。输入一个 URL,返回页面文本。代码结构如下:
1 | def web_scrape(url: str) -> str: |
它使用 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 | if search_result_text.startswith("Failed to") or len(search_result_text) < 50: |
这一步非常必要,因为不是所有链接都值得进入总结阶段,无效网页如果继续进入模型,很容易污染后面的研究摘要。
8.3 为什么要先总结再生成报告
抓到网页正文后,并不是直接生成最终报告,而是先做单页摘要,普通页面内容通常比较杂,例如一篇财经新闻里可能同时包含:
1 | 市场背景 |
如果直接把所有内容拼在一起生成报告,模型很容易被噪声带偏。所以先用 SUMMARY_PROMPT_TEMPLATE 对每个网页单独总结:
1 | prompt = SUMMARY_PROMPT_TEMPLATE.format( |
Prompt 的目标不是自由发挥,而是基于网页内容回答当前搜索问题,如果网页内容无法回答问题,就做简洁总结,这样可以把原始网页压缩成更稳定的研究材料。最终每条摘要会保存成:
1 | summary = { |
这里保留了来源 URL,后面生成报告时,可以把来源一起带入上下文,这比只保存摘要文本更可靠,也方便排查结果来源。
8.4 fallback 来源需要单独说明
在搜索阶段,代码会为每条搜索结果记录一个 is_fallback 标记。当搜索结果前两条里出现 wikipedia.org、baike.baidu.com 或 zhihu.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 | return { |
到这里,状态中已经有了完整的研究上下文
1 | search_results → search_summaries → research_summary |
后续报告生成并不直接依赖网页原文,而是依赖 research_summary。这样做有两个好处,首选减少噪声,其次是降低上下文长度压力。
最终期望得到的结果类似:
1 | { |
但这里也要注意,摘要本身仍然是模型生成内容,可能遗漏信息,也可能错误概括网页内容。摘要不是事实本身,而是对原始资料的一次压缩。
网页总结节点完成了从外部链接到研究材料的转换。它把分散的网页内容整理成统一的 research_summary,为后续评估和报告生成提供基础,这一阶段的核心变化是:
1 | URL → 网页文本 → 单页摘要 → 研究上下文 |
到这里,系统已经有了可用于写报告的材料。但材料是否相关、是否足够,仍然需要评估。
9. 引入评估节点形成循环
完成网页总结后,状态中已经有了 research_summary,按照普通 Workflow 的做法,这一步之后就可以直接开始报告生成。但 Agent 化改造的关键,恰恰是不是写报告,而是先判断当前材料是否值得继续使用,这一部分对应函数:
1 | evaluate_search_relevance() |
它负责评估搜索摘要与原始问题的相关性,并决定是否重新生成搜索查询。
9.1 为什么需要相关性评估
搜索和总结只能说明系统已经拿到一些内容,但这些内容是否能回答问题,还需要单独判断。例如问题是:
1 | 当前英伟达股票还值得买入吗? |
如果摘要主要来自百科页面,内容集中在:
1 | 英伟达公司历史 |
这些信息并非完全无用,但不足以回答“是否值得买入”。真正相关的信息更可能是:
1 | 数据中心收入增速 |
所以评估节点要判断的不是内容有没有信息量,而是内容是否服务于原始问题。这一步让系统从“拿到资料”升级为“判断资料是否可用”。
9.2 evaluate_search_relevance 的执行流程
函数首先读取当前状态:
1 | search_summaries = state.get("search_summaries", []) |
如果没有摘要,说明搜索或抓取阶段没有得到可用结果,此时不应该继续写报告,而应该重新生成搜索词:
1 | if not search_summaries or not research_summary: |
如果有摘要,就交给模型做相关性评估,Prompt 会要求模型围绕原始问题判断每条摘要是否相关,并返回结构化结果:
1 | { |
代码随后解析 JSON:
1 | evaluation = json.loads(json_str) |
并用一个简单阈值判断是否需要重新搜索:
1 | should_regenerate = relevance_percentage < 50 |
如果相关性低于 50%,系统会认为当前材料不足,需要回到搜索查询生成节点。如果相关性达到要求,就进入报告生成节点。
9.3 should_regenerate_queries 如何控制后续路径
评估节点最终返回两个字段:
1 | return { |
其中 relevance_evaluation 保存评估详情,方便后续生成新搜索词时参考,should_regenerate_queries 是流程控制标记,它不会直接调用搜索节点,它只是写入状态。
真正根据这个字段决定下一步去哪的,是后面的条件路由,这正是 LangGraph 的核心设计,节点只负责更新状态。Graph 负责根据状态决定路径。流程可以概括为:
1 | 总结完成 → 评估相关性 → 写入 should_regenerate_queries → 条件路由读取状态 → 决定重新搜索或生成报告 |
9.4 评估节点的边界
相关性评估很重要,但不能过度理解。它判断的是:
1 | 当前摘要是否有助于回答问题 |
它不能完全判断:
1 | 网页内容是否真实 |
尤其是模型参与评估时,本身也可能误判,如果摘要写得很顺,模型可能认为它相关。如果来源本身过时,模型未必能自动发现。
因此评估节点的价值在于提升流程质量,而不是保证事实真实性。它能减少明显偏题内容进入报告,但不能替代来源核验和事实验证。
对于严肃场景,尤其是投资、医疗、法律、政策等方向,最终报告中仍然需要明确资料范围和不确定性。
评估节点是 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 | graph = StateGraph(ResearchState) |
每个节点都有一个名字,Graph 后续通过这些名字组织执行路径。这里可以看到,节点本身并不复杂,都是前面已经写好的普通函数。LangGraph 是把这些函数连接成一个有状态、有路径、有判断能力的执行流程。
10.2 添加固定路径
注册节点之后,需要先添加基础执行路径:
1 | graph.add_edge("select_assistant", "generate_search_queries") |
这条路径和之前的 LCEL 流程很像:
1 | 选择助手 → 生成搜索词 → 执行搜索 → 网页总结 → 相关性评估 |
这部分是稳定路径,无论后续是否循环,第一次执行都要走完这几个节点。固定路径的价值在于保证系统不会一开始就乱跳。Agent 并不是完全自由运行,而是在明确边界内执行。
10.3 添加条件路由
真正让系统具备 Agent 特征的是条件路由,代码中通过 add_conditional_edges 添加:
1 | graph.add_conditional_edges( |
意思是,当 evaluate_search_relevance 执行完之后,不直接进入固定下一个节点,而是先调用:
1 | route_based_on_relevance() |
这个路由函数会读取当前状态,并返回下一步节点名称。如果返回generate_search_queries,流程就回到搜索词生成节点,开始新一轮搜索。如果返回write_research_report,流程就进入报告生成节点。这样一来,流程从线性结构变成了条件结构:
1 | 评估结果 |
这就是 Agentic Workflow 的核心。
10.4 条件路由如何控制循环
路由函数的核心逻辑是:
1 | iteration_count = state.get("iteration_count", 0) |
每完成一次评估,就增加一次迭代次数,然后先判断是否达到最大轮数:
1 | if new_iteration_count >= 3: |
如果达到 3 次,就不再继续搜索,直接进入报告生成。如果还没达到上限,再判断是否需要重新生成搜索词:
1 | if state.get("should_regenerate_queries", False): |
完整逻辑可以概括为:
1 | 评估完成 → 迭代次数 + 1 → 是否达到最大次数 → 是:生成报告 → 否:检查是否需要重新搜索 → 需要:回到搜索词生成 → 不需要:生成报告 |
这里有一个要点,最大循环次数优先级高于相关性判断。也就是说,即使系统认为结果仍不够好,只要已经达到上限,也必须结束。这可以避免无限循环。
10.5 报告节点与结束节点
当流程进入报告生成节点后,会执行:
1 | write_research_report() |
它读取状态中的:
1 | research_summary = state["research_summary"] |
然后使用报告 Prompt 生成最终结果:
1 | prompt = RESEARCH_REPORT_PROMPT_TEMPLATE.format( |
最后写回状态:
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 | 评估结果 |
这一步完成了从 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 | research_graph = create_research_graph() |
这里的 compile() 可以理解为把前面定义好的节点、边和条件路由整理成一个可执行对象。真正启动流程的是:
1 | app.invoke(initial_state) |
从这一刻开始,Graph 会按照入口节点运行,并在每个节点执行后更新状态。
11.2 initial_state 如何初始化
运行 Graph 前,需要先准备初始状态:
1 | initial_state = { |
这里除了 user_question 和 iteration_count,大部分字段一开始都是空。这就是状态驱动的设计,系统不是一次性准备好所有数据,而是在运行过程中逐步补全。初始状态只需要说明:
1 | 问题是什么 |
其余内容交给节点逐步生成。
11.3 一次完整执行过程
假设输入问题是:
1 | question = "当前英伟达股票还值得买入吗?" |
完整运行过程大致如下:
1 | select_assistant → generate_search_queries → perform_web_searches → summarize_search_results → |
如果第一次搜索结果相关性足够,流程会直接进入报告生成,如果相关性不足,系统会回到 generate_search_queries,带着上一轮搜索词和评估结果重新生成搜索方向。
如果连续多轮仍然不理想,达到最大迭代次数后,也会进入报告生成。这时最终报告应该基于已有资料输出,并在内容中保留信息不足的限制说明。
11.4 运行入口的作用
代码最后提供了一个测试入口:
1 | if __name__ == "__main__": |
这让整个 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_relevance 和 route_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