Files
AgentLab/backend/app/engine/schema.py
T
ThearchyHelios 66b8969bc5 变量静态分析:把拼错一个变量名的静默失败说破
这套编排靠 {{ }} 串数据流,而模板取不到值时**渲染成空字符串**
(resolve_path 返回 None → _stringify → "")。那是为了让半成品的图也能跑,
代价是:拼错一个变量名不报错、不警告、不留任何痕迹——下游拿到一段空文本,
然后模型基于空内容一本正经地编一段回答出来。这是这套东西最大的调试黑洞,
而界面上此前没有任何地方提过它。

app/engine/variables.py 做纯静态分析(不用跑图、不用有运行记录——用户在
编排到一半时最需要它,而那时候还没有任何一次运行可看):

- 产出侧:input 字段、各节点的 assign_to、每个节点的 nodes.<id>
- 引用侧:递归扫所有配置字符串(output.fields[].value、code 的 files 都是
  嵌套的,只扫顶层等于没扫),认得过滤器和下标
- 三类问题:
  · 引用了没人产出的 —— 十有八九是拼错,用 difflib 给出"是不是想写…"
  · 引用早于产出 —— 变量确实存在、名字也对,但那一步在这之后才跑,这里
    取到的是空。这类最难查,因为表里查得到
  · 产出了没人用 —— 只报 info,不拦着跑

前两类接进 validate_graph,画布上直接标红/标黄。另外开了
POST /api/workflows/variables 返回完整的变量表(谁产出、谁在哪个字段引用),
给接下来的变量表界面和输入补全用。

tests/test_variables.py 8 条,包括循环图不死循环(loop 的 body 连回自身很
常见)、节点读自己上一轮写的变量不该误报。
2026-09-18 10:08:45 +08:00

243 lines
9.0 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 enum import StrEnum
from typing import Any, 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 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