首页>AI>正文

Koog Agent 编程模型与四类代理选型实践

Feng 15 阅读 AI

上一篇文章聊了 Koog 在 JVM 生态里的定位。这篇往下走一层,看它的编程模型:一个 Agent 在代码里到底由哪些部件拼起来,以及四种 Agent 类型各自该在什么场景下用。

先把运行环境钉死

官方门槛是 JDK 17+、Kotlin 2.2.0+、Gradle 8.0+ 或 Maven 3.8+。稳定模块依赖 ai.koog:koog-agents:1.3.0,Beta 模块是 ai.koog:koog-agents-additions:1.3.0-beta。Maven 侧对应 koog-agents-jvm 和 koog-agents-additions-jvm。

这里有个容易踩的坑:Kotlin 2.2 的编译器插件与部分 Gradle 8.0.x 早期小版本存在元数据读取不兼容,症状是构建时报 Unsupported metadata version,而不是直白的版本错误。把 Gradle 升到 8.5 以上通常能消掉。另外稳定模块和 Beta 模块混用时,如果版本号没对齐(比如稳定版 1.3.0 配 Beta 1.2.0-beta),依赖解析会失败并提示找不到 koog-agents-additions 的传递依赖。校验方式很简单,跑一次 ./gradlew dependencies --configuration runtimeClasspath,确认两个 artifact 的版本前缀一致即可。

最小可运行单元

先看一段能跑起来的代码。下面用本地 LM Studio 起的 Qwen2.5 做后端:

fun main() = runBlocking {
 val apiKey = ""
 val baseUrl = "http://localhost:1234"
 val model = "qwen2.5-0.5b-instruct-mlx"

 val client = OpenAILLMClient(
 apiKey = apiKey,
 settings = OpenAIClientSettings(baseUrl = baseUrl)
 )

 val llmModel = LLModel(
 provider = LLMProvider.OpenAI,
 id = model,
 capabilities = listOf(
 LLMCapability.Completion,
 LLMCapability.OpenAIEndpoint.Completions
 ),
 contextLength = 32768
 )

 val agent = AIAgent(
 promptExecutor = MultiLLMPromptExecutor(client),
 llmModel = llmModel
 )

 val result = agent.run("用中文介绍一下你自己")
 print(result)
}

骨架就三步:造 Client 和 Model,造 Agent,调 run()。换成 Anthropic 或 DeepSeek 只是换 Client 实现类。引入 additions 模块后还能用一批 simpleXXXExecutor 快捷方法省掉手写 executor。

四个部件撑起一个 Agent

Koog 的 Agent 不是对 LLM 的薄封装,它是带类型的状态机。结构由四块决定:prompt executor 负责模型调用,strategy 定义流程走向,tools 提供外部能力,features 做运行时增强。这四者各管一摊,互不越界。

配置入口有两个:构造参数,或者 AIAgentConfig。后者适合需要多条消息、会话历史、细粒度 LLM 参数的场景。

系统提示词直接传 systemPrompt 即可。我拿 0.5B 的小模型试过,加上”你是一名经验丰富的音乐老师”之后,输出立刻换了口吻,虽然幻觉明显——这是小模型本身的毛病,不是提示词没生效。

温度这类参数走构造参数或 LLMParams。迭代上限用 maxIterations 控制,默认 50,防止工具调用和模型请求之间打转。只做几轮问答的 Agent 压到 10 完全够。

val agentConfig = AIAgentConfig(
 prompt = prompt(
 id = "assistant",
 params = LLMParams(temperature = 0.7)
 ) {
 system("你是一名经验丰富的音乐老师,熟练掌握钢琴、吉他、大提琴等多种乐器")
 },
 model = llmModel,
 temperature = 0.7,
 maxAgentIterations = 10
)

val agent = AIAgent(
 promptExecutor = MultiLLMPromptExecutor(client),
 agentConfig = agentConfig
)

Basic Agent:预定义流程的默认解

Basic Agent 走的是预置 strategy:收字符串输入,发给 LLM,模型决定要不要调工具,调了就执行并把结果回传,循环到模型不再请求工具,最后输出文本。理解 Koog 的运行方式,从它入手最快。

加工具分两步。模型能力列表里声明 LLMCapability.Tools,然后用 @Tool 标注方法:

@Tool
@LLMDescription("当用户询问你最喜欢的动物或宠物是什么时,调用此工具获取答案")
fun likeDog(): String {
 return "我最喜欢的动物是小狗。"
}

val agent = AIAgent(
 promptExecutor = MultiLLMPromptExecutor(deepSeekClient),
 systemPrompt = "你是一名经验丰富的音乐老师。",
 llmModel = DeepSeekModels.DeepSeekV4Flash,
 temperature = 0.7,
 toolRegistry = ToolRegistry { tool(::likeDog) },
 maxIterations = 10
)

换成在线 DeepSeek 后,模型确实在回答里调用了工具,把”小狗”这个答案织进了音乐老师的口吻里。

调试靠 EventHandler feature。在构造 lambda 里调 handleEvents,注册 onToolCallStarting 就能打印工具名和参数:

val agent = AIAgent(
 promptExecutor = MultiLLMPromptExecutor(deepSeekClient),
 systemPrompt = "你是一名经验丰富的音乐老师。",
 llmModel = DeepSeekModels.DeepSeekV4Flash,
 temperature = 0.7,
 toolRegistry = ToolRegistry { tool(::likeDog) },
 maxIterations = 10
) {
 handleEvents {
 onToolCallStarting { eventContext ->
 println("Tool called: ${eventContext.toolName} with args ${eventContext.toolArgs}")
 }
 }
}

Graph Agent:把流程画成状态机

