KotlinLLM 开源 ,一个可以在运行时自己生成永久 Kotlin 代码的 Agent 库

JetBrains 这次还真搞出来一个有意思的东西,大概理解就是:

应用为什么需要在每次请求时重复调用 LLM?为什么不能让模型在第一次遇到新问题时生成可以审查、测试、持续执行的代码?为什么不把模型输出从临时答案变成永久源码,把运行时调用变成一次性的代码演化?

哎~这就很有意思了是不是?它居然想的是,把 LLM 从"每次运行都要调用的在线服务",变成一种只有在程序遇到陌生场景时能够介入、然后能够把答案固化成普通 Kotlin 源码:

程序第一次遇到不会处理的数据时,让 LLM 现场编写一段 Kotlin 代码,编译后 hot replace 进去,以后再遇到同类数据就直接执行这段代码,不需要再调 LLM。

JetBrains 将这种机制称为 Smart macros,也就是智能宏,目前 KotlinLLM 提供两个核心入口:

r 复制代码
asLlm<F, T>(from, hint)
mockLlm<T>()

这里 asLlm 主要负责把输入转换成明确的 Kotlin 类型,mockLlm 是根据接口定义和真实调用过程,逐步生成一个有状态的接口实现。

而 JetBrains 把这些概念归纳成 Explicit、Persistent 和 Portable 这三个特性:

  • Explicit 是指代码里会明确出现 asLlm() 或者 mockLlm(),开发者一眼就能看出某段逻辑是 LLM 写的
  • Persistent 是指生成结果会保存为普通 Kotlin 文件,可以提交到 Git、参加代码审查、运行单元测试,也可以被后续开发者继续修改
  • Portable 属于一旦相关场景的代码已经生成,应用就可以像运行普通 Kotlin 一样运行,已经覆盖的场景不再需要插件和模型去推理

所以这里 KotlinLLM 最核心的能力是 asLlm<F, T>(from, hint) ,API 很简单,看起来就是接受一个类型为 F 的输入,同时返回一个明确的 Kotlin 类型 T ,比如:

ini 复制代码
val issuesApiUrl: String = asLlm(
    repoInput,
    hint = "GitHub API URL: get all issues, including closed"
)

表面上看这很像让模型根据提示词完成一次转换,但是实际是真正生成完成后,asLlm() 会在运行时查找对应的 AsLlmParser<F, T>

如果当前输入符合已经学会的场景,就直接执行已经生成的 Kotlin 实现,只有当输入不符合任何已有场景时,系统才会再次请求 LLM,为这个新场景生成一段补充逻辑。

源码里的 asLlm() 会根据输入类型和输出类型查找已经注册的 parser,然后调用它的 parse() 方法,找不到 parser 时才会报错或进入插件提供的生成流程。

例如一个程序需要处理不同来源的 JSON,同时把它们转换成统一的 Kotlin data class,传统做法一般是提前写一个尽可能通用的解析器:

kotlin 复制代码
data class Issue(
    val title: String,
    val url: String,
    val labels: List<String>
)

但在真实情况下 JSON 结构会很多变,就需要写很多数据类:

json 复制代码
{
  "title": "Fix documentation typo",
  "html_url": "https://github.com/...",
  "labels": [
    {"name": "good first issue"}
  ]
}
​
{
  "name": "Fix documentation typo",
  "url": "https://github.com/...",
  "tags": ["beginner"]
}

但是如果用 KotlinLLM ,App 第一次遇到某种格式 JSON 的时候,LLM 会观察实际输入、目标 data class 的构造函数和字段关系,然后生成对应的解析分支。

这时候再遇到相同结构的数据,程序就不需要模型参与,可以直接运行这个解析分支

所以这类似于一种很特殊的"学习"??针对某个场景程序学会了新场景的执行,实际就是项目里自己多出了一段可以持续执行的 Kotlin 源码。

而 KotlinLLM 最关键的设计就是每次遇到陌生输入时,模型只能提交两部分内容:

erlang 复制代码
guardExpression
caseHandlerBody

其中 guardExpression 负责判断当前输入是否属于某一种已经理解的场景,caseHandlerBody 负责处理这个场景,然后最终生成出来的逻辑,可以粗略理解成下面这样:

scss 复制代码
fun parse(input: String): Issue {
    return when {
        matchesScenarioA(input) -> handleScenarioA(input)
        matchesScenarioB(input) -> handleScenarioB(input)
        matchesScenarioC(input) -> handleScenarioC(input)
        else -> regenerateForNewScenario(input)
    }
}

比如程序第一次看到这样的 JSON:

json 复制代码
{
  "title": "Fix documentation typo",
  "labels": [{"name": "good first issue"}]
}

模型需要生成一个处理逻辑,但不能简单写成:

css 复制代码
input.isNotEmpty()

因为这个 guard 太宽了,这样以后只要输入不是空字符串,无论里面是不是 GitHub Issue,程序都会毫无逻辑地进入这个处理分支。

所以 KotlinLLM 的系统提示词会反复强调:

Guard 不是简单的合法性检查,而是一个场景分类器

