ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Koog:JVM开发者的Kotlin原生AI Agent框架,类型安全与协程驱动

2026/9/20 22:38:55 拓冰建站 浏览量
Koog:JVM开发者的Kotlin原生AI Agent框架,类型安全与协程驱动 1. 为什么 JVM 开发者需要一个原生 AI Agent 框架1.1 从“能跑”到“敢上生产”的鸿沟这两年 AI Agent 的概念火得一塌糊涂但凡是个开发者手里没搭过两个 Agent 都不好意思跟人打招呼。但如果你是一个常年混迹 JVM 生态的 Kotlin 或 Java 开发者大概率经历过这样的尴尬想给现有后端系统加一个智能问答或任务编排能力翻遍 GitHub发现主流框架清一色是 Python 的要么就是 TypeScript 的。硬着头皮用 Python 写个服务再通过 HTTP 跟主业务通信结果就是——类型对不上、序列化踩坑、部署多一套运行时、监控链路断成两截。我去年就干过这事。一个订单系统的智能客服模块用 Python 写 AgentKotlin 写业务两边靠 JSON 传数据。上线第一周就出了个经典事故Python 那边把null序列化成了NoneKotlin 这边反序列化直接抛异常整个对话链路挂了半小时。排查的时候我就在想要是 Agent 逻辑本身就能跑在 JVM 上用 Kotlin 的类型系统把数据结构卡死这种破事根本不会发生。Koog 这个框架就是冲着这个痛点来的。它是 JetBrains 推出的 Kotlin 原生 AI Agent 框架核心卖点非常明确类型安全、协程驱动、企业级可观测性。说白了它想让 JVM 开发者用自己最熟悉的语言和工具链把 Agent 当成一个正经的软件工程产物来写而不是当成一个“脚本玩具”。1.2 谁适合看这篇内容如果你符合下面任意一条这篇内容就是写给你的有 Kotlin 或 Java 基础想在自己的后端服务里嵌入 AI Agent 能力但不想引入 Python 运行时正在做 Android 端智能功能希望 Agent 逻辑能跟 App 代码共享类型定义团队已经在用 Spring Boot、Ktor 这类 JVM 框架需要 Agent 能无缝接入现有监控和依赖注入体系对 AI Agent 感兴趣但被 Python 生态的“动态类型自由”坑过想要更严谨的开发体验。我会从框架的设计思路讲起然后拆解核心概念再给出一套可以直接抄的实操流程最后把我踩过的坑和排查经验整理出来。全程用 Kotlin 代码示例但 Java 开发者也能看懂因为核心思想是通用的。2. Koog 的核心设计思路拆解2.1 为什么是“原生 Kotlin”而不是“Kotlin 绑定”市面上不少框架号称支持 Kotlin但本质上是 Java 库加个扩展函数或者 Python 库套个 JNI 壳。Koog 不一样它是从零用 Kotlin 写的这意味着它天然吃透了 Kotlin 的语言特性。最直接的体现就是协程。Agent 的执行过程本质上是异步的、可能长时间挂起的、需要并发处理多个任务的。传统做法是用线程池加 Future代码写起来又臭又长。Koog 直接用suspend函数定义 Agent 的每一步用Flow处理流式输出用CoroutineScope管理生命周期。你写出来的 Agent 逻辑读起来就像同步代码一样顺但底层是挂起复用的一个线程能扛几千个并发对话。另一个体现是类型安全。Koog 里定义工具Tool的时候输入输出的类型是编译期就确定的。比如你定义一个“查询订单”的工具输入是OrderQuery数据类输出是OrderResult密封类。如果 LLM 返回的 JSON 结构对不上框架在反序列化阶段就会报错而不是等到业务逻辑里才抛ClassCastException。这个差别在调试的时候是天壤之别——前者告诉你“模型输出格式错了”后者让你在一堆堆栈里找哪一行类型转换炸了。2.2 企业级能力不是口号是刚需“企业级”这个词被用烂了但在 Agent 框架这个语境下它对应的是几个非常具体的需求可观测性。Agent 执行过程中每一步的输入输出、耗时、token 消耗、工具调用结果都需要能被追踪。Koog 内置了事件监听机制你可以把每个 Agent 的执行过程导出到日志系统或 APM 工具里。我实测下来接 OpenTelemetry 大概只需要写十几行适配代码。可测试性。Agent 的行为依赖 LLM而 LLM 的输出是不确定的。Koog 提供了 Mock 工具和测试策略让你可以在单元测试里固定 LLM 的响应只验证 Agent 的编排逻辑是否正确。这一点对 CI/CD 流水线至关重要——你不可能每次跑测试都去调真实模型。依赖管理。企业项目里LLM 客户端、向量数据库、外部 API 通常都是通过依赖注入管理的。Koog 的设计允许你把 Agent 的各个组件注册到 Spring 或 Ktor 的容器里而不是在 Agent 内部硬编码new一个客户端。2.3 与 Python 框架的定位差异有人会问LangChain 那么成熟为什么不用我的看法是定位不同。LangChain 面向的是快速原型和实验它的动态特性让“先跑起来”变得很容易。但当你需要把 Agent 嵌入一个已经运行了三年的 Kotlin 后端系统时LangChain 的 Python 运行时就成了一个额外的运维负担。Koog 的定位更像是“Agent 领域的 Spring”——它不追求功能最全但追求工程上的严谨和可维护性。如果你的团队已经在 JVM 上投入了大量基础设施Koog 的迁移成本几乎为零。反过来如果你只是想做个小 demo 玩玩Python 生态确实更省事。3. 核心概念与类型安全实操解析3.1 Agent 的骨架Strategy 与 NodeKoog 里最核心的抽象是AgentStrategy和Node。你可以把 Strategy 理解成一张流程图Node 是图上的节点。每个 Node 做一件事要么调用 LLM要么执行工具要么做条件判断。这种设计的好处是执行路径显式化。在 Python 框架里Agent 的逻辑经常藏在一堆if-else和回调里跑起来之后你很难说清楚它到底走了哪条路。Koog 的 Strategy 是声明式的你可以直接把它画出来也可以在执行时记录实际走过的节点序列。下面是一个最简单的 Strategy 定义val orderAgentStrategy strategy(order-assistant) { val analyzeIntent by nodeString, Intent(analyze-intent) { input - llm.writeSession { appendPrompt { user(input) } requestLLMStructuredIntent() } } val handleQuery by nodeIntent, String(handle-query) { intent - when (intent.type) { query - callToolOrderQueryTool(intent.params) cancel - callToolCancelOrderTool(intent.params) else - 抱歉我暂时无法处理这个请求。 } } edge(analyzeIntent forwardTo handleQuery) }这段代码里analyzeIntent节点的输入是String输出是Intent类型。handleQuery的输入是Intent输出是String。编译器会检查边的连接是否类型匹配——如果你试图把analyzeIntent连到一个期望Int输入的节点上编译直接失败。这就是类型安全在 Agent 编排层面的体现。3.2 工具定义把外部能力“类型化”Agent 要干活就得调用外部工具。Koog 里定义工具的方式非常 Kotlin 风格class OrderQueryTool : ToolOrderQuery, OrderResult() { override val name query_order override val description 根据订单号查询订单状态 Serializable data class OrderQuery(val orderId: String) Serializable sealed class OrderResult { data class Success(val status: String, val amount: Double) : OrderResult() data class NotFound(val orderId: String) : OrderResult() } override suspend fun execute(input: OrderQuery): OrderResult { return orderService.findByOrderId(input.orderId) ?.let { OrderResult.Success(it.status, it.amount) } ?: OrderResult.NotFound(input.orderId) } }这里有几个关键点值得展开第一输入输出都是强类型。OrderQuery和OrderResult都是Serializable的数据类或密封类。Koog 会自动根据这些类型生成 JSON Schema发给 LLM 作为工具描述。LLM 返回的参数会被反序列化成OrderQuery对象如果字段缺失或类型不对框架会抛出明确的异常而不是让你在业务代码里做防御性判断。第二密封类表达结果状态。OrderResult用密封类定义了“成功”和“未找到”两种状态。调用方用when表达式处理时编译器会强制你覆盖所有分支。这比返回一个可能为null的对象或者一个带errorCode的通用响应要清晰得多。第三工具描述是给 LLM 看的。description字段会直接进入 prompt所以写的时候要像给同事解释一样说清楚这个工具干什么、什么时候用、参数是什么意思。我见过有人把 description 写成“查询订单”结果 LLM 经常在用户问“我的包裹到哪了”的时候不调用这个工具。改成“根据订单号查询订单的当前状态和金额适用于用户询问订单进度、物流状态、支付金额等场景”之后调用准确率明显提升。3.3 结构化输出让 LLM 返回你想要的类型LLM 的输出本质上是文本但 Koog 提供了requestLLMStructuredT()方法让你直接拿到类型化的对象。底层原理是框架根据T的类型定义生成 JSON Schema要求 LLM 按这个 Schema 输出 JSON然后自动反序列化。Serializable data class Intent( val type: String, val params: MapString, String, val confidence: Double ) val intent llm.writeSession { appendPrompt { system(你是一个意图识别助手请分析用户输入并返回结构化意图。) user(帮我查一下订单 12345 的状态) } requestLLMStructuredIntent() }实测下来GPT-4 和 Claude 系列对这种结构化输出的遵循度很高基本不需要额外的解析逻辑。但要注意Schema 越复杂模型出错的概率越高。我的经验是嵌套层级不要超过三层字段名用英文小写下划线枚举值尽量少。如果发现模型经常返回格式错误先简化 Schema而不是加更多提示词。4. 从零搭建一个可运行的 Agent 服务4.1 环境准备与依赖配置先说一下我的环境Kotlin 1.9.22、Gradle 8.5、JDK 17。Koog 对 JDK 版本的要求是 11 以上但建议用 17因为协程和虚拟线程的配合在 17 上更稳定。Gradle 依赖大概长这样dependencies { implementation(ai.koog:koog-core:0.1.0) implementation(ai.koog:koog-openai:0.1.0) implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0) implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3) implementation(io.ktor:ktor-server-netty:2.3.8) implementation(ch.qos.logback:logback-classic:1.4.14) }注意Koog 的版本迭代比较快建议去官方仓库确认最新版本号。我写这篇内容时用的是 0.1.0API 可能后续有调整。配置 LLM 客户端val llmClient OpenAILLMClient( apiKey System.getenv(OPENAI_API_KEY), model gpt-4-turbo, temperature 0.2 )temperature设成 0.2 是因为 Agent 场景下我们更希望模型稳定输出而不是发挥创造力。如果是做创意类 Agent可以调到 0.7 以上。4.2 定义工具集与业务逻辑对接假设我们有一个简单的订单服务接口如下interface OrderService { suspend fun findByOrderId(orderId: String): Order? suspend fun cancelOrder(orderId: String): Boolean } data class Order(val id: String, val status: String, val amount: Double)然后定义两个工具class QueryOrderTool(private val orderService: OrderService) : ToolQueryOrderTool.Input, QueryOrderTool.Output() { override val name query_order override val description 根据订单号查询订单状态和金额 Serializable data class Input(val orderId: String) Serializable sealed class Output { data class Found(val orderId: String, val status: String, val amount: Double) : Output() data class NotFound(val orderId: String) : Output() } override suspend fun execute(input: Input): Output { val order orderService.findByOrderId(input.orderId) return if (order ! null) { Output.Found(order.id, order.status, order.amount) } else { Output.NotFound(input.orderId) } } } class CancelOrderTool(private val orderService: OrderService) : ToolCancelOrderTool.Input, CancelOrderTool.Output() { override val name cancel_order override val description 根据订单号取消订单仅在订单状态为待发货时可取消 Serializable data class Input(val orderId: String) Serializable sealed class Output { data class Success(val orderId: String) : Output() data class Failed(val reason: String) : Output() } override suspend fun execute(input: Input): Output { return if (orderService.cancelOrder(input.orderId)) { Output.Success(input.orderId) } else { Output.Failed(订单状态不允许取消) } } }这里有个设计决策值得说工具的输出用密封类而不是布尔值或字符串。因为 LLM 需要根据工具返回的结果决定下一步动作。如果返回false模型不知道是“订单不存在”还是“状态不允许取消”后续对话就会很尴尬。用密封类把失败原因结构化模型能更准确地生成回复。4.3 编排 Strategy让 Agent 按流程走现在把工具和 LLM 串起来class OrderAgent( private val llmClient: LLMClient, private val orderService: OrderService ) { private val queryTool QueryOrderTool(orderService) private val cancelTool CancelOrderTool(orderService) private val strategy strategy(order-agent) { val parseIntent by nodeString, UserIntent(parse-intent) { input - llmClient.writeSession { appendPrompt { system( 你是一个订单助手请分析用户意图。 可能的意图类型query查询订单、cancel取消订单、unknown无法识别。 请返回 JSON 格式{type: ..., orderId: ..., confidence: 0.0-1.0} .trimIndent()) user(input) } requestLLMStructuredUserIntent() } } val routeIntent by nodeUserIntent, String(route-intent) { intent - when (intent.type) { query - { val result queryTool.execute(QueryOrderTool.Input(intent.orderId)) when (result) { is QueryOrderTool.Output.Found - 订单 ${result.orderId} 当前状态为 ${result.status}金额 ${result.amount} 元。 is QueryOrderTool.Output.NotFound - 没有找到订单 ${result.orderId}请确认订单号是否正确。 } } cancel - { val result cancelTool.execute(CancelOrderTool.Input(intent.orderId)) when (result) { is CancelOrderTool.Output.Success - 订单 ${result.orderId} 已成功取消。 is CancelOrderTool.Output.Failed - 取消失败${result.reason} } } else - 抱歉我没能理解您的需求请换一种说法试试。 } } edge(parseIntent forwardTo routeIntent) } suspend fun handle(userInput: String): String { return strategy.execute(userInput) } }这段代码有几个细节第一parseIntent节点只负责意图识别不碰业务。这样如果后续要支持新的意图只需要改 prompt 和routeIntent的分支不用动其他部分。第二routeIntent节点是纯业务逻辑不调 LLM。工具调用和结果格式化都在这里完成。这样做的好处是业务逻辑可以被单元测试覆盖不依赖模型输出。第三错误处理是显式的。每个when都覆盖了所有密封类分支编译器会帮你检查有没有漏掉情况。4.4 接入 Ktor 提供 HTTP 接口最后把它包成一个 HTTP 服务fun Application.module() { val orderService InMemoryOrderService() val llmClient OpenAILLMClient( apiKey System.getenv(OPENAI_API_KEY), model gpt-4-turbo ) val agent OrderAgent(llmClient, orderService) routing { post(/chat) { val request call.receiveChatRequest() val response agent.handle(request.message) call.respond(ChatResponse(response)) } } } Serializable data class ChatRequest(val message: String) Serializable data class ChatResponse(val reply: String)启动之后用 curl 测一下curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -d {message: 帮我查一下订单 12345 的状态}如果一切正常你会收到类似这样的响应{reply: 订单 12345 当前状态为待发货金额 299.0 元。}5. 常见问题与排查技巧实录5.1 模型不调用工具怎么办这是最常见的问题。用户问“我的订单到哪了”模型却直接编了一个回答没有调用query_order工具。排查思路如下先看工具描述是否清晰。前面说过description 要写清楚“什么时候用”。我习惯在 description 里加一句“当用户询问与XX相关的信息时必须调用此工具”。再看 prompt 里有没有给模型“不调用工具”的退路。如果 system prompt 里写了“如果你不确定可以直接回答”模型就会倾向于不调用工具。改成“如果你不确定请调用相关工具获取信息”会好很多。最后看模型能力。实测下来GPT-3.5 在工具调用上的稳定性明显不如 GPT-4 和 Claude 3。如果成本允许建议至少用 GPT-4 Turbo 级别。5.2 结构化输出反序列化失败报错信息通常是SerializationException: Field xxx is required。原因一般是模型返回的 JSON 缺少字段或者字段类型不对。解决方案一给字段设默认值。在Serializable数据类里给非关键字段加默认值Serializable data class UserIntent( val type: String unknown, val orderId: String , val confidence: Double 0.0 )解决方案二简化 Schema。如果嵌套太深模型容易漏字段。把嵌套结构拍平或者拆成多次 LLM 调用。解决方案三加重试逻辑。Koog 支持在节点级别配置重试策略。我一般设 2 次重试每次重试时在 prompt 里追加“上次输出格式有误请严格按照 Schema 返回”。5.3 协程作用域泄漏Agent 执行过程中如果抛异常协程可能没有正确取消导致资源泄漏。我的做法是在 Strategy 执行外层包一层supervisorScope并且给每个 LLM 调用设置超时suspend fun handle(userInput: String): String { return supervisorScope { withTimeout(30_000) { strategy.execute(userInput) } } }注意超时时间要根据业务场景调整。订单查询这种简单场景 10 秒够了如果涉及多轮工具调用可能需要 60 秒以上。5.4 常见问题速查表问题现象可能原因排查方向解决方案模型不调用工具工具描述不清晰检查 description 是否说明使用场景补充“必须调用”的提示反序列化失败Schema 太复杂或模型输出不稳定查看原始 JSON 输出简化 Schema、加默认值、加重试响应超时LLM 调用耗时过长查看各节点耗时日志设超时、换更快的模型、减少工具调用轮次并发下性能下降协程调度器配置不当检查 Dispatchers 使用用 Dispatchers.IO 处理阻塞调用内存占用高对话历史未清理检查 session 生命周期限制历史轮次、及时关闭 session6. 我踩过的坑与实操心得6.1 不要把所有逻辑都塞进一个 Strategy刚开始用 Koog 的时候我试图把意图识别、工具调用、结果格式化、多轮对话管理全部写在一个 Strategy 里。结果就是那个 Strategy 有十几个节点改一处逻辑要重新理解整张图。后来我学乖了按职责拆分多个 Strategy。意图识别一个、订单处理一个、售后处理一个每个 Strategy 只做一件事。主 Agent 根据用户输入路由到不同的子 Strategy。这样每个 Strategy 都可以独立测试和复用维护成本大幅下降。6.2 工具粒度要细但不要太细工具拆得太粗模型不知道怎么选拆得太细模型会在多个工具之间反复横跳。我的经验是一个工具对应一个完整的业务动作。比如“查询订单”是一个工具“取消订单”是一个工具但“获取订单号”和“根据订单号查状态”就不应该拆成两个——模型会先调第一个再调第二个多一轮交互还容易出错。6.3 日志要打全但不要打敏感信息Agent 的调试非常依赖日志。我一般会在每个节点的入口和出口打日志记录输入输出和耗时。但要注意用户输入和模型输出可能包含手机号、地址等敏感信息。我的做法是在日志框架里加一个脱敏过滤器把手机号中间四位、身份证后六位之类的字段替换成***。6.4 测试策略Mock LLM不 Mock 工具单元测试里我会 Mock LLM 客户端的响应让它返回固定的 JSON。但工具本身不 Mock而是用内存实现比如InMemoryOrderService。这样测试覆盖的是“Agent 编排逻辑 工具执行逻辑”只有 LLM 这一层是假的。如果连工具都 Mock 了测试就变成了“验证 Mock 调用次数”价值不大。6.5 版本升级要谨慎Koog 还在快速迭代API 变动比较频繁。我的建议是生产项目锁定版本号升级前先在测试环境跑一遍完整的回归用例。特别是 Strategy DSL 和序列化相关的 API小版本升级也可能有 breaking change。7. 这个框架后续还能怎么扩展Koog 目前的能力已经覆盖了大部分 Agent 场景但如果你需要更复杂的功能可以考虑这几个方向多 Agent 协作。把不同的 Strategy 注册成独立的 Agent通过消息队列或共享状态协调。比如一个“客服 Agent”负责接待一个“订单 Agent”负责查询一个“售后 Agent”负责退款三者通过事件驱动通信。持久化对话历史。Koog 的 session 默认在内存里重启就丢。可以接 Redis 或数据库把对话历史存下来实现跨会话的上下文保持。接入本地模型。Koog 的 LLM 客户端是抽象接口理论上可以接任何兼容 OpenAI API 的本地推理服务。如果对数据隐私有要求可以换成私有部署的模型。可观测性增强。把 Agent 执行的事件流导出到 OpenTelemetry在 Grafana 里看每个节点的耗时分布、工具调用成功率、token 消耗趋势。这对生产环境的容量规划和成本控制很有帮助。我在实际项目里把 Koog 用在一个内部工单系统上大概跑了三个月日均处理两千多条对话。最深的体会是类型安全带来的收益在前期不明显但在后期维护和排查问题时省下的时间远超预期。以前用 Python 写 Agent改一个字段要全局搜索确认没有遗漏现在编译器直接告诉你哪里对不上改完就能跑。这种踏实感是动态语言给不了的。