Koog(5) —— Strategy 详解:工作流编排、并行执行与状态传递

前言

前面的文章 Koog(2) —— Koog Agent 的编程模型与类型选择 介绍了不同 Agent 实现形式及其构建、配置和运行方式。本文继续讨论 Agent 的行为逻辑如何组织,重点是图式策略的组成、控制流、并行执行与数据传递。

本文以 Koog 1.3.0 的 Kotlin API 为基准。这里的“策略图”特指通过 strategy { } 构建的 AIAgentGraphStrategy;functionalStrategy 则用函数组织逻辑,不能把所有 Strategy 都等同于有向图。官方指南与该版本实现不一致的地方,下文会明确说明。除标明完整运行入口的示例外,其余代码是用于解释 API 的局部片段,需要放入相应 DSL 并补齐导入、工具与执行器配置。


一、Strategy graph

1.1 回顾:那个"拿来就用"的 strategy()

在 Koog(2) 讲 Graph-based agents 时,有这样一段示例代码(略有精简):

val simpleGraphStrategy = strategy<String, String>("Simple graph-based agent") {
    val requestFavoritePetTool by nodeLLMRequestOnlyCallingTools("requestFavoritePetTool")
    val executeTools by nodeExecuteTools("executeTools")
    val answerWithToolResult by nodeLLMSendToolResultsWithoutTools("answerWithToolResult")

    edge(nodeStart forwardTo requestFavoritePetTool)
    edge(requestFavoritePetTool forwardTo executeTools onToolCalls { true })
    edge(executeTools forwardTo answerWithToolResult)
    edge(answerWithToolResult forwardTo nodeFinish onTextMessage { true })
}

val agent = AIAgent(
    promptExecutor = MultiLLMPromptExecutor(deepSeekClient),
    llmModel = DeepSeekModels.DeepSeekV4Flash,
    strategy = simpleGraphStrategy,  // 把图交给 Agent
    // ... 其他配置省略
)

当时为了把例子跑通,重点放在了"用 graph 强制规定 Agent 的执行路径"这个效果上。但代码里的这一堆概念,并没有展开讲:

  • strategy<String, String> 的两个泛型是什么意思?
  • by nodeXXX(...) 这种写法为什么能行?
  • onToolCalls { true } 到底在检查什么?
  • 为什么图要从 nodeStart 出发、到 nodeFinish 结束?

这些问题就是本文要回答的。先把核心结论放在这里:

图式策略用有向图组织执行逻辑:节点(Node)回答“做什么”,边(Edge)回答“接下来去哪”。一次正常完成的图执行从 nodeStart 出发,到 nodeFinish 返回结果。

1.2 Strategy graph 的架构

Strategy graph 是 Koog 框架中智能体工作流的骨干。它们定义了智能体如何处理输入、与工具交互以及生成输出。策略图由通过边连接的节点组成,并通过条件决定执行流程。

创建策略图能够根据具体需求定制智能体的行为,无论是在构建简单的聊天机器人、复杂的数据处理流水线,还是介于两者之间的任何东西。

Strategy graph 由以下的组件组成:

概念 官方定义 类比
Strategy 图的顶层容器,用 strategy 函数创建,泛型参数指定输入和输出类型 整条流水线的总图纸
Subgraphs 图中的局部区域,可以有自己的工具集和上下文 流水线上的工段
Nodes 工作流中的单个操作或转换 工段上的工位
Edges 连接节点,定义流转条件和转换 工位之间的传送带

策略图从一个名为 nodeStart 的特殊节点开始,并在 nodeFinish 结束。 这些节点之间的路径由图中指定的边和条件决定。

把这几个概念标到 1.1 那段代码上:

flowchart LR subgraph S["strategy&lt;String, String&gt; —— 顶层容器"] nodeStart(["nodeStart"]) --> requestTool["requestFavoritePetTool<br/>(Node)"] requestTool -->|"onToolCalls(Edge 条件)"| executeTools["executeTools<br/>(Node)"] executeTools --> answer["answerWithToolResult<br/>(Node)"] answer -->|"onTextMessage(Edge 条件)"| nodeFinish(["nodeFinish"]) end

(这个例子只有一层,没有用到 Subgraph;Subgraph 在第四章单独讲。)

有几个点值得单独说:

1. strategy 的泛型 = Agent 的输入输出类型

strategy<String, String> 声明了这个工作流"输入是一个 String,返回也是一个 String",它直接决定了使用它的 Agent 是 AIAgent<String, String>。

2. 图必须连通:nodeStart → nodeFinish

每张策略图都有一条从 nodeStart(内置起始节点,代表用户输入进来)到 nodeFinish(内置结束节点,代表产出最终结果)的路径。如果某个节点没有任何路径能走到 nodeFinish,执行流到那里就断了。(这个坑在第九章排错部分展开。)

3. by nodeXXX(...) 是 Kotlin 属性委托

val executeTools by nodeExecuteTools("executeTools") 里的 by 是 Kotlin 的属性委托语法:先"登记"一个节点定义,等图真正执行到它时才运行。声明节点用 by,连接节点用 edge。

4. 条件挂在边上,不挂在节点上

onToolCalls { true } 是边的流转条件:"上一步 LLM 的响应里出现了工具调用,就走这条边"。把条件放在边上而不是节点里,是 Koog 图模型的关键设计——同一个节点的输出,可以按不同条件流向不同的下游,这就是第三章"条件分支"的基础。

1.3 Strategy graph 的核心组件

1.3.1 Node

节点是策略图的构建块。每个节点代表一个特定的操作。Koog 框架提供了预定义节点,同时也允许使用 node 函数创建自定义节点。

1.3.2 Edge

边连接节点并定义策略图中的操作流程。边通过 edge 函数和 forwardTo 中缀函数创建:

edge(sourceNode forwardTo targetNode)

1.3.3 Condition

条件决定了在策略图中何时沿某条特定的边继续执行。条件有多种类型,下面列出一些常见类型:

条件类型 说明
onCondition 通用条件,接收一个返回布尔值的 lambda 表达式。
onToolCalls 当 LLM 调用一个或多个工具时匹配的条件。
onTextMessage 当 LLM 以文本消息响应时匹配的条件。

这里列的是 1.3.0 可用的常见写法。官方指南还出现过 onToolNotCalled,但该版本没有提供同名函数,不能直接照用。onToolCalls 不仅筛选调用,还会把消息转换为 ToolCalls;onTextMessage 会提取文本并转换为 String。它们不是单纯的布尔判断。

在将输出传递给目标节点之前,还可以使用 transformed 函数先对其进行转换:

edge(sourceNode forwardTo targetNode
        onCondition { input -> input.length > 10 }
        transformed { input -> input.uppercase() }
)

1.3.4 Subgraph

子图是策略图中拥有各自工具集和上下文的独立部分。 一个策略图可以包含多个子图。每个子图都通过 subgraph 函数定义:

val strategy = strategy<Input, Output>("strategy-name") {
    val firstSubgraph by subgraph<FirstInput, FirstOutput>("first") {
        // Define nodes and edges for this subgraph
    }
    val secondSubgraph by subgraph<SecondInput, SecondOutput>("second") {
        // Define nodes and edges for this subgraph
    }
}

子图可以使用工具注册表中的任意工具。 也可以从该注册表中指定一组仅供子图使用的工具子集,并将其作为参数传给 subgraph 函数:

val strategy = strategy<Input, Output>("strategy-name") {
    val firstSubgraph by subgraph<FirstInput, FirstOutput>(
        name = "first",
        tools = listOf(someTool)
    ) {
        // Define nodes and edges for this subgraph
    }
   // Define other subgraphs
}

1.4 学习路线:三层能力 + 两个专题

我把 Koog 中 Strategies 的相关知识点组织成"三层能力 + 两个专题",也就是本文后续章节的内容,包含:预定义节点、预定义策略、自定义图、并行执行、节点间数据传递等。

flowchart TD L1["第一层:直接用预定义策略<br/>chatAgentStrategy() / reActStrategy()"] --> L2["第二层:用预定义节点组装自己的图<br/>nodeLLMRequest、nodeExecuteTools、edge ..."] L2 --> L3["第三层:完全自定义<br/>自定义 node、自定义 subgraph"] L2 -.-> T1["专题一:控制进阶<br/>条件分支、并行节点执行"] L2 -.-> T2["专题二:数据传递<br/>transformed、AIAgentStorage"]
  • 第一层:Koog 把常见模式做成了现成策略,strategy = chatAgentStrategy() 一行搞定。先判断"我的场景够不够典型",够典型就不用自己画图。
  • 第二层:预定义节点是积木,Strategy graph 是图纸。掌握十几个常用节点和边的条件写法,能拼出绝大多数工作流。
  • 第三层:积木不够用就自己造——自定义 node、自定义 subgraph。
  • 专题一:条件分支和并行执行,决定图的"控制力"上限。
  • 专题二:节点之间除了沿着边传数据,还能通过 AIAgentStorage 共享数据。

总结一下本章的核心:

Agent 类型决定"外壳"(Koog(2) 已讲),Strategy 决定"内核"。本文的图式策略是一张从 nodeStart 到 nodeFinish 的有向图,由策略、子图、节点和边共同组成;学习它可以按"用现成策略 → 攒预定义节点 → 完全自定义"三层递进。

