Java 团队想把 AI Agent 接进现有系统时,常年面对一个尴尬处境:主流 Agent 框架几乎都长在 Python 生态里,而企业真正的资产——领域模型、事务、权限、既有的服务层——全在 JVM 上。Spring 框架创始人 Rod Johnson 主导开源的 embabel-agent 就是冲着这个缺口来的:一个构建在 Spring AI 之上的 JVM Agent 框架,用 Kotlin 写成、对 Java 提供自然的使用方式。它和「把整条流程都交给大模型即兴发挥」的思路不同——核心规划用的是游戏行业成熟的 GOAP 算法(Goal Oriented Action Planning),计划由框架动态制定、每步执行后重新规划,而不是靠提示词求模型别乱来。官方给它的定位类比很精准:Spring AI 之于 Servlet API,embabel 之于 Spring MVC。本篇从模板脚手架开始,把注解模型、强类型领域模型、本地模型、单元测试与 MCP 双向集成完整跑一遍。
先理解:为什么规划不该全交给大模型
embabel 的核心主张是把 Agent 拆成两类工作:点任务交给 LLM(从一段话里提取结构化信息、改写文本),流程编排交给确定性算法。你只声明三类东西——动作(Action)、目标(Goal)、条件(Condition)——框架在运行时根据当前「世界状态」用 GOAP 自动找出一条能达成目标的动作序列;每个动作执行完还会重新评估条件、必要时重新规划,本质是一个 OODA 循环(观察-判断-决策-行动)。这带来两个直接好处:拆分后的 LLM 调用更便宜、更不容易出错;每段交互都可以写普通单元测试。另一张牌是强类型:动作方法的参数和返回值就是你的领域对象,框架按类型自动衔接数据流并推导前置/后置条件——没有魔法 Map,享受完整的重构支持。执行模式上它分 Focused(代码显式指定 agent)、Closed(按意图选 agent)与 Open(平台动用全部资源动态组装 agent)三档,Open 模式最灵活也最不确定,生产上建议从 Focused 起步。
版本敏感,动手前先对齐。embabel-agent 迭代很快:Maven Central 上的稳定版与仓库开发版差异明显,本篇涉及的注解名与类名(如 OpenAiModels.GPT_41)以你所用版本的官方文档与模板仓库为准;Gradle 用户注意还要显式声明 repo.spring.io/milestone 仓库(传递依赖里有 Spring 实验性组件,Gradle 不会从 BOM 继承仓库配置)。JDK 与 Spring Boot 的最低版本要求同样以官方模板为准。
Step 1:用官方模板起项目,装好依赖与密钥
# 方式一:官方 Java 模板(推荐,开箱即用)
git clone https://github.com/embabel/java-agent-template
cd java-agent-template
# 方式二:在现有 Spring Boot 工程加依赖(Maven 坐标)
# <dependency>
# <groupId>com.embabel.agent</groupId>
# <artifactId>embabel-agent-starter</artifactId>
# <version>0.3.0</version> <!-- 以 Maven Central 最新稳定版为准 -->
# </dependency>
# 配置模型密钥(示例最少需要 OpenAI)
export OPENAI_API_KEY=sk-xxxx
模板仓库把 Spring Boot 配置、依赖版本与示例结构都搭好了,是上手成本最低的入口;不想引模板就照上面的 Maven 坐标往自己工程里加 starter。密钥走环境变量注入,示例里的 OPENAI_API_KEY 必填——embabel 通过 Spring AI 适配各家模型,官方还建议同时备一个 Anthropic Key(部分示例两个都用)。除了 starter 本体,还有按需选装的模块:embabel-agent-starter-ollama(本地模型)、embabel-agent-starter-observability(全链路追踪)等。
implementation("com.embabel.agent:embabel-agent-starter:0.3.0");并在 repositories 里加上 maven("https://repo.spring.io/milestone"),缺了它解析依赖会直接失败。Step 2:用注解声明 Agent,动作与非动作混着写
@Agent(description = "根据用户输入的星座,找相关新闻写成短文")
public class StarNewsFinder {
// 动作一:LLM 点任务——从自然语言里提取结构化对象
@Action
public StarPerson extractStarPerson(UserInput userInput, Ai ai) {
return ai.withLlm(OpenAiModels.GPT_41)
.createObjectIfPossible(
"从这段输入提取人名与星座: " + userInput.getContent(),
StarPerson.class);
}
// 动作二:普通 Java 调用,完全不经过 LLM
@Action
public Horoscope retrieveHoroscope(StarPerson starPerson) {
return new Horoscope(horoscopeService.dailyHoroscope(starPerson.sign()));
}
}
这就是 embabel 的注解模型:类上 @Agent 描述这个 agent 干什么,方法上 @Action 声明动作。注意两个细节——方法参数由框架按类型注入:UserInput 是用户原始输入,StarPerson 是前一个动作的返回值,Ai 是 LLM 调用入口,数据流不用你手工传递;createObjectIfPossible 让模型尽力把文本转成强类型对象,转不出来时返回空而不是硬编。retrieveHoroscope 这个动作一行 LLM 调用都没有——embabel 刻意让「LLM 交互」和「普通代码」无缝混排,能用确定性代码的环节就不要花模型的钱,这是它区别于纯提示词编排框架的根本设计。
@JsonClassDescription 标类、@JsonPropertyDescription 标字段,这些描述会进入提示词,显著提升结构化抽取的准确率。Step 3:声明工具需求与目标,让规划器接手
// 动作声明需要 WEB 工具组,规划器据此判断该动作是否可行
@Action(toolGroups = {CoreToolGroups.WEB})
public RelevantNewsStories findNewsStories(
StarPerson person, Horoscope horoscope, Ai ai) {
String prompt = """
%s 是一位星座爱好者,星座是 %s。今日运势: %s
请联网搜索 %d 条相关新闻,各写一句摘要并附链接。
""".formatted(person.name(), person.sign(),
horoscope.summary(), 5);
return ai.withDefaultLlm().createObject(prompt, RelevantNewsStories.class);
}
// 用 @AchievesGoal 标注「完成此动作即达成目标」,流程到此收口
@AchievesGoal(description = "基于运势与新闻为用户写出趣味短文")
@Action
public Writeup writeup(StarPerson person,
RelevantNewsStories stories,
Horoscope horoscope, Ai ai) {
return ai.withLlm(LlmOptions.withModel(OpenAiModels.GPT_41_MINI)
.withTemperature(0.9))
.createObject("结合运势与新闻写一段趣味短文…", Writeup.class);
}
到这里,一个可被 GOAP 规划的完整链路就齐了:三个动作各自声明了输入类型(参数)与产出类型(返回值),findNewsStories 用 toolGroups 声明「我需要联网工具」,writeup 用 @AchievesGoal 声明「完成我就到站」。运行时你只管下达目标,框架自己算出「提取人物 → 查运势 → 搜新闻 → 写短文」的执行序列;如果某个动作的前置条件不满足(比如拿不到星座),它会自动换路径或报告不可达——这正是 GOAP 与「固定流程图」的区别:你写的代码是一堆可组合的能力,计划由运行时按需生成。
写操作类工具别交给 LLM 自主调用。官方明确提醒:把领域方法用 @Tool 暴露给模型前先确认安全性,会修改/删除数据的方法建议改成显式代码调用。读操作上网搜新闻可以放开,写操作必须留在你自己的代码里过审核。
Step 4:进交互式 Shell,跑通并观察规划过程
# 用官方示例仓库体验(含 StarNewsFinder 完整实现)
git clone https://github.com/embabel/embabel-agent-examples
cd embabel-agent-examples/scripts/kotlin
./shell.sh
# 在 Spring Shell 里运行 agent(-p 记录提示词,-r 记录模型回复)
execute "Lynda is a Scorpio, find news for her" -p -r
# 快捷键 x 等价 execute;chat 进入对话模式,平台按意图自动选 agent
chat
预期效果:execute 命令触发后,控制台能看到平台的执行轨迹——意图识别、目标选择、动作序列、每次 LLM 调用的输入输出。加上 -p -r 后提示词全程留痕,正好用来核对上一节的强类型提示词长什么样。开发期就把这个 Shell 当调试器用:改一行注解,回车重跑,几十秒内验证规划行为是否符合预期;Spring Shell 还支持历史记录(!! 重复上一条),重启后仍在,迭代体验接近 REPL。
embabel-agent-starter-ollama 依赖,框架自动连接本机 Ollama 端点,本地已拉取的模型全部可用——敏感数据场景先在本地把流程调通,再决定要不要上云模型。Step 5:不联网写单元测试,把提示词锁进断言
@Test
void writeupPromptMustContainKeyData() {
var context = new FakeOperationContext();
// 预置模型回复,动作执行后拿到的就是这个对象
context.expectResponse(new Writeup("今天适合出门走走"));
starNewsFinder.writeup(person, stories, horoscope, context);
// 断言提示词里带上了关键领域数据——提示词回归就这么简单
var inv = context.getLlmInvocations().getFirst();
assertTrue(inv.getPrompt().contains(person.getName()));
assertTrue(inv.getPrompt().contains(person.sign()));
}
这是 Java 团队最容易低估的一环:embabel 从零开始就是按可测试性设计的。FakeOperationContext 替身掉真实模型调用,你既能预置回复验证下游逻辑,也能反向断言「发给模型的提示词必须包含哪些领域数据、绑定了哪些工具组」——提示词改坏一个字段,测试当场红。把这类断言写进 CI,升级框架版本、换模型、调提示词都有回归兜底,这在「提示词散落在字符串里」的传统写法下几乎做不到。测试跑 mvn test,不需要任何网络与密钥;集成测试(*IT 结尾)才需要真实服务,默认按环境变量自动跳过。
Step 6:MCP 双向打通,接进更大的 Agent 生态
// embabel 平台可通过 SSE 暴露为 MCP Server,在 Claude Desktop 里配置:
// {
// "mcpServers": {
// "embabel": {
// "command": "npx",
// "args": ["-y", "mcp-remote", "http://localhost:8080/sse"]
// }
// }
// }
//
// 反方向:消费外部 MCP Server 走 Spring AI 客户端配置
// application.yml 里配 spring.ai.mcp.client,
// 支持 STDIO 与远程 Streamable HTTP 两种传输
MCP 集成是双向的:对外,你的 Spring 应用把 Agent 能力通过 SSE 端点暴露成 MCP Server,Claude Desktop 等客户端用 mcp-remote 桥接后就能直接调用;配 @AchievesGoal(export = @Export(remote = true, ...)) 的目标会以工具形态对外发布。调试时用官方 MCP Inspector(npx @modelcontextprotocol/inspector)连上 SSE 端点,手动触发工具、检查 prompts 与 resources。对内,借助 Spring AI 的 MCP 客户端配置,你的动作里可以消费任意外部 MCP Server 的工具——Docker Desktop(4.43.2+)内置的 MCP 目录里就有 Brave Search、Fetch、Wikipedia 等现成工具可开。观测性方面,加 embabel-agent-starter-observability 依赖即可零代码获得全链路 span(agent 生命周期、每个动作、LLM 调用与 token 用量),支持导出到 Zipkin 或 Langfuse。
暴露成 MCP Server 前先想清楚边界。对外发布的工具等于把能力开放给外部 Agent 调用,读接口先评估数据敏感面,写接口必须叠加你自己的鉴权与审计;不要把内部管理动作原样 @Export(remote = true) 出去。
常见问题速查
| 你遇到的现象 | 大概率原因 & 解决 |
|---|---|
| Gradle 解析依赖失败 | 缺 Spring Milestones 仓库。在 repositories 里显式加 repo.spring.io/milestone(Gradle 不继承 BOM 的仓库配置) |
| 注解类找不到/方法签名对不上 | 版本差异。注解与模型常量随版本演进,以你所用版本的官方文档与模板仓库为准 |
| 动作没被执行/规划不到目标 | 类型数据流断了:检查动作间参数与返回值的类型衔接,以及 toolGroups 对应工具是否可用 |
| 结构化抽取经常失败返回空 | 换 createObject 并给领域对象补 @JsonPropertyDescription;描述越具体,抽取越稳 |
| 想让 Claude 调用我的 Agent | SSE 暴露 MCP Server + mcp-remote 桥接(Step 6 配置),用 MCP Inspector 先本地验证 |
| 想本地跑不想花钱 | 加 embabel-agent-starter-ollama,用本机 Ollama 模型;注意本地小模型的结构化抽取能力上限 |