资讯动态

Embabel框架:如何将决策权从LLM手中收回,实现确定性Agent编排

发布时间:2026/10/21 21:20:10 来源:尧图企业网站定制
1. 为什么要把决策权从LLM手里拿回来第一次接触Embabel这个框架的时候我正在做一个多步骤任务编排的小工具。当时用的方案很直接把任务描述丢给大模型让它自己决定下一步调什么工具、传什么参数、什么时候结束。跑demo的时候效果惊艳一旦放到稍微复杂点的场景里就开始翻车——同样的输入今天走A路径明天走B路径后天干脆卡在某个循环里出不来。最要命的是出了问题你根本不知道它为什么这么决策日志里只有一堆自然语言没法复现没法调试。这个痛点其实很普遍。大模型本质上是个概率系统你让它做“决策”它给你的是“采样”。采样意味着不确定性而不确定性在工程系统里就是bug的温床。Embabel这个框架的核心思路就是把“决策”这件事从LLM手里收回来交给确定性的代码逻辑去管LLM只负责它真正擅长的事情——理解语义、生成内容、做模糊判断。这个分工一旦理清楚整个系统的可控性会有质的提升。这篇笔记适合两类人看一类是正在做Agent相关项目、被LLM的不确定性折磨过的开发者另一类是对Agent架构设计感兴趣、想搞清楚“确定性编排”和“自主决策”边界在哪里的技术人。我会从设计思路、核心机制、实操落地到踩坑排查把Embabel这套东西拆开讲透尽量做到你看完就能在自己的项目里试起来。需要提前说明的是Embabel是一个基于JVM的Agent框架用Kotlin/Java写起来最顺手核心概念围绕“Action”和“Goal”展开。如果你平时用Python做Agent开发思路完全通用只是API形式不同。我下面会尽量用语言无关的方式讲原理涉及代码的地方会标注清楚。2. 核心设计思路拆解2.1 确定性编排与概率性生成的边界划分传统Agent的做法是“一个大模型管所有”给它一个目标它自己规划步骤、自己选工具、自己判断是否完成。这种架构在简单场景下很优雅但一旦步骤超过三四个或者涉及条件分支、错误重试、并行任务就会变得极其脆弱。Embabel的做法是把整个流程拆成两层。上层是编排层用代码明确定义有哪些步骤、步骤之间的依赖关系、什么条件下走哪条分支。这一层是确定性的同样的输入必然走同样的路径。下层是执行层每个具体步骤内部可以调用LLM来做语义理解、内容生成、分类判断等。这一层是概率性的但被限制在一个很小的范围内即使输出有波动也不会影响整体流程的走向。打个比方编排层像是地铁线路图站点和换乘关系是固定的执行层像是每站上来的乘客他们说什么话、去哪里是随机的但不影响地铁本身的运行轨迹。这个类比我觉得挺贴切的理解了这个就理解了Embabel的核心价值。注意边界划分的关键在于“决策”和“执行”的区分。选择走哪条路是决策走这条路时具体说什么话是执行。前者必须确定后者可以灵活。2.2 为什么选择Action-Goal模型而不是Chain或Graph市面上做编排的方案很多有Chain式的线性流水线有Graph式的DAG还有State Machine式的状态机。Embabel选了Action-Goal模型这个选择背后有它的道理。Chain式的问题在于太死板只能一条路走到黑遇到条件分支就得嵌套嵌套三层以上就没法看了。Graph式灵活但需要你显式定义所有节点和边对于“目标驱动”的场景来说你得先想清楚所有可能的路径这在探索性任务里很难做到。State Machine适合状态明确的场景但Agent任务往往状态不那么清晰。Action-Goal模型的核心是你定义“目标”Goal框架帮你找“动作”Action来实现这个目标。每个Action声明自己能满足什么Goal、需要什么前置条件。框架根据当前状态和目标自动匹配可用的Action。这样你既保留了确定性Action是代码写的又获得了灵活性不用手动画所有路径。我实测下来这种方式在“目标明确但路径不确定”的场景里特别好用。比如“从一段文本里提取所有日期并格式化”这个目标你可以写多个Action分别处理不同格式的日期框架会自动选择合适的那个。2.3 状态管理的设计哲学Agent系统里最容易被低估的就是状态管理。很多框架把状态藏在LLM的上下文里靠对话历史来维持这在简单场景下能用但一旦需要持久化、需要回滚、需要多任务并行就彻底崩了。Embabel把状态显式化用一个Blackboard黑板来存储所有中间结果。每个Action执行完把结果写到黑板上下一个Action从黑板上读。这个设计借鉴了经典AI里的黑板系统好处是状态完全透明你可以随时查看当前有哪些数据、哪些Action已经执行过、哪些还没执行。更重要的是这种设计让可恢复性变得简单。如果某个Action失败了你可以从黑板上看到它执行前的状态重试或者换一个Action都行。我试过在一个长流程里模拟中途失败恢复起来比之前用对话历史的方式简单太多。2.4 与主流Agent框架的对比为了让你更清楚Embabel的定位我列个表对比一下几种常见方案维度纯LLM自主决策Chain式编排Graph式编排Embabel Action-Goal决策确定性低高高高路径灵活性高低中高可调试性差好好好状态管理隐式显式显式显式黑板学习曲线低低中中适合场景简单问答固定流程复杂DAG目标驱动任务从表里能看出来Embabel在确定性和灵活性之间找了个不错的平衡点。它不是要取代其他方案而是在“需要确定性但又不想写死所有路径”这个细分场景里提供了一个更合适的选择。3. 核心机制与实操要点3.1 Action的定义与匹配逻辑Action是Embabel里最基本的执行单元。定义一个Action需要声明三样东西它能满足什么Goal、它需要什么输入、它产生什么输出。框架根据当前Blackboard上的数据和待完成的Goal自动匹配可用的Action。匹配逻辑的核心是类型匹配和条件匹配。类型匹配看的是输入输出的数据类型比如一个Action需要String输入、产生Date输出那只有当Blackboard上有String且需要Date的时候它才会被考虑。条件匹配看的是你定义的前置条件比如“只有当文本长度大于100时才用这个Action”。这里有个实操要点Action的粒度要适中。太粗的话一个Action里塞太多逻辑失去了编排的意义太细的话Action数量爆炸匹配效率下降。我的经验是一个Action对应一个“原子操作”比如“调用某个API”、“解析某种格式”、“做一次分类判断”这样粒度刚好。// 一个典型的Action定义Kotlin示例 Action fun extractDateFromText( Input text: String, Output date: Date ): Date { // 这里可以调用LLM做语义理解 val parsed llmClient.parse(text, 提取其中的日期) return parseToDate(parsed) }上面这个例子展示了一个Action的基本结构。注意Input和Output注解框架靠它们来做类型匹配。实际写的时候你还可以加Condition注解来声明前置条件。3.2 Goal的声明与分解策略Goal是你想要达成的目标。Embabel支持Goal的层级分解一个大Goal可以拆成若干子Goal子Goal还可以继续拆。框架会自底向上地尝试满足这些Goal。分解策略有两种静态分解和动态分解。静态分解是你在代码里写死的比如“完成订单”这个Goal固定拆成“验证库存”、“扣减库存”、“生成订单”三个子Goal。动态分解是根据运行时情况决定的比如“处理用户请求”这个Goal根据请求类型不同拆成不同的子Goal。我建议大部分场景用静态分解因为可预测、好调试。只有在确实需要根据运行时数据决定拆解方式的时候才用动态分解。动态分解虽然灵活但会让流程变得难以追踪用多了就回到了“LLM自主决策”的老路上。提示Goal的命名要清晰用“动词名词”的形式比如“ExtractDateFromText”、“ValidateUserInput”。这样在日志里一眼就能看出当前在做什么。3.3 Blackboard的状态读写与并发控制Blackboard是整个系统的数据中心。所有Action的输入从Blackboard读输出往Blackboard写。Embabel的Blackboard支持类型化存储你可以按类型查询当前有哪些数据。并发控制是个容易被忽略的点。如果你的Agent需要并行执行多个Action那Blackboard的读写就需要加锁。Embabel提供了几种并发策略悲观锁、乐观锁、无锁适用于只读场景。我实测下来大部分场景用乐观锁就够了冲突概率低性能也好。// Blackboard读写示例Java Blackboard board new Blackboard(); board.put(userInput, 2024年3月15日要开会); String input board.get(userInput, String.class); // 按类型查询 ListDate dates board.getAll(Date.class);有个坑要注意Blackboard里的数据是不可变的。你put进去一个对象后续修改这个对象不会反映到Blackboard里必须重新put。这个设计是为了保证状态的一致性但刚开始用的时候容易踩坑以为改了对象就行了。3.4 条件分支与循环的处理方式条件分支在Embabel里通过Action的Condition注解来实现。你可以写一个条件表达式只有满足条件时这个Action才会被匹配。比如Action Condition(text.length 100) fun summarizeLongText(Input text: String, Output summary: String): String { // 长文本摘要逻辑 }循环的处理稍微复杂一点。Embabel没有显式的循环语法而是通过Goal的重复满足来实现。比如你要处理一个列表里的每个元素可以定义一个Goal“处理列表中的所有元素”然后框架会反复匹配Action直到所有元素都处理完。这里的关键是终止条件要明确。如果Goal的满足条件写得模糊框架可能会陷入无限循环。我的做法是给每个循环Goal加一个计数器处理完指定数量就强制结束。3.5 与LLM的集成方式Embabel不绑定任何特定的LLM你可以通过接口适配任何模型。集成方式有两种直接调用和通过Action封装。直接调用就是在Action内部直接调LLM的API适合简单的场景。通过Action封装是把LLM调用本身也做成一个Action这样可以在编排层控制什么时候调LLM、调哪个模型、传什么参数。我推荐后者因为这样LLM的调用也变成了可追踪、可替换的。// LLM调用封装成Action Action fun classifyIntent(Input text: String, Output intent: String): String { String prompt 判断以下文本的意图只返回意图标签\n text; return llmClient.complete(prompt); }集成的时候有个经验给LLM的prompt要尽量结构化。不要让它自由发挥而是明确告诉它输出格式。比如“只返回JSON”、“只返回一个词”、“按以下格式输出”。这样即使LLM有波动输出也在可控范围内。4. 完整实操流程与关键环节4.1 环境准备与项目初始化Embabel是基于JVM的所以你需要JDK 17以上。构建工具用Gradle或Maven都行我习惯用Gradle配置起来简洁一些。# 创建项目目录 mkdir embabel-demo cd embabel-demo # 初始化Gradle项目 gradle init --type kotlin-application然后在build.gradle.kts里加上Embabel的依赖。具体的版本号建议去官方仓库查最新的我这里写个示意dependencies { implementation(com.embabel:embabel-core:0.x.x) implementation(com.embabel:embabel-llm:0.x.x) // 其他依赖 }初始化完成后你会得到一个基本的项目结构。核心的类有三个Agent入口、Blackboard状态、Action执行单元。我建议先跑一个最小的demo确认环境没问题再往上加东西。4.2 定义第一个Goal和Action假设我们要做一个“从用户输入中提取日期并格式化”的小Agent。第一步是定义Goalclass ExtractDateGoal : Goal { override fun getName() ExtractDateFromText override fun getDescription() 从文本中提取日期并格式化为yyyy-MM-dd }然后定义Action来实现这个GoalAction class DateExtractor { EmbabelAction fun extract( Input text: String, Output formattedDate: String ): String { // 先用正则做初步提取 val regex Regex(\\d{4}年\\d{1,2}月\\d{1,2}日) val match regex.find(text) if (match ! null) { // 正则匹配到了直接格式化 return formatDate(match.value) } // 正则没匹配到调LLM做语义理解 val llmResult llmClient.complete( 从以下文本中提取日期格式为yyyy-MM-dd只返回日期\n$text ) return llmResult.trim() } private fun formatDate(raw: String): String { // 把2024年3月15日转成2024-03-15 val parts raw.replace(年, -).replace(月, -).replace(日, ).split(-) return %s-%02d-%02d.format(parts[0], parts[1].toInt(), parts[2].toInt()) } }这个例子里有个重要的设计决策先用确定性方法正则失败了再调LLM。这样大部分常见格式都能用正则搞定只有特殊情况才走LLM既快又省token。这个模式在实际项目里非常实用我后面还会展开讲。4.3 编排多个Action完成复杂任务单个Action太简单了实际任务往往需要多个Action协作。假设我们要做一个“会议安排”Agent流程是提取日期 → 提取参会人 → 检查日历冲突 → 生成会议邀请。class MeetingScheduler { EmbabelAction fun extractDate(Input text: String, Output date: String): String { // 提取日期逻辑 } EmbabelAction Condition(date ! null) fun extractAttendees(Input text: String, Output attendees: ListString): ListString { // 提取参会人逻辑 } EmbabelAction Condition(date ! null attendees ! null) fun checkConflict( Input date: String, Input attendees: ListString, Output hasConflict: Boolean ): Boolean { // 检查日历冲突 } EmbabelAction Condition(hasConflict false) fun generateInvite( Input date: String, Input attendees: ListString, Output invite: String ): String { // 生成邀请文本 } }注意每个Action的Condition注解它们定义了执行的前置条件。框架会根据Blackboard上的数据自动判断哪些Action可以执行。这个例子里如果date还没提取出来extractAttendees就不会被执行。编排的时候有个技巧把耗时的操作往后放。比如检查日历冲突需要调外部API那就等所有本地提取都完成了再调。这样如果前面的提取失败了就不会浪费一次API调用。4.4 参数计算与选择过程Embabel里有些参数是需要计算的比如超时时间、重试次数、并发度。这些参数没有固定值需要根据实际情况算。以超时时间为例。假设你的Agent要调一个外部APIAPI的P99延迟是2秒你希望99.9%的请求都能在超时前完成。那超时时间应该设多少根据经验超时时间设为P99的3倍比较合适也就是6秒。这样既能覆盖绝大多数情况又不会让失败请求等太久。重试次数也是类似。如果单次成功率是90%你希望整体成功率达到99.9%那需要重试几次算一下1 - (1-0.9)^n 0.999解得n 3。所以重试3次就够了。当然实际还要考虑重试的成本和幂等性不能无脑重试。并发度取决于你的资源限制。如果是调外部API看API的QPS限制如果是本地计算看CPU核数。我一般设成核数的1.5倍既能充分利用资源又不会因为上下文切换太频繁而降低效率。4.5 实操现场记录一个完整的运行过程让我记录一次完整的运行过程你能看到Blackboard上数据的变化。输入文本“下周三下午3点和张三、李四开会讨论Q2规划”第一步extractDate执行。它先用正则找日期没找到因为是“下周三”这种相对日期于是调LLM。LLM返回“2024-03-20”。Blackboard上现在有date 2024-03-20。第二步extractAttendees执行。它的条件是date ! null满足。调LLM提取人名返回[张三, 李四]。Blackboard上现在有date和attendees。第三步checkConflict执行。条件是date ! null attendees ! null满足。调日历API检查返回false没冲突。Blackboard上多了hasConflict false。第四步generateInvite执行。条件是hasConflict false满足。生成邀请文本返回“会议邀请2024-03-20 15:00参会人张三、李四议题Q2规划”。Blackboard上多了invite。整个流程走完所有Goal都满足了。你可以在日志里看到每一步的输入输出出了问题一目了然。5. 常见问题与排查技巧实录5.1 Action匹配失败怎么办这是最常见的问题。你定义了一个Action但框架就是不执行它。排查思路如下首先看类型是否匹配。Action声明的输入类型和Blackboard上的数据类型是否一致比如Action要ListString但Blackboard上是String[]那就匹配不上。这种问题在Java里尤其常见因为泛型擦除。其次看条件是否满足。Condition里的表达式是否返回true有时候表达式写错了比如用了而不是或者引用了不存在的变量都会导致条件永远为false。最后看Goal是否声明。Action要满足的Goal是否在Agent里注册了没注册的Goal框架不会去尝试满足。我整理了一个排查清单排查项检查方法常见错误类型匹配打印Blackboard上的数据类型泛型擦除导致类型不匹配条件满足在条件表达式里加日志表达式语法错误Goal注册检查Agent的Goal列表忘记注册或拼写错误Action可见性检查Action是否被扫描到包路径不对5.2 LLM输出不稳定导致流程异常即使你把决策权收回来了LLM在执行层还是可能出幺蛾子。比如你让它返回JSON它返回了一段带markdown标记的文本你让它返回一个词它返回了一句话。应对策略有三层。第一层是prompt约束明确告诉它输出格式并给出示例。第二层是输出解析用正则或JSON parser去提取需要的内容提取失败就重试。第三层是降级处理如果重试几次还是不行就走一个默认路径保证流程不中断。// 带重试和降级的LLM调用 public String callWithFallback(String prompt, String defaultValue) { for (int i 0; i 3; i) { try { String result llmClient.complete(prompt); String parsed parseOutput(result); if (parsed ! null) return parsed; } catch (Exception e) { log.warn(LLM调用失败第{}次重试, i 1); } } return defaultValue; }提示降级处理的默认值要选一个“安全”的值即使不对也不会造成严重后果。比如分类任务默认归到“其他”类而不是随便归到某一类。5.3 循环Goal不终止的排查循环Goal不终止通常是因为终止条件没写对。Embabel的循环是通过重复满足Goal实现的如果Goal的满足条件一直为true就会无限循环。排查方法在Goal的满足条件里加日志看每次判断的结果。如果一直是true说明条件写错了。常见错误是用了!而不是或者引用了会被Action修改的变量。另一个原因是Action产生了新的数据导致Goal又被满足。比如你有一个Goal“处理所有未处理的项”Action处理完一项后把它标记为已处理但如果标记逻辑有问题项还是未处理状态就会一直循环。我的做法是给循环加一个最大迭代次数超过就强制退出并报警。这样即使条件写错了也不会把系统跑死。5.4 性能瓶颈定位与优化Agent系统跑得慢原因可能有很多。我一般按这个顺序排查先看LLM调用。LLM调用通常是最大的耗时来源。用日志记录每次LLM调用的耗时如果占比超过50%就要考虑优化。优化方法包括用更小的模型、减少prompt长度、加缓存、把能确定性处理的逻辑从LLM里拿出来。再看外部API调用。如果Agent要调外部服务网络延迟可能成为瓶颈。优化方法包括并发调用、加超时、加缓存。最后看框架本身的开销。Embabel的Action匹配和Blackboard读写也有开销但通常不大。如果前面都优化了还是慢可以用profiler看一下热点在哪里。我实测过一个案例一个Agent跑一次要8秒其中LLM调用占了6秒外部API占了1.5秒框架开销0.5秒。把LLM调用从GPT-4换成更小的模型后降到3秒效果还能接受。5.5 独家避坑技巧汇总踩了这么多坑我总结了几条“血泪经验”第一条Action的输入输出尽量用简单类型。不要传复杂的对象用String、Integer、List这些基本类型。复杂对象在序列化和类型匹配时容易出问题。第二条Blackboard的key要有命名规范。我用的是“模块名.字段名”的格式比如“meeting.date”、“user.name”。这样在日志里一眼就能看出数据属于哪个模块。第三条每个Action都要有超时。不管是LLM调用还是外部API都要设超时。没有超时的Action就是一颗定时炸弹迟早会把整个流程卡死。第四条日志要记录Blackboard的快照。每个Action执行前后都打印一下Blackboard的内容出了问题可以精确回放。这个对调试帮助极大虽然日志量大一点但值得。第五条先写测试再写Action。Action的逻辑相对独立很适合单元测试。我习惯先写测试用例定义好输入输出再写Action实现。这样能保证每个Action的行为符合预期。6. 进阶玩法与扩展思路6.1 多Agent协作的编排方式单个Agent搞不定的时候可以考虑多Agent协作。Embabel支持多个Agent共享一个Blackboard每个Agent负责一部分Goal。这样你可以把复杂的任务拆给不同的Agent每个Agent内部还是确定性的编排。协作方式有两种串行和并行。串行就是Agent A做完传给Agent B适合有依赖关系的任务。并行就是多个Agent同时跑最后汇总结果适合独立子任务。我做过一个文档处理的例子Agent A负责提取文本Agent B负责分类Agent C负责摘要。三个Agent并行跑最后汇总。比单个Agent串行处理快了将近一倍。6.2 动态调整编排策略虽然Embabel强调确定性但也不是完全不能动态调整。你可以在运行时根据某些条件动态地启用或禁用某些Action或者调整Goal的优先级。比如一个客服Agent平时走标准流程但如果检测到用户情绪激动就切换到“优先安抚”的流程。这种动态调整是在编排层做的不是让LLM决定所以还是可控的。实现方式是通过Condition注解引用运行时状态。比如Condition(userEmotion angry)只有情绪是angry时才走这个分支。6.3 与现有系统的集成Embabel不是孤立的它需要和现有系统集成。常见的集成点有数据库、消息队列、外部API、监控系统。集成的时候要注意幂等性。Agent可能会重试如果集成点不是幂等的重试就会产生重复数据。我的做法是给每个操作生成一个唯一ID集成点根据ID去重。监控也很重要。Agent的每一步都要打点记录耗时、成功率、错误类型。这样出了问题能快速定位也能做容量规划。6.4 从Embabel学到的通用架构原则用了Embabel一段时间后我发现它背后的几个原则是通用的不只适用于这个框架原则一把不确定性限制在最小范围。不要让LLM做决策只让它做它擅长的事。决策交给确定性代码。原则二状态要显式。不要藏在上下文里要放在一个所有组件都能访问的地方。这样可调试、可恢复、可审计。原则三编排和执行分离。编排层管流程执行层管具体操作。两层各自独立演化互不影响。原则四每个单元都要可测试。Action是独立的可以单独测试。编排逻辑也是独立的可以单独测试。这样整个系统的质量就有保障。这几个原则我在其他项目里也用过效果都很好。Embabel只是把它们具象化了你可以把这些原则应用到任何Agent系统里不管用什么框架。最后分享一个我个人的体会把决策权从LLM手里拿回来一开始会觉得“限制了LLM的能力”但用久了你会发现这反而让LLM的能力发挥得更充分。因为它不用再操心“下一步该干什么”可以专注于“这一步怎么干好”。分工明确之后整个系统的稳定性和可维护性都会有质的提升。这个思路我觉得值得每个做Agent的人都试试。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