接下来,我们从最小的构建块——Node 开始。


二、Node:工作流的基本构建块

2.1 Node 的本质:一个类型化的函数

节点是 Koog 智能体工作流的基本构建块,每个节点表示工作流中的一个特定操作或转换,可以用边连接起来形成执行流。

节点可以把复杂逻辑封装为可复用的执行单元,再通过边组合进工作流。

更进一步说,每个 Node 本质上就是一个函数:接收一个指定类型的输入,返回一个指定类型的输出。

自定义一个最简单的节点:

val nodeLength by node<String, Int> { input ->
    input.length
}

接收一个 String,返回它的长度 Int。不需要继承任何类、实现任何接口,一个 lambda 就是一个节点。

Koog(2) 的示例代码中用过的那些 nodeXXX 预定义节点,全部符合这个定义:它们只是框架已经写好的"函数"。

2.2 常用预定义节点速查

下面按 Koog 1.3.0 的实际 Kotlin 签名列出常用节点,不是全部 API 清单。String 输入的 nodeLLMRequest* 与 Message.User 输入的 nodeLLMSendMessage* 是两组不同接口,不能混用。

2.2.1 辅助节点(Utility nodes)

节点 输入 → 输出 作用
nodeDoNothing<T> T → T 原样返回输入
nodeAppendPrompt<T> T → T 追加提示词,不请求模型
val passthrough by nodeDoNothing<String>("passthrough")
edge(nodeStart forwardTo passthrough)
edge(passthrough forwardTo nodeFinish)

2.2.2 LLM 节点(LLM nodes)

节点 输入 → 输出 作用
nodeLLMRequest String → Message.Assistant 追加用户文本,发起可使用当前工具集的请求
nodeLLMRequestOnlyCallingTools String → Message.Assistant 请求模型调用工具
nodeLLMRequestWithoutTools String → Message.Assistant 不向模型提供工具
nodeLLMRequestForceOneTool String → Message.Assistant 指定一个工具描述,要求模型调用该工具
nodeLLMRequestStructured<T> String → Result<StructuredResponse> 请求结构化结果,可配置修复解析器
nodeLLMRequestStreaming String → Flow 消费流式响应;另有将流转换为自定义输出的重载
nodeLLMRequestMultipleChoices String → LLMChoice 多候选生成,不等同于一次响应内的多个工具调用
nodeLLMSendMessageOnlyCallingTools Message.User → Message.Assistant 追加完整用户消息,请求模型调用工具
nodeLLMSendMessageForceOneTool Message.User → Message.Assistant 追加完整用户消息,要求调用指定工具
nodeLLMCompressHistory<T> T → T 根据压缩策略处理历史,透传节点输入

“要求调用工具”仍依赖 Provider 与模型对工具选择参数的支持;图中的条件边也应处理异常响应。LLMChoice、StructuredResponse、StreamFrame 等类型需要各自的导入与配置,这里只列出接口关系。

2.2.3 Tool 节点(Tool nodes)

节点 输入 → 输出 作用
nodeExecuteTools ToolCalls → ReceivedToolResults 执行一组调用,parallel = true 时并发执行
nodeExecuteSingleTool(调用消息重载) MessagePart.Tool.Call → ReceivedToolResult 执行一个已有工具调用
nodeExecuteSingleTool<Args, Result>(指定工具重载) Args → SafeTool.Result 不经 LLM 决策,直接调用给定工具
nodeLLMSendToolResults ReceivedToolResults → Message.Assistant 将执行结果追加到提示词,再请求模型
nodeLLMSendToolResultsWithoutTools ReceivedToolResults → Message.Assistant 回传结果后请求模型,不再提供工具
nodeLLMSendToolResultsOnlyCallingTools ReceivedToolResults → Message.Assistant 回传结果后要求继续调用工具

ToolCalls、ReceivedToolResults 是框架的封装类型,不能直接当作 List 使用。SafeTool.Result 区分成功与失败,指定工具重载的调用方应显式处理这两种情况。第八章会给出实际用法。

这些节点不用背:先按“请求模型”“执行工具”“回传结果”查找,再检查输入输出是否能衔接。

2.3 重点 LLM 节点详解

nodeLLMRequest:最常用的请求节点

追加用户文本到 prompt,再发起请求;nodeLLMRequest() 使用当前上下文中的工具集。若明确禁止工具调用,应选择 nodeLLMRequestWithoutTools():

val requestLLM by nodeLLMRequest("requestLLM")
edge(getUserQuestion forwardTo requestLLM)

Koog(2) 的示例代码中用过它的变体——"只允许调工具"的版本。

nodeAppendPrompt:先铺垫,再请求

它不请求 LLM,只往 prompt 里追加消息,用于在真正的 LLM 请求之前准备上下文,比如插入 system 指令或补充 user 消息:

val setupContext by nodeAppendPrompt<String>("setupContext") {
    system("You are a helpful assistant specialized in Kotlin programming.")
    user("I need help with Kotlin coroutines.")
}

nodeLLMCompressHistory:历史压缩

把当前 LLM prompt(消息历史)压缩为一条保留关键信息的摘要,适合长对话场景控制 token:

val compressHistory by nodeLLMCompressHistory<String>(
    "compressHistory",
    strategy = HistoryCompressionStrategy.FromLastNMessages(10),
    preserveMemory = true
)

历史压缩的完整机制(压缩策略、和记忆的关系)会在后面讲记忆与持久化的文章中展开,这里只需要知道:图里有一个现成的压缩节点,挂上去就能用。

nodeLLMRequestStructured 与 nodeLLMRequestStreaming

分别对应结构化输出和流式输出。这两个能力值得单独一篇文章,后续会专门讲,此处先记住节点名字和用途即可。

2.4 transform():转换节点的输出

AIAgentNodeDelegate 提供了 transform 成员方法,可以包装一个已有节点,并对它的输出应用转换。比如把 nodeLLMRequest 的响应转换成文本长度:

val lengthNode by nodeLLMRequest("llmRequest").transform<Int> { assistantMessage ->
    assistantMessage.parts.filterIsInstance<MessagePart.Text>().joinToString("\n") { it.text }.length
}

edge(nodeStart forwardTo lengthNode)
edge(lengthNode forwardTo nodeFinish)

理解这段代码:lengthNode 仍然是 String → Int 的节点(输入用户消息,输出文本长度),但内部复用了 nodeLLMRequest 的能力。transform 的价值就是让预定义节点"长成"你需要的形状。

2.5 自定义节点:从直接定义到封装复用

预定义节点不够用时,可以通过 node 定义自己的执行逻辑。下面从最简单的节点开始,再看如何封装为可复用的节点工厂,以及如何为节点增加配置参数和局部状态。

方式一:直接定义节点

在 strategy { } 或 subgraph { } 中直接定义节点,适合只在当前流程中使用的逻辑:

val textLength by node<String, Int>("textLength") { input ->
    input.length
}

这个节点接收 String,返回 Int。节点的输入输出类型由 node<String, Int> 指定,不要求与整个策略图的输入输出类型一致,只要连接它的上下游类型兼容即可。

方式二:提取为扩展函数,跨图复用

如果多个工作流都需要同一段节点逻辑,可以将它提取为返回 AIAgentNodeDelegate 的节点工厂。官方 Custom nodes 页面采用了在 AIAgentSubgraphBuilderBase 上定义扩展函数的写法。

AIAgentSubgraphBuilderBase<Input, Output> 是策略图构建器与子图构建器的共同抽象基类,提供起止节点、边连接等建图能力:

  • strategy { } 的接收者是 AIAgentGraphStrategyBuilder,继承自这个基类。
  • subgraph { } 的接收者是 AIAgentSubgraphBuilder,同样继承自这个基类。

因此,定义在这个基类上的扩展函数,可以在两种 DSL 块中调用。比如,封装一个记录输入、随后原样返回的日志节点:

import ai.koog.agents.core.dsl.builder.AIAgentNodeDelegate
import ai.koog.agents.core.dsl.builder.AIAgentSubgraphBuilderBase
import ai.koog.agents.core.dsl.builder.node
import ai.koog.agents.core.dsl.builder.strategy

fun AIAgentSubgraphBuilderBase<*, *>.loggingNode(
    name: String? = null
): AIAgentNodeDelegate<String, String> = node<String, String>(name) { input ->
    println("Input: $input")
    input
}

val loggingStrategy = strategy<String, String>("logging-example") {
    val logInput by loggingNode("logInput")

    edge(nodeStart forwardTo logInput)
    edge(logInput forwardTo nodeFinish)
}

这里有三个容易混淆的点:

  • AIAgentSubgraphBuilderBase<*, *> 中的 * 是星投影,表示这个扩展函数不依赖外围图的具体输入输出类型;它不会声明名为 Input、Output 的类型参数。
  • 返回类型 AIAgentNodeDelegate<String, String> 描述的是节点自身的输入输出,而不是外围图的输入输出。每次调用 loggingNode(...) 都会创建一个新的节点委托,供 by 声明节点。
  • 在 Koog 1.3.0 中,node() 是顶层函数,不是这个基类的成员。普通函数也可以调用它并返回节点委托;使用扩展函数是为了把节点工厂组织到建图 DSL 中,并不是为了获得调用 node() 的权限。

