最近在搞 LangChain4j 的 Agent 相关功能时,被一个非常基础但又绕不过去的问题卡了很久:大模型可以很准确地告诉你“应该怎么算”,但它自己不会真的去算。想要让模型生成代码、执行代码、再把计算结果拿回来继续推理,这个闭环在 Java 生态里一直缺一块好用的拼图。直到我翻到 LangChain4j 1.4.0 的更新日志,看到代码执行引擎(Code Execution Engine)相关的接口正式落地,才开始认真研究 GraalVM Polyglot 这条集成路线。
这篇文章不是官方文档的翻译,是我从零开始接入 LangChain4j 1.4.0 + GraalVM Polyglot 的真实记录,包含选型对比、完整可跑的代码、安全沙箱的设计思路,以及我实际踩过的版本兼容和 macOS 签名问题。如果你正在用 LangChain4j 做 Agent、Tool Calling 或者数据分析类的功能,这篇文章应该能帮你省掉至少两天的排查时间。
1. 1.4.0 终于补上的拼图:LangChain4j 的代码执行引擎离可用还差多少
1.1 大模型会“说”不会“做”,所有 Agent 框架都绕不开的断层
做过 Agent 的人都知道一个尴尬场景:你问模型“帮我统计这批数据的平均值和方差”,模型回答得头头是道,给你列出公式、分析步骤,但它不会真的帮你把 Excel 或数据库里的数字算出来。传统做法是把计算能力封装成 Function Calling 工具,比如你写一个 calculateAverage(List<Double>) 方法注册给模型。但问题在于,你不可能预知所有计算需求——今天要平均值,明天要中位数,后天可能要做回归分析,Function 是提前定义的,而用户的问法是无限的。
代码执行引擎的思路完全反过来:不让开发者在编译期穷举工具,而是给模型一个“运行时环境”,允许它自己写一段 JavaScript 或 Python 代码,由引擎去执行并把结果返回给模型。模型负责“想”,引擎负责“做”,这个闭环一旦打通,Agent 的能力边界就从“调用预设工具”扩展到了“任意计算逻辑”。
1.2 CodeExecutionEngine 接口与三个内置实现的定位
LangChain4j 1.4.0 里新增的代码执行引擎相关模块,核心是一个抽象后的执行器接口,职责非常单一:接收一段代码和语言标识,返回执行结果。它不关心代码是怎么被编译、怎么被隔离的,这些全部交给具体实现。
我整理了一下 1.4.0 里能直接用的几个实现,各有各的脾气:
| 实现 | 执行语言 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| JavaCodeExecutionEngine | Java | 无额外依赖,直接用 JDK 编译 | 编译速度慢,LLM 生成 Java 代码质量参差 | 内部工具链,模型可控性较强时 |
| LlamaCppCodeExecutionEngine | C/C++ | 性能强 | 依赖本地编译器,环境配置重 | 高性能计算类任务 |
| MavenExecutedCodeExecutionEngine | Java(Maven 工程) | 可依赖外部库 | 启动慢,临时项目搭建耗时 | 需要复杂依赖的片段 |
| 自定义 GraalVM 实现 | JS/Python 等多语言 | 轻量、启动快、语言生态丰富 | 需要自己写集成代码,沙箱边界要自己控制 | Agent 通用代码执行 |
前三个是框架自带的,但我实测一圈下来,感觉它们在“给 LLM 当执行器”这个场景下都有点别扭:Java 编译链路太重,C/C++ 对模型生成质量要求太高,Maven 方式每次执行都要折腾临时工程。真正让我觉得“对味”的还是基于 GraalVM Polyglot 自己实现的引擎——一个进程内嵌多语言运行时,不用起 Docker,不用外部服务,模型写 JS 我就跑 JS,写 Python 我就跑 Python,切换成本几乎为零。
1.3 为什么官方没做 GraalVM 集成,需要自己接
说实话,我也奇怪 LangChain4j 团队为什么不直接在包里内置一个 GraalVM 实现。后来看了看项目结构和 issue 讨论,大概明白了:GraalVM Polyglot 的 Context 配置项非常多,安全策略、语言白名单、资源限制这些和具体业务强相关,官方如果做了默认实现,反而会误导使用者——很多人会直接拿来跑模型生成的代码,如果安全配置给得太宽松,生产环境就是一个大洞;给得太严格,又会让代码执行能力形同虚设。
所以官方选择了只定义接口、让社区各自按需实现。这个设计我很认可。下面进入正题,我会从原理讲到实战,把 GraalVM 这条路线完整地拉通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GraalVM Polyglot 凭什么适合当 Agent 的“手”:原理与选型对比
2.1 Truffle 框架与多语言共享运行时
GraalVM Polyglot 的底层是 Truffle 语言实现框架。简单说,Truffle 提供了一整套构建抽象语法树解释器的 API,JavaScript、Python、Ruby 等语言在 GraalVM 里都实现成一个 Truffle 解释器。因为这些语言共享同一套运行时基础设施,所以它们可以在同一个 Context 里共存、互相调用,而且类型系统是统一的——通过 Value 对象来桥接。
这对 Agent 场景简直量身定做:你的 Agent 今天可能让模型写 JavaScript 算一道数学题,明天可能让模型写 Python 做文本处理,在同一个 JVM 进程里就能全部搞定,不需要起多个子进程,也不需要跟外部解释器做 IPC 通信。后面我会演示一个 JavaScript 里直接调用 Python 函数的例子,你就能直观感受到这个“互操作”有多顺滑。
2.2 和 JavaCompiler、Docker、外部沙箱API三条路线硬碰硬
我在决定用 GraalVM 之前,其实把另外三条主流路线都试了一遍,这里直接说结论。
第一条路线是用 javax.tools.JavaCompiler 把模型生成的 Java 代码编译成字节码,放到自定义类加载器里执行。这个方案的优点是纯 JDK 实现,没有任何新依赖。但缺点太致命了:Java 语法太重,模型生成的代码编译通过率低,而且反射调用的权限控制很麻烦。我用几个常见问题测过,比如“写一个函数判断一个数是不是质数”,模型生成的 Java 代码在 10 次里有 3 次编译失败,要么是缺 import,要么是泛型写错。对 Agent 来说,一次失败就意味着要多轮重试,对话体验非常糟糕。
第二条路线是容器隔离,让模型生成的代码在一个短暂的 Docker 容器里跑。安全隔离确实干净,但代价是启动时间。我本机实测,跑一个新的容器最快也要 800 毫秒到 1.5 秒,如果 Agent 需要连续执行多段代码(比如先清洗数据、再计算、再可视化),每一次往返都是秒级延迟,用户根本等不起。更别说容器资源管理和镜像拉取这些额外的心智负担。
第三条路线是接外部的代码执行 API。这种方案部署简单,但数据是要发给第三方服务的,大多数公司的数据处理场景根本过不了这一关。而且多一层网络调用就多一层不确定性,延迟也比本地执行高一个数量级。
对比一圈下来,GraalVM Polyglot 是唯一的“进程内多语言沙箱”方案:没有子进程开销,启动快,多语言互通,还能在 JVM 层做访问控制。对 Java 技术栈的团队来说,这是最顺手的选择。
2.3 用 1 分钟跑通 Polyglot 最小例子,看清它的运行模型
在进入 LangChain4j 集成之前,我先给你看一个最朴素的 Polyglot 例子,让你对它的运行模型有个直觉。
java复制import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Value;
public class PolyglotMinimal {
public static void main(String[] args) {
try (Context context = Context.create("js")) {
Value result = context.eval("js", "(function() { return 6 * 7; })();");
System.out.println(result.asInt());
}
}
}
这段代码做了三件事:创建一个 JavaScript 上下文、执行一段 JS 表达式、把结果转为 Java 类型打印出来。看起来很简单,但背后隐藏了 Polyglot 真正核心的设计思想:上下文(Context)是隔离单位,语言(Language)是执行单元,值(Value)是跨语言桥接的公共表示。
你在写集成代码时要记住一个关键点:Context 的创建是有成本的,因为它要初始化语言运行时。我的实测数据是,创建一个全新的 JS Context 大约需要 100 到 300 毫秒,如果每次执行代码都新建,Agent 多轮对话时会明显感觉“卡”。正确做法是把 Context 做成复用对象,一个 Agent 会话共用一个,执行完一段代码后继续复用,不要重复创建。
3. 集成实录:从 Maven 坐标到可用的 CodeExecutionEngine
3.1 环境准备:GraalVM JDK 版本怎么选,依赖怎么配
先解决“跑在哪个 JDK 上”的问题。GraalVM 有两个大版本线:GraalVM for JDK 21 和 GraalVM for JDK 23。我的建议是直接用 GraalVM for JDK 21,原因很简单:JDK 21 是 LTS 版本,GraalVM 团队对它支持周期最长,而且 24.x 系列的 polyglot 组件在 21 上已经非常稳定。JDK 23 的非长期支持特性对你做代码执行引擎没有额外收益,没必要冒险。
如果你下载的是完整的 GraalVM JDK 发行版,那么 JavaScript 运行时是内置的,直接可以用;Python 运行时则需要额外安装。这里有个很多人会踩的坑:如果你只是想在普通 JDK 上通过 Maven 依赖来用 polyglot API,那你必须额外引入语言运行时依赖,否则运行时会 Report "No language for id 'js' found"。
我最终的 pom 依赖是这样配的:
xml复制<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>polyglot</artifactId>
<version>24.1.2</version>
</dependency>
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>js-language</artifactId>
<version>24.1.2</version>
<type>pom</type>
</dependency>
<dependency>
<groupId>org.graalvm.polyglot</groupId>
<artifactId>python-language</artifactId>
<version>24.1.2</version>
<type>pom</type>
</dependency>
注意版本号要尽量对齐,我用的 24.1.2 是当前稳定线,如果你要升级 GraalVM 大版本,polyglot API 和语言组件最好一起升,避免出现 API 不兼容的问题。如果不想管这些麻烦事,就直接下载 GraalVM JDK,用它的 bin/java 跑你的 Spring Boot 应用或者普通 Java 进程,这样 JS 和 Python 运行时都在了,Maven 里只需要引一个 polyglot API。
3.2 核心实现:Context 装配、语言白名单与结果提取
直接上核心代码。我实现了一个 GraalVmCodeExecutionEngine,实现 LangChain4j 1.4.0 的代码执行引擎接口。为了演示的完整性,我把它写成带主方法的完整类,方便你直接跑。
java复制import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.EnvironmentAccess;
import org.graalvm.polyglot.HostAccess;
import org.graalvm.polyglot.Value;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
public class GraalVmCodeExecutionEngine {
// 语言白名单:只允许这两门语言被模型使用
private static final Set<String> SUPPORTED_LANGUAGES = Set.of("js", "python");
private final Context context;
private final ByteArrayOutputStream outBuffer;
private final ByteArrayOutputStream errBuffer;
public GraalVmCodeExecutionEngine() {
this.outBuffer = new ByteArrayOutputStream();
this.errBuffer = new ByteArrayOutputStream();
this.context = Context.newBuilder(SUPPORTED_LANGUAGES.toArray(new String[0]))
.allowHostAccess(HostAccess.NONE)
.allowHostClassLoading(false)
.allowIO(false)
.allowNativeAccess(false)
.allowEnvironmentAccess(EnvironmentAccess.NONE)
.option("js.ecmascript-version", "2023")
.out(outBuffer)
.err(errBuffer)
.build();
}
public String execute(String language, String code) {
if (!SUPPORTED_LANGUAGES.contains(language)) {
return "不支持的代码语言: " + language + ",仅支持: js, python";
}
String normalized = normalizeCode(language, code);
outBuffer.reset();
errBuffer.reset();
try {
Value result = context.eval(language, normalized);
String output = outBuffer.toString(StandardCharsets.UTF_8);
String errorOutput = errBuffer.toString(StandardCharsets.UTF_8);
String resultString = formatValue(result);
return buildExecutionResult(resultString, output, errorOutput);
} catch (Exception e) {
return "执行异常: " + e.getMessage();
}
}
private String normalizeCode(String language, String code) {
String trimmed = code.trim();
if (language.equals("js") && !trimmed.startsWith("(")) {
// 如果模型返回的是一段以 function 开头的代码,包一层立即执行函数
if (trimmed.startsWith("function")) {
return "(" + trimmed + ")();";
}
}
return trimmed;
}
private String formatValue(Value value) {
if (value == null || value.isNull()) {
return "null";
}
if (value.isString()) {
return value.asString();
}
if (value.isBoolean()) {
return String.valueOf(value.asBoolean());
}
if (value.isNumber()) {
if (value.fitsInLong()) {
return String.valueOf(value.asLong());
}
return String.valueOf(value.asDouble());
}
if (value.isHostObject()) {
return value.asHostObject().toString();
}
// 数组、对象等复杂类型直接走 toString
return value.toString();
}
private String buildExecutionResult(String resultString, String output, String errorOutput) {
StringBuilder sb = new StringBuilder();
if (!output.isEmpty()) {
sb.append("控制台输出:\n").append(output).append("\n");
}
if (!errorOutput.isEmpty()) {
sb.append("错误输出:\n").append(errorOutput).append("\n");
}
sb.append("返回值: ").append(resultString);
return sb.toString();
}
public void close() {
context.close(true);
}
}
这里解释几个关键设计:
allowHostAccess(HostAccess.NONE) 是最重要的一行。它禁止脚本访问 JVM 宿主对象,模型生成的代码不能反射拿到 Java 类、不能调用 System.exit、不能读环境变量。之前我看到有些示例代码用默认的 allowAllAccess(true) 来省事,这在本地实验可以,一旦上了 Agent 生产环境就是事故。
allowIO(false) 禁止脚本读写文件系统。这个很好理解,你肯定不想让模型生成一段 fs.rmSync('/') 在服务器上真的执行。GraalVM 的 IO 限制作用于所有语言,JS 的 fs 模块、Python 的 open 都会被拦下来。
out 和 err 设置自定义流,是为了捕获脚本的 console.log 或 print 输出。如果你不设置这两个流,脚本的输出会直接打到 JVM 进程的标准输出,你没法把输出内容返回给模型。
normalizeCode 是经验补丁。模型特别喜欢返回 function xxx() {...} 形式的代码片段,但我们要的是“立即执行并拿到返回值”。所以如果检测到以 function 开头,我会把整个函数体包进立即执行表达式里。
3.3 接入 AiServices:让模型在对话中调用执行器
LangChain4j 的 Agent 通常是通过 AiServices 搭起来的。要把上面这个执行器暴露给模型,标准做法是注册一个 @Tool 方法:
java复制import dev.langchain4j.agent.tool.Tool;
public class CodeInterpreterTool {
private final GraalVmCodeExecutionEngine engine;
public CodeInterpreterTool() {
this.engine = new GraalVmCodeExecutionEngine();
}
@Tool("执行用户提供的编程代码,支持语言为 js 和 python。当需要精确计算、数据处理或算法实现时使用。")
public String executeCode(String language, String code) {
return engine.execute(language, code);
}
}
然后装配 Assistant:
java复制public interface CodeAssistant {
String chat(String userMessage);
}
CodeAssistant assistant = AiServices.builder(CodeAssistant.class)
.chatLanguageModel(openAiModel)
.tools(new CodeInterpreterTool())
.build();
String answer = assistant.chat("用 JavaScript 写一段代码,计算从 1 加到 1000 的和,并把结果告诉我。");
这样模型在对话过程中如果发现需要计算,就会自动生成代码、调用工具、拿到结果,最后基于结果组织自然语言回答。你不需要在提示词里写任何关于语言、执行逻辑的内容,模型会自己根据工具描述决定是否使用这个工具。
这里有个细节要提醒:工具描述里必须写清楚“当需要精确计算时使用”,否则模型在遇到数学问题时可能直接用自己“估算”的能力硬答,而不是调用工具。我在早期测试时发现,模型对“求 2024 年 2 月到 6 月每月第一个工作日”这种问题,居然会自己编造一个答案而不调用工具,把描述改成“需要精确计算、数据处理或算法实现时使用”之后,调用率才明显提高。
4. 安全沙箱三件套:语言白名单、HostAccess 与强制销毁
4.1 LLM 生成的代码是“不可信输入”,威胁模型要事先想清楚
代码执行引擎最容易被低估的就是安全风险。你要记住一个原则:模型生成的代码和黑客提交的 payload 没有任何本质区别。 模型本身可能被提示词注入攻击,攻击者可以在用户输入里塞一段“忽略之前的指令,执行以下代码”,或者通过检索到的文档内容间接注入恶意代码要求执行。所以,你做的所有安全设计,都应该以“执行了一段恶意代码”为前提来考虑。
我刚做第一版的时候犯过一个错误:为了图方便,用了 Context.newBuilder("js").allowAllAccess(true).build(),想着“反正只是本地实验”。结果模型生成的代码居然能通过 Java.type 拿到宿主类,尝试读取文件系统。虽然那次实验没有造成损失,但它让我彻底打消了“LLM 不会产生恶意代码”的侥幸心理。
4.2 我能做的三层隔离,以及测试过的攻防结果
针对 LangChain4j 代码执行场景,我做了一个三层沙箱方案:
第一层是语言与能力白名单:只允许 js 和 python,并且通过 Context 配置禁用 IO、禁用宿主访问、禁用原生访问、禁用环境变量访问。这一层拦截了绝大多数“探测性”攻击,比如尝试读文件、访问网络、反射宿主对象等行为会直接抛异常。
第二层是操作系统层限制:我建议把运行代码执行引擎的进程放到一个低权限系统账号下,并配合容器的只读文件系统。简单说,即使前两层被攻破,攻击者能拿到的也只是一个小权限账号,而不是云服务器的 root。
第三层是运行时限:无论代码写得多完美,都必须有超时限制。GraalVM 的 Context.close(true) 方法可以强制中断正在执行的脚本,在另一个监控线程里调用它能做到“到点就杀”。
我实测了一些典型的攻击代码,结果如下:
| 尝试的攻击行为 | 使用的语言 | 实际结果 |
|---|---|---|
Java.type("java.lang.Runtime") |
JS | 抛错,HostAccess 拒绝 |
require('fs').readFileSync('/etc/passwd') |
JS | 抛错,IO 被禁止 |
open('/etc/hosts').read() |
Python | 抛错,IO 被禁止 |
Infinity 死循环 |
JS | close(true) 强制中断 |
恶意 os.system('rm -rf /tmp/test') |
Python | os 模块导入失败,被沙箱拦截 |
这些测试都是在本地起的临时容器里做的,结果符合预期。但这不代表你的应用完全安全——Polyglot 的沙箱边界在持续演进,建议你每次升级 GraalVM 版本后重新跑一遍攻击测试。
4.3 超时兜底:context.close(true) 和 Future 双保险
GraalVM 执行一个死循环脚本的时候,如果不用多线程去中断,整个调用线程会一直卡住。我自己写执行引擎时用了 Future 加超时,这样不管脚本写得再烂,都不会拖垮主线程。
java复制import java.util.concurrent.*;
public class TimeoutGuard {
private static final ScheduledExecutorService EXECUTOR = Executors.newScheduledThreadPool(2);
public static String executeWithTimeout(GraalVmCodeExecutionEngine engine,
String language,
String code,
long timeoutSeconds) throws Exception {
FutureTask<String> task = new FutureTask<>(() -> engine.execute(language, code));
ScheduledFuture<?> timeout = EXECUTOR.schedule(() -> {
engine.close(); // 强制关闭 Context,中断正在执行的脚本
}, timeoutSeconds, TimeUnit.SECONDS);
try {
return task.get(timeoutSeconds, TimeUnit.SECONDS);
} catch (TimeoutException te) {
engine.close();
return "执行超时(超过 " + timeoutSeconds + " 秒),已强制终止。";
} finally {
timeout.cancel(false);
}
}
}
注意这里用 lambda 捕获 engine 时,必须保证同一个时间点只有一个脚本在同一个 Context 里执行,否则 close 会影响其他正在运行的脚本。实践做法是一个会话一个引擎,或者用 ThreadLocal 给每个线程分配独立的 Context。
5. 实战踩坑:版本兼容、macOS 签名、Python 镜像、Maven 依赖
5.1 GraalVM 版本矩阵:JDK 21 还是 JDK 23
版本选择这件事,我在文章前面已经给了倾向性结论:首选 JDK 21 对应线。实际的版本对照如下:
| GraalVM 版本 | 对应 JDK | 状态 | 我的建议 |
|---|---|---|---|
| GraalVM 23.0.x | JDK 21 | 广泛使用 | 推荐 |
| GraalVM 24.x | JDK 21 | 稳定 | 推荐(我最终选用) |
| GraalVM 24.x | JDK 23 | 较新 | 不推荐,除非需要新特性 |
| GraalVM 25.x | JDK 23/25 | 最新 | 谨慎观望 |
另一个重点是 polyglot API 版本要与 GraalVM 大版本匹配。如果你用 GraalVM 24 运行,Maven 里就应该用 24.x 的 polyglot artifact,不要混用旧版 API。我的项目里统一到了 24.1.2,跑了大半个月没有遇到 API 层面的兼容问题。
5.2 macOS 上 jdk.internal.vm.Continuation 访问报错的修复
这个坑是 macOS 特有的,我在本地开发机上遇到的频率不低。具体症状是:启动一个用了 GraalVM 的应用时,JVM 直接抛 java.lang.IllegalAccessError: superclass access check failed: class jdk.internal.vm.Continuation 之类的错误。
排查下来,这是 GraalVM 的 JIT 编译器在 macOS 上重写了虚拟线程相关实现,但 macOS 的代码签名策略不允许 JVM 运行时动态访问 jdk.internal.vm 模块导致的。解决方式有几种:
- 用
codesign重新签名 GraalVM JDK:codesign --force --deep --sign - /path/to/graalvm,实测能解决大部分签名访问问题。 - 如果项目能跑在 Linux 开发环境,直接切 Linux,这个坑完全不存在。
- 升级到修复了该问题的 GraalVM 版本(24.x 以上明显减少)。
最后我给团队的建议是:本地开发可以用 macOS 加 codesign 顶着,CI 和服务器部署一律跑 Linux,省心很多。
5.3 JS 默认禁用、Python 模块缺失、原生镜像打包受限
先说 JS。很多人在普通 JDK 上引入了 polyglot 依赖,然后执行 context.eval("js", "1+1"),结果直接报 No language for id 'js' found。原因很简单:polyglot 只是 API 层,不包含任何语言运行时,你需要额外引入 js-language artifact,或者直接使用 GraalVM JDK。我在前面的 Maven 配置里已经给了正确写法。
再说 Python。GraalVM 的 Python 实现叫 GraalPy,它并不是一个完整的 CPython 环境。你用 python-language 依赖跑简单的数据处理没问题,但跑带 numpy、pandas 的脚本会失败,因为这些第三方 C 扩展库没法原生加载到 GraalPy 里。所以我的策略是:默认让模型优先用 JS 写代码,只有模型明确要求或用户指定时才使用 Python,并且在提示词里限制 Python 只能使用标准库。
最后说原生镜像(Native Image)。很多人看到 GraalVM 就想到“打包成 exe”,但我要泼个冷水:代码执行引擎不太适合打包成原生镜像。 原生镜像的静态分析要求所有类在构建时已知,而 Polyglot 是在运行时动态加载语言解释器的,两者在架构上天然冲突。我实测过几次,要么镜像体积巨大(因为要把整个语言运行时塞进去),要么构建直接失败。如果你想用 GraalVM 做 native-image 打包,请把代码执行引擎拆分到独立服务里,而不是作为一个模块打进去。
5.4 三个容易忽略的小问题
- 多线程复用同一个 Context 时,要保证没有并发执行。GraalVM 的 Context 默认是“单线程执行模型”,并发调用会抛
IllegalStateException。我采用的方式是给每个会话创建独立引擎实例,既不共享,也不加锁,避免锁竞争。 - 脚本里如果声明了全局变量,第二次执行时可能会被上一次的残留数据影响。例如第一次执行定义了
let x = 1,第二次执行又定义let x = 2,JS 会直接报语法错误。这个问题我在测试中踩到过,临时方案是每次执行前把代码包装成立即执行闭包,避免污染全局作用域。 - 输出流缓冲区的清理很关键。我在执行器里每次执行前都调用
outBuffer.reset(),否则上次执行的输出会残留到这次结果里,模型拿到的上下文就会错乱。
6. 端到端实测:让模型自己写代码算一个真实问题
6.1 场景与调用链路
我搭了一个相对完整的场景来验证:搭一个数据分析小助手,用户输入一段自然语言问题,模型判断需要计算时,会主动写代码调用执行器。这里用到的链路是:
- 用户提问:
帮我算出 1 到 20 所有质数的平方和 - 模型判断需要精确计算,调用
executeCode工具,并生成 JS 代码 - LangChain4j 把模型的工具请求路由到
CodeInterpreterTool - 执行器跑 JS,把结果返回给模型
- 模型基于返回值组织自然语言回复
实际对话日志里,模型生成的 JS 代码长这样:
javascript复制(function() {
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}
let sum = 0;
for (let i = 1; i <= 20; i++) {
if (isPrime(i)) sum += i * i;
}
return sum;
})();
执行器返回:
text复制返回值: 1027
模型得到这个返回后,直接回答用户:
text复制1 到 20 之间所有质数的平方和是 1027。质数分别是 2、3、5、7、11、13、17、19,它们的平方和正好为 1027。
整个链路非常顺滑,模型没有“猜”答案,而是真的“算”了一遍。
6.2 实测数据与性能基准
我从三个维度做了压测:单次执行耗时、Context 复用情况下的平均耗时、内存占用。
| 测试项 | 首次执行(含初始化) | 后续执行(Context 复用) |
|---|---|---|
简单表达式 1+1 |
~800ms | ~20ms |
| 1000 次循环求和 | ~900ms | ~40ms |
| 质数判断函数 | ~850ms | ~45ms |
| Python 内置 math 计算 | ~1.5s | ~120ms |
可以看到,首次执行包含语言运行时初始化,耗时较长,但后续执行基本都在几十毫秒级别,完全满足 Agent 多轮对话的交互要求。内存方面,一个 JS Context 大约占用 30~50MB 堆外内存,Python Context 会更大一些,大概在 100MB 级别。生产环境要注意给这些引擎预留独立的内存池,避免影响主业务应用的 GC。
6.3 代码执行引擎 vs function calling,什么时候用哪个
这是我在实际架构中最常被问到的问题。说一个我的判断标准:
- Function Calling 适合“固定、可枚举、低延迟”的操作。比如查天气、发邮件、查数据库,这类动作很明确,你需要精确控制参数和副作用。
- 代码执行引擎适合“开放、组合、高计算”的任务。比如数据清洗、数学推导、算法实现、文本处理。这类任务的共同点是无法预先穷举,但都可以归约为“写几行代码就能算”。
两者不是替代关系,是互补关系。我的项目里,Function Calling 负责和外部系统打交道的操作,代码执行引擎负责“模型脑子里的算法落地”。如果你在做 RAG 类应用,代码执行引擎也可以帮你做精确的中间计算,比如多路检索结果的混合重排时的加权打分计算,不需要依赖外部服务,直接在进程内完成。
在稳定性上,我的实际体验是:代码执行引擎与传统 function calling 相比,最大的不确定性来自模型的代码生成质量。如果你的模型经常写出语法错误的代码,建议在提示词里加一句“代码必须是可以直接执行的完整表达式或函数”,并给一个正例。实测下来,加入这句提示后,一次通过率从七成左右提高到了九成以上。
最后再分享一个小技巧:生产环境务必给代码执行引擎加一个独立的日志链路,把模型生成的原代码和执行结果都记录下来。这不仅是排障需要,也是后续做提示词优化的数据基础。我见过太多项目出了问题无法复盘,就是因为没留原始代码的日志——一旦模型生成了一段奇怪的代码把系统搞挂了,你连“它是怎么搞挂的”都不知道。代码执行引擎是个好能力,但它的前提是你能解释它每一次执行的行为。
