跳到主要内容

构建层:图 DSL、预制节点与预制策略

30 秒导读: 上一章(01-graph-engine.md)讲的是引擎——图被建好之后怎么跑。这一章讲开发者实际怎么把这张图写出来:用 strategy{} 开一张图,用 node{} / 预制节点当积木,用 edge(A forwardTo B onXxx{...}) 把积木连成有条件的箭头。Koog 自带的 singleRunStrategyreActStrategy 不是引擎内置的黑盒,而正是用这套 DSL 拼出来的现成范例,你可以照着抄。


1. 这是什么(先建立直觉)

一句话: 这是 Koog 的声明式建图层——你不写 while 循环、不手动调度节点,而是声明"哪些节点、谁连谁、什么条件下走哪条边",引擎(01 章)照着这张图跑。

它解决的问题: 一个 agent 的控制流,本质是"调 LLM → 看它想调工具还是想收尾 → 调工具 → 把结果喂回去 → 再问 LLM …"。如果用命令式代码写,很快就是一坨嵌套 if/while,难读也难测。Koog 的做法是把它拆成节点(node)+ 边(edge) 的状态图:

  • 节点 = 一步动作(发一次 LLM 请求、执行一批工具、压缩历史……)。
  • = 带条件的跳转("如果 LLM 这次回的是工具调用,就去执行工具节点")。

用起来什么样。 下面是 Koog 自带的最简单策略——单轮工具循环,一屏看完(agents/agents-core/.../core/agent/AIAgentSimpleStrategies.kt:29 singleRunStrategy):

strategy<String, String>("single_run") {
val nodeCallLLM by nodeLLMRequest() // 发请求
val nodeExecuteTool by nodeExecuteTools() // 执行工具
val nodeSendToolResult by nodeLLMSendToolResults() // 回填结果再问

edge(nodeStart forwardTo nodeCallLLM)
edge(nodeCallLLM forwardTo nodeExecuteTool onToolCalls { true }) // 想调工具 → 去执行
edge(nodeCallLLM forwardTo nodeFinish onTextMessage { true }) // 给了文本 → 收尾
edge(nodeExecuteTool forwardTo nodeSendToolResult)
edge(nodeSendToolResult forwardTo nodeFinish onTextMessage { true })
edge(nodeSendToolResult forwardTo nodeExecuteTool onToolCalls { true }) // 还想调 → 回到执行
}

看不懂细节没关系,先记住三件事:strategy{} 圈出一张图;by nodeXxx() 声明节点;edge(A forwardTo B onXxx{...}) 连一条带条件的边。这三样就是本章的全部。

一句话类比: 把它当成流程图 DSL。你画的是框和箭头,只不过框是 Kotlin 里的节点变量、箭头是 forwardTo,而"箭头上的判断条件"是 onToolCalls/onTextMessage 这类谓词。


2. 顶层全景(三个构建器 + 委托机制)

这套 DSL 只有三层嵌套结构,外加一个"委托"小魔法把节点变量接到图里。

2.1 三个构建器的包含关系

strategy<In,Out>("name") { ← 整张图(一个策略)
├─ nodeStart / nodeFinish ← 图自带的起点/终点(引擎注入)

├─ val x by node { ... } ← 自定义节点
├─ val y by nodeLLMRequest() ← 预制节点

├─ subgraph<In,Out> { ... } ← 子图:图中之图,可换工具集/模型
│ └─ 内部又是 node + edge

└─ edge(x forwardTo y onXxx{}) ← 边:把节点连起来
}
构建器干什么入口符号 · 文件
strategy{}开一张顶层图,定死 In→Out 类型和工具选择策略strategy · AIAgentGraphStrategyBuilder.kt:48
subgraph{}图里嵌一张子图,可局部换工具集 / LLM 模型 / 参数subgraph · AIAgentSubgraphBuilder.kt:359
node{}定义一个自定义节点(一段 suspend 逻辑)node · AIAgentSubgraphBuilder.kt:323
edge(...)连一条边(见 §3)edge · AIAgentSubgraphBuilder.kt:74

strategysubgraph 都继承同一个基类 AIAgentSubgraphBuilderBase(AIAgentSubgraphBuilder.kt:42),所以图和子图的写法完全一样——都有 nodeStart/nodeFinish,都能加节点和边。区别只在:顶层 strategy 是整个 agent 的入口;subgraph 是可复用、可换上下文的子块。