如果希望日志节点支持任意输入类型,可以将上面的工厂替换为泛型版本:

inline fun <reified T> AIAgentSubgraphBuilderBase<*, *>.loggingNode(
    name: String? = null
): AIAgentNodeDelegate<T, T> = node<T, T>(name) { input ->
    println("Input: $input")
    input
}

// 在 strategy { } 或 subgraph { } 中使用
val logInput by loggingNode<String>("logInput")

这里使用 inline 和 reified,是因为 node<T, T>() 需要获取输入输出的类型信息。这个 T 是节点工厂自己的类型参数,与外围图的类型参数无关。

方式三:为节点工厂增加配置参数

配置参数在创建节点时传入,用于定制节点的行为;节点真正执行时,仍然通过 input 接收上游数据。两者的时机不同:

fun AIAgentSubgraphBuilderBase<*, *>.truncateNode(
    maxLength: Int,
    name: String? = null
): AIAgentNodeDelegate<String, String> {
    require(maxLength >= 0) { "maxLength must be non-negative" }

    return node<String, String>(name) { input ->
        input.take(maxLength)
    }
}

// 在 strategy { } 或 subgraph { } 中使用
val truncateText by truncateNode(maxLength = 100, name = "truncateText")

maxLength 在建图时确定,并由执行 lambda 捕获;input 则是在执行流到达节点时才传入的文本。这样可以复用同一个节点工厂,在不同位置创建具有不同长度限制的节点。

方式四:通过闭包保存局部状态

节点工厂还可以创建局部变量,让返回的执行逻辑捕获它。例如,统计这段节点逻辑被执行了多少次:

fun AIAgentSubgraphBuilderBase<*, *>.countingNode(
    name: String? = null
): AIAgentNodeDelegate<String, String> {
    var counter = 0

    return node<String, String>(name) { input ->
        counter++
        println("Node executed $counter times")
        input
    }
}

counter 在调用 countingNode(...) 时创建,不是在每次执行节点时重新创建。同一闭包被重复执行时会保留计数;再次调用节点工厂,则会创建另一个独立的计数器。

但这种写法有明确的边界:

  • 不会自动按运行隔离:如果同一节点逻辑被多个 Agent 或多次运行复用,它们可能共用这个计数器,不能把它理解为“本次运行的计数”。
  • 不保证并发安全:counter++ 不是原子操作。上面的示例只用于说明串行执行下的闭包行为,不应直接用于并发计数。
  • 不会自动持久化:闭包变量不会因为启用了 Agent Persistence 就自动进入 checkpoint。

对于需要在工作流中管理、跨节点访问的运行状态,应优先考虑第七章介绍的 AIAgentStorage,并结合其运行生命周期和并行上下文规则设计读写方式;不要依赖闭包变量隐式共享。

还要区分构建期接收者和运行期接收者:扩展函数的接收者 AIAgentSubgraphBuilderBase 用于建图;传给 node 的执行 lambda,其接收者是 AIAgentGraphContextBase,执行时访问的 llm、storage 等能力来自后者。下面的 LLM 交互示例用的就是运行期上下文。

常见模式小结

官方文档总结了自定义节点的四种常见模式:

模式 特征 典型用途
Pass-through 透传 做点事,但返回原输入 日志、埋点
Transformation 转换 输入 → 加工 → 新输出 格式转换、数据清洗
LLM 交互 节点内 llm.writeSession 手动控制请求 精细控制 prompt 和模型
Tool 执行 节点内手动构造工具调用并执行 绕过 LLM 直接执行工具

其中"LLM 交互"模式后面并行执行和综合实战里都会用到,值得单独看一眼:

val summarizeTextNode by node<String, String>("summarize") { input ->
    llm.writeSession {
        appendPrompt {
            user("Please summarize the following text: $input")
        }
        val response = requestLLMWithoutTools()
        response.parts.filterIsInstance<MessagePart.Text>().joinToString("\n") { it.text }
    }
}

在自定义节点里,llm.writeSession 就是第 3 篇讲过的 LLM 写会话:追加 prompt、切换模型、发请求,全都可以手动控制。

2.6 类型决定连接

Strategy graph 最重要的一条规则:

节点的输入和输出类型决定了它在工作流中如何与其它节点连接。只有源节点的输出类型与目标节点的输入类型兼容,节点才能相连。

这就是为什么需要 transformed(边上的转换)和 transform()(节点上的转换):它们是类型不匹配时的"转接头"。

一句话总结本章:

Node 是类型化的函数;Koog 提供了覆盖"LLM 请求 / 工具执行 / 历史压缩 / 透传"的预定义节点;不够用时,一个 lambda 就是自定义节点;而节点能不能连上,看类型。

节点解决了"做什么",那"接下来去哪"呢?这就是 Edge 的事。下一章我们把第一章的基础组件拼成一张真正能跑的图,看看边如何构成完整的控制流。


三、组装:从基础图到条件分支

第一章中已经认识了 edge、forwardTo 和常见条件写法。这一章关心的问题是:这些边拼在一起,整体是怎么跑的?怎么表达分支和顺序?

3.1 最基本的工具调用图

下面是一个 Basic Agent 的策略图示例的执行流程:

  1. 将输入发送给 LLM。
  2. 如果 LLM 直接回复了消息,流程结束。
  3. 如果 LLM 调用工具,执行该工具。
  4. 把工具结果发回 LLM。
  5. 如果 LLM 回复了消息,流程结束。
  6. 如果 LLM 又调用工具,执行工具,回到第 4 步。
--- title: my-strategy --- stateDiagram state "nodeCallLLM" as nodeCallLLM state "executeToolCall" as executeToolCall state "sendToolResult" as sendToolResult [*] --> nodeCallLLM nodeCallLLM --> [*] : transformed nodeCallLLM --> executeToolCall : onCondition executeToolCall --> sendToolResult sendToolResult --> [*] : transformed sendToolResult --> executeToolCall : onCondition

对应代码:

val myStrategy = strategy<String, String>("my-strategy") {
    val nodeCallLLM by nodeLLMRequest()
    val executeToolCall by nodeExecuteTools()
    val sendToolResult by nodeLLMSendToolResults()

    edge(nodeStart forwardTo nodeCallLLM)
    edge(nodeCallLLM forwardTo nodeFinish onTextMessage { true })
    edge(nodeCallLLM forwardTo executeToolCall onToolCalls { true })
    edge(executeToolCall forwardTo sendToolResult)
    edge(sendToolResult forwardTo nodeFinish onTextMessage { true })
    edge(sendToolResult forwardTo executeToolCall onToolCalls { true })
}

理解这张图:

  • 三个节点构成"请求 → 执行 → 回传"的循环三角。
  • nodeCallLLM 和 sendToolResult 各有两条出边:LLM 回了文本 → 走向结束;LLM 要调工具 → 走向工具执行。这正是"条件挂在边上"的价值——同一个节点的输出,按响应内容分流。
  • sendToolResult → executeToolCall 这条回边形成了循环:LLM 可以连续调用多次工具,直到它给出最终文本。

顺便说一句:这张图就是官方 singleRunStrategy 的真身。Basic Agent 之所以"拿来就能用",就是因为它背后挂的就是这张图。

3.2 条件分支

当工作流需要根据中间结果走不同路径时,给同一个源节点定义多条带条件的出边:

val branchA by node<String, String> { input ->
    "Branch A: $input"
}

val branchB by node<String, String> { input ->
    "Branch B: $input"
}

edge(
    (someNode forwardTo branchA)
            onCondition { input -> input.contains("A") }
)
edge(
    (someNode forwardTo branchB)
            onCondition { input -> input.contains("B") }
)

输出包含 "A" 时可匹配分支 A,包含 "B" 时可匹配分支 B。若同时包含二者,按下文的边顺序规则选择;若二者都不包含,需要另设兜底边,否则会抛出 AIAgentStuckInTheNodeException。

一个关键细节:边按定义顺序评估。同一个源节点的多条出边,Koog 按代码中定义的顺序逐条检查条件,第一条满足的生效。所以宽泛的条件不要放在前面,否则会"截胡"后面的具体条件。

3.3 then:顺序流的语法糖

对于没有条件的顺序流,可以用 then 链式连接,比一条条写 edge 简洁:

nodeStart then firstNode then secondNode then nodeFinish

有条件流转时,仍然需要 edge + onXxx 的完整写法。

3.4 可视化:asMermaidDiagram()

"易于可视化"是 Graph-based Agent 的三大优势之一,来源就在这里——JVM 上可以为任何策略图生成 Mermaid 状态图:

val mermaidDiagram: String = myStrategy.asMermaidDiagram()
println(mermaidDiagram)

对 3.1 创建的图,输出如下:

---
title: my-strategy
---
stateDiagram
    state "nodeCallLLM" as nodeCallLLM
    state "executeToolCall" as executeToolCall
    state "sendToolResult" as sendToolResult

    [*] --> nodeCallLLM
    nodeCallLLM --> [*] : transformed
    nodeCallLLM --> executeToolCall : onCondition
    executeToolCall --> sendToolResult
    sendToolResult --> [*] : transformed
    sendToolResult --> executeToolCall : onCondition