模型需要检查 handler 真正依赖的事实,比如

  • 输入是否具有特定结构
  • 必需字段是否存在
  • 字段是否为空
  • 数组元素数量
  • 状态或标签值
  • 字段之间的关系
  • 枚举字符串
  • 数值范围以及特定格式标记

如果 handler 输入一定存在 titlelabels,那么 guard 就必须证明这两个字段存在;而如果 handler 假设 labels 是对象数组不是字符串数组,guard 也应该区分这两种结构。

所以 JetBrains 在提示词中会明确要求:

宁可生成一个相对保守、适用范围较窄的 guard,也不要把未知或存在实质差异的输入错误地归入已有场景。

也就是 KotlinLLM 本质上采用的是一种真实运行时场景驱动的增量式程序合成

css 复制代码
遇到场景 A
→ 生成场景 A 的判断条件和处理逻辑
​
遇到场景 B
→ 增加场景 B 的判断条件和处理逻辑
​
遇到场景 C
→ 再增加场景 C 的判断条件和处理逻辑

然后随着程序运行,智能宏背后的实现会不断扩展,逐步覆盖更多真实场景。

不过实际上 KotlinLLM 还不是一个普通运行库,它还需要借助 IntelliJ IDEA 插件、JVM 调试接口和热替换机制完成整个闭环,开发者不是直接用普通的 Run,这里需要用插件提供的 Run with KotlinLLM 启动项目。

启动时,插件首先扫描项目中的 asLlm()mockLlm() 调用点,然后生成一套基础代码,包括 parser、provider、mock 实现以及 bootstrap 类,然后公共 API 在第一次运行时会尝试加载:

复制代码
com.jetbrains.kotlinllm.generated.core.KotlinLlmBootstrap

这个 Bootstrap 会把已经生成的 parser 和 mock provider 注册到 KotlinLLM 的运行时管理器中,之后插件通过 Java Debug Interface,也就是 JDI,启动目标 JVM,同时监控所有智能宏调用:

  • 如果输入匹配已经生成的 guard,程序会直接执行普通 Kotlin 字节码,这个过程不需要模型,也不会经过 Agent
  • 如果没有任何 guard 能处理当前输入,生成代码就会进入一个重新生成钩子,插件在这里暂停目标 JVM,读取当前调用中的真实运行数据,包括:

    • 实际输入值
    • 输入类型
    • 输出目标类型
    • 当前调用的方法
    • 接口定义
    • 之前已经生成的分支
    • 可以复用的辅助函数
    • 上一次生成代码的编译错误

然后 KotlinLLM 会用 JetBrains 的 Koog Agent 框架启动一个受限制的代码生成 Agent,是的, Koog Agent ,这个 Agent 不会像普通 Coding Agent 那样修改整个项目,Koog 只能使用一组专门设计的工具,比如:

复制代码
readActualValues
grepActualInput
readTargetType
listToolbeltFunctions
readToolbeltFunction
registerToolbeltFunction
removeToolbeltFunction
submitCase

如果输入是一段非常长的 JSON,Agent 不一定需要一次读取全部内容,它会可以通过 grepActualInput 搜索特定字段,再分页读取相关区域。

而且它还可以读取目标 Kotlin 类型的构造函数和依赖类型,明确自己最后必须构造出什么对象,完成分析后,Agent 必须调用:

ini 复制代码
submitCase(
    guardExpression = "...",
    caseHandlerBody = "..."
)

插件会对提交的代码进行 Kotlin 语法检查、PSI 结构检查、启发式纯度检查和编译验证,只有通过这些检查后,新的 guard 和 handler 才会被写入生成源码。

然后插件会编译修改后的 Kotlin 文件,找到新的 class 文件,然后通过 JDI 重新定义当前 JVM 中已经加载的类,整个流程大致是:

swift 复制代码
程序遇到陌生输入
→ JVM 在重新生成钩子处暂停
→ 插件读取真实运行值
→ Koog Agent 分析输入和目标类型
→ Agent 提交 guard 和 handler
→ 插件验证并编译代码
→ 通过 JDI 热替换 class
→ 重新执行刚才失败的调用

所以程序不需要退出,也不需要重新启动整个应用,它可以在同一次运行过程里获得新的处理能力。

另外除了数据转换,KotlinLLM 还提供了 mockLlm<T>() ,它支持根据一个 Kotlin 接口生成有状态的测试模拟,比如:

kotlin 复制代码
interface ShoppingCart {
    fun add(product: Product)
    fun total(): Double
}

开发者可以直接写 val cart: ShoppingCart = mockLlm() ,然后第一次调用 cart.add(Product("Book", 20.0)) 的时候,KotlinLLM 会观察方法名、参数值、参数类型、接口定义和返回类型,然后这个调用场景生成实现。

然后再执行 val total = cart.total() ,它又可以根据接口语义和之前保存的状态,生成 total() 对应的逻辑。

另外 KotlinLLM 还给 mock 提供了一个可变状态:

swift 复制代码
state: MutableMap<Any?, Any?>