strategy{} 本身很薄:构造 AIAgentGraphStrategyBuilderapply(init) 跑你的建图代码、build() 产出 AIAgentGraphStrategy(AIAgentGraphStrategyBuilder.kt:48-59)。真正的执行留给引擎。

2.2 节点委托:by 的魔法

你会注意到节点总是用 val x by nodeXxx() 声明,而不是 val x = nodeXxx()。这是 Kotlin 的属性委托,Koog 用它做两件事:

  1. 懒实例化——nodeXxx() 返回的是 AIAgentNodeDelegate(一个"节点工厂"),真正的节点对象在第一次访问 x 时才建出来(AIAgentNodeDelegate.kt:47 getValue)。
  2. 自动命名——如果你没显式给名字,节点就用变量名当节点名:name ?: property.name(AIAgentNodeDelegate.kt:49)。所以 val nodeCallLLM by nodeLLMRequest() 建出的节点名就叫 "nodeCallLLM",测试和追踪时能对上号。
// 示意,非源码:by 背后发生了什么
val nodeCallLLM by nodeLLMRequest()
// ≈ 委托对象在你首次读 nodeCallLLM 时,building 出名为 "nodeCallLLM" 的真实节点

subgraph 走的是同一套委托机制(AIAgentSubgraphBuilder.kt:264 AIAgentSubgraphDelegate.getValue,同样 name ?: property.name)。

2.3 自定义节点 node{}

预制节点不够用时,自己写一个:node 接一个 suspend AIAgentGraphContextBase.(Input) -> Output 的 lambda(AIAgentSubgraphBuilder.kt:323)。lambda 的 receiver 是图上下文,里面能拿到 llmstorageenvironment 等——但这些上下文 API 的细节留给 03-llm-layer.md,本章只关心"怎么把节点摆进图里"。

// 示意:一个把输入转大写的自定义节点
val shout by node<String, String> { input ->
input.uppercase() // receiver 是 AIAgentGraphContextBase,这里没用到它
}

还有两个进阶建图工具,知道有即可:

  • parallel(...)(AIAgentSubgraphBuilder.kt:449):把多个节点并发跑,再用 merge{} 合并结果。注意源码里明说并行执行不支持 checkpoint(AIAgentSubgraphBuilder.kt:470)。
  • transform{}(AIAgentNodeDelegate.kt:68):给节点委托套一层输出转换,产出一个新委托。

3. 边 DSL:怎么把节点连起来

边是这套 DSL 里最需要讲清楚的部分。一条边回答三个问题:从哪个节点出?到哪个节点去?满足什么条件才走、走的时候要不要变形数据?

3.1 三段式:起点 forwardTo 终点 [谓词/变形]

nodeCallLLM forwardTo nodeExecuteTool onToolCalls { true }
└── 起点 ──┘ └链接符┘ └─── 终点 ───┘ └──── 条件谓词 ────┘
  • forwardTo(AIAgentNode.kt:145):最基础的连接符。A forwardTo B 返回一个 AIAgentEdgeBuilderIntermediate——一个"半成品边",默认原样透传 A 的输出给 B(Some(output),AIAgentNode.kt:151)。
  • edge(...)(AIAgentSubgraphBuilder.kt:74):把半成品边收尾、注册进图(fromNode.addEdge)。每条边都要用 edge(...) 包起来才生效。

半成品边上可以链式挂两类操作:变形过滤

3.2 两个原语:transformed(变形)与 onCondition(过滤)

所有条件谓词最终都归结到这两个基础方法(定义在 AIAgentEdgeBuilder.kt 的 AIAgentEdgeBuilderIntermediateBase):

原语作用定义
onCondition { ... }过滤:谓词返回 false 则这条边不走AIAgentEdgeBuilder.kt:62
transformed { ... }变形:把流过这条边的数据换成另一个值/类型AIAgentEdgeBuilder.kt:83

它们内部靠一个 Option(Some/None)在边上传递数据:onConditionfilter 把不满足的变成 None(边不触发),transformedmap 换值(AIAgentEdgeBuilder.kt:68、89)。记住这层就够了:过滤决定"走不走",变形决定"带什么数据走"。

3.3 现成谓词:别自己拆 Message

