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 ListString executedActions; private MapString, 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的决策循环是这样的框架从当前State出发找出所有preconditions满足的Action集合如果集合为空检查是否有兜底Action比如“请求用户澄清”如果集合有多个Action根据优先级或LLM的语义判断选择一个执行选中的Action获取结果根据Action的postconditions和结果更新State回到第1步直到达到终止状态或满足退出条件。这个循环里LLM只在第3步参与——当有多个Action都满足条件时让LLM根据语义理解选择一个。其他步骤都是确定性的代码逻辑。实操心得第3步的LLM选择不要给太多选项。我实测下来同时给LLM超过5个候选Action选择准确率会明显下降。如果候选Action很多建议先用Condition做一轮粗筛把候选降到3个以内再让LLM选。4. 完整实操从零搭建一个可控Agent4.1 环境准备与项目初始化Embabel目前主要支持JVM生态我以Java项目为例演示。如果你用Kotlin或Scala配置方式类似。首先创建项目结构mkdir embabel-agent-demo cd embabel-agent-demo # 初始化Maven项目具体命令根据你的构建工具调整在pom.xml或build.gradle里添加Embabel依赖。版本号建议用最新的稳定版我写这篇笔记时用的是0.9.x系列。dependency groupIdcom.embabel/groupId artifactIdembabel-core/artifactId version0.9.2/version /dependency dependency groupIdcom.embabel/groupId artifactIdembabel-llm-openai/artifactId version0.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(ListAction 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(ListAction 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。排查思路检查State转移条件是不是两个状态的转移条件互相满足比如状态A的转移条件是“X为真”状态B的转移条件是“X为假”但X的值在执行过程中没有变化。检查Action的postconditionsAction执行后是否真的改变了状态如果postconditions没有正确设置框架会认为状态没变可能重新触发同一个Action。检查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的线上事故率降了一个数量级。