1. 为什么要把决策权从LLM手里拿回来
先说一个我实际踩过的坑。之前在某公司的智能客服项目里,我们做了一个Agent,核心逻辑特别简单:用户问一句,LLM答一句,中间如果涉及查订单、退换货,就让LLM自己判断要不要调工具。上线第一周看着挺美,第二周就开始出幺蛾子。用户说“我不想要了”,LLM直接调了退货接口;用户问“你们几点关门”,LLM调了订单查询接口。最离谱的一次,LLM在上下文里看到用户之前提过“想投诉”,居然自作主张调了工单创建接口,把一条还没发生的投诉提前录进去了。
后来我把那段时间的调用日志拉出来做了个统计,差不多有12%的工具调用是LLM自己“加戏”加出来的。这些调用里有一大半不但没有帮用户解决问题,反而把状态搞乱了。你可以想象一下,一个Agent在无人值守的情况下,做出一个不在预期内的动作,这个动作还带了真实的业务副作用——这在金融、电商、医疗这些行业里,代价根本不是多花几毛钱token的问题。
我们当然可以靠提示词压制幻觉,比如在system prompt里反复写“不要擅自调用工具”“必须确认用户意图”。但说实话,只要让LLM直接拥有工具调用的决定权,这类问题就是压不干净的。LLM的本质是概率生成,它不是规则引擎,它不保证每一次输出都落在你画的边界里。所以我在做完那个项目之后,逐渐形成了一个明确的主张:LLM负责理解和表达,规则系统负责决策和兜底。这也是“Embabel”这个模式最核心的思想——把Agent的决策权从LLM手里拿回来,放到一个可审计、可测试、可预期的决策层里。
这篇文章就是围绕这个主题做的学习笔记。我会从设计思路、核心数据结构、实操流程、典型问题几个方面,把“决策权如何拿回来”这件事讲透。适合正在做Agent落地、被LLM不可控行为折腾过的开发者参考。
1.1 LLM直接决策的三个致命问题
先不急着说Embabel怎么设计,我们得把问题本身聊透。LLM直接做决策,至少有三个层面的隐患。
第一个是逻辑不可预期。LLM是一个概率模型,同样的输入,两次调用可能给出不同的动作选择。你可以在测试集上把准确率跑到99%,但线上那1%的样本,恰恰可能是最危险的。规则系统不会这样,同样条件永远触发同样规则,这是确定性带来的安全感。
第二个是权限过度扩张。当LLM直接拥有工具调用权的时候,它的权限边界实际上是由“模型理解”决定的,而不是由“系统配置”决定的。模型今天理解得好,权限就收敛;明天被用户两句误导就理解偏了,权限就发散。权限这种东西,不应该由模型的心情来决定。
第三个是流程不可审计。如果决策发生在模型内部,你只能看到最终调用了哪个工具,却看不到为什么调用。出了问题,你没法回答“这个动作是哪条规则触发的”“当时的上下文是什么状态”。在需要合规审查的场景里,这种黑盒子决策基本过不了关。
这三个问题本质上指向同一个结论:LLM适合做“理解”,不适合做“决定”。理解错了好歹只是答非所问,决定错了就直接产生业务副作用。
拆除LLM的决策权,并不意味着废掉LLM。与之相反,Embabel模式下,LLM依然承担核心的意图识别、实体抽取、话术组织工作,但它不再拥有“要不要调这个接口”的最终决定权。决定权被移交给了决策层,LLM只需要在决策层划定的范围内提建议,最终拍板的是一套确定性规则。
1.2 把决策权拿回来到底是什么含义
这里有一个容易产生误解的点:把决策权拿回来,不是说让LLM“不要乱动”,而是指在系统架构层面显式增加一个决策组件,让动作的最终选择不再隐含在LLM的生成概率里。
打个比方。以前的Agent架构像是请了一个业务员,这个业务员脑子灵活,但有时候会自作主张给你办一些你没让他办的事。Embabel模式则是给这个业务员配了一个“操作规范手册”和一位“合规审核员”。业务员依然负责听懂用户需求(这是LLM擅长的),但他提出的每一个操作,都必须先经过审核员对照手册确认,合规了才能执行。
这个审核员,就是代码。评判标准不是模型的概率,而是显式的条件判断:用户意图是什么、实体的属性是什么、当前会话的阶段是什么、历史状态是什么。所有条件都是确定的、可单元测试的、可控的。
这样一来,Agent的整体行为变成了“LLM建议 + 规则确认”的组合。回应用户话术的部分可以开放给LLM自由发挥,但所有副作用动作,全部走规则闸门。这就是Embabel的模式宣言:模型负责说,规则负责做。
2. Embabel的核心设计:把决策拆成可执行的单元
理解了目标之后,关键问题就是怎么落地。Embabel的设计里,最常见的落地方式是“决策表 + 动作单元”的组合。
整个决策层的核心不是一堆if-else堆出来的面条代码,而是一张结构化的决策表。每条决策规则由四部分组成:触发条件、上下文约束、动作单元、兜底策略。看起来简单,但把这四部分设计好,就能覆盖绝大多数Agent场景。
2.1 决策规则的四要素拆解
先说触发条件。这是决策层最核心的输入,通常由LLM的意图识别结果、实体抽取结果和会话状态三者共同构成。比如“退货退款”意图检测到,并且实体里含有订单号,这时候就触发退货退款相关的决策分支。触发条件写的越具体,决策碰撞越少。
再看上下文约束。这是容易被忽略的一层。同样一个意图,在不同的上下文状态下,应该执行的动作是不同的。比如用户说“我要退款”,如果当前订单状态是“待发货”,可以直接退款;如果状态是“已发货”,得先拦截物流;如果状态是“已完成”,就要走售后流程。上下文约束就是把这些状态条件加进去,让同一条规则在不同状态下走不同的动作分支。
动作单元是决策的产出。一个动作单元可以是调工具、改状态、发消息,也可以是“询问用户二次确认”。比如动作单元“创建退货单”的前置动作是“向用户确认退货原因”,这就构成了一个多步骤的原子操作组合。动作单元设计得越小,复用性越高。我们把动作粒度控制在“一个动作只做一件事”的程度,整体维护成本反而最低。
兜底策略则解决的是规则覆盖不全的问题。当没有任何一条规则匹配当前条件时,系统必须有一个默认行为。Embabel推荐的兜底是“只说不做”:只生成话术回复用户,不执行任何副作用动作。宁可让用户多等一轮,也不能做出一个无法撤回的动作。
这套结构与传统的状态机相比多了一层“语义输入”,与直接调LLM相比又多了一层“确定性闸门”。它卡在二者之间,既发挥了大模型的语义能力,又保留了规则系统的可控性。
2.2 决策与执行分离:动作单元是最小执行粒度
再往深一层看,Embabel把决策区分为“决策”和“执行”两个阶段。
决策阶段发生在规则引擎里,产出的是一个标准化的决策指令对象。这个对象只声明“要做什么”,不包含“怎么做”的细节。比如决策指令是“为用户创建退货单,关联参数order_id=12345”,这就够了。至于创建退货单要调哪个接口、传什么参数、做哪些校验,那是执行阶段的事。
执行阶段由动作单元负责。每个动作单元是一个独立的函数,输入是标准化的决策指令,输出是执行结果。这个函数内部可以调用外部API、查询数据库、更新缓存,甚至调用另一个Agent。但动作单元不自己做决策,它只负责执行。
这样的拆分带来了一个很大的好处:决策逻辑可以整体进行单元测试。我们可以在测试环境里构造一堆“意图+上下文状态”的组合,断言每一种组合下产生的决策指令是否符合预期。这一步做完,Agent的行为边界基本上就被锁死了,较之直接裸调LLM的方式,测试思路清晰了非常多。
我有一次在测试决策表的时候,用脚本自动遍历了700多种意图与状态的组合,一次跑完大概20秒,发现了30多个规则冲突。这种问题如果放到线上,每一个都可能变成一次事故。决策与执行分离之后,这类隐患排查变得非常简单。
3. 实操:从零搭一个Embabel决策层
接下来是干货部分。我用自己的一个内部模拟项目,完整演示一遍Embabel决策层的搭建过程。这个项目代号就叫“某订单助手Agent”,目标是让用户通过自然语言完成订单查询、退换货申请、物流追踪三个核心流程。需求很简单,但要把“LLM不直接决策”落实到代码层面,涉及的结构设计并不少。
3.1 准备阶段:清单与边界定义
动手写代码之前,先做两件事:盘点动作清单、定义边界。
动作清单是把Agent所有可能的副作用动作列出来,要求“不漏一项”。在订单助手这个项目里,动作清单是:
| 动作ID | 动作名称 | 副作用等级 | 所需参数 |
|---|---|---|---|
| ACT_QUERY_ORDER | 查询订单 | 无副作用 | order_id |
| ACT_QUERY_LOGISTICS | 查询物流 | 无副作用 | order_id |
| ACT_CREATE_RETURN | 创建退货单 | 有副作用 | order_id, reason |
| ACT_UPDATE_ADDRESS | 修改收货地址 | 有副作用 | order_id, new_address |
| ACT_CANCEL_ORDER | 取消订单 | 有副作用 | order_id, reason |
| ACT_SEND_MESSAGE | 发送回复 | 无副作用 | content |
定义边界是明确哪些动作LLM可以“建议”,哪些动作LLM连“建议”都不能提。在这个项目里,我的边界定义是:查询类动作允许LLM建议,但任何写操作(创建退货单、修改地址、取消订单)LLM只能输出结构化的“用户意图”,不能直接输出动作。动作由决策层根据意图和上下文决定。
这一步看上去简单,但大部分翻车案例都源于边界定义模糊。你可以把边界定义语句直接当作需求文档的一部分,评审通过再动工。
3.2 关键实现:把LLM输出变成可决策的结构化数据
边界定义完之后,下一步就是让LLM的输出“结构化”。Embabel模式里,LLM输出不再是随心所欲的文本,而是遵循一个限定协议的内容。订单助手的LLM输出协议长这样:
{ "intent": "create_return", // 枚举: query_order / query_logistics / create_return / update_address / cancel_order / chitchat "entities": { "order_id": "20250115AB12", "reason": "颜色不喜欢", "new_address": "某市某区某街道1号" }, "suggestion_action": "ACT_QUERY_ORDER", // LLM建议的动作,仅参考 "confidence": 0.92 }在提示词里,我们会要求LLM严格输出这个JSON结构。但注意,这里的suggestion_action字段我不会拿来直接执行,它只是备用参考。真正执行的动作,是决策层根据intent和entities自己算出来的。
为了让LLM稳定输出这种结构,有两件事必须做。第一是在提示词里给一个完整的输出示例,而不是只给字段描述。第二是在系统层面加一层JSON Schema校验,输出格式不对直接丢弃并提示模型重新生成。这两步做完,LLM输出的结构化成功率能稳定在98%以上。
3.3 决策表实现:规则描述的编写方法
有了结构化输出,就可以写决策表了。决策表推荐用JSON配置管理,便于版本控制和线上热更新。订单助手的核心决策表如下:
{ "rules": [ { "id": "R001", "name": "正常查询订单", "when": { "intent": "query_order", "order_exists": true }, "action": "ACT_QUERY_ORDER", "fallback": "ASK_ORDER_ID" }, { "id": "R002", "name": "订单查询但参数缺失", "when": { "intent": "query_order", "order_exists": false }, "action": "ACT_SEND_MESSAGE", "params": {"content": "请提供完整的订单编号"} }, { "id": "R003", "name": "创建退货单-待发货状态", "when": { "intent": "create_return", "order_status": "pending_shipment" }, "action": "ACT_CREATE_RETURN", "confirm": true }, { "id": "R004", "name": "创建退货单-已发货状态", "when": { "intent": "create_return", "order_status": "shipped" }, "action": "ACT_SEND_MESSAGE", "params": {"content": "您的订单已发出,如需退货请先拒收或联系人工客服处理"} }, { "id": "R005", "name": "创建退货单-已完成状态", "when": { "intent": "create_return", "order_status": "completed" }, "action": "ACT_CREATE_RETURN", "params": {"return_window": "post_sale"}, "confirm": true }, { "id": "R999", "name": "默认兜底", "when": {"intent": "chitchat"}, "action": "ACT_SEND_MESSAGE", "params": {"content": "闲聊兜底回复"} } ] }你可能已经注意到几个关键点。
第一个是规则里使用了order_exists、order_status这类上下文状态,这些状态不是LLM输出的,而是决策执行器从订单系统实时查询得到的。决策的输入必须是真实世界状态,而不是模型猜测的状态,这是核心原则。LLM说“订单状态正常”不算数,决策层自己查一次数据库才算数。
第二个是confirm字段。凡是带副作用且不可逆的动作(比如创建退货单),在决策表里直接标记需要二次确认。决策层在执行动作之前,先向用户发送确认话术,等用户确认后再动手。这一步虽然多一次交互,但能挡住大量误操作。
第三个是决策表的匹配顺序。规则引擎从上到下顺序匹配,命中了第一条就停住。所以在配置决策表时,越具体的规则越靠前,越泛化的规则越靠后,兜底规则永远最后。
这个配置的决策逻辑已经可以覆盖订单助手的主要场景。你要做的就是把这张表加载进一个规则引擎里执行。
3.4 动作注册与执行链路打通
决策表跑通了,还需要把动作单元注册进执行器里。动作注册器是一个映射结构,把动作ID映射到具体执行函数:
def init_action_registry(): registry = {} registry["ACT_QUERY_ORDER"] = query_order_action registry["ACT_QUERY_LOGISTICS"] = query_logistics_action registry["ACT_CREATE_RETURN"] = create_return_action registry["ACT_UPDATE_ADDRESS"] = update_address_action registry["ACT_CANCEL_ORDER"] = cancel_order_action registry["ACT_SEND_MESSAGE"] = send_message_action return registry每个动作函数有统一的签名:输入是决策指令对象和上下文,输出是执行结果。比如创建退货单的动作函数,内部逻辑包括校验订单状态、生成退货单号、通知仓储系统、返回确认信息。这些执行细节全部封装在函数内部,外部不感知。
完整链路跑下来就是这样:用户输入 -> LLM识别意图和实体 -> 输出结构化JSON -> 决策引擎查询真实状态 -> 匹配决策表 -> 生成决策指令(必要时先发确认消息)-> 调用动作执行函数 -> 把执行结果返回给LLM -> LLM组织最终话术回复用户。整个过程中,LLM参与了两处:开头理解用户,结尾组织话术。中间的所有决策判断,都在规则引擎里完成。
我在实际项目中就用这个链路跑通了一个演示Demo,从定义边界到全部打通,大概花了两个下午的时间。如果你熟悉JSON和Python基础,这个流程完全可以复刻。
4. 常见问题与排查技巧实录
Embabel落地过程中,会有一些典型问题。我把我在实践中碰到的高频问题整理成了一份速查表,按排查顺序排列,方便你出问题时对照定位。
4.1 决策层常见问题速查表
| 现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 用户问什么都走兜底规则 | LLM意图识别不准,输出intent不在决策表内 | 查看LLM原始输出,确认intent字段值是否合法 | 补充提示词示例,或在解析层增加非法值归一化映射 |
| 同一对话场景下动作时而执行时而不执行 | 上下文状态查询存在缓存延时 | 检查状态获取链路是否使用了短时缓存 | 副作用动作统一在决策前实时查库,不走缓存 |
| 决策规则冲突,多个规则同时命中 | 规则表中EC条件重叠,顺序不对 | 打印命中规则列表,逐条检查匹配条件 | 将具体规则前移,或合并重叠条件 |
| 二次确认消息发了,但用户回复后动作没执行 | 缺少确认响应的监听状态 | 检查决策层是否维护“等待确认”状态 | 增加会话状态持久化,确认响应单独走一个决策分支 |
| LLM输出合法JSON但内容和用户意图相反 | 提示词中没有强调“以用户原话为准” | 检查提示词示例是否产生了诱导 | 弱化示例中的意图归纳,强化对用户原话的引用 |
| 规则表一变更线上就开始出问题 | 缺少灰度发布机制 | 检查规则加载是否支持版本切换 | 决策表增加版本号,先切小流量灰度验证再全量 |
4.2 规则冲突的排查与设计规避
规则冲突是Embabel模式里最隐蔽的坑。第一次做决策表时,我写了大概40条规则,自认为覆盖得挺全,结果线上跑了一阵子,发现用户问“订单什么时候到”,有时候走物流查询,有时候走订单查询,业务上这两种结果细微之差别足以让用户困惑。
排查下来才发现,原因是有两条规则的触发条件出现了交集:一条是intent=query_logistics就查物流,另一条是intent=query_order并且order_status=shipped就查物流。当用户说“订单什么时候到”时,LLM识别出来的意图是query_order,但此时订单状态恰好是已发货,于是第二条规则也命中了——它把查物流这个动作给“抢跑”了。
解决方式有两个层面。第一是规则设计层面,规则之间不要出现“一个动作对外表现为另一种动作”这种隐含关系。同一种行为意图,只允许在一条规则里定义。如果你发现两条规则指向同一个动作,说明你的规则语义划分出了问题,应该通过合并条件来消除歧义而不是任其共存。
第二是工程层面,在规则引擎里增加冲突检测。具体做法是:加载规则表时,自动扫描所有规则对的触发条件组合。如果存在一组输入状态,使得两条规则同时命中,就把这组状态打印出来并警告。这一步做起来并不难,本质就是枚举所有intent与状态字段的组合,跑一遍规则匹配器,看有没有重复命中。我在订单助手里加了这个检测之后,后续新增规则都安心了很多。
4.3 无法算是LLM的责任:兜底时最容易犯的错
还有一类问题,严格来说不是Embabel的锅,但确实会影响使用者对Embabel的判断——就是兜底行为设计得太“干巴巴”。很多开发者把兜底策略设置为“只说不做”,结果用户随便说一句“不知道”,Agent就只回复一句“我没听明白”。这种体验是很差的。
改进方式是在兜底规则里适当引入LLM的自由回复能力。也就是说,兜底规则命中的时候,不直接输出预设话术,而是让LLM以“澄清用户意图”为目的生成一句话,并且这句话不会触发任何副作用动作。这相当于给“只说不做”戴上了一副更会说话的嘴。决策权依然不在LLM手上,但表达权还是给了它。
这一步优化做完,整体体验提升非常明显。用户不会觉得Agent像个死板的机器人,但同时系统层面的安全性一点没有降低。
5. 个人实践中的几点体会
最后说几句我在实际项目中反复验证过的经验。
第一,Embabel模式不是银弹,它解决的是“动作决策过于随机”的问题,不会解决“意图识别不准”的问题。如果你的LLM本身意图识别能力太差,决策表再完整也没用。所以我一般会花时间调提示词,确保意图识别准确率稳定在90%以上,再考虑引入Embabel。顺序反了,后面的功夫都是白搭。
第二,决策表的维护成本比你想象的低得多。很多人一听到“规则引擎”就联想到几千条if-else的维护噩梦,但Embabel的做法不一样:规则数量通常控制在30到80条之间,粒度是“状态+意图”而不是“具体话术”。而且每条规则就一行JSON,有版本管理,有冲突检测,改动之后放一小部分流量灰度跑几天,风险是可控的。
第三,决策阶段和执行阶段的日志一定要分开看。每次请求都沉淀一条决策日志,包含意图、状态、命中规则、决策指令;再沉淀一条执行日志,包含具体的API调用和返回结果。线上出问题的时候,先看决策日志,确认决策对了,再去看执行日志。这个习惯救过我很多次。
第四,二次确认机制是Embabel模式下最划算的投资。它虽然让流程多了一轮交互,但从业务安全角度来说,几乎零成本地消除了绝大多数不可逆操作的风险。涉及资金、订单、合同这一类场景,我建议哪怕流程繁琐一点也一定要加。
再分享一个小的实用技巧。在决策表里,我习惯给每个动作单元加上“预检函数”。创建退货单之前先查一下订单是否超过售后周期,修改地址之前先确认订单是否已发货。这些预检不写在决策条件里,而写在动作单元内部,作为最后一层防线。决策条件管“这个动作该不该做”,预检函数管“这个动作现在能不能做”。两者配合,安全性比单靠决策表高出一大截。
Embabel这个模式我目前已经在两个真实场景里落地了,回头再看当时被LLM自由决策坑得灰头土脸的日子,最大的感触是:大模型时代并不意味着把一切都交给概率。越是靠近业务副作用的地方,越需要确定性的控制。LLM负责聪明,规则负责可靠,各司其职,Agent才能真正从“好玩的Demo”变成“敢上线的系统”。