直接用 onCondition 判断 LLM 回了什么很啰嗦。所以 Koog 在 AIAgentEdges.kt 里预制了一批语义化谓词,全是 onCondition + transformed 的组合封装。最常用的三个:

谓词什么时候这条边走走时带的数据定义
onToolCalls { pred }LLM 回复里有工具调用且至少一个满足 predToolCalls(过滤后的调用列表)AIAgentEdges.kt:71
onTextMessage { pred }LLM 回复里有文本且满足 predString(拼接后的文本)AIAgentEdges.kt:54
onCondition { pred }自定义任意条件原样透传AIAgentEdgeBuilder.kt:62

onToolCalls { true } / onTextMessage { true } 就是"只要是工具调用/只要是文本就走"——这正是 §1 那段 singleRunStrategy 分岔用的写法。它们底层都先走 onMessageParts(AIAgentEdges.kt:40)按消息片段类型过滤,再 transformed 出干净数据,帮你省掉手动 is/filterIsInstance 的活

其余谓词按需查表:

谓词用途定义
onToolCall(tool) / onToolCall(tool){pred}只匹配某个特定工具的调用AIAgentEdges.kt:119 / :89
onIsInstance(klass)按运行时类型过滤 + 转型AIAgentEdges.kt:20
onSuccessful { pred } / onFailure { pred }按工具结果的成功/失败分岔(配合 SafeTool.Result)AIAgentEdges.kt:141 / :161
asUserMessage { } / asToolResultMessage { }把数据变形成用户消息 / 工具结果消息AIAgentEdges.kt:175 / :193

3.4 语法糖:then

线性、无条件的连接可以用 then 代替 edge(... forwardTo ...):A then B 内部就是 edge(A forwardTo B) 并返回 B,方便串写(AIAgentSubgraphBuilder.kt:89)。structuredOutputWithToolsStrategynodeStart then setStructuredOutput then transformInput 就是这么用的(AIAgentStrategies.kt:231)。注意:then 无条件,一有分岔就得回到 edge(... onXxx)


4. 预制节点目录

AIAgentNodes.kt(共 907 行)是一整柜现成节点。它们几乎都是同一个模子:node(name) { llm.writeSession { …一次 LLM/工具动作… } }。你不需要逐个看源码,按"这一步要干嘛"分类挑即可。下面按职责列出主力节点(细节 API 见 03-llm-layer.md / 04-tools.md)。

4.1 发 LLM 请求(输入 String → 输出 Message.Assistant)

同一族 6 个变体,区别只在"允不允许 / 强不强制调工具":

节点行为定义
nodeLLMRequest普通请求,工具可调可不调AIAgentNodes.kt:96
nodeLLMRequestOnlyCallingTools强制只能调工具,不许纯文本AIAgentNodes.kt:115
nodeLLMRequestWithoutTools完全不暴露工具,只要文本AIAgentNodes.kt:134
nodeLLMRequestForceOneTool强制调某个指定工具AIAgentNodes.kt:154
nodeLLMRequestMultipleChoices要多个候选回复(LLMChoice)AIAgentNodes.kt:174
nodeLLMSendMessage*上面每一个的"输入是 Message.User"孪生版AIAgentNodes.kt:729 起

4.2 执行工具

节点输入 → 输出说明定义
nodeExecuteToolsToolCallsReceivedToolResults执行一批工具调用;parallel=true 时并发AIAgentNodes.kt:464
nodeExecuteSingleToolTool.CallReceivedToolResult执行单个调用AIAgentNodes.kt:610
nodeExecuteSingleTool(tool)ToolArgSafeTool.Result直接用给定参数调某工具,可选把调用写进 promptAIAgentNodes.kt:626

4.3 回填工具结果(输入 ReceivedToolResults → 输出 Message.Assistant)

和 4.1 对称的 6 个变体,把工具结果作为用户消息写回 prompt 后再问 LLM:

节点行为定义
nodeLLMSendToolResults回填结果 + 普通请求AIAgentNodes.kt:479
nodeLLMSendToolResultsOnlyCallingTools回填 + 强制调工具AIAgentNodes.kt:501
nodeLLMSendToolResultsWithoutTools回填 + 禁工具AIAgentNodes.kt:522
nodeLLMSendToolResultsForceOneTool回填 + 强制某工具AIAgentNodes.kt:544
nodeLLMSendToolResultsMultipleChoices回填 + 多候选AIAgentNodes.kt:566
nodeLLMSendToolResultsStreaming回填 + 流式AIAgentNodes.kt:587

