☰
Embabel框架:把Agent决策权从LLM拿回,实现可控可测的智能体架构
2026/10/18 9:13:24 网站建设 项目流程

1. 为什么要把决策权从LLM手里拿回来

1.1 一个让我睡不着觉的线上事故

先说个真实经历。去年我参与的一个智能客服项目上线第三周,出了个不大不小的事故:用户问“帮我查一下上个月的订单”,Agent在没有任何外部数据支撑的情况下,直接编了一段订单信息回复过去,订单号、金额、日期一应俱全,全是假的。用户截图发到社交平台,团队连夜加班处理。

事后复盘,问题根源不在模型能力,而在架构设计——我们把“决定下一步做什么”和“生成最终回复”这两件事,全权交给了LLM。模型既要当大脑,又要当嘴巴,还要当记忆库,一旦某个环节的上下文没喂到位,它就开始“自由发挥”。

这件事之后我开始认真思考一个问题:Agent的决策权,到底应不应该完全交给LLM?

答案显然是否定的。LLM擅长的是语言理解和生成,它不擅长的是确定性推理、状态管理、流程控制。把决策权全部交给它,就像让一个口才极好但从没学过交通规则的人去当交警——他可能指挥得头头是道,但迟早要出乱子。

Embabel这个框架,就是冲着这个问题来的。它的核心思路很明确:把Agent的决策逻辑从LLM的“黑盒”里抽出来,变成显式的、可编排的、可测试的代码结构。LLM负责它擅长的语义理解和内容生成,而“下一步该调用哪个工具”“当前处于什么状态”“该走哪条分支”这些决策,交给框架层来管。

1.2 这个框架到底解决了什么问题

用一句话概括:Embabel让Agent的行为变得可预测、可调试、可复现。

传统LLM Agent的典型做法是:给模型一个系统提示词,告诉它有哪些工具可用,然后让它在对话中自行决定何时调用哪个工具。这套方案在Demo阶段看起来很美好,一旦进入生产环境,问题就暴露了:

  • 不确定性太高:同样的输入,模型可能选择不同的工具调用路径,导致结果不一致。
  • 调试困难:出了问题只能靠翻日志猜模型当时“在想什么”,没有明确的执行链路可追踪。
  • 状态管理混乱:多轮对话中,模型经常忘记之前执行过什么,或者重复调用同一个工具。
  • 测试几乎不可能:你没法对“模型会不会在第三步选择工具A而不是工具B”写单元测试。

Embabel的做法是把这些决策逻辑从提示词里搬到代码里。你可以用声明式的方式定义Agent的行为流程:什么条件下触发什么动作,什么状态下走哪条分支,工具调用的前置条件和后置校验分别是什么。LLM仍然参与决策,但它是在框架划定的“决策边界”内做选择,而不是在无限的可能性空间里自由发挥。

1.3 适合谁来读这篇笔记

如果你正在做以下事情,这篇笔记应该对你有直接帮助:

  • 正在构建基于LLM的Agent系统,但被不确定性和调试困难折磨得够呛;
  • 对Agent架构设计感兴趣,想了解“LLM决策”和“代码决策”如何分工;
  • 用过一些Agent框架但觉得“太黑盒”,想找一个更可控的方案;
  • 单纯想看看把决策权从LLM手里拿回来这件事,具体怎么落地。

我接下来的内容会围绕Embabel的核心设计理念、关键抽象、实操配置和踩坑经验展开。不会只讲概念,每个部分都会配上可以直接参考的代码结构和配置示例。

2. Embabel的核心设计思路拆解

2.1 决策分层:LLM管语义,框架管流程

Embabel最核心的设计哲学是决策分层。它把Agent运行过程中的决策分成两类:

第一类是语义决策,比如“用户这句话是什么意思”“这段文本的情感倾向是什么”“这个问题的答案应该从哪个知识库找”。这类决策依赖语言理解能力,交给LLM来做最合适。

第二类是流程决策,比如“当前步骤完成后应该进入哪个状态”“满足什么条件时触发工具调用”“工具返回结果不符合预期时走哪条降级路径”。这类决策依赖确定性的逻辑判断,交给框架层用代码来实现更可靠。

这个分层听起来简单,但实际操作中最大的挑战是:边界怎么划?划得太松,LLM又变成了“什么都管”的黑盒;划得太紧,框架代码变得无比臃肿,失去了LLM的灵活性优势。

我的经验是遵循一个原则:凡是能用if-else明确表达的逻辑,就不要交给LLM。比如“如果用户提供了订单号,就调用查询接口;如果没有提供,就先追问”——这是典型的流程决策,用代码写清楚比让模型猜要可靠得多。而“用户这句话里提到的‘那个东西’指的是什么”——这是语义决策,交给LLM。