把这段输出贴到任何支持 Mermaid 的地方(IDE 插件、GitHub、博客平台)就能直接渲染出图。图反映生成时的代码结构;代码修改后需要重新生成。transformed、onCondition 是生成器提取的内部实现标签,不一定与 DSL 中 onTextMessage、onToolCalls 的名称一致,也不会完整展示条件 lambda 的业务含义。

一句话总结:

顺序流用 then,分支用多条带条件的出边,循环用回边;边按定义顺序评估条件;一张 asMermaidDiagram() 就能让图"画"出来。


四、Subgraph:封装与复用局部流程

4.1 Subgraph 是什么

Subgraph 定义:子图是策略内自包含的处理单元,用于把复杂工作流拆解成可管理的顺序步骤。每个子图:

  • 有唯一的名称;
  • 内部是一张由节点/子图和边构成的图;
  • 可以使用工具注册表的全部工具,也可以只用其中的一个子集;
  • 接收上一个子图(或初始用户输入)的输出,产出传递给下一个子图(或最终输出)。

每个子图在自己的上下文中执行,这个上下文提供:环境、agent 输入、agent 配置、LLM 上下文(包括对话历史)、状态管理器、storage、会话与策略。

第一章 1.3.4 已经看过 subgraph 的创建语法。补一个理解方式:

subgraph 之于 strategy,就像函数之于程序——声明输入类型、封装内部逻辑、返回输出类型,可以被组合和复用。

4.2 子图中的工具配置

让子图"只能用某些工具"有三种方式。

方式一:定义时直接指定(第一章已见)

val mySubgraph by subgraph<String, String>(
    tools = listOf(AskUser)
) {
    // 子图定义
}

也可以用 toolSelectionStrategy 指定选择策略:

val subgraphIdentifier by subgraph<Input, Output>(
    name = "subgraph-name",
    toolSelectionStrategy = ToolSelectionStrategy.ALL
) {
    // Define nodes and edges for this subgraph
}

方式二:从工具注册表获取

val mySubgraph by subgraph<String, String>(
    tools = listOf(toolRegistry.getTool("AskUser"))
) {
    // 子图定义
}

方式三:运行时动态调整

在子图的节点里,通过 llm.writeSession 动态过滤当前可用的工具:

this.llm.writeSession {
    tools = tools.filter { it.name in listOf("first_tool_name", "second_tool_name") }
}

这三种方式的差别:前两种是静态声明,图跑起来之前就定了;第三种是运行时按逻辑调整,同一个子图在不同条件下可以暴露不同的工具集。

4.3 Multi-part strategies:多子图串联

复杂工作流可以拆成多个子图,各管一段。官方文档给了一个非常典型的 assistant 示例,整体结构是:

flowchart LR nodeStart([nodeStart]) --> research["researchSubgraph<br/>(联网搜索)"] research --> plan["planSubgraph<br/>(制定计划)"] plan --> execute["executeSubgraph<br/>(执行动作)"] execute --> nodeFinish([nodeFinish])

下面是根据官方示例整理并补齐结束边的策略片段。WebSearchTool、DoAction、DoAnotherAction 是示意的业务工具,需要自行实现并注册到 Agent 的 ToolRegistry;代码本身不是完整应用。每个工具循环都保留“继续调用”和“文本结束”两类出边:

val strategy = strategy<String, String>("assistant") {

    // 子图 1:调研 —— 只能用搜索工具
    val researchSubgraph by subgraph<String, String>(
        "research_subgraph",
        tools = listOf(WebSearchTool())
    ) {
        val nodeCallLLM by nodeLLMRequest("call_llm")
        val nodeExecuteTool by nodeExecuteTools()
        val nodeSendToolResult by nodeLLMSendToolResults()

        edge(nodeStart forwardTo nodeCallLLM)
        edge(nodeCallLLM forwardTo nodeExecuteTool onToolCalls { true })
        edge(nodeExecuteTool forwardTo nodeSendToolResult)
        edge(nodeSendToolResult forwardTo nodeExecuteTool onToolCalls { true })
        edge(nodeSendToolResult forwardTo nodeFinish onTextMessage { true })
        edge(nodeCallLLM forwardTo nodeFinish onTextMessage { true })
    }

    // 子图 2:规划 —— 不给任何工具,纯推理
    val planSubgraph by subgraph<String, String>(
        "plan_subgraph",
        tools = listOf()
    ) {
        val nodeUpdatePrompt by node<String, Unit> { research ->
            llm.writeSession {
                rewritePrompt {
                    prompt("research_prompt") {
                        system(
                            "You are given a problem and some research on how it can be solved." +
                                    "Make step by step a plan on how to solve given task."
                        )
                        user("Research: $research")
                    }
                }
            }
        }
        val nodeCallLLM by nodeLLMRequest("call_llm")

        edge(nodeStart forwardTo nodeUpdatePrompt)
        edge(nodeUpdatePrompt forwardTo nodeCallLLM transformed { "Task: $agentInput" })
        edge(nodeCallLLM forwardTo nodeFinish onTextMessage { true })
    }

    // 子图 3:执行 —— 只能用动作类工具
    val executeSubgraph by subgraph<String, String>(
        "execute_subgraph",
        tools = listOf(DoAction(), DoAnotherAction()),
    ) {
        val nodeUpdatePrompt by node<String, Unit> { plan ->
            llm.writeSession {
                rewritePrompt {
                    prompt("execute_prompt") {
                        system(
                            "You are given a task and detailed plan how to execute it." +
                                    "Perform execution by calling relevant tools."
                        )
                        user("Plan: $plan")
                    }
                }
            }
        }
        val nodeCallLLM by nodeLLMRequest("call_llm")
        val nodeExecuteTool by nodeExecuteTools()
        val nodeSendToolResult by nodeLLMSendToolResults()

        edge(nodeStart forwardTo nodeUpdatePrompt)
        edge(nodeUpdatePrompt forwardTo nodeCallLLM transformed { "Task: $agentInput" })
        edge(nodeCallLLM forwardTo nodeExecuteTool onToolCalls { true })
        edge(nodeExecuteTool forwardTo nodeSendToolResult)
        edge(nodeSendToolResult forwardTo nodeExecuteTool onToolCalls { true })
        edge(nodeSendToolResult forwardTo nodeFinish onTextMessage { true })
        edge(nodeCallLLM forwardTo nodeFinish onTextMessage { true })
    }

    // 三个子图串联
    nodeStart then researchSubgraph then planSubgraph then executeSubgraph then nodeFinish
}

这个例子有三个值得咀嚼的点:

  1. 每个子图有自己的工具集:research 只能搜索,plan 什么工具都不给(纯推理),execute 只能执行动作。工具隔离让"这一段该干什么"变得非常明确,也防止 LLM 在规划阶段乱调工具。
  2. rewritePrompt 让每个子图"带着自己的任务说明书"工作:进入子图先重写 prompt(换 system 指令、塞入上一阶段的结果),相当于每个工段开工前先换一张工单。
  3. 子图之间靠类型衔接:三个子图都是 String → String,所以能直接串。如果中间需要换类型,用 transformed 在边上转。

4.4 预定义子图:subgraphWithTask / subgraphWithVerification

如果连子图内部的"请求-执行-回传"循环都懒得写,Koog 提供了两个现成子图。

subgraphWithTask:任务执行子图

给它一段任务文本和一组工具,它自动完成"请求 LLM → 执行工具 → 汇总结果"的完整流程:

val processQuery by subgraphWithTask<String, String>(
    tools = listOf(searchTool, calculatorTool, weatherTool),
    llmModel = OpenAIModels.Chat.GPT4o,
    parallelTools = false,
    assistantResponseRepeatMax = 3,
) { userQuery ->
    """
    You are a helpful assistant that can answer questions about various topics.
    Please help with the following query:
    $userQuery
    """.trimIndent()
}

两个参数值得注意:

  • parallelTools:工具是否并行执行,默认顺序执行。
  • assistantResponseRepeatMax:允许的最大助手响应次数。超过就认为任务无法完成,防止无限循环。

这类子图也可以通过 llmParams 设置任务专用的模型参数。以上工具名均为示意,需要替换为已注册的工具实例。

subgraphWithVerification:带验证的任务子图

subgraphWithTask 的加强版:保证 LLM 在流程结束时调用验证工具,检查任务是否真正完成,并返回 CriticResult(成功/失败状态 + 详细反馈)。适合需要质检环节的流程:

val verifyCode by subgraphWithVerification<String>(
    tools = listOf(runTestsTool, analyzeTool, readFileTool),
    llmModel = AnthropicModels.Opus_4_6,
    parallelTools = false,
    assistantResponseRepeatMax = 3,
) { codeToVerify ->
    """
    You are a code reviewer. Please verify that the following code meets all requirements:
    1. It compiles without errors
    2. All tests pass
    3. It follows the project's coding standards

    Code to verify:
    $codeToVerify
    """.trimIndent()
}

一句话总结本章:

