汇合节点只跑一次:长短支路不再把它触发两遍,循环的 done 出口也不再被提前放行

多条边汇入同一个节点时,编译器对每条入边各 add_edge 一次。LangGraph 对这种
接法的语义是"任一上游跑完就触发一次":两条支路一样长时,两次触发落在同一步
被合并,看不出问题;一长一短时,汇合节点先拿短支路的结果跑一次,长支路到了
再跑一次——模型调用、工具副作用、下游全部翻倍。循环体里更糟:每轮汇合两次,
循环节点就多推进一格,done 出口在循环跑到一半时就放行了,成果节点跑了 3 次。
README 写的"自动等待汇聚"只在支路等长时成立。

修法是给汇合节点加 defer(等图里其余待执行的任务都跑完再跑一次),而不是
屏障 add_edge([a, b], j):分支之后的二选一汇合,没走的那条永远不会到,屏障
会一直等,而 LangGraph 没有可执行任务时会安静结束——汇合节点和下游就被无声
跳过。代价写在 _join_nodes 的注释和 README 里:它也会等图里无关、恰好在跑的支路。

汇合 = 两个以上前向上游,循环回边不算。回边识别因此从 layout.py 挪到
schema.back_edges,加了 roots 参数:执行语义要从入口起 DFS,否则循环体里的
前向边可能被误判成回边;排版照旧按节点顺序起算,行为不变(check-canvas-layout
全过)。