4.4 结构化 / 流式 / 压缩 / 审核 / 杂项

节点干什么定义
nodeLLMRequestStructured要 LLM 按 schema 产出结构化对象,带可选纠错 parserAIAgentNodes.kt:236 / :260
nodeSetStructuredOutput给后续请求预置结构化输出格式(不发请求)AIAgentNodes.kt:691
nodeLLMRequestStreaming流式请求,产出 Flow<StreamFrame> 或变换后的 Flow<T>AIAgentNodes.kt:197 / :219
nodeLLMCompressHistory把历史压成 TLDR 摘要(省 token),输入原样透传AIAgentNodes.kt:376
nodeLLMModerateMessage / nodeLLMModerateText对消息/文本跑内容审核AIAgentNodes.kt:315 / :342
nodeAppendPrompt只往 prompt 追加消息,输入透传AIAgentNodes.kt:55
nodeDoNothing纯透传占位节点AIAgentNodes.kt:38

一个共性值得记:nodeLLMCompressHistorynodeAppendPromptnodeDoNothing 这类输入类型 = 输出类型的节点,是"副作用节点"——它们改的是会话状态(prompt/历史),数据本身原样往下传,方便插在流水线中间不打断类型链。


5. 预制策略:四个可抄的完整范例

预制策略就是"用本章 DSL 拼好的成品图"。读它们既是学 API,也是拿来即用。

5.1 singleRunStrategy —— 最小工具循环

已在 §1 展示。四个要点:起点 → 请求;请求分岔(工具/文本);执行 → 回填;回填再分岔(继续工具 / 收尾)。这是"发请求 / 执行 / 回填"三节点的最小闭环,大多数简单 agent 够用(AIAgentSimpleStrategies.kt:29)。

5.2 chatAgentStrategy —— 逼 agent 用工具而非闲聊

在 singleRun 基础上加了个 giveFeedbackToCallTools 自定义节点:当 LLM 回纯文本时,不收尾,而是回一句"别用纯文本,去调工具"再重问,形成自环(AIAgentStrategies.kt:31)。还演示了按具体工具名收尾:onToolCalls { tc -> tc.tool == "__exit__" }(AIAgentStrategies.kt:64)——即约定一个 __exit__ 工具作为对话终止信号。

5.3 reActStrategy —— 推理/行动交替(源码自带流程图)

ReAct(Reason + Act,推理与行动交替)是 agent 经典范式:先让 LLM"想一步",再让它"做一步",循环。Koog 源码里直接画了这张图(AIAgentStrategies.kt:78-86):

+-------+ +---------------+ +---------------+ +--------+
| Start | ----> | CallLLMReason | ----> | CallLLMAction | ----> | Finish |
+-------+ +---------------+ +---------------+ +--------+
^ | Finished? Yes
| | No
| v
| +---------------+
+-------------| ExecuteTool |
+---------------+

对应到代码,它比 singleRun 多了两样:一是用 storage 存了个 reasoning_step 计数器,按 reasoningInterval 决定隔几步插一次"请思考"提示(AIAgentStrategies.kt:128、161);二是把"推理请求"和"行动请求"拆成不同节点(nodeCallLLMReasonInputrequestLLMWithoutTools 只推理不给工具,nodeCallLLM 才给工具)。工具执行后回到 nodeCallLLMReason 再进推理环(AIAgentStrategies.kt:171-177)。

5.4 singleRunStrategyWithHistoryCompression —— 加自动压缩

和 singleRun 同构,只在"执行工具之后"插一个岔口:用 onCondition 检查历史是否太大,太大就先走 nodeLLMCompressHistory 压缩再继续,否则正常回填(SingleRunStrategyWithHistoryCompression.kt:70-71):

edge(nodeExecuteTool forwardTo nodeCompressHistory
onCondition { llm.readSession { config.isHistoryTooBig(prompt) } })
edge(nodeExecuteTool forwardTo nodeSendToolResult
onCondition { llm.readSession { !config.isHistoryTooBig(prompt) } })

这就是 onCondition 的典型价值: 同一个起点、两条互斥条件边,实现"看情况走哪条"。阈值逻辑由调用方传的 HistoryCompressionConfig 决定(SingleRunStrategyWithHistoryCompression.kt:27)。