Subgraph 是策略里的"函数":唯一命名、类型化输入输出、自己的工具集和上下文;复杂工作流拆成多个子图串联,工具隔离 + rewritePrompt 让每段职责清晰;懒得写循环就用 subgraphWithTask,要质检就用 subgraphWithVerification。

至此,图的"局部"讲完了。但如果我们根本不想自己画图呢?Koog 还提供了一批现成的整体策略——预定义策略,下一章见。


五、预定义策略与常见模式

Koog 把两类东西做成了"现成的":整体策略(这一章)和局部子图(上一章 4.4 节)。整体策略拿到就能当 Agent 的 strategy 用,覆盖两类最典型的 Agent 模式:聊天交互和推理-行动循环。

5.1 chatAgentStrategy:聊天交互策略

chatAgentStrategy() 在 1.3.0 中不是普通问答循环,而是带工具使用反馈的策略:

  1. 初始请求若直接返回文本,进入反馈节点,要求模型改为调用可用工具。
  2. 反馈阶段继续返回文本时,会再次反馈,直到出现工具调用或达到运行限制。
  3. 执行工具并回传结果后,文本响应可以正常结束;新的工具调用则继续循环。
  4. 工具结果回传后的响应若包含 __exit__ 调用,可走专门的结束路径。

因此,它适用于希望先使用工具的交互流程,不表示所有阶段都禁止文本输出,也不会自动提供跨 run 的持久会话记忆。多轮历史如何保存,需要另行配置。

使用方式(需要 ai.koog.agents.ext.agent.chatAgentStrategy 导入):

val chatAgent = AIAgent(
    promptExecutor = promptExecutor,
    llmModel = model,
    // 使用 chatAgentStrategy 作为 agent 的策略
    strategy = chatAgentStrategy(),
    // 注册 agent 可用的工具
    toolRegistry = ToolRegistry {
        tool(searchTool)
        tool(weatherTool)
    }
)

suspend fun main() {
    val result = chatAgent.run("What's the weather like today and should I bring an umbrella?")
}

适用场景:

  • 需要使用工具的会话型 agent
  • 根据用户请求执行动作的助手
  • 需要访问外部系统或数据的聊天机器人
  • 希望强制使用工具的场景。

5.2 reActStrategy:推理与行动交替

ReAct(Reasoning and Acting)策略在推理和执行两个阶段之间交替,动态处理任务。它的循环是:

  1. 推理当前状态,规划下一步。
  2. 基于推理采取行动。
  3. 观察行动结果。
  4. 重复,直到得出最终答案。

reActStrategy 在 1.3.0 中提供以下参数:

参数 类型 默认值 说明
reasoningInterval Int 1 推理步的间隔,必须大于 0
name String "re_act" 策略名称
reasoningPrompt String "Please give your thoughts about the task and plan the next steps." 推理阶段的提示词

以下沿用官方银行场景的推理与行动示意,不是本文实测输出。原例的日期跨两个自然月,因此这里将问题表述为“这段时间花了多少钱”,避免把滚动区间当成自然月:

第 1 步:用户输入——“2025-05-19 至 2025-06-18 期间我花了多少钱?”

第 2 步:推理——agent 先想清楚步骤:

I need to follow these steps:
1. Get all transactions from 2025-05-19 to 2025-06-18
2. Filter out deposits (positive amounts)
3. Calculate total spending

第 3 步:行动(一)——根据推理调用 get_transactions 工具:

{tool: "get_transactions", args: {startDate: "2025-05-19", endDate: "2025-06-18"}}

工具返回交易记录:

[
  {date: "2025-05-25", amount: -100.00, description: "Grocery Store"},
  {date: "2025-05-31", amount: +1000.00, description: "Salary Deposit"},
  {date: "2025-06-10", amount: -500.00, description: "Rent Payment"},
  {date: "2025-06-13", amount: -200.00, description: "Utilities"}
]

第 4 步:再推理——拿到结果,继续想:

I have the transactions. Now I need to:
1. Remove the salary deposit of +1000.00
2. Sum up the remaining transactions

第 5 步:行动(二)——剔除正数金额,调用 calculate_sum:

{tool: "calculate_sum", args: {amounts: [-100.00, -500.00, -200.00]}}

工具返回 -800.00。

第 6 步:最终回复——

You spent $800.00 during the specified period on groceries, rent, and utilities.

对比 chatAgentStrategy 就能看出差别:Chat 策略侧重工具使用反馈;ReAct 显式插入"先想后做"的推理步,把多步推理任务拆成 推理 → 行动 → 观察 的循环。

使用方式:

val bankingAgent = AIAgent(
    promptExecutor = promptExecutor,
    llmModel = model,
    // 使用 reActStrategy
    strategy = reActStrategy(
        reasoningInterval = 1,
        name = "banking_agent"
    ),
    toolRegistry = ToolRegistry {
        tool(getTransactions)
        tool(calculateSum)
    }
)

suspend fun main() {
    val result = bankingAgent.run("How much did I spend last month?")
}

以上为策略使用片段,依赖已配置的执行器和工具;本文没有将它作为真实模型实测输出。

5.3 三种常见策略模式

下面整理官方指南中的三类策略模式。其中 singleRunStrategy() 是已有的预定义函数;tool-based 与 streaming data 是组织工作流的模式名称,不代表另有同名 API,也不是三种互斥分类。

模式一:singleRunStrategy(单次运行)

非交互场景,处理一次输入,返回结果。3.1 节已经讲过它的代码——就是那张"请求 → 执行 → 回传"的三角循环图。Basic Agent 预定义策略背后就是它。

模式二:tool-based strategy(工具型)

以工具为核心的工作流:LLM 决定调用什么工具,执行后把结果回传,循环直到 LLM 给出文本响应。结构上与 singleRun 一致,官方示例的侧重点在于展示如何组织这个循环,可作为业务工具流的模板。

模式三:streaming data strategy(流式数据)

下面是流程片段:Book、markdownBookDefinition() 和 parseMarkdownStreamToBooks() 需要业务代码定义,不能只复制本段就运行。它在节点内部发起流式请求,边接收边解析:

val agentStrategy = strategy<String, List<Book>>("library-assistant") {
    val getMdOutput by node<String, List<Book>> { booksDescription ->
        val books = mutableListOf<Book>()
        val mdDefinition = markdownBookDefinition()

        llm.writeSession {
            appendPrompt { user(booksDescription) }
            // 以 mdDefinition 定义的形式发起流式响应
            val markdownStream = requestLLMStreaming(mdDefinition)
            // 流式解析并处理每一本书
            parseMarkdownStreamToBooks(markdownStream).collect { book ->
                books.add(book)
                println("Parsed Book: ${book.title} by ${book.author}")
            }
        }

        books
    }
    edge(nodeStart forwardTo getMdOutput)
    edge(getMdOutput forwardTo nodeFinish)
}

这个例子也演示了 strategy 的输出类型可以是任意类型:strategy<String, List<Book>>,输入文本、输出书籍列表。

5.4 选型:什么时候用现成的,什么时候自己画

把本系列讲过的选项放到一起:

场景 建议
简单问答、单次任务 Basic Agent(默认预定义策略,即 singleRun 模式)
希望先使用工具、需要文本反馈纠偏 chatAgentStrategy()
多步推理任务(先想后做) reActStrategy()
某个"工段"要任务化/带验证 subgraphWithTask / subgraphWithVerification
固定流程、多阶段、需要工具隔离 自定义图 + subgraph 串联
多方案竞争、批量并发 自定义图 + parallel(下一章)

判断顺序始终是:先看预定义策略够不够 → 再看预定义节点能不能拼出来 → 最后才自定义节点和子图。

一句话总结:

chatAgentStrategy 提供工具使用反馈,reActStrategy 管"先想后做";三种常见模式(单次运行、工具型、流式数据)是自建图的标准起手式;选型永远从预定义往自定义走。


六、并行节点执行

前面的图虽然包含分支和循环,但一次只沿匹配的路径继续执行。但很多任务天然是并发的,比如以下三类典型情况:

  • 同一个输入,同时用不同的模型或方法处理;
  • 多个相互独立的操作并行执行;
  • 竞争评选模式:生成多个方案,再比较选出最好的。

6.1 parallel()

并行节点执行通过 parallel 方法发起:

val nodeName by parallel<String, String>(
   firstNode, secondNode, thirdNode /* 更多节点 */
) {
   // 合并策略写在这里
   selectByMax { it.length }
}

结构上把它读成一句话:这几个节点并发跑,结果出来后按某种策略合并成一个。

一个实际例子——三个节点分别统计 token 数、字符数、单词数,并发执行后取最大值:

val calc by parallel<String, Int>(
   nodeCalcTokens, nodeCalcSymbols, nodeCalcWords,
) {
   selectByMax { it }
}

执行完成后,calc 这个"合成节点"的输出就是三个结果中的最大值,下游节点拿到的就是这一个值——对图的其余部分来说,parallel 就是一个普通节点。

6.2 四种合并策略

并行执行的核心问题是:多个结果出来了,留哪个、怎么合?Koog 提供四种合并策略:

