Day 03 / 共 20 天 · 第 1 周 核心概念

Graph:Agent 就是 Block 图

第二个核心概念。一个 AI 智能体 = 一张由 Block 连成的图。今天读 data/graph.py:Node(节点)+ Link(连线)+ Graph(容器)怎么表达一个智能体。

📍 你在整门课的位置 · 第 1 周 核心概念
D2 Block 概念 D3 Graph 图 D4 运行平台 D5 执行旅程
💡 今天的类比世界观:Graph = 一张"地铁线路图" 一个 Agent = 三样东西,用地铁来记:Node = 站点(图里摆的一台具体设备)、Link = 连接站点的轨道(数据顺着轨道从上一站流到下一站)、Graph = 整张线路网starting node = 始发站(列车从哪发车);图校验 = 通车前的安全检查(有没有断头轨、孤岛站、接错口)。今天都用"线路图"来想。

👶 小白:Node 和 Block 到底啥区别?听起来都是"一个方块"。

👨‍🏫 老师:Block 是"设备型号",Node 是"这张图里摆的那一台具体设备"。就像"格力空调 KFR-35"是型号(Block 类),你家客厅那台、卧室那台是两个实例(两个 Node)——同型号,但各有自己的位置、配置和连线。所以一个 Block 可以在同一张 Graph 里被摆成好几个 Node,各干各的。

L01

一个 Agent = 三样东西

🤔 痛点:有了一堆 Block,怎么"记住"它们该怎么连、按什么顺序跑? Day 02 我们有了积木,但积木散落一地没用。谁的输出接谁的输入?先跑哪个后跑哪个?这套"连接关系"必须被存成一份能保存、能校验、能执行的数据,而不是只画在纸上。
💡 本质:Agent = 一张有向图(Graph)= 节点 + 连线 把每个 Block 摆成一个节点(Node),用连线(Link)把上游输出口接到下游输入口,整张图(Graph)就完整描述了"这个 Agent 由哪些能力、按什么数据流组成"。所以"搭 Agent"本质就是"建一张图",前端画布画的、后端存的、引擎跑的,都是这同一张图。

在数据层,一个 Agent 被表达成三样东西(data/graph.py):

  • Node(节点):图里的一个格子,绑定一个 Block(Day 02)。
  • Link(连线):一根"管子",把某节点的输出口连到另一节点的输入口。
  • Graph(图):把 Node 和 Link 装起来的容器 = 一个智能体。
Node1读网页—Link→ Node2LLM总结—Link→ Node3发邮件
对应前端画布 前端画布上一个方块 = 一个 Node(绑定某 block_id);方块之间的连线 = 一条 Link;整个画布 = 一个 Graph。你在界面上拖 Block、连线,前端就在构造这个 Graph 数据结构,存到后端。运行时执行引擎(第3周)按图拓扑跑各 Node。可视化画布和后端数据模型是一一对应的——理解了 Node/Link/Graph,就理解了画布背后的一切。
L02

Link:连线

# graph.py:82
class Link(BaseDbModel):
    source_id: str      # 源节点 id
    sink_id: str        # 目标节点 id
    source_name: str    # 源节点的哪个输出口
    sink_name: str      # 目标节点的哪个输入口
    is_static: bool = False
读法:一条 Link 精确描述"A 节点的 result 输出口 → B 节点的 topic 输入口"。source_name/sink_name 让同一节点能有多个输入/输出口,图能精确表达数据流向。
Node A · 读网页 source_id 输出口 result Node B · LLM 总结 sink_id 输入口 topic 一条 Link source_name=result → sink_name=topic
一条 Link = 四个字段精确定位:把 source 节点的某个"输出口"接到 sink 节点的某个"输入口"。因此同一节点能有多个口,数据流向毫不含糊。
is_static(静态连线)是什么? 普通连线:值流过去,被下游消费一次就没了。静态连线:源是一个"常量型"输出(Day 02 的 static_output Block),值可以被反复复用——比如一个"配置值"块的输出连到多个下游、或在循环里每轮都能用。执行引擎(Day 13)对静态连线有特殊处理:会用"最近一次的值"回填等待中的节点。static 让"常量/配置"能在图里被多处、多次引用,不会用一次就消失。
L03

Node:节点

# graph.py:104
class Node(BaseDbModel):
    block_id: str                   # 指向某个 Block 的实现(Day 02)
    input_default: BlockInput       # dict[输入口名, 用户手填的默认/常量值]
    input_links: list[Link]         # 进来的连线
    output_links: list[Link]        # 出去的连线
# node.block 属性(graph.py:122)用 get_block(block_id) 取回真正的 Block
读法:Node 本身不含逻辑——逻辑在它引用的 Block 里(node.block)。Node 只存"我用哪个 Block(block_id)+ 用户在画布上手填了哪些输入(input_default)+ 我连了哪些线"。
input_default 是什么? 有些输入口的值来自上游连线(动态),有些是用户在画布上直接手填的常量(比如"发邮件"块的"收件人"填死是你的邮箱)——后者就存在 input_default。所以一个节点的完整输入 = input_default(手填的)+ 入边传来的(上游给的)。如果一个必填口既没手填也没连线,校验会报错(L06)。若 Block 被删了,node.block 返回占位对象,运行时只 yield 一个 error,不会让整图崩——健壮性设计。
L04

Graph 容器与自动 I/O schema

Graphgraph.py 分层:GraphBaseMeta → BaseGraph → Graph → GraphModel)持有 nodes + links + 元数据(version、name、instructions…)。最妙的是计算字段自动推导 Agent 的对外 I/O

