Day 11 / 共 20 天 · 第 3 周 执行引擎

Graph 模型深入

Day 03 建立了 Node/Link/Graph 的直觉,今天深入 data/graph.py:三层模型、计算字段、两级校验、版本/fork/子图拍平——为读执行引擎打好基础。

📍 你在整门课的位置 · 第 3 周 执行引擎
D10 SDK D11 Graph 模型深入 D12 ExecutionManager D13 数据流/拓扑
💡 今天的类比世界观:Graph 模型 = 一套"建筑蓝图" Day 03 画了草图,今天看正式蓝图:三层模型 = 封面信息 / 图纸主体 / 带家具的完整图(元数据、结构、含节点连线);计算字段自动推导 I/O = 蓝图自动帮你统计门窗数量(不用手数);两级校验 = 报建审图(先查结构合不合规,再查线路接得对不对);fork = 复印一份蓝图再改子图拍平 = 把"标准户型子图"展开合并进大图。今天都用"蓝图"来想。

👶 小白:为什么一张图要存很多版本,覆盖掉旧的不行吗?

👨‍🏫 老师:因为你在改图的同时,可能有正在运行的执行还在用旧版——直接覆盖会让跑到一半的任务"图纸突然变了"而错乱。存多版本就像建筑图纸留档:新楼按新图建,已开工的仍按当初那版图,历史可追溯、能回滚。这也是"可控、可复现"这条平台哲学的体现。

L01

三层 Graph 模型

🤔 痛点:一张图(Graph)在代码里到底"长什么样"? 你在画布上拖了几个 Block、连了几条线,点保存。后端拿到的是什么?如果只用一个大类装下所有信息(元数据 + 节点 + 连线 + 子图 + 用户 + 时间戳……),那"我只想列个图名清单"也得把整张图连节点全查出来——又慢又浪费。
💡 本质:Graph = "节点(Node) + 连线(Link)",再按用途分层加厚 一张图的核心就两样:Nodegraph.py:104,一个 Block 实例)和 Linkgraph.py:82,一条连线:source_id→sink_id)。AutoGPT 用继承把它包成"由薄到厚"的几层——列清单用薄的、真执行用最厚的 GraphModelgraph.py:385)。同一份数据,按场景取需要的那层。

Graph 用继承分层(graph.py:191-397),初学者记住递进关系:

  • GraphBaseMeta:191):核心元数据——version、is_active、name、description、instructions、forked_from_id。
  • BaseGraph:206):加 nodes + links,并提供一批计算字段(L02)。
  • Graph:348):加 sub_graphs(拍平的子图列表)。
  • GraphModel:385):最完整,带 user_id/created_at 和执行期全部计算字段。
为什么分这么多层? 不同场景用不同"厚度"的模型:只需要元数据(列个图列表)用薄的 GraphBaseMeta;要执行用最全的 GraphModel。分层让每个场景只带它需要的字段,减少不必要的数据加载/传输。继承复用了公共字段,不重复定义。这是数据建模的分层技巧。
🏭 生活类比:一套工厂流水线的图纸 把一个 Graph 想成一张工厂流水线的图纸:Node 是一个个工位(每个工位干一道工序),Link 是工位之间的传送带(前一个工位的产出顺着传送带送到下一个工位)。而"三层模型"就像同一张图纸的不同详细版本:贴在车间门口的只印个"产线名称+版本号"(薄的 GraphBaseMeta,扫一眼就知道是哪条线);发给工人施工的那份才画满所有工位和传送带(最厚的 GraphModel)。同一条产线,不同场合拿不同详细度的图纸——没必要每次都把整张大图搬出来。
🧠 一句话复述 Graph = 一张"工位(Node) + 传送带(Link)"的流水线图纸,代码里按"要看多细"分成薄→厚四层,执行时用最厚的那层。

简化版 → 真实版代码对照(帮你看懂继承是怎么"逐层加厚"的):

🧒 简化版(脑子里这么想)

