Rod Johnson 又回来了,这次做的是 Agent 框架
看到 Embabel 的时候我愣了一下。作者是 Rod Johnson,Spring 的创始人。他 2025 年开始做这个框架,2026 年初发布了 1.0。
我花了一个月读源码、写了两个 POC。这篇记录我对它的理解,重点是 GOAP 这个规划机制到底解决了什么问题,以及它和 ReAct 的真实差异——不是概念上的,是我们在同一个任务上跑出来的数据差异。
先说 ReAct 的问题在哪
要理解 GOAP 的价值,得先说清楚 ReAct 的局限。我们生产上跑的客服 Agent 就是 ReAct 模式,半年下来最头疼的一件事是:它走一步看一步,走错了不知道回头。
看一个真实的失败案例。用户问「帮我查一下我上个月买的那个东西能不能退」:
[step 1] 思考:需要找到用户上个月的订单
动作:queryOrders(userId=xxx, period=lastMonth)
观察:返回 7 个订单
[step 2] 思考:有 7 个订单,需要确定是哪一个
动作:queryOrderDetail(orderNo=SO001)
观察:商品是「保温杯」
[step 3] 思考:用户说「那个东西」,可能是这个保温杯
动作:checkRefundable(orderNo=SO001)
观察:可以退款
[step 4] 思考:已确认可以退款
回答:「您上个月购买的保温杯可以退款」
问题很明显:它查了第一个订单就停止了,没有确认「保温杯」是不是用户说的那个。7 个订单里还有 6 个没看。
但更深层的问题是:ReAct 没有「目标」的概念。它每一步的决策依据是「上一步的观察」,而不是「我要达成的目标是什么,现在离目标还差什么」。所以在 step 3 它认为「已经能回答了」,就停了。它不知道「确定是哪一件商品」这个子目标还没真正达成。
我们后来的补救是在 prompt 里加各种「如果找到多个候选要逐一确认」的规则。但这类规则加多了,prompt 变得臃肿,而且总会有覆盖不到的情况。
GOAP:目标导向的行动规划
GOAP(Goal-Oriented Action Planning)是游戏 AI 里的老技术了,2000 年代初就用在《FEAR》这类游戏里。它的核心思路是:不做「下一步做什么」的决策,而是做「为了达到目标,需要满足哪些前提条件」的规划。
三个核心概念:
- Goal(目标):要达到的世界状态,比如「已确定退款资格」;
- Action(动作):有 precondition(前置条件)和 effect(产生的状态变化);
- Planner(规划器):从当前状态出发,搜索一条能达成目标的动作序列。
在 Embabel 里,Action 是用 Java 方法加上注解表达的:
@Agent(description = "处理用户的退款咨询")
public class RefundAgent {
@Action
public OrderSet findOrders(UserContext user, OperationContext ctx) {
List<Order> orders = orderService.query(user.id(), Period.lastMonth());
return new OrderSet(orders);
}
/**
* 注意 precondition:要求 OrderSet 是「已消歧」的
*/
@Action(precondition = "orderSetDisambiguated")
public RefundEligibility checkEligibility(OrderSet orders, OperationContext ctx) {
return refundService.check(orders.selected());
}
@Action
public DisambiguatedOrderSet disambiguate(OrderSet orders, UserContext user) {
// 让模型基于用户的原始描述,从多个订单中确定一个
return ctx.ai().withLlm()
.createObject("""
用户说:%s
候选订单:%s
请判断用户指的是哪一个。如果无法确定,返回 NEED_CLARIFY。
""".formatted(user.originalQuery(), orders.summary()),
DisambiguatedOrderSet.class);
}
/**
* 这个 Goal 定义了「什么叫做成了」
*/
@AchievesGoal(description = "已确定用户所指商品的退款资格,并给出明确答复")
@Action
public Answer answer(RefundEligibility eligibility, DisambiguatedOrderSet order) {
return Answer.of(eligibility, order);
}
}
关键点在 @Action(precondition = "orderSetDisambiguated") 这一行。它声明的是「我不能凭空执行,必须先有人把歧义消除掉」。
规划器看到这个声明后,会自动推导出:要执行 checkEligibility,必须先得到 DisambiguatedOrderSet;而能产出 DisambiguatedOrderSet 的动作是 disambiguate。所以即使模型没意识到要消歧,规划器也会强制插入这一步。
规划过程长什么样
Embabel 运行时的输出(我们加了 debug 日志)是这样的:
[Planner] Goal: RefundEligibilityAnswer
[Planner] Current state: {UserContext, OperationContext}
[Planner] Searching for plan...
Candidate plan A (cost 0.42):
1. findOrders → OrderSet
2. disambiguate → DisambiguatedOrderSet [satisfies orderSetDisambiguated]
3. checkEligibility → RefundEligibility
4. answer → Answer [AchievesGoal]
Candidate plan B (cost 0.87):
1. findOrders → OrderSet
2. askUserToClarify → Clarification [satisfies orderSetDisambiguated]
3. checkEligibility → RefundEligibility
4. answer → Answer
[Planner] Selected plan A (lower cost)
[Executing] step 1 → findOrders ... OK (7 orders)
[Executing] step 2 → disambiguate ... OK (selected SO004, confidence 0.91)
[Executing] step 3 → checkEligibility ... OK
[Executing] step 4 → answer ... OK
注意它生成了两条候选计划,选了 cost 更低的那条。cost 是动作声明的:
@Action(cost = 0.15) // 便宜:一次模型调用
public DisambiguatedOrderSet disambiguate(...) { ... }
@Action(cost = 0.60) // 贵:要打断用户,体验差
public Clarification askUserToClarify(...) { ... }
「优先自己判断,实在不行再问用户」这条业务规则,被表达成了一个数字。我觉得这是 GOAP 最优雅的地方——把决策偏好从 prompt 里的自然语言,变成了可计算、可比较的数值。
失败重规划:这才是真正的价值
如果只有静态规划,GOAP 相比 ReAct 的优势有限。真正的差异在执行失败后的重规划。
看一个真实案例。我们的运维场景,Agent 要判断服务为什么变慢:
[Executing] step 1 → collectMetrics(service=order-svc) ... OK
[Executing] step 2 → queryRecentDeploy(service=order-svc) ... FAILED
deploy API 返回 503,部署系统正在维护
[Replanning] 状态回退,前一个动作的效果被撤销
[Replanning] 当前状态: {MetricsSnapshot}
[Replanning] 重新搜索计划...
Candidate plan A (cost 0.52): ← 原来的,依赖 deploy 数据,不可用
Candidate plan C (cost 0.68):
1. collectMetrics → MetricsSnapshot [已完成,复用]
2. queryLogForDeploy → DeployEvidence [改用日志推断]
3. analyzeCorrelation → Diagnosis
4. answer → Answer [AchievesGoal]
[Planner] Selected plan C
[Executing] step 2 → queryLogForDeploy ... OK (found deploy at 14:32 in logs)
[Executing] step 3 → analyzeCorrelation ... OK
它做的是:部署 API 挂了,就换个方式获取部署信息——从日志里找。而且已经完成的 collectMetrics 结果被复用了,没重跑。
同样场景下 ReAct 的表现(我们用同一个任务测过 20 次):
| 行为 | ReAct | Embabel GOAP |
|---|---|---|
| 换个方式获取部署信息 | 6/20 次 | 20/20 次 |
| 重复调用失败的工具 | 9/20 次 | 0/20 次 |
| 直接放弃并说无法判断 | 5/20 次 | 0/20 次 |
| 重试已完成的步骤 | — | 0/20(状态可复用) |
ReAct 在工具失败时最常见的反应是「再试一次」(9/20),因为它没有别的思路。而 GOAP 的规划器会重新搜索整个动作空间,天然就能找到替代路径。
这个差异的本质是:ReAct 的「下一步」是模型生成的,GOAP 的「下一步」是搜索出来的。模型生成受限于它当前看到的上下文和它的「注意力」,容易陷入刚才那条路;搜索是全局的,会考虑所有满足前提条件的动作。
完整对比实测
我们把同一个任务集(120 个运维诊断任务,其中包含 30 个需要多步推理的、20 个会触发工具失败的)跑了两个框架。同一个模型(qwen-max),同样的工具集。
| 指标 | ReAct(Spring AI) | Embabel GOAP |
|---|---|---|
| 任务成功率 | 68.3% | 82.5% |
| 平均步数 | 5.8 | 6.4 |
| 平均 token 消耗 | 18,400 | 22,100 |
| 平均耗时 | 14.2s | 19.6s |
| 工具失败后恢复率 | 45% | 100% |
| 死循环率 | 2.5% | 0% |
| 需要人工澄清的比例 | 18% | 24% |
成功率提升 14 个百分点,但代价是:token 多 20%,耗时多 38%,人工澄清多 6 个百分点。
最后一项值得注意。GOAP 更容易触发「问用户」,因为它的 askUserToClarify 动作在规划器眼里是一个合法路径,只要 cost 合适就会被选中。模型在 ReAct 模式下反而更「莽」一点,不确定就猜一个答案。从准确性看这是好事,从用户体验看不一定。
我们后来把 askUserToClarify 的 cost 从 0.60 调到 0.85,让它更倾向于自己判断,人工澄清率降到 19%,成功率只掉 1.2 个点。
类型系统:Embabel 最有意思的设计
这一节和 GOAP 无关,但我觉得是 Embabel 最值得学的地方。
Embabel 的 Action 签名是类型驱动的。上面那个例子里,checkEligibility(OrderSet orders, ...) 的参数类型是 OrderSet,规划器就知道「要执行这个方法,世界状态里必须已经有一个 OrderSet 实例」。
这意味着动作之间的依赖关系是由 Java 类型系统保证的,不是靠 prompt 描述。编译器就能检查出「这个动作的输入永远无法满足」这类错误。
@Action
public Report generateReport(
SalesData data, // 必须有 SalesData
DateRange range, // 必须有 DateRange
Optional<SegmentFilter> seg // 可选,没有也能执行
) { ... }
对比一下 ReAct 的做法——工具之间的依赖靠 description 里的文字描述(「调用本工具前请先调用 XXX」)。这类描述的有效性完全依赖模型的理解,我们生产上就有模型忽略这类提示导致工具报错的情况。
但类型驱动也带来一个限制:动作的组合方式被类型约束死了,不够灵活。有些创意性的、非结构化的任务(比如「写一篇分析报告」),很难用类型化的状态转移表达。这也是 Embabel 明确说自己适合的场景是「有明确目标的结构化任务」而不是「开放式创作」的原因。
Java 侧的接入现状
Embabel 是 Kotlin 写的,但对 Java 的支持还行。几个实际情况:
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-api</artifactId>
<version>1.0.2</version>
</dependency>
<!-- 需要 Spring Boot 4.x 或 3.5+ -->
<dependency>
<groupId>com.embabel.agent</groupId>
<artifactId>embabel-agent-starter</artifactId>
<version>1.0.2</version>
</dependency>
Java 里写 Action 和 Kotlin 基本一样,但有几个地方要注意:
- 领域对象必须是不可变的,且要有稳定的
equals/hashCode,因为规划器用它做状态匹配。我们用 record,很合适; - precondition 的字符串表达式在 Java 里没法做编译期检查,写错了运行时才报错。我们做法是把它抽成常量,加单元测试覆盖;
- Kotlin 协程的模型在 Java 里看不到,但基本不用关心,Embabel 内部处理了。
另外说下成熟度。1.0 版本发布到现在四个月,我们遇到两个问题:
- 规划器的搜索在高分支因子下(动作数 > 30)会明显变慢,我们有个 Agent 从 12 个动作扩到 41 个之后,规划耗时从 80ms 涨到 2.4s。后来是靠拆分成子目标解决的;
- 文档比较薄,尤其是 precondition 表达式的完整语法,我最后是读源码确认的。
这两个问题都不致命,但说明它确实还是个年轻框架。
我们的结论
做完了两个 POC,我们的决定是:运维诊断 Agent 用 Embabel,客服 Agent 继续用 ReAct,新场景默认 ReAct。
理由是场景匹配度。做个对比:
| 维度 | 运维诊断(选 GOAP) | 客服问答(选 ReAct) |
|---|---|---|
| 任务结构 | 明确、可分解 | 开放、多变 |
| 动作集合 | 固定 20 个左右 | 40+ 且持续增加 |
| 失败处理 | 关键,工具经常挂 | 重试即可 |
| 状态可类型化 | 能(Metrics、Log、Deploy 都是明确的对象) | 难(对话上下文很难类型化) |
| 对延迟的容忍 | 可容忍 20s | 要求 3s 内 |
客服场景的对话状态是没法干净地类型化的——你说「用户上一轮的情绪」是什么类型?而运维场景天然就是一堆结构化对象在流转。这个差异决定了 GOAP 在运维场景如鱼得水,在客服场景别扭。
还有一个现实因素:我们的客服 Agent 已经跑了半年,积累了大量 prompt 调优经验和评测集。换框架意味着全部重来,而收益主要是「工具失败后的恢复率」——这个在客服场景影响不大(我们的客服工具可用性 99.8%)。不值得。
小结
GOAP 给我的最大启发不是「规划比反应好」,而是把 Agent 的决策逻辑从「模型的一次生成」拆成了「模型生成候选 + 确定性算法做选择」。
ReAct 里,「下一步做什么」这整个决策都由模型完成。GOAP 里,模型只负责执行单个动作(比如消歧、判断),而「用哪些动作、按什么顺序」是由规划器基于声明式的前提条件和代价算出来的。能用确定性算法解决的部分,就不该交给模型——这是这两年我做 AI 工程最坚定的一个信念。
结果就是:GOAP 的 Agent 行为更可预测、失败能恢复、不会死循环。代价是灵活性下降、token 和延迟上升、以及对任务结构有要求。
至于要不要用,我的判断标准和一年前看任何新框架时一样:先问你的任务是不是结构化的、能不能用类型表达。是,GOAP 值得试;不是,别硬套。我们的运维 Agent 现在跑得不错,但我也很清楚,如果哪天要让它「自由发挥地排查一个全新的问题类型」,它大概率不如 ReAct。