数据截至 (上游 commit 4fb2db0ea903)
01 · 图模型:节点、边、触发与条件
本章讲清三件事:节点是什么数据结构、边携带哪些规则、
trigger与condition为什么是两个独立维度。这是看懂后面执行引擎的地基。
1. 节点(Node):带输入输出队列的执行单元
它要解决的小问题。 图里的每个框,既要能「接收上游的多条消息」,又要能「产出消息给下游」,还要记住「我这一轮被触发了没」。
Node 这个 dataclass 就是这一切的载体(entity/configs/node/node.py:62-80):
input: List[Message]/output: List[Message]—— 输入队列、输出队列。config: BaseConfig—— 类型专属配置(agent 的就是AgentConfig,loop 的就是LoopCounterConfig)。context_window: int—— 执行后保留多少条输入(见第 04 章)。predecessors/successors/_outgoing_edges—— 图结构。start_triggered: bool—— 是不是被显式标成了起点。
节点类型是插件化注册的,不是写死的 if-else。runtime/node/builtin_nodes.py:27-101 把每种类型注册进 registry:
| type | 干什么 | executor |
|---|---|---|
agent | 调 LLM + 工具,核心 | AgentNodeExecutor |
human | 暂停等人类输入 | HumanNodeExecutor |
python | 跑仓库内 Python 片段 | PythonNodeExecutor |
passthrough | 原样转发上游 输出 | PassthroughNodeExecutor |
literal | 每次被触发就吐出固定文本 | LiteralNodeExecutor |
loop_counter | 计数,达到上限才放行 | LoopCounterNodeExecutor |
loop_timer | 计时,到时间才放行 | LoopTimerNodeExecutor |
subgraph | 内嵌另一张图 | SubgraphNodeExecutor |
注册表机制见 runtime/node/registry.py:46-67 的 register_node_type——它同时把节点的配置 schema 注册进 schema_registry,这样前端「配置图」UI 能自动拿到每种节点的可填字段。
2. 边(EdgeLink):一条边携带的全部规则
它要解决的小问题。 「A 连到 B」远不止画根线。真实问题是:A 跑完后,要不要把数据搬给 B?满足什么条件才搬?搬完要不要把 B 点亮让它执行?
这三件事在 EdgeLink(entity/configs/node/node.py:37-56)里是三个独立字段:
| 字段 | 含义 | 直觉 |
|---|---|---|
condition | 放行条件(默认 true) | 「闸门开不开」 |
carry_data | 是否把上游 Message 入队到目标 | 「搬不搬货」 |
trigger | 是否把目标节点点亮(使其可执行) | 「按不按下游的启动按钮」 |
keep_message | 搬过去的消息打 keep 标记(清理时不删) | 「这条货是常驻库存」 |
clear_context / clear_kept_context | 入队前先清目标已有输入 | 「先清空 B 的桌面再放新文件」 |
trigger 与 condition 是两个正交维度(最容易混)
这是 ChatDev 图模型最关键、也最反直觉的一点。看 ChatDev_v1.yaml 里的两类边:
# 示意,摘自 ChatDev_v1.yaml:744-752
- from: USER
to: Programmer Coding
trigger: false # ← 只送数据、不启动
carry_data: true
keep_message: true
# 示意,摘自 ChatDev_v1.yaml:506-511
- from: Coding Phase Prompt for Assistant
to: Programmer Coding
trigger: true # ← 既送数据、又启动
carry_data: true
两条边都指向 Programmer Coding。USER 边 trigger: false 表示「我只是把用户任务作为背景塞进它的输入队列(且 keep_message,常驻),但不是我让它跑」;真正让它跑的是 trigger: true 的那条 prompt 边。
为什么这么设计? 因为一个 agent 节点常常需要「多份输入(任务 + 角色提示 + 上游产物)」拼成一次对话,但只该被其中一个信号启动一次。把「搬数据」和「启动」拆开,就能精确表达这种「多份素材、单点触发」的语义。
判定「节点是否被触发」的逻辑见 Node.is_triggered()(entity/configs/node/node.py:452-459):只要有一条 trigger=true 且 triggered=true 的入边,或自己是 start 节点,就算被点亮。
3. 边的执行:condition → 清理 → 搬数据 → 点亮
一条边在上游跑完后被处理时,顺序是固定的。核心在 EdgeConditionManager._process_with_condition(runtime/edge/conditions/base.py:91-175):
出边处理流程(从上到下):
1. 算 condition(把上游输出文本喂给 evaluator)
│ 不满足 → 直接 return,这条边当作不存在
▼ 满足
2. clear_context / clear_kept_context → 先清目标输入队列
3. carry_data?
是 → 准备 payload(可能经 payload_processor 改写)→ append 到目标 input
否 → 跳过搬数据
4. trigger?
是 → edge_link.triggered = True (目标在本轮变得可执行)
思路/直觉: 边是一个「带门的传送带」。门(condition)先决定通不通;通了才搬货(carry_data);搬完再决定按不按目标的启动键(trigger)。三步解耦,组合出极强的表达力。
4. 起点与「谁也点不亮的节点」
图必须显式声明起点。GraphManager._determine_start_nodes(workflow/graph_manager.py:273-319)要求 YAML 里 start: 至少有一个节点,否则直接报错(workflow/graph_manager.py:312-316)。起点节点在执行前被打上 start_triggered=True 并灌入初始任务消息(workflow/graph.py:288-298)。
建图后还会扫一遍「有前驱、但没有任何 trigger=true 入边」的节点并告警——它们永远不会执行(workflow/graph_manager.py:321-342)。这是给「画错图」的人的一个安全网。
5. 小结
- 节点 = 带 input/output 队列 + 触发态的执行单元,类型插件化注册。
- 边 =
condition(放不放行)×carry_data(搬不搬数据)×trigger(点不点亮)三个正交开关。 - 「多份素材、单点触发」靠
trigger:false+trigger:true的边组合表达。
下一章:执行器拿到这张图后,到底按什么顺序跑节点,以及循环/环怎么实现。