Files
AgentLab/backend/app/engine/schema.py
T
ThearchyHelios 66cc96b463 汇合节点只跑一次:长短支路不再把它触发两遍,循环的 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。内置模板里 ③④ 的成果节点是二选一汇合,行为不变。
2026-09-25 14:33:56 +08:00

305 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from collections import defaultdict
from enum import StrEnum
from typing import Any, Iterable, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class NodeType(StrEnum):
"""画布上可用的节点类型。新增类型 = 加一个枚举值 + 一个执行器 + 一个前端渲染。"""
INPUT = "input"
OUTPUT = "output"
LLM = "llm" # 单次模型调用
AGENT = "agent" # 带工具循环的 ReAct agent
SUPERVISOR = "supervisor" # 多 agent 调度
TOOL = "tool" # 直接调用一个工具
CODE = "code" # 沙箱里跑代码
BRANCH = "branch" # 条件分支
LOOP = "loop" # 循环 / 迭代
SUBGRAPH = "subgraph" # 嵌套另一个工作流
MEMORY = "memory" # 长期记忆读写
RETRIEVE = "retrieve" # 知识库检索
TRANSFORM = "transform" # 数据整形
HUMAN = "human" # 人工介入
VALIDATE = "validate" # 结构化校验 + 自动重试
METRICS = "metrics" # 口径卡:受控指标集,叙述层唯一合法的数字来源
class Position(BaseModel):
x: float = 0
y: float = 0
class NodeData(BaseModel):
"""节点上的可编辑内容。config 保持松散 dict,各执行器自己解析成强类型模型,
这样前端加字段不会让整张图反序列化失败。"""
label: str = ""
description: str = ""
config: dict[str, Any] = Field(default_factory=dict)
class GraphNode(BaseModel):
id: str
type: NodeType
position: Position = Field(default_factory=Position)
data: NodeData = Field(default_factory=NodeData)
@field_validator("id")
@classmethod
def _valid_id(cls, v: str) -> str:
if not v or not v.replace("_", "").replace("-", "").isalnum():
raise ValueError(f"节点 id 只能包含字母数字、下划线和连字符:{v!r}")
# LangGraph 保留了这两个名字
if v in ("__start__", "__end__"):
raise ValueError(f"{v!r} 是保留节点名")
return v
@property
def config(self) -> dict[str, Any]:
return self.data.config
@property
def title(self) -> str:
return self.data.label or self.id
class GraphEdge(BaseModel):
id: str = ""
source: str
target: str
# 分支节点用 sourceHandle 区分走哪条路:"true"/"false" 或自定义 case key
sourceHandle: str | None = None
targetHandle: str | None = None
label: str = ""
@model_validator(mode="after")
def _fill_id(self) -> "GraphEdge":
if not self.id:
self.id = f"{self.source}:{self.sourceHandle or ''}->{self.target}"
return self
@property
def branch_key(self) -> str | None:
return self.sourceHandle if self.sourceHandle not in (None, "out", "source") else None
class GraphSpec(BaseModel):
"""一整张可执行的图。前端 React Flow 的画布状态直接序列化成它。"""
nodes: list[GraphNode] = Field(default_factory=list)
edges: list[GraphEdge] = Field(default_factory=list)
viewport: dict[str, Any] = Field(default_factory=dict)
# 图级别的默认值,节点没配 provider/model 时回退到这里
defaults: dict[str, Any] = Field(default_factory=dict)
def node_map(self) -> dict[str, GraphNode]:
return {n.id: n for n in self.nodes}
def outgoing(self, node_id: str) -> list[GraphEdge]:
return [e for e in self.edges if e.source == node_id]
def incoming(self, node_id: str) -> list[GraphEdge]:
return [e for e in self.edges if e.target == node_id]
def entry_nodes(self) -> list[GraphNode]:
"""入口 = input 节点;没有 input 节点时退化为所有无入边的节点。"""
inputs = [n for n in self.nodes if n.type == NodeType.INPUT]
if inputs:
return inputs
return [n for n in self.nodes if not self.incoming(n.id)]
def terminal_nodes(self) -> list[GraphNode]:
outputs = [n for n in self.nodes if n.type == NodeType.OUTPUT]
if outputs:
return outputs
return [n for n in self.nodes if not self.outgoing(n.id)]
# --------------------------------------------------------------------------
# 校验
# --------------------------------------------------------------------------
class ValidationIssue(BaseModel):
level: Literal["error", "warning"] = "error"
node_id: str | None = None
edge_id: str | None = None
message: str
class ValidationResult(BaseModel):
ok: bool = True
issues: list[ValidationIssue] = Field(default_factory=list)
def add(self, message: str, *, level: str = "error", node_id: str | None = None,
edge_id: str | None = None) -> None:
self.issues.append(
ValidationIssue(level=level, message=message, node_id=node_id, edge_id=edge_id) # type: ignore[arg-type]
)
if level == "error":
self.ok = False
def topology_of(spec: GraphSpec) -> tuple[tuple, tuple]:
"""图的骨架:节点的 id 与类型、加上所有的边。**不含任何配置。**
断点续跑要用它。LangGraph 的 checkpoint 是按节点名存的(编译时
`builder.add_node(node.id, …)`),而节点配置是运行时从 spec 现读的
(`NodeContext.cfg`)。所以拿一张只改了配置的图去续,checkpoint 对得上,
前面跑过的节点结果照样能用;一旦增删节点或改连线,checkpoint 里的
通道状态就和新图对不上了——续下去跑出来的是一份似是而非的结果,
比直接报错糟得多。
这正是 resume 那边踩过的坑:不看有没有 checkpoint 就发 Command,
LangGraph 拿空 state 从 START 重跑,最后还落成 succeeded。
"""
return (
tuple(sorted((n.id, str(n.type)) for n in spec.nodes)),
tuple(sorted((e.source, e.target, e.sourceHandle or "") for e in spec.edges)),
)
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()
nodes = spec.node_map()
if not spec.nodes:
result.add("图是空的,先拖一个节点进来")
return result
# 引用完整性
for edge in spec.edges:
if edge.source not in nodes:
result.add(f"边指向了不存在的源节点 {edge.source!r}", edge_id=edge.id)
if edge.target not in nodes:
result.add(f"边指向了不存在的目标节点 {edge.target!r}", edge_id=edge.id)
if not spec.entry_nodes():
result.add("找不到入口:每个节点都有入边,图里存在环且没有起点")
# 节点级必填项
for node in spec.nodes:
cfg = node.config
if node.type == NodeType.BRANCH:
cases = cfg.get("cases") or []
if not cases:
result.add("分支节点至少要配一个条件分支", node_id=node.id)
handles = {e.sourceHandle for e in spec.outgoing(node.id)}
for case in cases:
key = case.get("key")
if key and key not in handles:
result.add(
f"分支 {key!r} 没有连出去的边", level="warning", node_id=node.id
)
if "default" not in handles:
result.add(
"建议给分支节点连一条 default 边,兜住所有条件都不满足的情况",
level="warning",
node_id=node.id,
)
elif node.type == NodeType.TOOL:
if not cfg.get("tool"):
result.add("工具节点还没选工具", node_id=node.id)
elif node.type == NodeType.SUBGRAPH:
if not cfg.get("workflow_id"):
result.add("子图节点还没选要嵌套的工作流", node_id=node.id)
elif node.type == NodeType.VALIDATE:
if not cfg.get("schema"):
result.add("校验节点需要一个 JSON Schema", node_id=node.id)
elif node.type == NodeType.METRICS:
defs = cfg.get("metrics") or []
if not defs:
result.add("口径卡还没有定义指标", node_id=node.id)
for d in defs:
if not d.get("id") or not d.get("expression"):
result.add(f"指标定义缺 id 或 expression:{d.get('id') or '(空)'}",
node_id=node.id)
elif node.type == NodeType.OUTPUT:
contract = cfg.get("contract")
if contract and not contract.get("metrics_from"):
result.add("出具契约缺 metrics_from(指标来自哪个口径卡节点)",
node_id=node.id)
elif node.type == NodeType.LOOP:
if not spec.outgoing(node.id):
result.add("循环节点没有循环体", node_id=node.id)
if node.type in (NodeType.LLM, NodeType.AGENT, NodeType.SUPERVISOR):
if not (cfg.get("model") or spec.defaults.get("model")):
result.add(
"没有指定模型,将回退到默认 provider",
level="warning",
node_id=node.id,
)
# 孤儿节点
for node in spec.nodes:
if node.type in (NodeType.INPUT, NodeType.OUTPUT):
continue
if not spec.incoming(node.id) and not spec.outgoing(node.id):
result.add("节点没有任何连线,不会被执行", level="warning", node_id=node.id)
# 变量问题也进校验结果:模板取不到值只会渲染成空字符串,不报错,
# 所以拼错一个变量名在运行时是完全静默的——只能在这里说破。
# 放在最后导入,避免 variables 反过来 import schema 形成环。
from app.engine.variables import analyze as _analyze_vars
for issue in _analyze_vars(spec).issues:
# info 级是"产出没人用"这类提示,不进校验(它不影响能不能跑)
if issue.level == "info":
continue
result.add(issue.message, level=issue.level, node_id=issue.node_id)
return result