数据截至 (上游 commit 0076f21220aa)
图引擎:一张 JSON 怎么变成可执行的 LangGraph
本章讲引擎的地基:
GraphEngine.__init__启动后,如何把前端的{nodes, edges}一步步编译成一张能跑的 LangGraph 状态图。
1. 这一步要解决什么
前端给的只是静态描述:一堆节点、一堆连线(JSON)。LangGraph 需要的是程序化构建:对每个节点 add_node(id, fn),对每条边 add_edge(a, b),分支处用 add_conditional_edges。
所以图引擎的核心任务,就是做这个翻译——并且翻译得正确,尤其是分支、汇聚、循环这些容易出错的结构。
2. 编译五步走
构造函数末尾只有两行,却是整条编译流水线的起点:
# graph_engine.py:69-70
self.build_edges()
self.build_nodes()
build_nodes()(graph_engine.py:262)内部按顺序做五件事:
① init_nodes 每个 node JSON → 节点对象,add_node 进 LangGraph,
顺带标出 start / end / 要中断的节点
② add_edge(START, start_node) / add_edge(end_node, END)
③ build_node_level 给每个节点算「从 start 出发的最长路径深度」
④ add_node_edge 逐节点翻译出边(普通边 / 条件边 / output 的 fake 边)
⑤ build_more_fan_in_node 处理「多入边」节点要不要等(见第 02 章)
最后:graph_builder.compile(checkpointer=..., interrupt_before=...)
下面拆开看关键的三步。
3. ① 节点实例化:NodeFactory 按 type 造对象
它要解决的小问题: JSON 里每个节点只有个 type 字符串("llm"、"condition"…),得变成真正能执行的对象。
思路: 经典工厂模式——一张 type → 类 的映射表,查表实例化。
# nodes/node_manage.py:16-30(节选)
NODE_CLASS_MAP = {
NodeType.START.value: StartNode,
NodeType.LLM.value: LLMNode,
NodeType.CONDITION.value: ConditionNode,
NodeType.OUTPUT.value: OutputNode,
# ... 共 13 种
}
NodeFactory.instance_node(nodes/node_manage.py:38)查表,查不到就抛 Unknown node type。
实例化时,每个节点对象拿到三样关键依赖:它的入边/出边(从 EdgeManage 查)、共享的 GraphState、回调。同时引擎记下三张映射,供后面编译用:
# graph_engine.py:224-228(节选)
self.nodes_map[node_data.id] = node_instance
self.nodes_fan_in[node_instance.id] = self.edges.get_source_node(node_instance.id) # 入边来源
self.nodes_next_nodes[node_instance.id] = self.edges.get_next_nodes(node_instance.id) # 全部下游
nodes_fan_in(每个节点有哪些上游)是第 02 章「要不要等」判定的输入。
两个特殊处理(init_nodes,graph_engine.py:207):
note类型直接跳过——它是前端的注释便签,不参与执行(graph_engine.py:216)。- 每个
output节点会额外造一个OutputFakeNode(graph_engine.py:255),用来承载「输出后要等用户交互」的中断。这是 HITL 的关键技巧,详见第 03 章。
4. ③ 节点层级:算「从 start 到我的最长路径」
它要解决的小问题: 后面判断「多入边节点要不要等上游」时,需要知道两个节点谁在前、谁在后。node_level 就是这个先后的度量。
思路: 从 start 出发做 DFS,每到一个节点,把它的 level 取「已有值」和「当前深度」的较大者——所以叫最长路径。遇到已访问过的节点就回头,天然处理了循环。
# graph_engine.py:190-205(简化)
def mark_node_level(node_id, node_map, level):
if node_id in node_map: # 本条路径上已来过 → 是个环,停
return
self.node_level[node_id] = max(self.node_level.get(node_id, 0), level)
node_map[node_id] = True
for nxt in self.edges.get_target_node(node_id) or []:
mark_node_level(nxt, node_map.copy(), level + 1) # copy:每条路径独立
注意 node_map.copy():用「本条路径的已访问集」而非全局集来判环——这样菱形结构(A→B、A→C、B→D、C→D)里 D 不会被误判成环,只有真正回指祖先才算环。
5. ④ 出边翻译:三种边、三种译法
add_node_edge(graph_engine.py:86)对每个节点决定怎么连下游。按节点类型分三类:
┌─ end / fake_output ───────────→ 不处理出边(end 已连 END;fake 由 output 统一接)
│
节点类型 ─┼─ output ─────────→ output → output_fake → add_conditional_edges(按用户选择路由)
│
├─ condition ─────→ add_conditional_edges(按条件判定路由)
│
└─ 普通节点 ───────→ add_edge(self, target);但若 target 是多入边节点,先跳过
(留给 build_more_fan_in_node 统一处理)
普通边最简单(graph_engine.py:118-125):对每个下游 add_edge。但有个关键跳过——如果下游是多入边节点,这里不连,留到第 02 章统一决定要不要等:
# graph_engine.py:121-125
if self.nodes_fan_in.get(node_id) and len(self.nodes_fan_in.get(node_id)) > 1:
continue # 多入边节点:延后处理
self.graph_builder.add_edge(node_instance.id, node_id)
条件边用 LangGraph 的 add_conditional_edges(graph_engine.py:111-114):传入节点的 route_node 函数和「可能去的所有目标」的映射。运行时 route_node 返回哪个 id,就走哪条边。ConditionNode.route_node 返回命中条件分支的目标(nodes/condition/condition.py:45)。
6. ⑤ 编译 + 递归上限
所有边连完后,一次 compile 收尾:
# graph_engine.py:287-290
self.graph = self.graph_builder.compile(checkpointer=MemorySaver(),
interrupt_before=interrupt_nodes)
self.graph_config['recursion_limit'] = max(
(len(nodes) - len(end_nodes) - 1) * self.max_steps, 1) + len(end_nodes) + 1
两个细节值得记:
checkpointer=MemorySaver():用内存检查点保存执行现场,这是「能从断点续跑」的前提(配合interrupt_before,见第 03 章)。recursion_limit按「节点数 × 单节点最大执行次数」动态算。因为支持循环,一个节点可能被反复执行;max_steps是每个节点的执行次数上限(在BaseNode.run里强制,nodes/base.py:215),recursion_limit则是整图的步数上限,防止死循环跑飞。