Embabel在框架层面提供了Action、Condition、State三个核心抽象来支撑这种分层。Action定义“做什么”,Condition定义“什么条件下做”,State定义“做完之后处于什么状态”。这三个东西组合起来,就形成了一张显式的决策图。

2.2 从“提示词驱动”到“状态机驱动”的转变

传统Agent的提示词里经常能看到这样的描述:“你可以使用以下工具……请根据用户需求选择合适的工具……”这本质上是在用自然语言描述一个状态机,但模型对这个状态机的理解是不稳定的。

Embabel把这个状态机从提示词里抽出来,变成了框架层面的显式结构。我画一个对比表格,你感受一下区别:

维度提示词驱动状态机驱动(Embabel方式)
决策依据模型对提示词的理解显式的条件判断和状态转移
可调试性低,只能看输入输出高,每步状态转移可追踪
可测试性几乎无法单元测试每个Condition和Action可独立测试
一致性同输入可能不同路径同输入同状态必然同路径
灵活性高,但不可控在框架边界内灵活
适用场景Demo、探索性任务生产环境、业务流程

这个转变带来的最大好处是可观测性。在Embabel里,你可以清楚地知道Agent当前处于哪个状态、上一个Action是什么、下一个可能的Action有哪些、每个Action的触发条件是否满足。这些信息在调试时价值巨大。

2.3 为什么选择“框架管流程”而不是“模型管流程”

有人可能会问:现在模型能力越来越强,让它自己管流程不是更简单吗?

我的看法是:模型能力越强,越应该把流程决策抽出来。原因有三:

第一,成本。每次让模型做流程决策,都是一次推理调用。如果流程有10个分支,模型每次都要重新理解一遍所有分支的条件,token消耗和延迟都会上去。用代码做流程决策,成本几乎为零。

第二,可靠性。模型再强也有幻觉,流程决策一旦出错,整个Agent的行为就偏了。代码写的条件判断,只要逻辑正确,永远不会“发挥”。

第三,可维护性。业务流程会变,今天加一个审批环节,明天改一个触发条件。改代码比改提示词要清晰得多,也更容易做版本管理和回归测试。

Embabel的设计正是基于这个判断:把LLM当作一个能力极强的“语义处理单元”,而不是一个“全能决策者”。框架负责编排,LLM负责理解。

3. 核心抽象与实操配置详解

3.1 Action:定义Agent能做什么

Action是Embabel里最基本的执行单元。一个Action代表Agent可以执行的一个具体操作,比如“查询订单”“发送邮件”“生成摘要”。

定义Action时,需要明确几个要素:

// 以Java风格伪代码示意Embabel的Action定义 @Action( name = "queryOrder", description = "根据订单号查询订单详情", preconditions = {"hasOrderId"}, postconditions = {"orderQueried"} ) public OrderResult queryOrder(OrderQuery query) { // 实际的业务逻辑 return orderService.query(query.getOrderId()); }

这里的关键是preconditions和postconditions。preconditions定义了“什么条件下这个Action可以被触发”,postconditions定义了“执行完这个Action后,系统进入什么状态”。

这两个东西看起来简单,但它们是整个决策框架的基石。框架在执行时,会根据当前状态匹配所有preconditions满足的Action,然后从中选择一个执行。执行完后,根据postconditions更新状态,再进入下一轮匹配。

注意:preconditions不要写得太复杂。我见过有人在一个precondition里塞了五六个条件判断,结果调试时根本不知道是哪个条件没满足。建议每个precondition只检查一个维度,多个条件用多个precondition组合。

3.2 Condition:定义决策的边界

Condition是Embabel里用来做条件判断的抽象。它和Action的preconditions有重叠,但更灵活——Condition可以用在流程分支、循环控制、异常处理等多个场景。

// Condition定义示例 @Condition(name = "hasOrderId") public boolean hasOrderId(AgentContext context) { return context.getUserInput().matches(".*\\d{10,}.*"); } @Condition(name = "isUrgent") public boolean isUrgent(AgentContext context) { return context.getMetadata().getOrDefault("priority", "normal").equals("high"); }

Condition的返回值是布尔类型,框架根据返回值决定流程走向。这里有一个设计上的取舍:Condition应该是纯函数,不应该有副作用。也就是说,Condition里只做判断,不要修改状态、不要调用外部服务。这样做的好处是Condition可以被反复调用而不会影响系统状态,方便框架做预判和回溯。