6. 教学示例:亲手拼一个 ReAct 环

把前面所有零件用一个最小可读的例子串起来。目标:演示 node + edge 如何拼成"请求 → 分岔 → 执行 → 回环"的 ReAct 骨架。

// 示意,非源码:一个教学版 ReAct 环,只保留骨架
strategy<String, String>("mini_react") {
// 1) 三个节点:发请求、执行工具、回填结果
val ask by nodeLLMRequest() // String → Message.Assistant
val act by nodeExecuteTools() // ToolCalls → ReceivedToolResults
val feed by nodeLLMSendToolResults() // ReceivedToolResults → Message.Assistant

// 2) 入口:用户输入进第一次请求
edge(nodeStart forwardTo ask)

// 3) 请求后的分岔:想调工具就去执行,给了文本就收尾
edge(ask forwardTo act onToolCalls { true }) // 有工具调用 → 执行
edge(ask forwardTo nodeFinish onTextMessage { true }) // 纯文本 → 结束

// 4) 执行后回填,回填后再分岔——这一步形成"环"
edge(act forwardTo feed) // 无条件:执行完必然回填
edge(feed forwardTo act onToolCalls { true }) // 还想调工具 → 回到执行(闭环!)
edge(feed forwardTo nodeFinish onTextMessage { true }) // 收尾
}

重点看第 4 组边: feed forwardTo act 让"回填"能跳回"执行",这条回边就是循环的本体——LLM 每轮想继续调工具,就沿它转一圈;想收尾了,onTextMessage 那条边把它送到 nodeFinish。整张图没有一行 while,循环完全由"带条件的边 + 引擎的主循环(01 章)"实现。

对照 §5.1 会发现:这个教学例子和真实的 singleRunStrategy 几乎一字不差——预制策略就是这么写出来的,没有黑魔法。

建图时的两个约束(引擎会校验):终点必须从起点可达,否则 build() 抛异常(AIAgentSubgraphBuilder.kt:127、204);子图内节点名不能重复(AIAgentSubgraphBuilder.kt:153)。


7. 代码地图(导航索引)

所有路径相对克隆根,公共前缀 agents/agents-core/src/commonMain/kotlin/ai/koog/agents/

主题文件关键符号
顶层图构建器 + strategy{} 入口core/dsl/builder/AIAgentGraphStrategyBuilder.ktAIAgentGraphStrategyBuilderstrategy
图/子图基类、subgraph{}node{}thenparallelcore/dsl/builder/AIAgentSubgraphBuilder.ktAIAgentSubgraphBuilderBasesubgraphnodethenparallel
节点委托(懒实例化 + 自动命名)、transformcore/dsl/builder/AIAgentNodeDelegate.ktAIAgentNodeDelegategetValuetransform
边收尾构建、onCondition / transformed 原语core/dsl/builder/AIAgentEdgeBuilder.ktAIAgentEdgeBuilderonConditiontransformed
forwardTo 连接符core/agent/entity/AIAgentNode.ktforwardTo
语义化边谓词(onToolCalls/onTextMessage/onToolCall/onSuccessful…)core/dsl/extension/AIAgentEdges.ktonToolCallsonTextMessageonToolCallonIsInstanceonSuccessfulonFailure
预制节点目录(请求/执行/回填/结构化/流式/压缩/审核)core/dsl/extension/AIAgentNodes.ktnodeLLMRequestnodeExecuteToolsnodeLLMSendToolResultsnodeLLMRequestStructurednodeLLMRequestStreamingnodeLLMCompressHistorynodeLLMModerateMessage
预制策略:单轮循环core/agent/AIAgentSimpleStrategies.ktsingleRunStrategy
预制策略:chat / ReAct / 结构化+工具ext/agent/AIAgentStrategies.ktchatAgentStrategyreActStrategystructuredOutputWithToolsStrategy
预制策略:带历史压缩的单轮ext/agent/SingleRunStrategyWithHistoryCompression.ktsingleRunStrategyWithHistoryCompressionHistoryCompressionConfig

继续阅读: 图怎么被执行 → 01-graph-engine.md;节点里 llm.writeSession{} / requestLLM() 等会话 API → 03-llm-layer.md;工具的定义与 schema → 04-tools.md