新增 test_fan_in.py 5 例:长短支路、循环体内汇合(修前分别跑了 2 次和 4 次);
二选一汇合、被分支剪掉一路的汇合(守着别把修法做成屏障);回边不算汇合。
292 → 297 passed。内置模板里 ③④ 的成果节点是二选一汇合,行为不变。
This commit is contained in:
ThearchyHelios
2026-09-25 14:33:56 +08:00
parent 8905fe5e60
commit 66cc96b463
5 changed files with 303 additions and 42 deletions
+3 -1
View File
@@ -150,7 +150,9 @@ flowchart LR
{{ vars.items | json }} 过滤器:json / compact / upper / length / first …
```
连线即并行:单个节点连出多条边构成 fan-out,多条边汇入同一节点时自动等待汇聚。
连线即并行:单个节点连出多条边构成 fan-out,多条边汇入同一节点时自动等待汇聚——
汇合节点等图里其余待执行的任务都跑完再执行一次,支路一长一短也不会被触发两遍;
分支之后的二选一汇合不会去等没走的那条。代价是它也会等图里与它无关、恰好还在跑的支路。
自动排版按拓扑分层:并行的支路落在同一列,层内按中位数启发式排序以减少交叉,
分支节点的出口按 yes/no 的声明顺序自上而下,循环的回边不参与分层(否则循环体会
+33 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import time
from collections import defaultdict
from typing import Any, Awaitable, Callable
from langgraph.errors import GraphBubbleUp
@@ -10,7 +11,7 @@ from langgraph.graph import END, START, StateGraph
from app.core.events import EventType
from app.engine.context import NodeContext, NodeError, RunContext
from app.engine.nodes import control, human, io, knowledge, llm, metrics, multi, tools
from app.engine.schema import GraphNode, GraphSpec, NodeType
from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges
from app.engine.state import GraphState
NodeRunner = Callable[[GraphState, NodeContext], Awaitable[dict[str, Any]]]
@@ -238,6 +239,35 @@ def _make_router(
return route, path_map
def _join_nodes(spec: GraphSpec, node_map: dict[str, GraphNode]) -> set[str]:
"""有两个以上上游、要等汇齐了才能跑的节点。
每条入边各 add_edge 一次时,LangGraph 的语义是"任何一个上游跑完就触发
一次"。两条支路一样长,两次触发落在同一步里被合并,看不出问题;一长一短,
汇合节点就先拿着短的那边跑一次、长的到了再跑一次——模型调用和工具副作用
都翻倍,下游跟着重跑;循环体里的汇合还会让循环节点多推进一格,把 done
出口提前放行。
不用屏障(add_edge([a, b], j)):分支之后的二选一汇合,没走的那条永远
不会到,屏障会一直等,而 LangGraph 没有可执行任务时会安静地结束——汇合
节点和它下游的一切就这么被跳过了,连报错都没有。defer 的语义是"等图里
其余待执行的任务都跑完,再跑这一次":该到的都到齐了,没走的也不会被等。
代价是它也会等图里与它无关、恰好还在跑的支路。
循环回边不算:循环节点的入边是"入口 + 回边",那不是汇合。
"""
entries = [n.id for n in spec.entry_nodes()]
back = back_edges(node_map, spec.edges, roots=entries)
upstream: dict[str, set[str]] = defaultdict(set)
for e in spec.edges:
if e.source not in node_map or e.target not in node_map:
continue
if (e.source, e.target, e.sourceHandle or "") in back:
continue
upstream[e.target].add(e.source)
return {node_id for node_id, sources in upstream.items() if len(sources) >= 2}
def compile_graph(spec: GraphSpec, run_ctx: RunContext) -> StateGraph:
"""把图定义编译成未 compile 的 StateGraph。
@@ -246,9 +276,10 @@ def compile_graph(spec: GraphSpec, run_ctx: RunContext) -> StateGraph:
"""
builder = StateGraph(GraphState)
node_map = spec.node_map()
joins = _join_nodes(spec, node_map)
for node in spec.nodes:
builder.add_node(node.id, _wrap(node, run_ctx))
builder.add_node(node.id, _wrap(node, run_ctx), defer=node.id in joins)
# 入口
entries = spec.entry_nodes()
+2 -38
View File
@@ -25,7 +25,7 @@ from __future__ import annotations
from collections import defaultdict, deque
from statistics import median
from app.engine.schema import GraphNode, GraphSpec, NodeType
from app.engine.schema import GraphNode, GraphSpec, NodeType, back_edges
#: 画布节点的宽度。前端 NodeCard 的 `width: 238` 是写死的,这里必须跟着它——
#: 排版算出来的走廊是给连线用的,宽度估错了走廊就白留。
@@ -85,42 +85,6 @@ def _dedupe_edges(spec: GraphSpec, nodes: dict[str, GraphNode]) -> list:
return out
def _back_edges(nodes: dict[str, GraphNode], edges: list) -> set[tuple[str, str, str]]:
"""DFS 森林里的回边。剔掉它们,剩下的必然是无环的。
迭代式而不是递归:一张几百个节点的图不该因为递归深度挂掉。
"""
adj: dict[str, list] = defaultdict(list)
for e in edges:
adj[e.source].append(e)
WHITE, GREY, BLACK = 0, 1, 2
state = dict.fromkeys(nodes, WHITE)
back: set[tuple[str, str, str]] = set()
for root in nodes:
if state[root] != WHITE:
continue
stack: list[tuple[str, int]] = [(root, 0)]
state[root] = GREY
while stack:
node_id, i = stack[-1]
out = adj[node_id]
if i >= len(out):
state[node_id] = BLACK
stack.pop()
continue
stack[-1] = (node_id, i + 1)
edge = out[i]
target = edge.target
if state[target] == GREY:
back.add((edge.source, edge.target, edge.sourceHandle or ""))
elif state[target] == WHITE:
state[target] = GREY
stack.append((target, 0))
return back
def _ranks(
nodes: dict[str, GraphNode], edges: list, back: set[tuple[str, str, str]],
) -> dict[str, int]:
@@ -390,7 +354,7 @@ def auto_layout(
# 自环不参与分层:它不跨层,留着只会让"自己的前驱是自己"这种噪音渗进排序
edges = [e for e in edges if e.source != e.target]
back = _back_edges(nodes, edges)
back = back_edges(nodes, edges)
rank = _ranks(nodes, edges, back)
preds: dict[str, list[str]] = defaultdict(list)
+44 -1
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from collections import defaultdict
from enum import StrEnum
from typing import Any, Literal
from typing import Any, Iterable, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -167,6 +168,48 @@ def topology_of(spec: GraphSpec) -> tuple[tuple, tuple]:
)
def back_edges(
node_ids: Iterable[str], edges: Iterable[GraphEdge], *, roots: Iterable[str] = (),
) -> set[tuple[str, str, str]]:
"""DFS 森林里的回边,以 (source, target, sourceHandle) 表示。剔掉它们,剩下的必然无环。
哪条边算"回边"取决于 DFS 从哪里出发。排版不在乎,按节点顺序起算就行;
执行语义在乎——得从入口(roots)起算,否则循环体里的一条普通前向边可能被
误判成回边。两端有一端不在 node_ids 里的悬空边直接忽略。
迭代式而不是递归:一张几百个节点的图不该因为递归深度挂掉。
"""
WHITE, GREY, BLACK = 0, 1, 2
state = dict.fromkeys(node_ids, WHITE)
adj: dict[str, list[GraphEdge]] = defaultdict(list)
for e in edges:
if e.source in state and e.target in state:
adj[e.source].append(e)
back: set[tuple[str, str, str]] = set()
for root in [*roots, *state]:
if state.get(root) != WHITE:
continue
stack: list[tuple[str, int]] = [(root, 0)]
state[root] = GREY
while stack:
node_id, i = stack[-1]
out = adj[node_id]
if i >= len(out):
state[node_id] = BLACK
stack.pop()
continue
stack[-1] = (node_id, i + 1)
edge = out[i]
target = edge.target
if state[target] == GREY:
back.add((edge.source, edge.target, edge.sourceHandle or ""))
elif state[target] == WHITE:
state[target] = GREY
stack.append((target, 0))
return back
def validate_graph(spec: GraphSpec) -> ValidationResult:
"""编译前的静态检查。错误会挡住运行,警告只在画布上提示。"""
result = ValidationResult()
+221
View File
@@ -0,0 +1,221 @@
"""汇合节点只跑一次:等该到的都到了,再动手。
多条边汇入同一个节点时,编译器以前对每条入边各调一次 add_edge。LangGraph
对这种接法的语义是"任何一条入边的上游跑完,就触发一次"——两条支路一样长时,
两次触发落在同一步里被合并成一次执行,看起来一切正常;一长一短时,汇合节点
先拿着短支路的结果跑一次(长支路的数据还是旧的),长支路到了再跑一次。模型
调用、工具副作用全部翻倍,下游也跟着跑两遍,而第一次的半截结果已经进了事件流。
修法不能是一律改成屏障(add_edge([a, b], j)):分支之后的"二选一"汇合,
没走的那条永远不会到,屏障会一直等下去——而 LangGraph 在没有可执行任务时
会安静地结束运行,汇合节点和它下游的一切就这么被跳过了,连报错都没有。
所以这里守两头:长短支路只跑一次,分支剪掉的那条不许把汇合节点饿死。
"""
from __future__ import annotations
import asyncio
import pytest
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
from app.engine.compiler import compile_graph
from app.engine.context import RunContext
from app.engine.runner import run_manager
from app.engine.schema import GraphSpec
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
def node(nid, ntype, **config):
return {
"id": nid, "type": ntype, "position": {"x": 0, "y": 0},
"data": {"label": nid, "config": config},
}
def text(nid, template, assign_to=None):
cfg = {"mode": "template", "template": template}
if assign_to:
cfg["assign_to"] = assign_to
return node(nid, "transform", **cfg)
async def _run(graph, *, timeout=20.0):
run = await run_manager.start(graph=graph, input_payload={"question": "随便"})
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
async with SessionLocal() as session:
row = await session.get(Run, run.id)
if row and row.status in ("succeeded", "failed", "cancelled"):
return row
await asyncio.sleep(0.05)
raise AssertionError(f"运行 {run.id} 没有在 {timeout}s 内结束")
async def _starts(run_id: str) -> list[str]:
async with SessionLocal() as session:
rows = list((await session.execute(
RunEvent.__table__.select().where(RunEvent.run_id == run_id)
.order_by(RunEvent.seq)
)).mappings())
return [r["node_id"] for r in rows if r["type"] == "node.started" and r["node_id"]]
async def test_unequal_branches_join_once_with_both_results():
"""一长一短两条支路汇合:汇合节点只跑一次,而且拿到的是两边的最终结果。"""
graph = {
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
text("a1", "A", assign_to="a"),
text("a2", "{{ vars.a }}!", assign_to="a"),
text("b", "B", assign_to="b"),
text("j", "{{ vars.a }}+{{ vars.b }}", assign_to="joined"),
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.joined }}"}]),
],
"edges": [
{"source": "start", "target": "a1"},
{"source": "a1", "target": "a2"},
{"source": "a2", "target": "j"},
{"source": "start", "target": "b"},
{"source": "b", "target": "j"},
{"source": "j", "target": "out"},
],
}
run = await _run(graph)
assert run.status == "succeeded", run.error
starts = await _starts(run.id)
assert starts.count("j") == 1, f"汇合节点跑了 {starts.count('j')} 次:{starts}"
assert starts.count("out") == 1, f"下游跟着重跑了:{starts}"
assert run.output["结果"] == "A!+B"
async def test_branch_merge_does_not_wait_for_the_road_not_taken():
"""分支之后的二选一汇合:没走的那条永远不会到,不能一直等它。"""
graph = {
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
node("br", "branch", cases=[{"key": "yes", "condition": "true"}]),
text("x", "走了 yes", assign_to="road"),
text("y", "走了 default", assign_to="road"),
text("j", "汇合:{{ vars.road }}", assign_to="joined"),
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.joined }}"}]),
],
"edges": [
{"source": "start", "target": "br"},
{"source": "br", "target": "x", "sourceHandle": "yes"},
{"source": "br", "target": "y", "sourceHandle": "default"},
{"source": "x", "target": "j"},
{"source": "y", "target": "j"},
{"source": "j", "target": "out"},
],
}
run = await _run(graph)
assert run.status == "succeeded", run.error
starts = await _starts(run.id)
assert "y" not in starts
assert starts.count("j") == 1, starts
assert run.output["结果"] == "汇合:走了 yes"
async def test_join_still_runs_when_one_feeder_is_pruned_by_a_branch():
"""一条支路被分支整个剪掉:汇合节点照样要跑,下游不能被安静地跳过。"""
graph = {
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
text("c", "C", assign_to="c"),
node("br", "branch", cases=[{"key": "yes", "condition": "false"}]),
text("x", "X", assign_to="x"),
text("j", "只有 {{ vars.c }}", assign_to="joined"),
node("out", "output", fields=[{"name": "结果", "value": "{{ vars.joined }}"}]),
],
"edges": [
{"source": "start", "target": "c"},
{"source": "start", "target": "br"},
# default 出口没接线:分支判 default 就在这里结束,x 永远不会跑
{"source": "br", "target": "x", "sourceHandle": "yes"},
{"source": "c", "target": "j"},
{"source": "x", "target": "j"},
{"source": "j", "target": "out"},
],
}
run = await _run(graph)
assert run.status == "succeeded", run.error
starts = await _starts(run.id)
assert "x" not in starts
assert starts.count("j") == 1, starts
assert run.output["结果"] == "只有 C"
async def test_join_inside_a_loop_runs_once_per_iteration():
"""循环体里的长短汇合:每一轮汇合一次,不多不少。"""
graph = {
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
node("lp", "loop", mode="foreach", items="[1, 2]", max_iterations=5),
text("p", "第 {{ vars.item }} 轮"),
text("q1", "长"),
text("q2", "长长"),
text("r", "短"),
text("j", "合"),
node("out", "output", fields=[{"name": "结果", "value": "完"}]),
],
"edges": [
{"source": "start", "target": "lp"},
{"source": "lp", "target": "p", "sourceHandle": "body"},
{"source": "p", "target": "q1"},
{"source": "q1", "target": "q2"},
{"source": "p", "target": "r"},
{"source": "q2", "target": "j"},
{"source": "r", "target": "j"},
{"source": "j", "target": "lp"},
{"source": "lp", "target": "out", "sourceHandle": "done"},
],
}
run = await _run(graph)
assert run.status == "succeeded", run.error
starts = await _starts(run.id)
assert starts.count("p") == 2, starts
assert starts.count("j") == 2, f"两轮应该各汇合一次:{starts}"
def test_loop_back_edge_does_not_make_the_loop_node_a_join():
"""循环节点的入边是"入口 + 回边",这不是汇合,不该被推迟。
推迟只会让它白等图里其他还在跑的支路;真正需要等的是有两条以上
前向入边的节点。
"""
graph = GraphSpec.model_validate({
"nodes": [
node("start", "input", fields=[{"name": "question"}]),
node("lp", "loop", mode="foreach", items="[1]"),
text("body", "x"),
text("a", "a"),
text("b", "b"),
text("j", "j"),
node("out", "output", fields=[]),
],
"edges": [
{"source": "start", "target": "lp"},
{"source": "lp", "target": "body", "sourceHandle": "body"},
{"source": "body", "target": "lp"},
{"source": "lp", "target": "a", "sourceHandle": "done"},
{"source": "lp", "target": "b", "sourceHandle": "done"},
{"source": "a", "target": "j"},
{"source": "b", "target": "j"},
{"source": "j", "target": "out"},
],
})
builder = compile_graph(graph, RunContext(run_id="t", thread_id="t", spec=graph))
deferred = {nid for nid, spec in builder.nodes.items() if spec.defer}
assert deferred == {"j"}