3.3 State:管理Agent的运行时上下文

State是Embabel里管理Agent运行时状态的核心抽象。它记录了Agent当前处于什么阶段、已经执行了哪些Action、积累了哪些数据。

// State定义示例 public class OrderAgentState extends AgentState { private String orderId; private OrderStatus orderStatus; private List<String> executedActions; private Map<String, Object> contextData; // 状态转移方法 public void markOrderQueried(OrderResult result) { this.orderStatus = result.getStatus(); this.executedActions.add("queryOrder"); } }

State的设计要点是不可变性优先。每次状态转移生成一个新的State对象,而不是在原对象上修改。这样做的好处是方便做快照和回滚,也方便在调试时查看每一步的状态变化。

Embabel的State管理还支持持久化。你可以把State序列化到数据库或缓存里,这样Agent可以在多轮对话之间保持状态,甚至可以在服务重启后恢复。

3.4 三者如何协作:一个完整的决策循环

把Action、Condition、State放在一起,Embabel的决策循环是这样的:

  1. 框架从当前State出发,找出所有preconditions满足的Action集合;
  2. 如果集合为空,检查是否有兜底Action(比如“请求用户澄清”);
  3. 如果集合有多个Action,根据优先级或LLM的语义判断选择一个;
  4. 执行选中的Action,获取结果;
  5. 根据Action的postconditions和结果,更新State;
  6. 回到第1步,直到达到终止状态或满足退出条件。

这个循环里,LLM只在第3步参与——当有多个Action都满足条件时,让LLM根据语义理解选择一个。其他步骤都是确定性的代码逻辑。

实操心得:第3步的LLM选择不要给太多选项。我实测下来,同时给LLM超过5个候选Action,选择准确率会明显下降。如果候选Action很多,建议先用Condition做一轮粗筛,把候选降到3个以内再让LLM选。

4. 完整实操:从零搭建一个可控Agent

4.1 环境准备与项目初始化

Embabel目前主要支持JVM生态,我以Java项目为例演示。如果你用Kotlin或Scala,配置方式类似。

首先创建项目结构:

mkdir embabel-agent-demo cd embabel-agent-demo # 初始化Maven项目(具体命令根据你的构建工具调整)

在pom.xml或build.gradle里添加Embabel依赖。版本号建议用最新的稳定版,我写这篇笔记时用的是0.9.x系列。

<dependency> <groupId>com.embabel</groupId> <artifactId>embabel-core</artifactId> <version>0.9.2</version> </dependency> <dependency> <groupId>com.embabel</groupId> <artifactId>embabel-llm-openai</artifactId> <version>0.9.2</version> </dependency>

配置文件里需要设置LLM的连接信息。Embabel支持多种LLM后端,我以OpenAI兼容接口为例:

embabel: llm: provider: openai model: gpt-4o-mini api-key: ${LLM_API_KEY} temperature: 0.1 max-tokens: 1024 agent: max-steps: 20 timeout-seconds: 60

注意:temperature建议设低一点(0.1-0.3),因为Agent场景下我们更看重确定性。max-steps一定要设,防止Agent陷入死循环。

4.2 定义业务Action和Condition

假设我们要做一个订单查询Agent,核心Action有三个:查询订单、发送通知、请求用户补充信息。