合并策略 作用 一句话例子
selectBy 按谓词函数选第一个满足的 选第一个包含 "programmer" 的笑话
selectByMax 按比较函数选最大值 选最长的笑话
selectByIndex 按选择函数返回的索引选 让另一个 LLM 当评委,选出最佳
fold 把所有结果折叠成一个值 把所有笑话拼成一段文字

selectBy:按条件挑

val nodeSelectJoke by parallel<String, String>(
   nodeOpenAI, nodeAnthropicSonnet, nodeAnthropicOpus,
) {
   selectBy { it.contains("programmer") }
}

1.3.0 会先等待全部分支完成,再按传给 parallel 的节点顺序查找第一个满足条件的结果,不是“谁先完成就选谁”。如果没有任何匹配,selectBy 会抛出 NoSuchElementException,实际业务需要设计兜底。

selectByMax:选最大

val nodeLongestJoke by parallel<String, String>(
   nodeOpenAI, nodeAnthropicSonnet, nodeAnthropicOpus,
) {
   selectByMax { it.length }
}

选最长的那个。

selectByIndex:让"评委"来选

这个最有意思——选择函数本身可以是一次 LLM 请求:

val nodeBestJoke by parallel<String, String>(
   nodeOpenAI, nodeAnthropicSonnet, nodeAnthropicOpus,
) {
   selectByIndex { jokes ->
      // 用另一个 LLM 来判断哪个笑话最好
      llm.writeSession {
         model = OpenAIModels.Chat.GPT4o
         appendPrompt {
            system("You are a comedy critic. Select the best joke.")
            user("Here are three jokes: ${jokes.joinToString("\n\n")}")
         }
         val response = requestLLMStructured<JokeRating>()
         response.getOrThrow().data.bestJokeIndex.also { index ->
             require(index in jokes.indices) { "模型返回的笑话索引越界:$index" }
         }
      }
   }
}

三个候选笑话全部生成后,再发一次结构化请求让 GPT-4o 当评委,返回最佳笑话的索引。"多方案并行生成 + LLM 评选",这是竞争评选模式的完整形态。

fold:聚合全部

val nodeAllJokes by parallel<String, String>(
   nodeOpenAI, nodeAnthropicSonnet, nodeAnthropicOpus,
) {
   fold("Jokes:\n") { result, joke -> "$result\n$joke" }
}

这里聚合的是输出字符串,不是把所有分支的消息历史或 storage 写入自动合并。四种策略的上下文保留规则见第七章。

6.3 官方示例改编:Best joke 策略

以下根据官方 Best joke 示例整理策略主体,补充输出类型、评分数据类和索引校验。三个不同模型并行生成笑话,再由 GPT-4o 评选最佳。

这不是独立运行的完整应用:还需要支持 OpenAI 与 Anthropic 的执行器、两家的凭据,以及 Agent 的组装与调用。不要只配置一个 Provider 却在节点里切换到另一个 Provider。模型可用性也取决于账号权限。

评分数据类(6.2 的 JokeRating 也使用这个定义):

@kotlinx.serialization.Serializable
data class JokeRating(val bestJokeIndex: Int)
val strategy = strategy<String, String>("best-joke") {
    // 三个节点分别用三个模型生成笑话
    val nodeOpenAI by node<String, String> { topic ->
        llm.writeSession {
            model = OpenAIModels.Chat.GPT4o
            appendPrompt {
                system("You are a comedian. Generate a funny joke about the given topic.")
                user("Tell me a joke about $topic.")
            }
            val response = requestLLMWithoutTools()
            response.parts.filterIsInstance<MessagePart.Text>().joinToString("\n") { it.text }
        }
    }

    val nodeAnthropicSonnet by node<String, String> { topic ->
        llm.writeSession {
            model = AnthropicModels.Sonnet_4_5
            appendPrompt {
                system("You are a comedian. Generate a funny joke about the given topic.")
                user("Tell me a joke about $topic.")
            }
            val response = requestLLMWithoutTools()
            response.parts.filterIsInstance<MessagePart.Text>().joinToString("\n") { it.text }
        }
    }

    val nodeAnthropicOpus by node<String, String> { topic ->
        llm.writeSession {
            model = AnthropicModels.Opus_4_6
            appendPrompt {
                system("You are a comedian. Generate a funny joke about the given topic.")
                user("Tell me a joke about $topic.")
            }
            val response = requestLLMWithoutTools()
            response.parts.filterIsInstance<MessagePart.Text>().joinToString("\n") { it.text }
        }
    }

    // 并行生成,然后让 GPT-4o 评选最佳
    val nodeGenerateBestJoke by parallel(
        nodeOpenAI, nodeAnthropicSonnet, nodeAnthropicOpus,
    ) {
        selectByIndex { jokes ->
            llm.writeSession {
                model = OpenAIModels.Chat.GPT4o
                rewritePrompt {
                    prompt("best-joke-selector") {
                        system("You are a comedy critic. Give a critique for the given joke.")
                        user(
                            """
                            Here are three jokes about the same topic:

                            ${jokes.mapIndexed { index, joke -> "Joke $index:\n$joke" }.joinToString("\n\n")}

                            Select the best joke and explain why it's the best.
                            """.trimIndent()
                        )
                    }
                }

                val response = requestLLMStructured<JokeRating>()
                val index = response.getOrThrow().data.bestJokeIndex
                require(index in jokes.indices) { "模型返回的笑话索引越界:$index" }
                index
            }
        }
    }

    // 连接节点
    nodeStart then nodeGenerateBestJoke then nodeFinish
}
flowchart LR nodeStart([nodeStart]) --> p["parallel:三个模型并发生成"] p --> judge["selectByIndex:GPT-4o 评选最佳"] judge --> nodeFinish([nodeFinish])

这个例子有两个值得注意的点:

  1. 并行不只是"同一个调用发三次"——三个生成节点各自在 llm.writeSession 里指定了不同的 model,并行的是三个不同的模型。这就是官方说的"同一输入同时用不同模型处理"。
  2. 评委逻辑写在合并策略里——selectByIndex 的 lambda 在所有并行结果就绪后执行,天然适合"汇总后再决策"的场景。

以上为策略使用片段,依赖已配置的执行器和工具;本文没有将它作为真实模型实测输出。

6.4 注意事项

并行不是免费的,官方文档明确列了开销和约束:

  • 资源约束:每个并行节点都会创建一个新的协程;多个 LLM API 同时调用要考虑速率限制和成本。
  • 上下文是 fork 出来的:每次并行执行都会 fork 一个上下文,合并结果时需要决定保留哪个上下文、或如何组合不同执行的上下文。
  • 性能:上下文的 fork 与合并有计算成本,并行数太多会发生资源竞争。

因此官方建议只并行这样的操作:相互独立、执行时间长、不共享可变状态。反过来说,如果一个节点只是做个字符串转换,并行化反而得不偿失。

一句话总结:

parallel() 让多个节点并发执行,四种合并策略决定结果怎么合:selectBy 按条件挑、selectByMax 选最大、selectByIndex 让 LLM 当评委、fold 全要;并行的代价是协程、fork 上下文和资源竞争,只并行独立且耗时的操作。


七、节点间数据传递:AIAgentStorage

7.1 边传递与 storage

节点的返回值沿边传给下游,是最直接的数据流。若较早的结果需要在后面的非相邻节点中读取,可以使用 AIAgentStorage,避免中间节点一直透传与自身逻辑无关的数据。

方式 机制 适合
边传递 上游输出成为下游输入,必要时转换 显式表达主数据流
AIAgentStorage 通过节点上下文的 storage.set/get 按 key 存取 在同一执行上下文中跨节点、跨顺序子图保留辅助状态

storage 不是外部数据库,也不是所有并行分支共享的实时白板。并行上下文的行为见 7.4。

7.2 AIAgentStorageKey:名称决定键相等性

通过 createStorageKey<T>(name) 创建类型化的键:

val userDataKey = createStorageKey<UserData>("profile.user-data")

在 Koog 1.3.0 中,AIAgentStorageKey 的 equals 和 hashCode 按 name 判断;不是按对象实例判断。对于相同值类型:

val key1 = createStorageKey<UserData>("profile.user-data")
val key2 = createStorageKey<UserData>("profile.user-data")
check(key1 == key2)
// 在节点执行逻辑中:
// storage.set(key1, UserData("John", 26))
// storage.get(key2) 可以取到上面存入的值。

官方指南曾描述“同名 key 的实例互不相等”,但这与 1.3.0 的发布实现不一致。本文按该版本源码及离线运行结果说明。

类型参数 T 约束单个 key 的存取接口,不负责阻止名称冲突。不要创建同名但不同类型的 key;它们仍然可能命中同一个存储项,造成类型错误。复用组件时建议使用业务前缀,如 profile.user-data、travel.weather。

7.3 示例:明确写入与读取顺序

下面根据官方存取示例补齐图连接,并将数据类声明为可序列化,方便后续讨论持久化。只有内存 set/get 时不要求 @Serializable;这里采用 kotlinx.serialization,需要启用对应编译插件。

import ai.koog.agents.core.agent.entity.createStorageKey
import ai.koog.agents.core.dsl.builder.node
import ai.koog.agents.core.dsl.builder.strategy
import kotlinx.serialization.Serializable