需要显式控制走向时,用图。节点是动作(LLM 调用、工具执行),边是数据流。好处是可可视化、状态可持久化、结构可组合。

还是”问宠物”那个例子,用图强制规定路径:先让模型只准调工具,再执行工具,最后让模型基于工具结果组织语言、且不准再调工具。

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 })
}

三个预定义节点的区别值得记牢:nodeLLMRequest 允许模型自由说话或调工具;nodeLLMRequestOnlyCallingTools 强制必须调工具;nodeLLMSendToolResultsWithoutTools 只让模型整理文本,不再开放工具。

onToolCalls { true } 和 onTextMessage { true } 不是执行动作,是给边加通行条件——上一步产出了工具调用或文本消息,才走这条边。整个策略必须有一条从 nodeStart 到 nodeFinish 的通路。

把策略塞进 Agent:

val agent = AIAgent(
 promptExecutor = MultiLLMPromptExecutor(deepSeekClient),
 llmModel = DeepSeekModels.DeepSeekV4Flash,
 strategy = simpleGraphStrategy,
 systemPrompt = """
 你是一名经验丰富的音乐老师。
 当用户询问你最喜欢的动物或宠物是什么时,必须调用 likeDog 工具获取答案。
 """.trimIndent(),
 temperature = 0.7,
 toolRegistry = ToolRegistry { tool(likeDog) },
 maxIterations = 10
) {
 handleEvents {
 onToolCallStarting { eventContext ->
 println("Tool called: ${eventContext.toolName} with args ${eventContext.toolArgs}")
 }
 }
}

这套结构是后面理解 Subgraph、Edge 条件转换的基础。

Functional Agent:函数即流程

逻辑简单、想快速验证时,把整个流程写成一个函数。泛型参数照样定义输入输出类型。

val simpleFunctionalStrategy = functionalStrategy<String, String>("Simple functional agent") { input ->
 val toolCallMessage = requestLLM(input)
 val toolCalls = getToolCalls(toolCallMessage)
 require(toolCalls.isNotEmpty()) {
 "LLM did not call any tool. Try strengthening the system prompt or use a model mode that supports required tool_choice."
 }
 val toolResults = executeTools(toolCalls)
 val finalAnswer = sendToolResultsWithoutTools(toolResults)
 finalAnswer.textContent()
}

getToolCalls 只从模型回复里挑出工具调用片段。这里没用强制调用,靠系统提示词引导,所以加了 require 兜底——模型没调工具就抛异常,避免后续空跑。想强制调用可以换 requestLLMOnlyCallingTools(),但部分模型在 Thinking 模式下不支持强制 tool_choice,会直接报错,这点要提前确认。

代价是不易可视化、没有状态持久化。流程一复杂就该换图。

Planner Agent:让规划器自己找路

Planner 目前还在测试模块,API 可能变。它的思路是迭代规划周期:生成或更新计划、执行单步、检查是否完成,没完成就回到第一步。你只定义带类型输入输出的动作,边由规划器自己连。可控性低于图,但动态性更强。

LLM-based planner 用模型生成和评估计划,状态是单个字符串。SimpleLLMPlanner 只在开头规划一次,之后照做;SimpleLLMWithCriticPlanner 多了 assessPlan,用 LLM 判断计划是否还有效、要不要重规划。

GOAP 走的是另一条路:不靠 LLM 生成计划,用 A* 搜索在预定义动作里找满足目标且总成本最低的序列。三个概念——状态、动作、目标。动作里 precondition 决定能否执行,belief 是规划器脑内推演的状态变化,execute 是真实执行,cost 影响路径选择。

拿煮咖啡举例,状态用 data class 承载:

private data class CoffeeState(
 override val agentInput: String,
 val hasBoiledWater: Boolean = false,
 val hasCoffeeBeans: Boolean = false,
 val hasCoffee: Boolean = false,
 val steps: List<String> = emptyList()
) : GoapAgentState<String, String>() {
 override fun provideOutput(): String = """
 目标:$agentInput
 GOAP 执行动作:${steps.joinToString(" -> ")}
 当前状态:${if (hasCoffee) "已经准备好一杯热咖啡。" else "还没有完成目标。"}
 """.trimIndent()
}

策略里声明三个动作和一个目标,规划器会自动排出 boil-water -> add-tea-leaves -> brew-tea。目标里的 cost 是启发式估算,数的是当前状态离目标还差几件事,配合动作成本做 A* 的路径选择。想让”电热水壶烧水”优先于”用锅烧水”,把前者 cost 调低即可。

这里有个必须守住的约束:action 的 name 在同一 strategy 内要唯一且稳定。GOAPPlanner 内部生成的 plan 是 action name 列表,执行阶段靠 name 反查动作。重名会让执行阶段命中非预期动作;改名而旧 plan 或快照还在,恢复执行会直接失败。验证方法是跑一遍完整流程后打印 plan 的 name 序列,与注册的动作名逐一比对,确认无重复、无缺失。

选型对照

Basic 适合先摸清运行方式,预定义 strategy 覆盖多数常见流程。Graph 适合要明确工作流、状态持久化和组合结构的场景,也是理解后续 Strategy Graph 的入口。Functional 胜在原型速度,代价是可视化和持久化都缺。Planner 面向需要把高层目标拆成多步、并根据每步结果调整的复杂任务,但处于 Beta,可控性弱于图。

测试层面补一句:Graph 策略可以用固定输入跑 agent.run(),断言最终文本和工具调用事件序列;GOAP 策略则适合断言 plan 的 action 顺序,因为它的执行是确定性的,不依赖模型随机性。这两类校验方式不同,别混用。

Koog Agent 编程模型与四类代理选型实践

ID:7779

Feng
这位作者很神秘,还没有填写简介。