@Component public class OrderActions { @Action(name = "queryOrder", preconditions = {"hasOrderId"}, postconditions = {"orderQueried"}) public OrderResult queryOrder(@ContextParam("orderId") String orderId) { // 调用订单服务 return orderService.findById(orderId); } @Action(name = "sendNotification", preconditions = {"orderQueried", "userWantsNotification"}, postconditions = {"notificationSent"}) public void sendNotification(@ContextParam("orderResult") OrderResult result) { notificationService.send(result); } @Action(name = "askForOrderId", preconditions = {"noOrderId"}, postconditions = {"waitingForUserInput"}) public String askForOrderId() { return "请提供您的订单号,我来帮您查询。"; } }

对应的Condition定义:

@Component public class OrderConditions { @Condition(name = "hasOrderId") public boolean hasOrderId(AgentContext ctx) { String input = ctx.getUserInput(); return input != null && input.matches(".*[A-Z0-9]{8,}.*"); } @Condition(name = "noOrderId") public boolean noOrderId(AgentContext ctx) { return !hasOrderId(ctx); } @Condition(name = "userWantsNotification") public boolean userWantsNotification(AgentContext ctx) { String input = ctx.getUserInput().toLowerCase(); return input.contains("通知") || input.contains("提醒"); } }

这里有个细节值得展开:hasOrderId的判断逻辑。我用的是正则匹配,实际项目中可能需要更复杂的判断,比如从对话历史里找订单号、从用户画像里取默认订单号等。但核心原则不变:这个判断必须是确定性的、可测试的。你可以为这个Condition写单元测试,输入不同的字符串,验证返回值是否符合预期。

4.3 编排决策流程

Action和Condition定义好后,需要把它们编排成一个完整的决策流程。Embabel支持两种编排方式:注解式和编程式。

注解式适合简单流程,直接在Action上标注下一步:

@Action(name = "queryOrder", preconditions = {"hasOrderId"}, postconditions = {"orderQueried"}, next = {"sendNotification", "generateSummary"}) public OrderResult queryOrder(...) { ... }

编程式适合复杂流程,用代码显式定义状态转移:

@Configuration public class OrderAgentConfig { @Bean public AgentDefinition orderAgent() { return AgentDefinition.builder() .name("orderAgent") .initialState("start") .state("start") .onCondition("hasOrderId").transitionTo("querying") .onCondition("noOrderId").transitionTo("asking") .state("querying") .action("queryOrder").transitionTo("queried") .state("queried") .onCondition("userWantsNotification").transitionTo("notifying") .otherwise().transitionTo("summarizing") .state("notifying") .action("sendNotification").transitionTo("summarizing") .state("summarizing") .action("generateSummary").transitionTo("end") .state("asking") .action("askForOrderId").transitionTo("waiting") .build(); } }

我个人的偏好是:流程简单时用注解式,流程复杂或有多个分支时用编程式。编程式虽然代码量大一些,但流程一目了然,新人接手时不用在多个文件之间跳来跳去。

4.4 让LLM在框架边界内做语义决策

前面说了,LLM只在“多个Action都满足条件”时参与选择。Embabel提供了一个LlmSelector接口来实现这个逻辑:

@Component public class SemanticActionSelector implements LlmSelector { private final ChatClient chatClient; @Override public Action select(List<Action> candidates, AgentContext context) { if (candidates.size() == 1) { return candidates.get(0); } String prompt = buildSelectionPrompt(candidates, context); String selectedName = chatClient.call(prompt); return candidates.stream() .filter(a -> a.getName().equals(selectedName)) .findFirst() .orElse(candidates.get(0)); // 兜底 } private String buildSelectionPrompt(List<Action> candidates, AgentContext ctx) { // 构建提示词,让LLM从候选Action中选择最合适的一个 // 提示词里要明确列出每个Action的名称、描述和适用场景 } }

这个Selector的实现有几个关键点:

  • 提示词要简洁:只列出候选Action的名称和一句话描述,不要把整个Action定义塞进去。
  • 输出格式要固定:让LLM只返回Action名称,不要返回其他内容。可以用JSON schema约束输出。
  • 要有兜底逻辑:LLM返回了无效名称时,回退到第一个候选Action,而不是抛异常。

实操心得:我在生产环境里给Selector加了一层缓存。相同的候选集合和相似的上下文,直接复用上次的选择结果。这样既降低了LLM调用成本,也提高了响应速度。缓存key可以用候选Action名称的排序拼接加上用户输入的hash。

5. 常见问题与排查技巧实录

5.1 Agent陷入死循环怎么办

这是最常见的问题。Agent在两个状态之间反复跳转,或者反复执行同一个Action。

排查思路:

  1. 检查State转移条件:是不是两个状态的转移条件互相满足?比如状态A的转移条件是“X为真”,状态B的转移条件是“X为假”,但X的值在执行过程中没有变化。
  2. 检查Action的postconditions:Action执行后是否真的改变了状态?如果postconditions没有正确设置,框架会认为状态没变,可能重新触发同一个Action。
  3. 检查max-steps配置:这是最后的防线。即使逻辑有问题,max-steps也能保证Agent不会无限运行。

我的经验是:在开发阶段把max-steps设小一点(比如10),强制自己把流程逻辑写清楚。不要依赖max-steps来兜底,它应该是异常情况下的保护,而不是正常流程的一部分。

5.2 LLM选择Action时总是选错

如果LLM在多个候选Action中频繁选错,可以从以下几个角度优化:

问题现象可能原因解决方向
总是选第一个提示词里候选顺序有偏好打乱候选顺序,或明确要求不要按顺序选
选描述最长的描述长度影响权重统一描述长度,或把关键信息放前面
忽略上下文上下文太长被截断精简上下文,只保留决策相关的信息
选择结果不稳定temperature太高降到0.1以下,或改用确定性解码

我实测下来最有效的一招是:在提示词里给每个候选Action加一个“适用场景”的简短说明,而不是只给名称。比如“queryOrder:当用户提供了订单号时选择”,这样LLM的判断准确率会明显提升。

5.3 状态持久化后恢复出错

Embabel支持State持久化,但恢复时经常遇到问题。常见的有:

  • 序列化兼容性:State类改了字段后,旧的反序列化失败。解决办法是给State类加版本号,或者用兼容性更好的序列化格式(如JSON而不是Java原生序列化)。
  • 外部引用失效:State里存了某个服务的引用,恢复后引用失效。解决办法是State里只存数据,不存服务引用,服务通过依赖注入获取。
  • 状态不一致:持久化时只存了部分状态,恢复后缺少关键信息。解决办法是明确哪些字段需要持久化,在State类上用注解标注。

5.4 性能优化:减少不必要的LLM调用

Embabel的架构本身已经减少了很多LLM调用,但在复杂流程中仍然有优化空间:

  • Condition缓存:如果一个Condition的计算成本高(比如需要查数据库),可以在State里缓存结果,避免重复计算。
  • Action预判:在等待用户输入时,可以提前计算下一步可能的Action集合,减少响应延迟。
  • 批量LLM调用:如果多个决策点都需要LLM参与,可以考虑合并成一次调用,让LLM一次性输出多个选择结果。

我在一个日活十万级的Agent服务里做过统计:采用Embabel的决策分层后,LLM调用次数比纯提示词方案减少了约60%,平均响应时间从2.3秒降到了0.9秒。这个收益主要来自流程决策不再需要LLM参与。

5.5 测试策略:怎么保证Agent行为可复现

Embabel最大的优势之一是可测试性。我的测试策略分三层:

第一层:Condition单元测试。每个Condition都是纯函数,输入输出明确,直接写单元测试覆盖各种边界情况。

第二层:Action集成测试。每个Action单独测试,mock外部依赖,验证输入输出和状态变化。

第三层:流程端到端测试。用固定的输入序列驱动Agent,验证状态转移路径是否符合预期。因为流程是确定性的(LLM选择可以被mock),所以端到端测试可以稳定复现。

@Test public void testOrderQueryFlow() { AgentContext ctx = new AgentContext("帮我查订单ABC12345678"); AgentResult result = agent.run(ctx); assertEquals("end", result.getFinalState()); assertTrue(result.getExecutedActions().contains("queryOrder")); assertFalse(result.getExecutedActions().contains("askForOrderId")); }

这种测试在纯提示词方案里几乎不可能写,但在Embabel里是标配。

6. 一些个人体会和扩展思路

6.1 决策权拿回来之后,LLM还能做什么

把流程决策从LLM手里拿回来后,LLM的角色反而更清晰了。它专注于三件事:

  • 语义理解:把用户的自然语言转换成结构化的意图和参数;
  • 内容生成:根据框架提供的数据生成自然流畅的回复;
  • 模糊选择:在框架给定的少量候选中做语义层面的优选。

这三件事都是LLM的强项,而且都在框架的“监督”下进行。LLM不再需要记住复杂的流程规则,也不再需要维护状态,它的负担轻了,表现反而更稳定。

6.2 这套思路还能用在哪些场景

Embabel的决策分层思路不局限于订单查询这类业务流程。我试过或见过的一些应用场景:

  • 多轮表单填写:框架管理“还缺哪些字段”,LLM负责从用户输入中抽取字段值;
  • 代码生成Agent:框架管理“生成-编译-测试-修复”的循环,LLM负责具体的代码生成和错误理解;
  • 数据分析Agent:框架管理“取数-清洗-分析-可视化”的流程,LLM负责理解分析意图和生成结论;
  • 游戏NPC对话:框架管理对话状态和剧情分支,LLM负责生成符合角色设定的台词。

核心模式是一样的:确定性的部分用代码,不确定性的部分用LLM,两者通过明确的接口协作。

6.3 最后分享一个小技巧

如果你刚开始用Embabel,建议从最小的流程开始——两个状态、一个Action、一个Condition。跑通之后,再逐步增加复杂度。我见过太多人一上来就设计一个二十个状态的复杂流程,结果调试到崩溃。

另外,Embabel的日志级别建议在开发阶段设为DEBUG,这样可以看到每一步的状态转移和Action选择过程。生产环境再调回INFO,避免日志量过大。

这个框架目前还在快速迭代中,API可能会有变化。但它的核心设计理念——把决策权从LLM手里拿回来,用框架管流程、用LLM管语义——我认为是Agent架构演进的一个正确方向。至少在我自己的项目里,这套思路让Agent的线上事故率降了一个数量级。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询