@Serializable
data class UserData(val name: String, val age: Int)

val userDataKey = createStorageKey<UserData>("profile.user-data")

val storageStrategy = strategy<String, String>("storage-example") {
    val saveData by node<String, String> { message ->
        storage.set(userDataKey, UserData("John", 26))
        message
    }
    val retrieveData by node<String, String> { message ->
        val user = storage.getValue(userDataKey)
        "Hello ${user.name}, here's a message for you: $message"
    }

    nodeStart then saveData then retrieveData then nodeFinish
}

存用 set(key, value);get(key) 在键不存在时返回 null,getValue(key) 则抛出 NoSuchElementException。示例由执行顺序保证先写后读,所以使用 getValue。

这两个节点不必直接相连:可以在中间插入其他节点。但它们仍需接入执行路径,且必须保证读取前已写入。storage 不会调度节点执行。

7.4 并行上下文与合并规则

1.3.0 的 parallel 会为每条分支 fork 上下文,包含 storage.copy():

  • 分支可以读取 fork 前已有的存储项。
  • 分支里的 storage.set(...) 修改自己的副本,不会自动广播到其他分支。
  • selectBy、selectByMax、selectByIndex 返回被选中结果及其上下文,下游因此沿用该分支的状态。
  • fold 聚合输出值,保留基础上下文,不会自动合并各分支的 storage 写入或消息历史。

例如,基础值为 base,两个分支分别写入 A、B 并返回同名字符串:fold 可以得到输出 AB,但下游读取的基础值仍为 base;若选择第一个分支,下游值则为 A。本文已用离线执行验证这两种情况。

还要注意:storage 的复制是容器层面的浅复制,不保证任意可变值对象都会深拷贝。不要把共享可变对象塞入 storage 后,依赖分支原地修改它来通信。

需要汇总多分支数据时,优先让各分支显式返回结果,在合并步骤聚合,再由后续节点写入 storage。不要假定“有 Mutex”就等于“分支共享一个存储”或“读—改—写具有原子性”。

7.5 序列化与持久化边界

set/get 直接访问内存对象,不执行 JSON 序列化。序列化相关 API 则有不同要求:

API 作用
remove(key) / clear() 删除单项 / 清空存储
putAll(map) / putAll(otherStorage) 导入内存项或另一个 storage 的内容
copy() 复制存储容器
toSerializedMap() 返回以键名为索引的 Map<String, JSONElement>
putAllSerialized(map) 导入序列化项,读取时按 key 的类型信息反序列化

该版本没有本文原稿所列的 toMap()。toSerializedMap() 会忽略无法被当前 JSONSerializer 序列化的值,不能把“能存进 storage”理解为“肯定能保存到 checkpoint”。

使用 kotlinx.serialization 时,通常为自定义模型加 @Serializable,并确保其字段也受支持;如果配置其他序列化器,则以该序列化器的要求为准。

Agent Persistence 需要另外安装与配置。只有成功序列化、纳入 checkpoint 且能够在恢复时反序列化的数据,才能被可靠还原。本文离线校验覆盖了 storage 的序列化导出、导入和读取,不等同于完整的进程重启、checkpoint 恢复测试。具体存储后端、恢复一致性和外部副作用将在后续文章展开。

边表达主数据流,storage 保存上下文内的辅助状态;键名需要避免冲突,并行分支状态需要显式合并,持久化则必须满足序列化与 checkpoint 配置要求。


八、综合示例:确定性查天气,再由模型生成建议

本章是自行设计的教学示例,不是官方天气服务示例。重点是把必须由程序保证的步骤与由模型生成的内容分开。

8.1 需求与流程

输入使用结构化的 TravelRequest(city, intent),只讨论今天的出行。自然语言提取城市、日期不在本例范围内。

  1. 在子图中直接执行天气工具,不让模型决定是否查询。
  2. 工具返回结构化 Weather,依据 isRainy 做互补分支,不匹配“雨”字。
  3. 程序组装室内或户外的活动限制,两个 LLM 节点并行生成简洁版与详细版。
  4. fold 合并文本,收尾节点从基础 storage 读取先前保存的天气。
flowchart TD start([nodeStart]) --> research["research 子图:直接执行天气工具"] research --> save["saveWeather:保存结构化结果"] save -->|"isRainy"| indoor["室内活动限制"] save -->|"!isRainy"| outdoor["户外活动限制"] indoor --> polish["parallel:简洁版 + 详细版"] outdoor --> polish polish --> compose["fold 合并;读取基础 storage"] compose --> finish([nodeFinish])

天气工具只返回北京、上海的固定演示数据,未知城市明确失败。没有访问真实天气 API,不能将下列数据当成天气预报。

8.2 可编译的 Kotlin 示例

以下代码可放入一个 Kotlin 文件。校验环境使用 JDK 21、Kotlin 2.4.10、kotlinx.serialization 编译插件,依赖如下:

implementation("ai.koog:koog-agents:1.3.0")
implementation("ai.koog:koog-agents-additions:1.3.0-beta")

本例的 DeepSeek 客户端来自 additions 依赖中的 Beta 模块,版本与 API 稳定性需要单独留意。运行入口读取 DEEPSEEK_API_KEY,会调用真实模型并产生费用;不要把密钥写入源码。

import ai.koog.agents.core.agent.AIAgent
import ai.koog.agents.core.agent.entity.createStorageKey
import ai.koog.agents.core.dsl.builder.*
import ai.koog.agents.core.dsl.extension.*
import ai.koog.agents.core.environment.SafeTool
import ai.koog.agents.core.tools.Tool
import ai.koog.agents.core.tools.ToolRegistry
import ai.koog.prompt.executor.clients.deepseek.DeepSeekLLMClient
import ai.koog.prompt.executor.clients.deepseek.DeepSeekModels
import ai.koog.prompt.executor.llms.MultiLLMPromptExecutor
import ai.koog.prompt.executor.model.PromptExecutor
import ai.koog.prompt.message.MessagePart
import ai.koog.serialization.typeToken
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.Serializable

@Serializable
data class TravelRequest(val city: String, val intent: String)

@Serializable
data class WeatherArgs(val city: String)

@Serializable
data class Weather(val city: String, val summary: String, val isRainy: Boolean)

// 固定测试数据,不是真实天气服务;未知城市明确失败。
class WeatherTool : Tool<WeatherArgs, Weather>(
    argsType = typeToken<WeatherArgs>(),
    resultType = typeToken<Weather>(),
    name = "get_weather",
    description = "查询指定城市今天的天气(演示数据)"
) {
    override suspend fun execute(args: WeatherArgs): Weather = when (args.city) {
        "北京" -> Weather("北京", "小雨,18 度", true)
        "上海" -> Weather("上海", "晴,26 度", false)
        else -> error("演示天气数据不支持城市:${args.city}")
    }
}

fun travelStrategy(weatherTool: WeatherTool) =
    strategy<TravelRequest, String>("travel-advisor") {
        val weatherKey = createStorageKey<Weather>("travel.weather")
        val requestKey = createStorageKey<TravelRequest>("travel.request")

        // 确定性执行工具,不让 LLM 决定是否查询、查询哪个城市。
        val research by subgraph<TravelRequest, Weather>(
            name = "research_weather", tools = listOf(weatherTool)
        ) {
            val toArgs by node<TravelRequest, WeatherArgs> { request ->
                require(request.city.isNotBlank()) { "城市不能为空" }
                storage.set(requestKey, request)
                WeatherArgs(request.city)
            }
            val queryWeather by nodeExecuteSingleTool<WeatherArgs, Weather>(
                tool = weatherTool, doAppendPrompt = false
            )
            val unwrap by node<SafeTool.Result<Weather>, Weather> { result ->
                when (result) {
                    is SafeTool.Result.Success -> result.result
                    is SafeTool.Result.Failure -> error("天气查询失败:${result.message}")
                }
            }
            nodeStart then toArgs then queryWeather then unwrap then nodeFinish
        }

        val saveWeather by node<Weather, Weather> { weather ->
            storage.set(weatherKey, weather)
            weather
        }
        val indoorAdvice by node<Weather, String> { weather ->
            "城市:${weather.city};今天:${weather.summary};" +
                "出行意图:${storage.getValue(requestKey).intent}。仅建议室内活动,提醒携带雨具。"
        }
        val outdoorAdvice by node<Weather, String> { weather ->
            "城市:${weather.city};今天:${weather.summary};" +
                "出行意图:${storage.getValue(requestKey).intent}。可以考虑户外活动,提醒防晒补水。"
        }

        val shortVersion by node<String, String> { context ->
            llm.writeSession {
                // 每个分支独立设置任务,避免累积互相冲突的 system 指令。
                rewritePrompt {
                    ai.koog.prompt.dsl.prompt("travel-short") {
                        system("根据给定的今天的天气和活动限制,用中文一句话概括建议,不改变活动类型。")
                        user(context)
                    }
                }
                val response = requestLLMWithoutTools()
                val text = response.parts.filterIsInstance<MessagePart.Text>()
                    .joinToString("\n") { it.text }
                check(text.isNotBlank()) { "模型没有返回简洁版文本" }
                "【简洁版】$text"
            }
        }
        val detailedVersion by node<String, String> { context ->
            llm.writeSession {
                rewritePrompt {
                    ai.koog.prompt.dsl.prompt("travel-detailed") {
                        system("根据给定的今天的天气和活动限制,生成中文详细建议与注意事项,不改变活动类型。")
                        user(context)
                    }
                }
                val response = requestLLMWithoutTools()
                val text = response.parts.filterIsInstance<MessagePart.Text>()
                    .joinToString("\n") { it.text }
                check(text.isNotBlank()) { "模型没有返回详细版文本" }
                "【详细版】$text"
            }
        }
        val polish by parallel<String, String>(shortVersion, detailedVersion) {
            fold("出行建议:") { acc, text -> "$acc\n\n$text" }
        }
        val composeFinal by node<String, String> { advice ->
            val weather = storage.getValue(weatherKey)
            "【今日天气·演示数据】${weather.city}:${weather.summary}\n\n$advice"
        }

        edge(nodeStart forwardTo research)
        edge(research forwardTo saveWeather)
        edge(saveWeather forwardTo indoorAdvice onCondition { it.isRainy })
        edge(saveWeather forwardTo outdoorAdvice onCondition { !it.isRainy })
        edge(indoorAdvice forwardTo polish)
        edge(outdoorAdvice forwardTo polish)
        edge(polish forwardTo composeFinal)
        edge(composeFinal forwardTo nodeFinish)
    }

