直播导语:同学们好,欢迎来到我们第二周的直播课!在这一周里,我们一起探索了 LangGraph 的强大功能,并完成了 DeepResearch 这个相当有挑战性的结业项目。我看了大家提交的“作业”(我们以此前构建的 DeepResearch 项目为蓝本),发现同学们都非常有创造力,但也暴露了一些在设计复杂多智能体系统时非常典型的、共通的“误区”。本次直播,我们将采用一种新颖的形式——代码审查(Code Review)。我将选取几份匿名的“学生作业”,和大家一起分析其中的设计亮点与不足,并现场进行重构。这比单纯讲解理论更具实战意义,希望能帮助大家“从错误中学习”,避开未来开发中的“坑”,快速成长为一名优秀的“AI 架构师”。
目录
- 本次作业回顾:构建 DeepResearch 深度研究助手
- 核心要求:实现 Supervisor-Worker 架构,完成“规划-并行研究-汇总”流程。
- 作业一(“小明同学”)点评:强耦合的“巨型”节点
- 代码现象:将 Supervisor 的规划、所有 Worker 的调用逻辑、以及最终的聚合逻辑,全部写在一个巨大的
if-elif-else结构的节点函数中。 - 问题分析:
- 违反“单一职责原则”,节点功能混乱。
- 可读性、可维护性差,难以修改或扩展。
- 无法实现真正的并行。
- 重构方案:将“规划”、“执行”、“聚合”拆分成独立的、职责清晰的节点,通过图的边来连接它们。
- 代码现象:将 Supervisor 的规划、所有 Worker 的调用逻辑、以及最终的聚合逻辑,全部写在一个巨大的
- 作业二(“小红同学”)点评:脆弱的“硬编码”通信
- 代码现象:Supervisor 的 Prompt 中硬编码了 Worker 的名字("Researcher_1", "Researcher_2"),并且假设总是存在两个 Worker。在主逻辑中,通过
state['worker_outputs']['Researcher_1']这样硬编码的方式来获取结果。 - 问题分析:
- 缺乏动态性:如果任务只需要一个 Worker,或者需要五个 Worker,整个系统就会出错。
- 扩展性差:每增加一个新 Worker,都需要修改多处代码。
- 重构方案:
- 让 Supervisor 动态决定需要多少个 Worker,并将 Worker 列表存入
State。 - 主图逻辑根据
State中的 Worker 列表,动态地创建和分发任务。 - 聚合节点通过遍历
state['worker_outputs']字典来收集结果,而不是依赖固定的 key。
- 让 Supervisor 动态决定需要多少个 Worker,并将 Worker 列表存入
- 代码现象:Supervisor 的 Prompt 中硬编码了 Worker 的名字("Researcher_1", "Researcher_2"),并且假设总是存在两个 Worker。在主逻辑中,通过
- 作业三(“小华同学”)点评:被忽视的“状态管理艺术”
- 代码现象:
AgentState中只定义了一个messages字段。所有的数据,包括任务计划、中间结果、最终报告等,都以AIMessage或HumanMessage的形式被塞进messages列表。 - 问题分析:
- 语义模糊:很难从一长串消息中区分哪些是用户输入,哪些是工具结果,哪些是最终报告。
- 效率低下:将大量结构化数据(如 JSON 格式的计划)作为普通文本放入
messages,会消耗不必要的 Token,并增加 LLM 的解析难度。 - 状态访问困难:后续节点需要遍历和解析整个
messages列表才能找到自己需要的信息。
- 重构方案:精心设计
AgentState!- 为不同类型的数据创建独立的、类型明确的字段,如
task: str,plan: List[str],research_findings: Dict[str, str],final_report: str。 messages字段只用来存储核心的、用于驱动 LLM 对话的历史。- 这种结构化的状态,使得任何节点都可以通过
state['plan']或state['final_report']直接、高效地访问所需数据。
- 为不同类型的数据创建独立的、类型明确的字段,如
- 代码现象:
- “学霸”的作业长啥样?一个优秀架构的最佳实践
- 模块化:将 Supervisor、Worker、图的定义等分离到不同的文件中。
- 动态化:整个图的结构(特别是 Worker 的数量)是根据 Supervisor 的规划动态生成的。
- 结构化状态:拥有一个设计良好、字段丰富的
AgentState。 - 错误处理:考虑了 Worker 执行失败的情况,并设计了重试或降级逻辑。
- 持久化:集成了 Checkpointer,关键任务可断点续传。
- 总结:从“能跑就行”到“优雅健壮”的进阶之路
1. 本次作业回顾:构建 DeepResearch 深度研究助手
首先,我们快速回顾一下本次“大作业”的核心要求:
- 输入:一个复杂的研究主题(字符串)。
- 核心架构:必须体现 Supervisor-Worker 的协作模式。
- Supervisor:负责将输入的主题分解成一个包含多个步骤的研究计划。
- Workers:根据计划,并行地对每个步骤进行研究(调用搜索工具)。
- Aggregator:负责将所有 Worker 的研究结果汇总成一份最终报告。
- 输出:一份结构化的研究报告。
大部分同学都成功地让程序跑了起来,完成了基本功能,值得肯定!但是,实现功能只是第一步。如何让我们的代码更具可读性、可维护性、可扩展性,是区分“初级开发者”和“高级架构师”的关键。下面我们就来看几个典型的例子。
2. 作业一(“小明同学”)点评:强耦合的“巨型”节点
“小明同学”的思路很直接:用一个函数来处理所有事情。
代码现象
# “小明同学”的作业节选
def mega_agent_node(state):
# 第一次进入,没有计划,认为是 Supervisor
if not state.get('plan'):
print("Supervisor is planning...")
plan = create_plan(state['task'])
# 直接在这里就调用 worker 逻辑
outputs = {}
for step in plan:
# 串行调用
outputs[step] = run_research_worker(step)
# 直接在这里就聚合
report = aggregate_outputs(outputs)
return {"final_report": report, "plan": plan}
else:
# 逻辑上永远不会走到这里
return {}
workflow = StatefulGraph(AgentState)
workflow.add_node("mega_node", mega_agent_node)
workflow.set_entry_point("mega_node")
workflow.add_edge("mega_node", END)
app = workflow.compile()
问题分析
这段代码虽然“能用”,但它完全违背了 LangGraph 的设计哲学,存在严重问题:
- 违反“单一职责原则”:这个
mega_agent_node节点身兼数职,它既是 Supervisor(规划),又是 Dispatcher(分发),还是 Worker(执行),最后还是 Aggregator(聚合)。这使得节点的逻辑异常臃肿和混乱。 - 可读性与可维护性差:想象一下,如果
run_research_worker的逻辑需要修改,或者你想在聚合前加入一个“审核”步骤,你都需要在这个巨大的函数内部进行修改,很容易引入新的 Bug。 - 丧失并行能力:最致命的是,这种写法将所有 Worker 的调用变成了一个简单的
for串行循环,完全没有利用到多智能体并行处理以提升效率的优势。 - 滥用 LangGraph:它实际上只用到了 LangGraph 的
StatefulGraph来做一个状态容器,完全没有使用到 LangGraph 最核心的“图”和“边”的编排能力。这无异于买了一辆法拉利,却只用它来听收音机。
重构方案
正确的做法是拥抱“图”思维,将不同的职责拆分到不同的节点中:
# 重构后的思路
def supervisor_node(state): ...
def worker_node(state): ...
def aggregator_node(state): ...
workflow = StatefulGraph(AgentState)
# 1. 添加职责单一的节点
workflow.add_node("supervisor", supervisor_node)
workflow.add_node("parallel_workers", worker_node) # worker_node 可以并行执行
workflow.add_node("aggregator", aggregator_node)
# 2. 用“边”来定义清晰的流程
workflow.set_entry_point("supervisor")
workflow.add_edge("supervisor", "parallel_workers")
workflow.add_edge("parallel_workers", "aggregator")
workflow.add_edge("aggregator", END)
app = workflow.compile()
核心启示:节点应该是小的、职责单一的;流程的复杂性应该体现在图的拓扑结构(边)上,而不是单个节点的内部逻辑。
3. 作业二(“小红同学”)点评:脆弱的“硬编码”通信
“小红同学”已经掌握了拆分节点的基本思想,但她在节点间的通信和协作方式上,采用了大量的“硬编码”。
代码现象
# “小红同学”的 Supervisor Prompt 节选
SUPERVISOR_PROMPT = """
...你的任务是创建一份包含两个步骤的研究计划。
Step 1 将由 Researcher_1 执行。
Step 2 将由 Researcher_2 执行。
"""
# “小红同学”的聚合节点节选
def aggregator_node(state):
output_1 = state['worker_outputs']['Researcher_1']
output_2 = state['worker_outputs']['Researcher_2']
final_report = f"Report part 1: {output_1}\nReport part 2: {output_2}"
return {"final_report": final_report}
问题分析
这种硬编码的方式使得整个系统非常脆弱和僵化:
- 缺乏动态性:Supervisor 被“焊死”,永远只能生成两个步骤的计划,分配给两个固定的 Worker。如果用户的任务很简单,只需要一个步骤,怎么办?如果任务很复杂,需要五个步骤,又该怎么办?
- 扩展性极差:假设你想为团队增加一个新的 Worker——
Researcher_3。你需要去修改 Supervisor 的 Prompt,修改聚合节点的代码,甚至可能需要修改图的结构。每增加一个成员,都需要对系统的多个部分进行“手术”。
重构方案
我们应该追求一种动态的、数据驱动的架构。Worker 的数量和身份不应该由代码写死,而应该由 Supervisor 在运行时动态决定,并存入 State 中。
# 重构后的 Supervisor Prompt
SUPERVISOR_PROMPT = """
...根据用户的任务,生成一个合适的研究步骤列表。步骤的数量应该根据任务的复杂度决定,可以是 1 到 5 步。
"""
# 重构后的 Supervisor Node
def supervisor_node(state):
plan: Plan = supervisor_runnable.invoke({"task": state['task']})
# 动态生成 team_members 列表
team_members = [f"Researcher_{i+1}" for i in range(len(plan.steps))]
# 将动态信息存入 State
return {"plan": plan.steps, "team_members": team_members}
# 重构后的 Aggregator Node
def aggregator_node(state):
# 不再关心具体的 key 是什么,直接遍历字典
all_outputs = "\n\n".join(state['worker_outputs'].values())
# ... 后续逻辑
同时,主图的逻辑也应该根据 state['team_members'] 来动态地分发任务,而不是连接到写死的节点上。
核心启示:用“状态”来传递配置和通信,而不是用“代码”来硬编码。让你的系统由数据(State)驱动,而不是由结构驱动。
4. 作业三(“小华同学”)点评:被忽视的“状态管理艺术”
“小华同学”的代码在图结构上非常清晰,但她在 AgentState 的设计上犯了懒。
代码现象
# “小华同学”的 AgentState
class AgentState(TypedDict):
# 只有孤零零一个 messages
messages: Annotated[List[BaseMessage], operator.add]
# “小华同学”的 Supervisor 节点
def supervisor_node(state):
# ...
plan_str = "1. First step...\n2. Second step..."
# 把 plan 用 AIMessage 包装起来
return {"messages": [AIMessage(content=plan_str, name="Supervisor")]}
# “小华同学”的 Worker 节点
def worker_node(state):
# 需要从 messages 列表里找到 supervisor 的那条消息来获取计划
plan = parse_plan_from_messages(state['messages'])
# ...
问题分析
将所有信息,无论类型和用途,都一股脑地塞进 messages 列表,是一种非常常见的“坏味道”(Bad Smell)。
- 语义模糊:
messages列表的语义应该是“用于驱动 LLM 对话的历史记录”。将计划、研究结果、最终报告等结构化数据伪装成“消息”存入其中,会污染其原始语义。 - 效率低下:LLM 的上下文窗口是宝贵的资源。将一个可能很长的、包含大量非对话信息的计划或报告全文,在每一次 LLM 调用时都重复传入,是巨大的 Token 浪费。LLM 也不得不从一堆“聊天记录”中费力地提取出它需要的结构化信息。
- 状态访问困难:任何一个节点想要获取之前某个节点产生的数据(比如计划),都必须去遍历整个
messages列表,并进行复杂的解析。这既不高效,也不可靠。
重构方案
精心设计你的 AgentState!把它当作一个数据库的表结构来设计。
# 重构后的 AgentState
class AgentState(TypedDict):
# 核心任务
task: str
# 用于 LLM 对话的核心历史
messages: Annotated[List[BaseMessage], operator.add]
# Supervisor 产生的结构化数据
plan: Optional[List[str]]
# Workers 产生的结构化数据
worker_outputs: Optional[Dict[str, str]]
# Aggregator 产生的最终结果
final_report: Optional[str]
这种设计的好处是显而易见的:
- 职责清晰:
messages只负责对话。plan只负责存储计划。各司其职。 - 访问高效:任何节点想获取计划,只需
state['plan']即可,简单、直接、高效。 - Token 优化:在调用某个节点(比如 Worker)时,我们可以只把
state['task']和state['plan']中分配给它的那一部分传给它,而不需要传入完整的、包含无关信息的messages历史。
核心启示:AgentState 是你多智能体系统的“单一事实来源”(Single Source of Truth)。花时间精心设计它,是构建一个健壮、高效系统的最佳投资。
5. “学霸”的作业长啥样?一个优秀架构的最佳实践
综合以上分析,一个堪称“学霸”级别的 DeepResearch 项目,应该具备以下特质:
- 模块化(Modularity):代码被清晰地组织在不同文件中。
main.py负责组装和运行,graph.py负责定义图的结构,nodes.py负责实现所有节点函数,state.py负责定义AgentState。 - 动态化(Dynamism):Supervisor 能够根据任务动态生成计划,图的执行逻辑(特别是并行 Worker 的数量和调用)能够自适应地根据
State中的计划进行调整。 - 结构化状态(Structured State):
AgentState设计得非常完善,包含了任务、计划、中间产物、最终结果等多个专用字段,而不仅仅是一个messages列表。 - 健壮性(Robustness):代码考虑了异常情况。例如,如果某个 Worker 执行失败,它不会导致整个系统崩溃,而是会将错误信息存入
State,Supervisor 在下一轮决策中可以看到这个错误,并可能决定重试或将任务重新分配给其他 Worker。 - 持久化(Persistence):集成了 Checkpointer 机制,使得任何一个长时间的研究任务都可以被中断和恢复。
6. 总结:从“能跑就行”到“优雅健壮”的进阶之路
通过这次“作业点评”,我们看到了从一个功能“可用”的程序,到一个架构“优秀”的系统之间存在的巨大鸿沟。
初学者往往追求“能跑就行”,容易写出功能耦合、硬编码、状态管理混乱的代码。而一名优秀的 AI 架构师,则会始终思考:
- 我的节点职责是否单一?
- 我的协作方式是否足够动态?
- 我的状态管理是否足够清晰?
- 我的系统是否考虑了异常和扩展?
希望这次 Code Review 能帮助大家在未来的项目中,少走弯trouble,多一些架构师的深度思考,真正从“代码实现者”蜕变为“系统设计者”。