class 薄图:      # 只有名字、版本
    name; version
class 中图(薄图): # 加上工位和传送带
    nodes; links
class 厚图(中图): # 加上用户、时间、执行期字段
    user_id; created_at

👨‍🏫 真实版(graph.py)

class GraphBaseMeta(...)   # :191 name/version/is_active
class BaseGraph(GraphBaseMeta):  # :206 + nodes/links + 计算字段
    nodes: list[Node]     # Node :104
    links: list[Link]     # Link :82
class Graph(BaseGraph):   # :348 + sub_graphs
class GraphModel(Graph, GraphMeta):  # :385 + user_id/created_at
数字具象:一张真实的 Agent 图通常就 几个到几十个节点、十几条连线——不是成千上万,所以"每次访问现算计算字段"(L02)完全不心疼。
L02

计算字段自动推导 I/O(Day 03 深化)

# graph.py:218 input_schema —— 扫描所有 BlockType.INPUT 节点
# graph.py:230 output_schema —— 扫描所有 BlockType.OUTPUT 节点
# 合成整个 Agent 对外的输入/输出 JSON schema
📝 举个例子:input_schema 是怎么"现算"出来的 假设你的图里放了两个 INPUT 块:一个口名 city(字符串)、一个口名 days(整数)。访问 graph.input_schema 时,代码扫一遍所有 BlockType.INPUT 节点,现场拼出:
输入 {nodes: [INPUT(city), INPUT(days)]} → 输出 {"city": {"type":"string"}, "days": {"type":"integer"}}
前端就据此渲染出"city 输入框 + days 数字框"。你在画布上删掉 days 块,下次访问 input_schema 自动就没有 days 了——不用改任何"schema 字段"。
读法:这些是 Pydantic 的 computed_field——不是存储的,而是每次访问时从图结构现算Agent 有哪些输入参数?扫一遍图里的 INPUT 块自动得出。前端表单、API 文档、调用参数全靠它。
"计算字段"为什么优于"存储字段"? 如果把 Agent 的 input_schema 存成一个独立字段,一旦图改了(加/删 INPUT 块),你得记得同步更新它——容易忘、容易不一致。计算字段"从图现算"——图是唯一真相,schema 永远和图一致,不可能不同步。代价是每次访问要算一下(但图不大,可忽略)。用"派生"代替"存储冗余"——消除不一致的经典手法。
L03

能力探测字段

还有一批计算字段"扫一遍节点判断图有没有某种能力"(graph.py:244-302):has_external_trigger(有 webhook 触发块吗)、has_human_in_the_loop(有需人工介入的块吗)、has_sensitive_action(有敏感操作吗)、trigger_setup_info 等。

这些"能力探测"有什么用? 平台需要知道一个 Agent 的"性质"来决定怎么对待它:有外部触发→需要注册 webhook;有人工介入→执行可能会暂停等人;有敏感操作→UI 上给用户警示。与其让用户手动标注"我这个 Agent 有没有 X",不如从图结构自动探测——放了对应类型的块,能力就有。又是"让数据自己描述自己"(L02 同一思想)。
L04

starting_nodes(Day 03 复习)

# graph.py:401
@property
def starting_nodes(self):
    outbound_nodes = {link.sink_id for link in self.links}   # 被连入的节点
    input_nodes = {n.id for n in self.nodes if n.block.block_type == BlockType.INPUT}
    return [n for n in self.nodes if n.id not in outbound_nodes or n.id in input_nodes]
读法:起始节点 = 没有入边的节点 + 所有 INPUT 节点——执行引擎从这里开始"灌数据"。Day 12 的执行引擎会用它确定"图从哪些节点开跑"。
Node 还有个健壮设计:若 Block 被删除,node.block 返回占位 _UnknownBlockBasegraph.py:1957),运行时只 yield 一个 error,避免整图崩溃NodeModel:135)是数据库版,多了 graph_id/version,stripped_for_export 导出时抹掉凭证/密钥/webhook(安全)。
L05

