给 AI Agent 装上"大脑":Java语言中Code Interpreter 的设计与实现
当我们为 Agent 逐个硬编码工具时,其实是在用"人类穷举"来弥补"AI 自主性"的不足。本文探讨一种更优雅的思路------让 Java Agent 像 ChatGPT Code Interpreter 一样,在运行时自己写代码来解决问题。
一、问题背景:硬编码工具的困境
在构建 AI Agent 时,最常见的架构是 Tool Calling------为 Agent 预先定义一组工具(函数),Agent 通过 LLM 推理决定调用哪个工具、传什么参数。这个模式在 OpenAI Function Calling、LangChain Tools、Spring AI 等主流框架中被广泛采用。
然而,当我们真正开始为 Agent 编写工具时,会迅速掉入一个陷阱:工具永远写不完。
想让 Agent 知道今天星期几?写一个 DateTimeTool。想让它做数学计算?再写一个 MathCalculatorTool。想让它生成 UUID?再来一个 UUIDGeneratorTool。Base64 编解码?正则匹配?日期差值计算?单位换算?JSON 格式化?......
每种场景都要写一个工具类------定义参数 Schema、实现 execute 方法、编写描述文档。工具列表无限膨胀,维护成本直线上升。
更荒谬的是:LLM 本身就会编程 。它明明可以写代码算出 2026年4月29日到国庆节还有多少天,却因为没有对应的工具而无法回答。我们用硬编码工具,实际上是在人为限制 AI 的能力边界。
ChatGPT Code Interpreter 给出的启示
OpenAI 在 2023 年推出的 Code Interpreter 优雅地解决了这个问题------Agent 遇到需要计算、数据处理的场景时,自己写一段 Python 代码,然后在沙箱中执行,拿到结果后整理回复。
一个工具,顶一万个工具。
这个思路的本质是一个认知转换 :与其给 Agent 100 种专用工具,不如给它一个"万能工具"------代码执行能力。
那么问题来了:Python 有 exec(),Java 生态怎么做到?
二、技术选型:为什么是 Groovy?
Java 是编译型语言,不像 Python 那样可以直接 exec() 执行动态代码。但 JVM 生态提供了多种动态执行方案:
| 方案 | 原理 | 优势 | 劣势 |
|---|---|---|---|
javax.script (Nashorn) |
内嵌 JS 引擎 | JDK 自带 | JDK 11 已废弃,功能受限 |
| GraalJS | 新一代 JS 引擎 | 性能好 | 需要额外依赖 GraalVM |
| Jython | JVM 上的 Python | Python 语法 | 仅支持 Python 2,生态老旧 |
| Java Compiler API | 运行时编译 Java | 完整 Java 能力 | 需要 JDK 环境,编译慢 |
| Groovy | JVM 动态语言 | 语法兼容 Java,可直接调用 Java API | 需要额外依赖(~7MB) |
我们最终选择了 Groovy,原因很直接:
2.1 语法与 Java 几乎完全兼容
LLM 最擅长生成的语言之一就是 Java/Groovy。Agent 不需要"学"一门新语言,直接用 Java 风格写代码即可:
groovy
// 这段代码既是合法的 Java,也是合法的 Groovy
LocalDateTime now = LocalDateTime.now();
println(now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
同时 Groovy 还提供了更简洁的语法糖,LLM 可以灵活使用:
groovy
// Groovy 独有的便利写法
println((1..100).sum()) // Range + 集合方法
println([1,2,3].collect { it * 2 }) // 闭包
println(UUID.randomUUID()) // 直接调用 Java API
2.2 可以直接调用所有 Java 标准库
这是最关键的优势。Groovy 运行在 JVM 上,可以直接使用 java.time、java.util、java.math、java.text 等所有 Java 标准库,无需额外适配。这意味着 Agent 获得了与宿主 Java 应用同等的计算能力。
2.3 即时执行,无需编译
Groovy 脚本通过 GroovyShell 直接解析执行,无需 javac 编译步骤,启动速度快,适合 Agent 实时交互场景。
三、架构设计:两层结构,各司其职
Code Interpreter 的设计遵循单一职责原则,拆分为两层:
┌──────────────────────────────────────────────────────┐
│ Agent Engine │
│ 用户提问 → LLM 生成代码 → 调用 code_interpreter │
├──────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────┐ │
│ │ CodeInterpreterTool │ ← 工具协议层 │
│ │ - 定义工具 Schema │ 面向 LLM 的接口 │
│ │ - 参数解析 │ 遵循 Function Calling │
│ │ - 结果格式化 │ 协议规范 │
│ └──────────┬────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ GroovySandbox │ ← 安全执行层 │
│ │ - AST 安全检查 │ 编译期拦截危险代码 │
│ │ - 超时控制 │ 运行期限制执行时间 │
│ │ - 输出截断 │ 防止无限输出 │
│ │ - 自动导入 │ 开箱即用 │
│ └───────────────────────┘ │
│ │
└──────────────────────────────────────────────────────┘
- 工具协议层 (
CodeInterpreterTool):面向 LLM,定义工具的 JSON Schema、参数描述和示例,让 LLM 知道"怎么用这个工具"。这一层是可替换的------无论你的 Agent 框架是 LangChain、Spring AI 还是自研的,只需要按框架的工具接口适配即可 - 安全执行层 (
GroovySandbox):面向安全,纯粹负责"在受控环境中安全地执行一段 Groovy 代码"。这一层是框架无关的,可以独立使用
四、安全沙箱:GroovySandbox 的三道防线
让 Agent 动态执行代码,安全是第一优先级 。如果 LLM 生成了一段 Runtime.getRuntime().exec("rm -rf /") 的代码,后果不堪设想。
GroovySandbox 构建了三道防线:
4.1 第一道防线:AST 编译期拦截
Groovy 提供了 SecureASTCustomizer,可以在代码编译阶段(而非运行时)就拦截危险代码。这是最前置、最彻底的防护------代码还没运行就被拒绝了。
黑名单包(禁止 import *):
java
private static final List<String> BLOCKED_STAR_IMPORTS = Arrays.asList(
"java.io", // 文件读写
"java.nio", // NIO 文件操作
"java.net", // 网络请求
"java.lang.reflect", // 反射
"java.lang.invoke", // 方法句柄
"java.sql", // 数据库操作
"javax.net", // SSL/网络
"javax.script", // 脚本引擎(防止嵌套执行)
"groovy.lang", // Groovy 内部 API
"org.codehaus.groovy" // Groovy 编译器 API
);
黑名单类(精确拦截):
java
private static final List<String> BLOCKED_CLASSES = Arrays.asList(
"java.lang.Runtime", // 系统命令执行
"java.lang.ProcessBuilder", // 进程创建
"java.lang.System", // System.exit() 等
"java.lang.Thread", // 线程操作
"java.lang.ClassLoader", // 类加载器
"java.lang.Class" // 反射入口
);
这意味着,以下代码在编译阶段就会被拒绝,根本不会执行:
groovy
// ❌ 全部会被 AST 检查拦截
Runtime.getRuntime().exec("whoami") // 系统命令
new File("/etc/passwd").text // 文件读取
new URL("http://evil.com").text // 网络请求
Thread.sleep(999999) // 线程阻塞
System.exit(0) // 强制退出
4.2 第二道防线:执行超时控制
即使代码通过了 AST 检查,仍然可能出现死循环或超长计算。GroovySandbox 将代码执行放入独立线程,并设置严格的超时限制:
java
public static SandboxResult execute(String code, Integer timeoutSeconds) {
int timeout = (timeoutSeconds != null && timeoutSeconds > 0)
? Math.min(timeoutSeconds, 30) : DEFAULT_TIMEOUT_SECONDS; // 最长 30 秒
ExecutorService executor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "groovy-sandbox");
thread.setDaemon(true); // 守护线程,不阻止 JVM 退出
return thread;
});
try {
Future<SandboxResult> future = executor.submit(createTask(code));
return future.get(timeout, TimeUnit.SECONDS);
} catch (TimeoutException e) {
return SandboxResult.error("代码执行超时(限制 " + timeout + " 秒)");
} finally {
executor.shutdownNow(); // 超时后立即终止
}
}
设计要点:
- 默认 10 秒 超时,最大 30 秒,由 Agent 传入参数控制
- 使用 守护线程(daemon),即使代码卡死也不会阻止应用退出
- 超时后通过
shutdownNow()强制中断执行
4.3 第三道防线:输出长度限制
防止代码生成海量输出(如打印一个超大数组),导致内存溢出或响应体过大:
java
private static final int MAX_OUTPUT_LENGTH = 10000;
String printedOutput = outputWriter.toString();
if (printedOutput.length() > MAX_OUTPUT_LENGTH) {
printedOutput = printedOutput.substring(0, MAX_OUTPUT_LENGTH)
+ "\n... [输出被截断,超过 " + MAX_OUTPUT_LENGTH + " 字符]";
}
对 println() 的标准输出和表达式的返回值都做了截断处理,确保输出可控。
五、开箱即用:自动导入常用类
为了让 LLM 生成的代码尽量简洁,GroovySandbox 通过 ImportCustomizer 预先导入了最常用的 Java 标准库:
java
// 通配符导入
imports.addStarImports(
"java.util", // List, Map, Set, Collections...
"java.util.stream", // Stream API
"java.time", // LocalDate, LocalDateTime, Instant...
"java.time.format", // DateTimeFormatter
"java.math", // BigDecimal, BigInteger
"java.text", // SimpleDateFormat, NumberFormat
"java.util.concurrent" // CountDownLatch, ConcurrentHashMap...
);
// 精确导入高频使用类
imports.addImports(
"java.util.UUID",
"java.time.LocalDate",
"java.time.LocalDateTime",
"java.time.ZoneId",
"java.time.format.DateTimeFormatter",
"java.math.BigDecimal",
"java.util.Base64"
// ... 更多
);
这样,Agent 生成的代码可以直接使用这些类,无需写 import 语句:
groovy
// 不需要 import,直接使用
println(LocalDate.now())
println(UUID.randomUUID())
println(Base64.encoder.encodeToString("Hello".bytes))
六、工程设计:可选依赖与条件加载
在实际工程中,并非所有应用都需要 Code Interpreter 能力。一个好的框架设计应该做到按需加载------需要的人自动获得,不需要的人零感知。
在 Spring Boot 生态中,这可以通过 @ConditionalOnClass 优雅地实现:
java
@Component
@ConditionalOnClass(name = "groovy.lang.GroovyShell")
public class CodeInterpreterTool implements AgentTool {
// 仅当 classpath 中存在 Groovy 时才加载
}
配合 Maven 的 <optional>true</optional> 声明:
xml
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
<version>3.0.19</version>
<optional>true</optional> <!-- 不传递给下游 -->
</dependency>
行为如下:
| 集成方操作 | 结果 |
|---|---|
| 主动引入了 Groovy 依赖 | GroovyShell 类存在 → Code Interpreter 自动注册 ✅ |
| 未引入 Groovy 依赖 | GroovyShell 类不存在 → 不加载,不报错 ✅ |
这是 Spring Boot 生态中"能力随依赖而来"的标准设计模式,也适用于非 Spring 的框架------核心思想是:代码执行引擎作为可选模块,与 Agent 核心解耦。
七、端到端工作流
当用户向 Agent 提问时,Code Interpreter 的完整工作流如下:
用户:"2026年4月29日到国庆节还有多少天?"
│
▼
┌─── Agent Engine ───┐
│ 组装 system prompt │
│ 包含 code_interpreter │
│ 工具的描述和参数说明 │
└────────┬───────────┘
│
▼
┌─── LLM 推理 ──────┐
│ 分析:这是日期计算问题 │
│ 决定:调用 code_interpreter │
│ 生成代码: │
│ ┌──────────────────────┐ │
│ │ def today = LocalDate │ │
│ │ .of(2026, 4, 29) │ │
│ │ def national = LocalDate │ │
│ │ .of(2026, 10, 1) │ │
│ │ def days = ChronoUnit │ │
│ │ .DAYS.between( │ │
│ │ today, national) │ │
│ │ println("还有${days}天") │ │
│ └──────────────────────┘ │
└────────┬───────────────────┘
│
▼
┌─── CodeInterpreterTool ──┐
│ 解析参数(code, timeout) │
│ 调用 GroovySandbox.execute │
└────────┬─────────────────┘
│
▼
┌─── GroovySandbox ────────┐
│ 1. AST 安全检查 → 通过 ✅ │
│ 2. 自动导入 java.time.* │
│ 3. 编译 + 执行脚本 │
│ 4. 捕获 println 输出 │
│ 5. 检查超时 → 未超时 ✅ │
│ 6. 截断检查 → 长度正常 ✅ │
└────────┬─────────────────┘
│
▼
┌─── 返回结果 ────────────┐
│ {"success": true, │
│ "stdout": "还有155天"} │
└────────┬────────────────┘
│
▼
┌─── LLM 整理回复 ───────┐
│ "从2026年4月29日到 │
│ 国庆节(10月1日), │
│ 还有 155 天。" │
└─────────────────────────┘
八、实际效果:一个工具的万能性
以下是 Agent 实际使用 code_interpreter 解决各类问题的示例:
8.1 日期时间
用户:"今天星期几?距离下周五还有几天?"
Agent 生成的代码:
groovy
def today = LocalDate.now()
def dayOfWeek = today.getDayOfWeek()
println("今天是: " + dayOfWeek)
def nextFriday = today.with(java.time.temporal.TemporalAdjusters.next(DayOfWeek.FRIDAY))
def daysUntil = java.time.temporal.ChronoUnit.DAYS.between(today, nextFriday)
println("距离下周五还有 " + daysUntil + " 天")
8.2 数学计算
用户:"一个圆的半径是 7.5 厘米,它的面积和周长分别是多少?"
Agent 生成的代码:
groovy
def radius = 7.5
def area = Math.PI * Math.pow(radius, 2)
def circumference = 2 * Math.PI * radius
println(String.format("面积: %.2f 平方厘米", area))
println(String.format("周长: %.2f 厘米", circumference))
8.3 数据转换
用户 :"帮我把 Hello World 2026 做 Base64 编码"
Agent 生成的代码:
groovy
def text = "Hello World 2026"
def encoded = Base64.encoder.encodeToString(text.bytes)
println("原文: " + text)
println("Base64: " + encoded)
8.4 生成 UUID
用户:"帮我生成 5 个 UUID"
Agent 生成的代码:
groovy
(1..5).each { i ->
println("${i}. ${UUID.randomUUID()}")
}
8.5 复杂数据处理
用户:"我有一组成绩 [85, 92, 78, 95, 88, 76, 91, 83],帮我算平均分、最高分、最低分,以及及格率(60分以上)"
Agent 生成的代码:
groovy
def scores = [85, 92, 78, 95, 88, 76, 91, 83]
def avg = scores.sum() / scores.size()
def max = scores.max()
def min = scores.min()
def passRate = scores.count { it >= 60 } / scores.size() * 100
println("成绩列表: " + scores)
println(String.format("平均分: %.1f", avg))
println("最高分: " + max)
println("最低分: " + min)
println(String.format("及格率: %.0f%%", passRate))
关键洞察:以上五类场景,如果用传统的硬编码方式,至少需要 5 个工具类、几百行代码。而 Code Interpreter 只需要一个工具,LLM 自己"写"出解决方案。
九、更大的图景:Code Interpreter + 自主记忆
Code Interpreter 不是终点,而是一个更强大闭环的起点。
如果 Agent 还具备自主记忆能力(如 Hermes Agent 提出的 Skill 自学习机制),Code Interpreter 就会与记忆系统形成有趣的协同效应:
第一次遇到问题 第 N 次遇到同类问题
┌────────────────────┐ ┌────────────────────┐
│ 用户:"算一下复利" │ │ 用户:"算一下复利" │
│ ↓ │ │ ↓ │
│ LLM 从零推理 │ │ 从记忆中检索到 Skill │
│ 生成 Groovy 代码 │ │ 直接套用代码模板 │
│ 沙箱执行 → 成功 │ │ 沙箱执行 → 成功 │
│ ↓ │ │ ↓ │
│ 记忆系统记录并提炼 │ ──学习──→ │ 更快、更准、更省Token │
│ 存储为可复用 Skill │ │ │
└────────────────────┘ └────────────────────┘
这个闭环的关键在于:
- Agent 使用
code_interpreter成功解决了一个问题 - 记忆系统追踪到这次执行:记录了"用户问了什么 → Agent 生成了什么代码 → 执行结果是什么"
- 提炼为 Skill 并持久化:将具体的参数值抽象为通用的代码模板
- 下次遇到类似问题时,Skill 被注入到 system prompt 中:Agent 直接复用已验证的代码模式
这形成了一个"代码能力自进化 "的闭环------Agent 不仅能动态写代码,还能从自己写的代码中学习和积累经验。每解决一个新问题,Agent 就变得更聪明一点。
十、设计权衡与反思
任何架构选择都有取舍。以下是我们在实践中的思考:
10.1 当前的权衡
| 维度 | 选择 | 理由 | 另一种选择及其代价 |
|---|---|---|---|
| 安全模型 | 黑名单 + AST | 平衡安全性和可用性 | 白名单更安全,但会严重限制 LLM 的代码生成自由度 |
| 超时策略 | 固定上限 30 秒 | Agent 交互场景足够 | 无限制会有死循环风险;过短则复杂计算无法完成 |
| 语言选择 | 仅 Groovy | 与 Java 语法兼容,LLM 生成质量高 | 多语言增加复杂度,且 LLM 在不同语言间切换容易出错 |
| 沙箱粒度 | JVM 进程内 | 零部署成本,延迟低 | 独立进程/容器更安全,但引入了分布式调用的复杂度 |
10.2 未来可能的演进方向
- 可配置安全策略 :允许不同场景自定义允许/禁止的包和类,如内网场景可放开
java.net - 编译缓存:对相同代码做编译结果缓存,避免重复编译开销(Groovy 编译约 50-200ms)
- 多语言支持:通过 GraalVM Polyglot 支持 Python、JavaScript 等语言,让 LLM 选择最适合的语言
- 资源配额:限制内存使用量,防止代码消耗过多堆内存
- 容器级隔离:高安全要求场景下,将代码执行放入独立容器,实现操作系统级的资源隔离
- 代码审计日志:记录所有执行过的代码和结果,供安全审计和合规检查
总结
回到开头的问题:Agent 的工具永远写不完,怎么办?
答案是:不要穷举工具,而是给 Agent 编程能力。
Code Interpreter 的本质是一个认知转换------从"我来预定义 Agent 能做什么",到"让 Agent 自己决定怎么做"。这不仅解决了工具膨胀的问题,更释放了 LLM 最擅长的能力之一------代码生成。
在 Java 生态中,Groovy 是实现这一理念的理想载体:
-
语法兼容:LLM 用 Java 风格写代码,零学习成本
-
API 共享 :直接调用
java.time、java.util等标准库,与宿主应用同等能力 -
安全可控:AST 编译期拦截 → 运行时超时控制 → 输出长度限制,三道防线保障安全
一个 Code Interpreter
= DateTimeTool + MathCalculatorTool + UUIDGeneratorTool
+ StringTool + Base64Tool + RegexTool + JsonTool + ...∞
与其给一个人 100 种专用工具,不如教会他编程。
一个工具,无限可能。