suspend fun runTravel(
    executor: PromptExecutor,
    request: TravelRequest,
    maxIterations: Int = 30
): String {
    val weatherTool = WeatherTool()
    val agent = AIAgent(
        promptExecutor = executor,
        llmModel = DeepSeekModels.DeepSeekV4Flash,
        strategy = travelStrategy(weatherTool),
        toolRegistry = ToolRegistry { tool(weatherTool) },
        maxIterations = maxIterations
    )
    return try {
        withTimeout(60_000) { agent.run(request) }
    } finally {
        agent.close()
    }
}

fun main() = runBlocking {
    val apiKey = System.getenv("DEEPSEEK_API_KEY")
        ?: error("请设置 DEEPSEEK_API_KEY;本示例会调用真实模型并产生费用")
    val executor = MultiLLMPromptExecutor(DeepSeekLLMClient(apiKey = apiKey))
    try {
        println(runTravel(executor, TravelRequest("北京", "今天参观博物馆")))
    } finally {
        executor.close()
    }
}

8.3 确定性和预算分别保证什么

  • 查询由图保证:nodeExecuteSingleTool(tool = ...) 直接调用已注册工具;结果失败就在 unwrap 中终止,不会悄悄伪造晴天继续生成。
  • 分支由数据保证:isRainy 来自工具结果,不经过 LLM 总结,所以“无雨”之类的措辞不会影响分支判断。工具注册表仍负责实际执行,子图工具描述的可见性不等于外部系统的权限控制。
  • 意图不会丢失:输入请求保存在 travel.request 中,活动限制同时包含城市、天气和意图。
  • 模型只负责生成措辞:正常路径发起两次非流式请求。程序保证先查天气、选择对应分支,但提示词不能保证模型完全遵守活动限制。生产场景仍需输出校验;两次请求也不意味着固定 token 成本。
  • 并行不写共享状态:天气在 fork 前保存。fold 保留基础上下文,所以最后能读回天气;代码没有依赖并行分支写入互相可见。
  • 预算分层设置:maxIterations = 30 是经过离线成功路径验证的图执行上限,不是“最多调用模型 30 次”。另外用 withTimeout(60_000) 限制本次运行的协程时间;真实服务还应配置 HTTP 超时、重试及 token/费用限额。修改图或增加循环后应重新测试预算。
  • 资源归属明确:runTravel 关闭自己创建的 Agent;调用方负责关闭传入的执行器。

8.4 已验证范围

本章代码已在 Koog 1.3.0 依赖下编译。离线执行使用明确标记的 PromptExecutor 测试替身,不连接厂商 API,不评估生成质量。校验程序实际打印的结果如下:

PASS travel 北京: branch=仅建议室内 modelCalls=2
PASS travel 上海: branch=可以考虑户外 modelCalls=2
PASS unknown city: explicit failure before model calls
PASS iteration budget: maxIterations=1 rejects execution

其中 modelCalls 是执行器测试替身的调用次数,不是线上模型调用记录。未知城市和过小预算用例预期抛异常;它们验证了失败路径。真实 DeepSeek 联调、网络超时和模型输出质量尚未验证。


九、最佳实践与排错

9.1 最佳实践

官方 Custom strategy graphs 页面给出的建图最佳实践,逐条整理如下:

  1. 保持简单:从简单的图开始,按需增加复杂度。不要一上来就画"蜘蛛网"。
  2. 描述性命名:给节点和边起有意义的名字,图会好懂得多。
  3. 覆盖所有路径和边界情况:确保每条路径最终都能到达结束节点。
  4. 用各种输入测试图:确保图的行为符合预期。
  5. 给图写文档:记录图的目的和行为,方便将来维护。
  6. 从预定义策略或常见模式起步:第五章的三种模式就是标准起手式。
  7. 长对话用历史压缩:减少 token 消耗(挂 nodeLLMCompressHistory)。
  8. 用子图组织图、管理工具访问:第四章的 multi-part 模式。

写自定义节点时(Custom nodes 页面),还有几条针对性的建议:每个节点只做一件事(单一职责)、为参数写清楚文档、做好错误处理避免整个工作流失败、尽量设计成可复用、合适时用泛型参数化。

9.2 常见问题排查

官方 Troubleshooting 部分列了五个常见问题,都是实战中真的会撞上的:

1. 图走不到结束节点

  • 检查是否所有路径最终都通向 nodeFinish;
  • 检查条件是不是太严,导致某一步没有任何边能满足;
  • 检查循环是否有正常出口;缺少结束边时,文本响应会因没有匹配出边而抛异常,持续的工具调用则可能耗尽迭代预算。

第八章使用 isRainy / !isRainy 两条互补条件,保证布尔状态都有边可走。对更复杂的数据类型,也应覆盖空值、异常状态等边界。

2. 工具调用不执行

  • 工具是否正确注册进了 ToolRegistry(第 4 篇讲过的注册流程);
  • LLM 节点到工具执行节点的边,条件是否正确(onToolCalls { true });
  • 如果在子图里,工具是否在该子图的 tools 子集里。

3. 历史变得太大

  • 加历史压缩节点 nodeLLMCompressHistory;
  • 用条件检查历史长度,超阈值才压缩:
edge(
    (nodeExecuteTool forwardTo nodeCompressHistory)
        onCondition { _ -> llm.readSession { prompt.messages.size > 100 } }
)
  • 用更激进的压缩策略,比如 FromLastNMessages 设更小的 N。

4. 图的行为不符合预期

  • 条件定义是否正确;
  • 边条件按定义顺序评估——这是本章最想强调的坑。同一个源节点的多条出边,按代码中定义的顺序逐条检查,第一条满足的生效。如果一条宽泛的条件排在具体条件前面,具体条件永远轮不到;
  • 是否不小心用更宽泛的条件覆盖了预期条件。

5. 性能问题

  • 精简图:删掉不必要的节点和边;
  • 相互独立的操作用并行(第六章);
  • 压缩历史;
  • 使用更高效的节点和操作。

子图相关的两个问题(来自 Custom subgraphs 页面)也顺带列一下:

  • 子图里工具不可用:检查工具是否正确注册在工具注册表里。
  • 子图不按预期顺序执行:检查子图之间的连接顺序、输出是否正确传给下一个子图、以及子图是否真的连通——特别注意条件边要覆盖所有情况,否则可能在节点上因无匹配出边抛出 AIAgentStuckInTheNodeException。

十、后记

本文从 Koog(2) 里那张"拿来就用"的图出发,把 Strategy 的内部结构拆开讲了一遍。最后用一张表回顾整个心智模型:

组件 职责 一句话
Node 做什么 类型化的函数,预定义节点是积木,一个 lambda 就是自定义节点
Edge 接下来去哪 条件挂在边上:顺序(then)、分支(多条条件出边)、循环(回边)
Subgraph 模块化 策略里的"函数":唯一命名、自己的工具集和上下文
parallel 并发 多节点竞赛/聚合,selectBy/selectByMax/selectByIndex/fold 四种合并
AIAgentStorage 上下文状态 按键名存取;并行分支 fork 副本,状态保留取决于合并策略

对于本文讨论的 Graph-based Agent,图式策略描述了输入处理、工具执行、分支、并行与数据流。Functional Agent 可以直接用函数组织行为,不应把本篇的图模型泛化到所有策略实现。而搭图永远从三层递进里选:先预定义策略,再预定义节点组装,最后才完全自定义。

图跑起来之后,一个新问题随之而来:多轮会话怎么办?历史越来越长怎么办?Agent 执行到一半挂了怎么办?下一篇我们讲 Koog 的记忆、历史压缩与持久化。

参考资料

posted @ 2026-10-16 09:40  SharpCJ  阅读(63)  评论(0)    收藏  举报