两级图校验(Day 03 深化)

validate_graphgraph.py:686)对主图和每个子图都跑,两大类:

  • 结构校验:995):每条 Link 两端节点/Block 存在、连接的输入输出口名字在 Block schema 里。还会把"源是 static_output 的连线"标记为 is_static=True:1041)。
  • 节点校验:735):必填字段已填或已连线(provided_inputs = input_default 的 key + 入边的 sink_name)、字段依赖、凭证 discriminator、auto-credentials 不能硬编码。
一条 Link(连线)的解剖 —— graph.py:82 上游 Node A source_id (:83) source_name(输出口名) 下游 Node B sink_id (:84) sink_name(输入口名) 数据顺着连线流动 is_static? (:87/:1042) 若源是 value/常量块 → is_static=True(该值可反复取用,Day 13)
一条 Link 记 4 个关键字段:源节点+源输出口名、汇节点+汇输入口名——校验就是核对这四者在各自 Block 的 schema 里真实存在;再判断要不要打 is_static 标记。
for_run=True(执行前)全量严校验;否则只校验 I/O/AGENT 节点。"保存时轻校验、运行时严校验"——让半成品能存盘,但真跑前堵死所有坑。(Day 03 讲过,这里看到源码位置。)provided_inputs 的逻辑很关键:一个输入口"有值"= 用户手填了(input_default)有连线连进来(入边)——两者之一即可,否则必填校验报错。
L06

版本管理

每个 (id, version) 是一行。__create_graphgraph.py:1623)保存时若版本已存在自动 +1:1662);is_active 标记当前活跃版本;set_graph_active_version:1436)激活某版本、把其它设非活跃。

为什么改图要存新版本而非覆盖?可回溯:能看到 Agent 的演进历史。② 可回滚:新版本改坏了,切回旧版本。③ 执行绑版本:一次执行记录了"用的是 v3",即使你后来改成 v4,那次执行的语义仍清晰(能重现)。④ 不影响正在跑的:你改图时,正在运行的旧版本执行不受影响。版本化 = 安全地演进 + 可追溯——生产系统对"配置/流程"的标准处理(和 eino 编译产物、git 版本一个道理)。
L07

fork 与子图拍平

  • forkfork_graph):复制整图,reassign_ids:621)给所有 node/link/graph 重新生成 UUID、清凭证引用、记 forked_from_id。像 git fork——基于别人的 Agent 改自己的。
  • 子图拍平get_sub_graphs:1375):从主图出发,广度优先逐层查出所有被 AgentExecutorBlock 引用的子图,拍平成 sub_graphs 列表。
为什么要"拍平"子图? Day 03 讲了 Agent-as-Block(一个 Agent 嵌进另一个)。执行引擎需要知道"这次执行涉及哪些图"(主图 + 所有嵌套的子图),才能一次性加载好。"拍平"= 把递归嵌套的子图结构,展开成一个扁平列表——执行前一次查全,避免执行到子图节点时才临时去查(慢、易错)。这是"执行前把依赖备齐"的准备工作,Day 12 执行引擎会用到。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 三层 Graph 模型为什么分层?
  • 计算字段(input_schema 等)为什么优于存储字段?
  • 能力探测字段有什么用?
  • 两级校验(for_run)为什么?provided_inputs 怎么判断输入齐没齐?
  • 版本管理为什么存新版不覆盖?子图为什么要拍平?

✋ 动手

P=autogpt_platform/backend/backend/data
sed -n '191,302p' $P/graph.py | head -60      # 三层模型 + 计算字段
sed -n '686,740p' $P/graph.py | head -40      # 校验
grep -n 'def fork_graph\|def get_sub_graphs\|set_graph_active_version' $P/graph.py
明天预告 · Day 12执行引擎 manager——心脏!读 executor/manager.py:ExecutionManager 进程架构(MQ + 线程池 + 事件总线)、一次图执行的完整生命周期、分布式锁去重。
← Day 10 SDK Day 12 · 执行引擎 →