# graph.py:218 input_schema —— 扫描所有 BlockType.INPUT 节点
# graph.py:230 output_schema —— 扫描所有 BlockType.OUTPUT 节点
# 合成整个 Agent 对外的输入/输出 JSON schema
读法:Agent 有哪些输入参数,不用人工声明——框架扫描图里有哪些 INPUT 类型的 Block 节点自动推导出来。你在图里放了一个"输入:topic"的 INPUT 块,这个 Agent 就自动有了一个 topic 输入参数。
为什么"自动推导 I/O"很聪明? 前端表单、API 文档、调用参数都需要知道"这个 Agent 要什么输入、产出什么输出"。如果让你手动维护一份 schema,容易和图不一致。从图结构自动推导——图里放了哪些 INPUT/OUTPUT 块,Agent 就有哪些输入/输出,永远同步。还有 has_external_triggerhas_human_in_the_loop 等计算字段,都是"扫一遍节点判断图有没有某能力"。让数据结构自己描述自己——减少人工维护、杜绝不一致。
L05

起始节点:从哪开始跑

# graph.py:401
@property
def starting_nodes(self):
    outbound_nodes = {link.sink_id for link in self.links}  # 所有"被连入"的节点
    input_nodes = {node.id for node in self.nodes if node.block.block_type == BlockType.INPUT}
    return [node for node in self.nodes
            if node.id not in outbound_nodes or node.id in input_nodes]
读法:起始节点 = 没有任何入边的节点(没人给它喂数据,所以它是源头)+ 所有 INPUT 节点。执行引擎从这些点开始"灌数据",然后顺着连线往下流。
📝 举个例子:这张图的起点算出来是哪几个 图有 3 个节点、2 条线:读网页(N1) → LLM总结(N2) → 发邮件(N3)
outbound_nodes(被连入的)= {N2, N3}(N2 被 N1 连、N3 被 N2 连);
• N1 不在 outbound_nodes 里 → N1 是起始节点
• 若把 N1 换成一个 BlockType.INPUT 节点,即使它被连入也会因"是 INPUT"被强制算作起点。
结论:引擎从 [N1] 开跑,跑完 N1 的输出灌给 N2,再灌给 N3。
为什么这么定起点? 拓扑执行(Day 13)需要知道"从哪开始"。一个没有入边的节点,意味着它不依赖任何上游、可以立即执行(比如"定时触发"块、或用户手填了所有输入的块)。INPUT 节点则是"接收 Agent 外部输入"的入口。从源头灌入,数据像水一样顺着连线往下流,直到没有节点可跑——这就是图执行的直觉。Day 12/13 看执行引擎怎么实现。
L06

图校验

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

  • 结构校验:995):每条 Link 两端节点必须存在、Block 必须存在、连接的输入/输出口名字必须在 Block schema 里。
  • 节点校验:735):必填字段是否已填或已连线、字段间依赖、凭证字段等。
"保存时轻校验、运行时严校验" 有个参数 for_runfor_run=True(真要跑之前)做全量严格校验——把所有坑堵住;否则只校验 I/O 节点。为什么分两档?让你编辑中的半成品图也能存盘(不强求填完所有字段),但真要执行前把所有问题暴露。这又是"fail-fast + 兼顾编辑体验"的平衡——和 CrewAI 的声明式校验、eino 的编译期检查同一哲学:错误尽早暴露,但别妨碍开发中途保存。
L07

版本、fork 与子图

  • 版本:每个 (id, version) 是一行;保存时若版本已存在自动 +1(graph.py:1662)。is_active 标记当前活跃版本。改了图不覆盖旧版,而是存新版——能回溯、能回滚。
  • forkfork_graph):复制整图,给所有 node/link 重新生成 UUID、清掉凭证引用、记 forked_from_id像 git fork——基于别人的 Agent 改出自己的。
  • 子图(Agent-as-Block):一个 Agent 能作为一个 Block 嵌进另一个 Agent!AgentExecutorBlockblocks/agent.py,block_type=AGENT)——它的输入存"要跑哪个子图 + 喂什么",运行时发起一次完整的子图执行。
Agent-as-Block 是杀手锏 你可以把一个搭好的 Agent 当成一块积木,嵌到更大的 Agent 里。比如把"网页摘要"Agent 当一个块,用在"每日简报"Agent 里。这就是递归组合——复杂 Agent 由小 Agent 拼装,执行引擎对子图一视同仁(子图就是又一次完整的图执行)。和 eino 的 AddGraphNode(子图嵌套)、CrewAI 的 Flow 内嵌 Crew 同一思想——组合是复用的终极形态。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • Agent 由哪三样东西表达?和前端画布怎么对应?
  • Link 的四个关键字段?is_static 是什么?
  • Node 为什么不含逻辑?input_default 是什么?
  • Agent 的 I/O schema 为什么能自动推导?起始节点怎么定?
  • 版本/fork/子图各解决什么?Agent-as-Block 为什么强大?

✋ 动手

P=autogpt_platform/backend/backend
sed -n '82,133p' $P/data/graph.py          # Link + Node
sed -n '218,240p' $P/data/graph.py         # 自动 I/O schema
sed -n '401,411p' $P/data/graph.py         # 起始节点
sed -n '24,50p' $P/blocks/agent.py         # Agent-as-Block
明天预告 · Day 04:概念够了,跑起来!运行平台——docker compose 启动后端 + 前端 + Postgres + Redis + RabbitMQ + Supabase 全家桶,看这个生产级 SaaS 的进程编排。
← Day 02 Block Day 04 · 运行平台 →