也就是生成的实现不一定只是"输入某个参数,返回固定答案",还可以记住之前发生过的调用, 比如 add() 可以把商品写入状态,total() 再从状态中计算总价,也就是它更接近一个根据测试行为逐渐形成的有状态 fake,不是普通的固定返回值 mock。

不过 JetBrains 对模型的限制也相当明确:

不能随便返回 null、空列表、零、false 或无条件固定答案,模型必须根据接口名称、方法名、参数、返回类型和当前状态,尝试实现这个方法在当前场景中真正应该具有的行为。

目前 KotlinLLM 模型只能生成非常有限的一段局部 Kotlin 逻辑:

  • 只能使用纯 Kotlin
  • 不能添加 import
  • 不能定义新的类和字段
  • 不能调用外部库
  • 不能随意执行副作用
  • 不能使用 labeled return
  • 必须返回规定的 ParsedResult
  • 不符合当前场景时应返回 null
  • 必须让 guard 覆盖 handler 所依赖的假设

也就是当模型发现多个场景之间需要共享辅助逻辑时,可以把函数注册到 Toolbelt ,但是 Toolbelt 函数也必须通过语法和启发式纯度检查,并附带 KDoc、参数说明、返回值说明和调用示例,不过目前实现中缺少 KDoc 只会产生警告,并不会阻止注册。

然后 Agent 读取运行数据的次数也受到限制,检查工具预算默认是 12 次,剩余五次时系统会开始警告,超出预算后则要求 Agent 停止继续读取,根据已有信息提交实现。

不过源码里看起来这套预算逻辑,还没还没接入?

比如 JetBrains 给出的 GitHub Issue Radar 这个 Demo ,Demo 要读取不同 GitHub 仓库的 Issue,同时找出适合新手参与的任务,这里的特点是就是不同项目使用的标签并不统一,有的叫 good first issue,有的叫 beginnereasystarter,开发者很难提前把所有可能的情况写死。

然后 KotlinLLM 的做法是先在代码中留下几个待补全的转换点:

ini 复制代码
val apiUrl: String = asLlm(repoUrl)
​
val rawIssues: List<String> = asLlm(response)
​
val issue: JsonIssue = asLlm(rawJson)
​
val beginnerFriendly: Boolean = asLlm(label)

程序第一次遇到某种输入时,LLM 会根据真实数据生成对应的 Kotlin 逻辑。例如:

  • 怎样把 GitHub 仓库地址转换成 Issues API 地址
  • 怎样从返回的 JSON 中拆出单条 Issue
  • 怎样把 JSON 字段映射到 Kotlin 对象
  • 怎样判断一个标签是否表示"适合新手"

这些逻辑生成后会保存成 Kotlin 源码,之后再遇到相同类型的数据就可以直接执行已经生成的代码,不需要再次请求模型。

JetBrains 用 20 个真实 GitHub 仓库、三万多条 Issue 进行了实验,识别新手任务的召回率约为 0.89,另外 官方说编译和类热替换只占总耗时的大约 1%。

目前来看他比较适合做数据层面的处理,比如:

  • 多种 JSON 或 API 返回格式的适配
  • 日志、CSV、配置文件解析
  • 旧数据结构向新 DTO 转换
  • 原型阶段快速生成数据处理逻辑
  • 根据接口和测试调用生成简单 fake 或 mock

也就是能够把输出答案固化成 Kotlin 代码的场景,但是实际上未来应该往 UI 发展更合适,如果能固化场景 UI ,这个能力就实用很多了。

不过在运行时做这个,虽然目前还是需要在 IntelliJ IDEA 的 Run with KotlinLLM 模式下,但是确实要佩服 JetBrains 的脑洞。

链接

blog.jetbrains.com/research/20...

相关推荐
名字还没想好☜1 小时前
React createPortal 实战:模态框逃出 overflow:hidden、事件冒泡与焦点管理
前端·javascript·react.js·ecmascript·react·createportal
EDA365电子论坛1 小时前
AI 智能管控差分线间距规范,消除内外间距统一导致耦合失效问题
人工智能
武子康1 小时前
OpenAI Tibo:同样的 Rate Card,为什么更强的 Sol 反而更快耗尽 Codex 限额
人工智能·chatgpt·openai
游戏开发爱好者81 小时前
iOS应用加固方案全解析:源码加固与IPA在线加固对比
android·macos·ios·小程序·uni-app·cocoa·iphone
北冥you鱼1 小时前
深入解析 DEX 项目池流动性设计原理:从恒定乘积到集中流动性
人工智能·区块链
微学AI1 小时前
一款童年游戏对超级智能体应用开发的启示 — 从《武林群侠传》看 Agent 架构设计的“江湖智慧“
人工智能·游戏·agent
OBiO20131 小时前
AAV心脏靶向选型与HFpEF治疗新策略:Sub1靶点从发现到验证全解析
人工智能
用户059540174461 小时前
Playwright测试AI记忆存储踩坑实录:这个时序问题让我排查了6小时
前端·css
xx_xxxxx_1 小时前
AI基本结构12-lstm实现nlp
人工智能·pytorch·rnn·lstm