图执行引擎:strategy / subgraph / node / edge 与主循环
30 秒导读: Koog 把一个 agent 的行为画成一张有向图——节点(node)是"做一件事",边(edge)是"做完这件事后往哪走"。整张图由一个统一引擎驱动:一个
while(true)主循环,反复"跑当前节点 → 按边挑下一个节点",直到走到终点节点(finish)才停。本章只讲这套骨架和 控制流,不讲怎么用 DSL 搭图(见 02),也不讲节点里具体调 LLM 做了什么(见 02/03)。
本章所有源码引用都在克隆的这个目录下,为简洁,下文路径省略这段公共前缀:
agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/例如下文写agent/entity/AIAgentSubgraph.kt:288,完整路径是agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/entity/AIAgentSubgraph.kt:288。
1. 这是什么(零基础也能懂)
一句话定义: Koog 的执行引擎,就是把"agent 该怎么一步步干活"写成一张流程图,然后用一台通用的图解释器去跑它。
它要解决的问题。 一个 agent 干活通常不是一条直线,而是"想一下 → 要不要调工具 → 调完再想 → 够了就收尾"这种带分支、带循环的过程。如果每个 agent 都手写一套 if/else + while,代码会乱成一团、没法复用、没法插桩观测。Koog 的做法是:把"走哪一步"这件事从业务代码里抽出来,交给一个统一引擎,业务只需声明"有哪些节点、节点之间怎么连"。
一句话直觉/类比。 把它想成地铁网:
- 节点(node) = 车站,每站干一件具体的事(比如"问一次 LLM""执行一个工具")。
- 边(edge) = 轨道,决定"这站办完事之后开往哪一站";轨道上还能装闸机(条件),不满足条件就不走这条。
- 引擎 = 那台一直在开的列车:到站(执行节点)→ 看该走哪条轨道(选边)→ 开过去,循环往复,到终点站(finish)才停。
四个承重概念(本章从头到尾就围着这四个转):
| 概念 | 白话 | 代码里是谁 |
|---|---|---|
| strategy(策略) | 整张图 = 一个 agent 的完整行为剧本 | AIAgentGraphStrategy(本质是"根子图") |
| subgraph(子图) | 图里可嵌套的一块,自带起点/终点,可局部换工具 | AIAgentSubgraphBase |
| node(节点) | 图里一个执行单元,做一件事,输出一个值 | AIAgentNodeBase |
| edge(边) | 有向连线,决定下一步去哪,可转换/过滤数据 | AIAgentEdge |
本节不出现底层代码。记住一句话就行:Koog 把 agent 建模成有向图,再用一个统一循环去驱动它。
2. 顶层全景(它大概怎么转)
2.1 从"调用 agent"到"图开始跑"的调用链
先看外层入口,搞清楚"我调 agent.run(...) 之后,控制权是怎么一路传到那台图解释器的"。
用户代码
│ agent.run(input)
▼
AIAgent<Input,Output> 顶层接口:只声明 run / createSession
│ (agent/AIAgent.kt:15 expect abstract class)
▼
GraphAIAgent 图式 agent 的实现:持有 strategy + promptExecutor + toolRegistry
│ (agent/GraphAIAgent.kt:48)
│ run() 建一个 Session
▼
AIAgentRunSessionImpl 一次运行的生命周期管理:准备 context、发 pipeline 事件
│ (agent/AIAgentRunSessionImpl.kt:88 调 strategy.execute)
▼
AIAgentGraphStrategy.execute 策略 = 根子图;负责 checkpoint 恢复钩子,再委托给子图引擎
│ (agent/entity/AIAgentGraphStrategy.kt:74)
▼
AIAgentSubgraphBase ★ 真正的图执行引擎 ★
.executeWithInnerContext while(true) 主循环在这里
(agent/entity/AIAgentSubgraph.kt:270)
怎么读这张图: 从上到下是"越来越靠近引擎内核"。前三层是脚手架(接口、运行会话、策略包装),第四层 AIAgentSubgraphBase 才是本章的主角——那个 while(true) 循环就住在它里面。
2.2 部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
AIAgent | 顶层抽象接口,只规定 run / createSession;是 expect abstract class,多平台 | agent/AIAgent.kt:15 |
GraphAIAgent | 图式 agent 的具体实现,组装 strategy / executor / tools / features,准备执行 context | agent/GraphAIAgent.kt:48 |
AIAgentGraphStrategy | 策略,本质是"根子图";额外承担 checkpoint 恢复(restoreStateIfNeeded) | agent/entity/AIAgentGraphStrategy.kt:26 |
AIAgentSubgraphBase | 图执行引擎:选工具 → 跑主循环 → 发子图事件 | agent/entity/AIAgentSubgraph.kt:48 |
AIAgentNodeBase | 节点基类:持有出边列表 edges、负责 resolveEdge 选边 | agent/entity/AIAgentNode.kt:22 |
AIAgentEdge | 有向边:forwardOutput 返回 Option,命中/不命中 | agent/entity/AIAgentEdge.kt:23 |
ExecutionPointNode | 让子图能"强制从某个节点、带某个输入继续"——支撑跳转与 checkpoint | agent/entity/ExecutionPointNode.kt:9 |
2.3 主线走一遍(高层,不进代码)
一次 agent.run(input) 的大致流向:
run(input)
→ 建 Session,准备 AIAgentGraphContext(装 LLM 上下文、状态、存储、pipeline)
→ strategy.execute(context, input)
→ 先看有没有 checkpoint 要恢复(有就把执行点搬到断点处)
→ 进入子图引擎 executeWithInnerContext:
currentNode = 起点; currentInput = input
loop:
iterations++;若超上限 → 抛异常
output = currentNode 执行(input)
若被请求"中断"(跳转/回滚) → return null(交给上层重跑)
edge = 在 currentNode 的出边里,挑第一条"命中"的
若没有命中的边:
是 finish 节点 → 收尾,退出循环
不是 finish → 抛"卡死"异常
否则:currentNode = edge.目标节点; currentInput = edge 转换后的值
→ 返回 finish 节点的输出
看懂这段"大盘",下面第 3 节就逐个把关键机制拆开讲。
3. 核心原理(逐个机制,由浅入深)
3.1 节点与边:图的两种"零件"
节点:一个执行单元 + 一串出边
节点基类是 AIAgentNodeBase<in TInput, TOutput>——泛型说明它吃一个 TInput、吐一个 TOutput。它身上最关键的两样东西:
- 出边列表
edges:这个节点能通向哪些下家。它只能追加、不能乱改(private set,只经addEdge扩展),而且有序——选边时按顺序匹配,第一条命中的胜出。
真实源码(agent/entity/AIAgentNode.kt:52-64,edges 与 addEdge):
public var edges: List<AIAgentEdge<TOutput, *>> = emptyList()
private set
public open fun addEdge(edge: AIAgentEdge<TOutput, *>) {
edges = edges + edge
}
这段说明:边是一条条追加进来的,顺序就是声明顺序,后面选边靠的正是这个顺序。
- 执行方法
execute:节点干的活。注意它的返回类型是TOutput?——允许返回null,表示"这次没产出、待会儿要重来"(用于中断/恢复,见 3.4)。普通节点(SimpleAIAgentNodeImpl)总是返回非空值。
引擎调节点时走的是去类型化的包装 executeUnsafe(agent/entity/AIAgentNode.kt:133),内部把 Any? 强转成 TInput 再调 execute——因为引擎主循环里 currentInput 是 Any?,编译期对不上具体类型,只能在这一层做 unchecked cast。
边:带条件的有向连线
边 AIAgentEdge<IncomingOutput, OutgoingInput> 连接源节点的输出类型和目标节点的输入类型。它的灵魂是那个 forwardOutput 函数(agent/entity/AIAgentEdge.kt:23-26):
public expect class AIAgentEdge<IncomingOutput, OutgoingInput> internal constructor(
fromNode: AIAgentNodeBase<*, IncomingOutput>,
toNode: AIAgentNodeBase<OutgoingInput, *>,
forwardOutput: suspend (context, output: IncomingOutput) -> Option<OutgoingInput>,
)
关键设计:forwardOutput 返回 Option(Koog 内部的"有值/无值"容器,agent/utils/Option.kt:Some 装值、None 表示空)。这一个返回值同时编码了两件事:
forwardOutput 返回 | 含义 | 引擎的反应 |
|---|---|---|
Some(转换后的值) | 这条边命中了 | 走这条边,把 Some 里的值当作下一个节点的输入 |
None | 这条边不命中(条件不满足) | 跳过它,继续试下一条边 |
也就是说,"条件判断"和"数据转换"被合并进同一个函数:一条边既可以无脑透传(forwardTo 默认 Some(output),见 agent/entity/AIAgentNode.kt:145-153),也可以在 forwardOutput 里做过滤(不满足就 None)或映射(满足就 Some(变形后的值))。这就是 Koog 表达"分支"的方式:同一个节点挂多条边,每条边自己判 断要不要接这单。
AIAgentEdge是expect class,JVM 侧的actual(jvmCommonMain/.../AIAgentEdge.kt)里forwardOutputUnsafe做output as IncomingOutput的去类型化转发,和节点的executeUnsafe是一对配套的"擦除类型"入口。
选边:resolveEdge 的"顺序匹配、命中即停"
节点跑完后,引擎要决定"往哪走"——这就是 resolveEdge(agent/entity/AIAgentNode.kt:87-100):
public suspend fun resolveEdge(context, nodeOutput: TOutput): ResolvedEdge? {
for (currentEdge in edges) {
val output = currentEdge.forwardOutputUnsafe(nodeOutput, context)
if (!output.isEmpty) { // 命中(Some)
return ResolvedEdge(currentEdge, output.value)
}
}
return null // 没有任何边命中
}
语义两句话:
- 按
edges顺序逐条试,第一条返回Some的边就是答案,连同它转换后的输出打包成ResolvedEdge返回。 - 一条都不命中就返回
null——这个null是主循环的重要信号(见 3.3:它要么意味着"到终点了",要么意味着"卡死了")。
选边流程图:
节点输出 nodeOutput
│
├─ 边①.forwardOutput → Some? ── 是 ──▶ 走边①,输入 = Some.value (停止,不再试后面)
│ └ 否(None)
├─ 边②.forwardOutput → Some? ── 是 ──▶ 走边②
│ └ 否
├─ 边③ … 依次
│
└─ 全部 None ──▶ resolveEdge 返回 null
3.2 子图与策略:图可以套图,策略是"最外层那张图"
子图 = 可嵌套、可换工具的一块图
AIAgentSubgraphBase(agent/entity/AIAgentSubgraph.kt:48)本身也是一个 AIAgentNodeBase——这是 Koog 的一个漂亮设计:子图对外看就是一个"大节点",有输入有输出,可以像普通节点一样嵌进更大的图里。它的输入/输出类型直接取自内部的起点/终点节点(inputType = start.inputType、outputType = finish.outputType,agent/entity/AIAgentSubgraph.kt:58-59)。
子图比普通节点多出来的能力,是可以为自己这一段局部覆盖运行环境:
| 子图可局部覆盖的东西 | 参数 | 作用 |
|---|---|---|
| 可用的工具集 | toolSelectionStrategy | 这段只暴露哪些工具(见 3.5) |
| LLM 模型 | llmModel | 这段临时换个模型 |
| 采样参数 | llmParams | 这段临时换 temperature 等 |
| 响应处理器 | responseProcessor | 这段的响应后处理 |
| 历史隔离 | freshHistory | 只继承父级 system 消息,子图内的对话结束即丢弃 |
进入子图执行时,execute(agent/entity/AIAgentSubgraph.kt:173)会先算出生效工具集、用这些覆盖值 copy 一个新的 LLM 上下文替换进 context,跑完再还原原上下文(freshHistory 时直接丢弃子图内产生的对话)。这层"进出子图的上下文换挡",让局部配置不会泄漏到子图之外。
策略 = 根子图 + checkpoint 恢复钩子
AIAgentGraphStrategy(agent/entity/AIAgentGraphStrategy.kt:26)是 expect class,其基类 AIAgentGraphStrategyBase(:43)直接继承 AIAgentSubgraphBase。所以一句话:
策略就是"最外层那张子图"(根子图)——它没有更外层的父图,就是整个 agent 的完整行为。
策略比普通子图多的,是它是执行的总入口,因此额外挂了 checkpoint(检查点)恢复逻辑。看它的 execute(agent/entity/AIAgentGraphStrategy.kt:74-85):
override suspend fun execute(context, input: TInput): TOutput? {
restoreStateIfNeeded(context) // 跑之前:有断点就先恢复到断点
var result: TOutput? = super.execute(context, input) // 委托给子图引擎跑一遍
// 如果这次没跑出结果、但 context 里又攒了新的"恢复数据",说明发生了跳转/回滚,循环再跑
while (result == null && context.getGraphAgentContextData() != null) {
restoreStateIfNeeded(context)
result = super.execute(context, input)
}
return result
}
两个恢复钩子:
restoreStateIfNeeded(:87):看 context 里有没有GraphAgentContextData(一份"断点快照");有就调restoreDefault恢复,然后清掉这份数据。restoreDefault(:104-142):把断点里的东西一样样搬回来——设置执行点(跳到断点节点之后)、还原 LLM 会话(模型、消息历史、参数、工具子集)、还原 storage、还原iterations计数。
这就解释了策略层那个外层 while:子图引擎每次遇到"要跳转/回滚"就返回 null 并在 context 留下断点数据;策略层看到 null + 有断点数据,就恢复现场再跑一遍,直到真正产出结果。跳转的机制细节见 3.4。
断点里的节点定位靠
setExecutionPointAfterNode(:199):它按nodePath(以路径分隔符连接的节点路径,首段是 agent id 被丢弃)在metadata.nodesMap里找到那个"已完成的节点",用它保存的output解码后,解析出它的下一条边,把执行点钉在"该节点之后"。若那是个FinishNode(无出边、输入即输出),则直接重启该 finish 节点。
3.3 主循环:整台引擎的心脏
这是本章最核心的一段——executeWithInnerContext 里的 while(true)(agent/entity/AIAgentSubgraph.kt:270-341)。它就是那台"到 站→选轨→开车"的列车。
先看初始化(:273-286):currentNode 默认是起点 start;但如果存在强制执行点(getExecutionPoint() 非空,比如刚从 checkpoint 恢复,或被请求跳转),就改从那个节点、带那个输入开始,并把执行点重置。
再看循环体,一圈干四件事:
真实源码骨架(agent/entity/AIAgentSubgraph.kt:288-327,有删减):
while (true) {
// ① 迭代计数 + 上限保护
context.stateManager.withStateLock { state ->
if (++state.iterations > context.config.maxAgentIterations) {
throw AIAgentMaxNumberOfIterationsReachedException(context.config.maxAgentIterations)
}
}
// ② 执行当前节点(去类型化调用)
val nodeOutput: Any? = currentNode.executeUnsafe(context, currentInput)
// ③ 中断信号:context 里出现"恢复数据"= 有人请求跳转/回滚 → 交还上层
if (context.getGraphAgentContextData() != null) {
return null
}
// ④ 选边
val resolvedEdge = currentNode.resolveEdgeUnsafe(context, nodeOutput)
if (resolvedEdge == null) {
if (currentNode == finish) { // 没边可走 + 正好在终点 → 正常收尾
currentInput = nodeOutput
break
} else { // 没边可走 + 不在终点 → 卡死
throw AIAgentStuckInTheNodeException(currentNode, nodeOutput)
}
}
currentNode = resolvedEdge.edge.toNode // 前进到下一个节点
currentInput = resolvedEdge.output // 带上边转换后的输入
}
四步逐条讲清楚:
① 迭代上限——防死循环的保险丝。 每转一圈,state.iterations 自增;一旦超过 context.config.maxAgentIterations,立刻抛 AIAgentMaxNumberOfIterationsReachedException。这个上限默认只有 3(agent/config/AIAgentConfig.kt:115,maxAgentIterations: Int = 3),意味着不显式调大的话,一个 agent 最多走 3 步就会因超限而停——这是新手最容易撞到的坑。计数放在 withStateLock 里改,iterations 是受"是否 active"守护的属性(agent/entity/AIAgentState.kt:22)。
② 执行节点。 executeUnsafe 去类型化地跑当前节点,拿到 nodeOutput: Any?。节点内部具体做什么(调 LLM、执行工具……)是后续章节的事,引擎在这一层只把它当黑盒。
③ 中断检查——把"跳转/回滚"接出循环。 节点跑完后,引擎立刻检查 context.getGraphAgentContextData()。若非空,表示在节点执行期间有人往 context 塞了"恢复数据"(即请求了跳转到别处、或回滚到 checkpoint),这一轮不能再正常选边,于是 return null 退出——把控制权交还给策略层那个外层 while(见 3.2),由它恢复现场后重跑。
④ 选边与退出。 用 3.1 的 resolveEdge 挑下一条边:
resolveEdge 结果 | 当前是否 finish 节点 | 引擎动作 | 依据 |
|---|---|---|---|
| 命中某条边 | —— | 前进:currentNode/currentInput 换成边的目标与转换值 | AIAgentSubgraph.kt:325-326 |
null(无边命中) | 是 | 正常退出:把节点输出当作最终结果,break | :315-318 |
null(无边命中) | 否 | 卡死:抛 AIAgentStuckInTheNodeException | :319-322 |
"卡死"这个设计值得记: finish 节点天然没有出边(FinishNode.addEdge 直接抛异常,agent/entity/AIAgentNode.kt:299-301),所以"走到 finish"和"没边可走"是同一件事——引擎正是靠 currentNode == finish 来区分"这是正常终点"还是"这是个死胡同"。任何非终点节点却没有一条边命中它的输出,都被判为图设计漏洞,当场报错而不是默默挂起。
退出之后(:329-340):把 currentInput(此时装着 finish 的输出)强转成 TOutput 返回;转不动就抛 IllegalStateException,提示 finish 节点必须产出与声明输出类型相容的值。
主循环一圈的状态流转:
┌─────────────────────────────────────────────────────────┐
│ ① iterations++ ──超上限──▶ 抛 MaxIterations 异常 │
│ │未超 │
│ ▼ │
│ ② 跑 currentNode → nodeOutput │
│ │ │
│ ▼ │
│ ③ context 有恢复数据? ──是──▶ return null(交还策略层重跑) │
│ │否 │
│ ▼ │
│ ④ resolveEdge(nodeOutput) │
│ ├─ 命中 ─▶ currentNode/Input 换新 ─┐ │
│ ├─ null 且在 finish ─▶ break(收尾) │ │
│ └─ null 且非 finish ─▶ 抛 Stuck 异常│ │
└──────────────────────────────────────────┘
└──────────── 回到 ① ────────────┘
3.4 ExecutionPoint:中断与跳转的实现底座
要解决的小问题: 有时需要"别按图的默认流程走,而是强行从节点 X、带输入 Y 继续"——比如从 checkpoint 恢复,或运行时动态改道。
机制: 接口 ExecutionPointNode(agent/entity/ExecutionPointNode.kt:9-33)给子图三个动作:
| 方法 | 作用 |
|---|---|
enforceExecutionPoint(node, input) | 钉住下一步:强制从 node、带 input 开始 |
getExecutionPoint() | 读当前被钉住的执行点(没有则 null) |
resetExecutionPoint() | 清除,恢复默认流程 |
AIAgentSubgraphBase 用两个私有字段 forcedNode / forcedInput 实现它(agent/entity/AIAgentSubgraph.kt:86-112)。enforceExecutionPoint 在已设置时拒绝重复设置(抛 IllegalStateException),保证同一时刻只有一个待跳转目标。
它怎么接进主循环: 就是 3.3 开头那段——主循环启动前先 getExecutionPoint(),非空就从被钉住的节点开始并 resetExecutionPoint()(:276-286)。而"发起跳转"的一侧,则是 3.3 步骤③:节点执行期间往 context 塞恢复数据 → 主循环 return null → 策略层 restoreDefault 里调 setExecutionPointAfterNode → 内部 enforceExecutionPoint 把目标钉好 → 下一轮 super.execute 的主循环从钉住的点接着跑。
一句话串起来: ExecutionPoint 是"跳转"的数据表示,return null 是"跳转"的控制流出口,策略层的 while + restore 是"跳转"的再入口。三者合起来,让 Koog 能在不破坏 while(true) 结构的前提下,支持任意的中断—恢复—跳转。