1. 为什么我们急着把决策权从 LLM 手里收回来
先说结论:我最近大半个月都在折腾一套叫 Embabel 的思路——把 Agent 的决策权从 LLM 手里拿回来。听起来有点反直觉,毕竟现在大家都在堆模型、喂提示词、让大模型“自由发挥”,怎么反而要收权?但如果你真的把一个有实际业务压力的 Agent 放到线上,跑过几轮真实请求,你就会明白我在说什么。
先交代一下背景。之前我做过一个自动化办公助手,场景不复杂:收到邮件 → 读取附件 → 判断类型 → 调用对应工具 → 回执结果。最初的设计非常“LLM 中心化”,也就是所有决策都靠大模型完成:识别邮件类型靠它,决定调用哪个工具靠它,解析工具返回结果也靠它。demo 阶段很惊艳,演示什么都对,可真上了测试环境,问题全来了。
最典型的例子:某工具的返回内容是 JSON 数组,里面有几百条数据,LLM 需要把其中特定的字段提取出来。结果模型偶尔会“自作主张”多过滤几条记录,或者干脆把字段名改了。你问它为什么,它告诉你“我觉得这样更合理”。这在技术演示里是灵活性,在业务流程里就是事故。和同事复盘的时候,一个做过传统工作流引擎的老哥说了句让我印象很深的话:你这不是在做 Agent,你是在赌模型每次心情都好。
后来我开始接触 Embabel,核心思路就一句话:把那些确定性逻辑从 LLM 手里拆出来,交还给代码去执行;LLM 只负责它真正擅长的部分——理解语义、生成内容、处理模糊场景。这个思路不是反对 LLM,而是把 LLM 放回它应该在的位置。
这篇文章不打算讲什么高深理论,就记录一下我这段时间的实践笔记:怎么拆分决策、怎么设计可回退的兜底方案、怎么处理模型“擅自发挥”的问题、以及哪些坑是只有跑过真实场景才会踩到的。适合那些正在用 LLM 做 Agent、并且已经开始觉得“不太稳”的人。
2. 认知先行:LLM 不是不可靠,是不该承担确定性的责任
很多做 Agent 的人有一个潜在假设:既然大模型什么都会,那就把所有判断都交给它,这样最灵活。这个假设在单轮问答里成立,在长链路任务里就是灾难。
2.1 把“决策”拆成两类:语义决策和确定性决策
我先复盘一个自己做过的坏例子。某次需求是做一个“客服工单自动分派”功能:用户发来工单,系统根据内容判断归属组,再分配给对应处理人。我的第一版实现是让 LLM 直接输出一个 JSON,包含“groupId”和“assignee”。看起来没问题,但实际坑在细节里。
模型经常把 groupId 写错、写混淆,或者输出一个“看起来差不多但不存在的 ID”。你说它不聪明吗?不是,它甚至能准确判断出工单语义归属哪个部门。问题是,部门映射关系是一个确定性的配置表,几百个分组 ID 之间的模糊关系不应该靠模型去“回忆”。
后来我改成两步走:LLM 只输出“部门名称”或“工单类别”这种语义化结果,然后由代码查配置表、映射到具体 ID,再走分配逻辑。改完之后准确率从“偶尔出错”直接变成“几乎稳定”,因为模糊到精确的转换由代码保证。
这里就引出一个关键区分:语义决策是“这段话想表达什么”的判断,确定性决策是“符合条件时执行哪个动作”的判断。前者交给 LLM,天经地义;后者交给代码,天经地义。很多 Agent 不稳定的根源,是两者混在了一起。
2.2 关键的“离场”不等于“退场”:LLM 的边界职责
我在上一版的问题里,确实把决策权拿回来了。但有没有可能走另一条路——通过调整 prompt 让模型输出得更规范?可以,我试过。加了一堆 few-shot 示例、JSON Schema 约束、甚至加了“不要编造 ID”的强调。效果有所改善,但致命问题还在:你没办法保证百分百。
凡是确定性决策,就需要百分百的保证。业务系统最怕的就是“99.9% 准确率”,那 0.1% 出错的时候恰恰是最贵的。而 LLM 天然是概率性的,无论你怎么调 prompt,它都不是 CLI 程序,不可能承诺“只要输入 A,必定输出 B”。把这种任务压给它,本质上是用概率工具做确定性工程。
Embabel 的做法是划出边界:LLM 只输出它必须输出的东西。把复杂的任务编排逻辑、工具调用参数映射、结果校验都下沉到代码层。它对模型的要求反而是“少做点”。
2.3 我的参考思路:让 LLM 当顾问,代码当执委会
把决策权“拿回来”之后,整个系统的角色关系会变成这样:
- 理解用户意图、拆分任务 → LLM
- 决定调用哪个工具、用什么参数 → 代码层的路由逻辑(基于规则或映射表)
- 执行工具调用 → 代码
- 对结果做校验、判定是否重试 → 代码
- 对失败情况做兜底,例如换一种问法重新请求 LLM → 编排层
非要用一句话概括:LLM 是顾问,只提建议;代码是执委会,负责拍板。当然,这不意味着代码要写死所有情况,而是让代码充当一个“有明确规则的执行框架”,LLM 的输出只是这个框架的输入之一。
拿那个客服工单分派举例:用户说“我的网络老是断”,模型输出“网络故障”。然后代码拿着“网络故障”去查映射表,发现对应网络组的 ID 是 201,再进行分派。如果模型输出的是“电脑蓝屏”,但映射表里根本没有这一项,代码会主动走“未知分类”流程,而不是直接报错或让模型再猜一次。
这一步做完之后,线上的稳定性肉眼可见地提升了。接下来聊具体的实现细节。
3. 实现落地:从 prompt 工程转向编排体系
这一部分聊聊实际怎么操作。我的实践环境是 TypeScript + Node.js,框架用的某开源 Agent 编排库,模型接口走的是统一的 OpenAI 兼容层。核心不是用什么框架,而是把决策点从“模型输出格式”转移到“代码调度逻辑”。
3.1 第一步:把 LLM 的输出从“指令”改为“信号”
大多数人写 Agent 的 prompt 时,会要求模型“输出你要执行的步骤”,甚至“输出工具的调用参数”。但我的经验是:不要让模型输出指令,让它输出信号。指令是“帮我调用邮箱发送邮件,收件人是 X,内容是 Y”,信号是“该事项的类别是邮件发送,收件人信息如下,需要草拟的内容如下”。
为什么区别重要?因为指令往往隐含了执行路径,而模型一旦指定路径就很容易在细节上出错。它可能把工具名写成它自己瞎编的一个,也可能在参数里塞入格式不正确的字段。信号则只表达语义,不做路径选择。路径选择由代码来完成。
我的 prompt 模板大致这样设计:
你是任务分析器。你的职责是分析用户请求,输出以下 JSON: { "intent": "邮件发送/工单创建/数据查询/未知", "params": { "描述性字段": "提取自用户原话" }, "confidence": 0-1 } 注意:你不需要决定如何执行,只需要判断用户想做什么,并提取相关信息。模型输出后,代码拿intent字段去路由。confidence是给兜底逻辑用的,如果太低,就进入人工确认或二次询问流程。
这算是第一步,把“模型做决定”改成“模型做标记”。实际操作里,模型的输出准确率提升了非常多,因为它的任务简单了很多,不再需要在大段工具列表里挑一个工具名出来。标记错误的情况也会出现,但大多数错误可以通过三层校验拦下来。
3.2 第二步:工具映射与参数校验层
路由层拿到 intent 之后,会查一个工具注册表。这个注册表长这样:
| intent 值 | 工具函数 | 参数映射规则 | 失败处理策略 |
|---|---|---|---|
| 邮件发送 | sendEmail | params.sender → from,params.content → text | 重试一次,再失败返回错误 |
| 工单创建 | createTicket | params.category → categoryId(配置表映射) | 找不到映射时进入未知分类流 |
| 数据查询 | queryDatabase | params.tableName + params.fields → SQL 模板 | 模板不匹配则拒绝执行 |
这个表的重点在于:intent 不是自由文本,而是一个枚举。模型输出的 intent 只能在这个枚举里选。我实测发现,让模型输出“开放文本”会出现很多标准不一的说法,比如“发个邮件”和“mail it”会输出不同结果。但如果你在 prompt 里给出枚举并说明“只能返回其中之一”,稳定性会好很多。
参数校验也交给代码。模型输出 params 中的“描述性字段”后,代码会检查:必填字段是否齐全、格式是否符合预期(比如 email 有没有 @)、值是否在合法范围内。校验不通过的直接挡回去,而不是传给工具执行。
这一步的核心是“信任边界”。工具层不信任模型输出的任何参数,它只信任校验层的结论。哪怕模型输出的参数是“看起来差不多”的,只要校验不通过就是不执行。
3.3 第三步:确定性逻辑全部代码化,这一块没有任何商量余地
真正常踩的坑是:某些开发者把计算、条件分支也交给模型。例如“如果用户等级是 VIP 就分配到 VIP 队列,否则走普通队列”。这个逻辑如果让 LLM 来写,等于让概率模型做精确判断,非常不靠谱。正确做法是让代码读取用户等级,然后 if-else 判断。
Embabel 体系里有一个原则:凡是能写进代码的规则,就不要写进 prompt。体现在项目里就是:路由、权限、优先级、配额、重试策略、超时时间,这些都写在环境配置和代码常量里。LLM 的输入里甚至不应该包含这些信息,免得它“权衡”后做错误决定。
为什么不能包含?我遇到过模型自作聪明地把 “5 秒超时” 改成 “10 秒超时”,只因为用户说“稍微慢一点也没关系”。这在聊天里没问题,在系统里就是超时失控。把超时参数从模型可见范围里移走之后,这个问题直接消失了。
第三部分做完,系统已经有雏形了:LLM 只输出意图信号,路由层做映射,参数校验层把关,确定性逻辑全部代码化。接下来聊真实的运行情况和调试心得。
4. 实操复盘:我如何一步步把决策点迁移出来
4.1 第一版探索结果与瓶颈
先说第一版的运行数据。纯 LLM 决策版本的 Agent 我跑了大概三天测试,做了两百多次请求。看板上的数据是这样的:
- 用户模糊提问时,任务识别成功率约 85%
- 但工具参数正确率只有 60% 左右
- 产生“幻觉工具”的次数:大约 7 次
- 故障恢复时,有 4 次直接执行了错误动作
也就是说,意图判断本身还行,但一旦牵扯到执行层,正确率就掉到没法用的水平。这完全符合预期:人的语言理解能力模型已经很强,但让它准确记住工具参数格式就是强人所难。
第二版把工具调用改成代码映射,参数校验也加进来之后,工具参数正确率提升到 95% 以上。剩下的 5% 主要发生在模型输出语义信息格式不清晰时,比如用户一句话里包含多个意图,模型只输出其中一个。
4.2 三处标准化设计直接降低了出错的频率
我做了三处标准化设计,效果非常直接。
第一处是意图枚举标准化。模型输出必须对应到枚举值,枚举定义是不可中间修改的。若用户输入的内容无法映射到枚举值时,模型输出“unknown”。这个枚举不是后端工程师随便写的,而是把过去项目里的对话日志、工单系统里的历史标签翻出来,统计高频词后整理的,大概十几个核心枚举。
第二处是参数结构扁平化。模型输出 params 时,一律使用平铺的简单字段,禁止嵌套对象。嵌套对象会让校验逻辑复杂化,而且模型输出嵌套对象的出错概率比扁平对象高不少。比如“地址”这个信息,我不让模型输出{"address": {"city": "上海", "street": "xx"}},而是拆成city和street两个平级字段。字段少了,校验规则就能写得更细。
第三处是结果后处理标准化。工具执行完返回值,不是直接丢给用户,而是先过一次“结果摘要器”。这个摘要器的 prompt 规定:只能总结工具返回值,不能新增信息,不能推测原因。有了这个约束,模型在总结时不会胡乱添加“看起来可能”的内容。
这三处做完,系统进入一个比较健康的状态。但还不够,因为现在的方案中,模型如果错误输出意图,整个链路就错了。接下来就是兜底设计和错误容忍。
4.3 拿回决策权后,Agent 的行为变得“老实”且可解释
拿回决策权之后,最直观的变化是 Agent 不再“自作主张”。以前它可能绕开参数校验直接执行,现在每一步都被代码把住:意图必须匹配枚举,参数必须通过校验,工具必须存在于注册表,执行结果必须走摘要器。
这个变化带来的副产品是“可解释性”。当 Agent 出错时,我能从日志里清楚地看到是哪一步没通过,是意图识别错了,还是参数校验没通过,还是路由表里没有对应工具。而纯 LLM 版本的日志就是一个 “大黑盒”:模型自己决定了做什么,你根本无从复盘。
我还做过一个对比实验:同样的 20 条真实指令,分别跑“纯模型决策”和“Embabel 风格决策”,前者有 5 条执行不完全正确,后者只有 1 条不通过校验(但没执行错误操作)。对于那 1 条不通过的,系统返回了“无法识别意图”的提示,用户补充了描述后成功执行。用户体验没有变差,因为模型还在继续配合用户调整输入。
此类经验进一步肯定:不要把执行权交给模型。要让它当“翻译官”,通过代码来做“指挥官”。
5. 常见问题与排查心得:这八条值得背下来
实践过程中我积累了非常多的真实问题,全部整理如下,每个都附上排查步骤,方便直接照做。
| 问题表现 | 根因定位方法 | 解决操作 |
|---|---|---|
| 模型输出意图命中不了枚举 | 查看原始模型输出,对比枚举差异 | 增加同义词映射提示,或增加模糊匹配规则 |
| 参数缺少必填字段 | 校验层日志显示缺哪个字段 | 在该 intent 的 prompt 示例中增加该字段的示例 |
| 工具列表太长,模型选错 | 检查 routing 表的命中率 | 缩小单次可选工具范围,分场景加载不同工具集 |
| 模型输出“LLM 认为”的 ID | 打开 raw response,查看模型原话 | 把 ID 映射全部移到代码层,模型只输出名称 |
| 相同输入不同输出 | 对比多次日志,确认是模型波动 | 增加确定性校验层,对无关条件取固定值 |
| 重试失败的反馈循环 | 检查重试日志,发现模型每次输出不同 | 重试只应发生在参数校验失败场景,不应用于意图判定 |
| 字段改用新格式后老数据报错 | 检查缓存或历史记录 | 清理缓存并做数据迁移 |
| 一次请求输出多个意图 | 检查 prompt 是否要求“必要时候拆任务” | 改为只让模型输出主意图,子任务通过代码拆分 |
5.1 最容易忽略的编排陷阱
这一类问题最隐蔽。很多 Agent 框架强调“自主规划”,但规划过度展开时非常难追踪。有些模型会输出一步、两步、三步…其中任务类型互相嵌套,某些步骤甚至根本不该执行。Embabel 思路下,步骤规划不应该发生在 LLM 输出层,而应该由代码根据主意图来派生子步骤。
例如用户要求“汇总本周销售数据并发邮件给经理”,如果让 LLM 自由输出步骤,它可能输出“读取数据库 → 汇总数据 → 生成报告 → 查经理邮箱 → 发送邮件 → 确认成功”。这里面每一步都有自由度,出错概率是累加的。但如果代码里预设了“销售周报”这个编排流程,那就只有两个环节:数据聚合、邮件发送。每个环节的细节由代码保证,模型只负责提供条件值(比如“本周”这个时间范围)。
我给自己的一个强制约束是:不要让模型决定编排步骤的数量和顺序。模型的输出应该是“叶子值”或者“叶子意图”,不是“流程树”。
5.2 什么时候可以放权:低成本、可逆操作的例外场景
当然,不是说所有决策权都必须收回来。我的建议是区分操作的成本与可逆性。
- 低风险、可逆的操作:例如生成邮件措辞、修改文案语气、给用户一个礼貌回复。这些可以放权给 LLM,因为它做错也无伤大雅。
- 中风险、难回退的操作:例如发数据查询、写内容草稿。可以让 LLM 参与但必须经过代码校验。
- 高风险、不可逆操作:例如真实发送邮件、执行删除、修改数据库、扣款。一律代码强制校验、二次确认、甚至人工审批。
这个分级需要写在项目的显眼位置。有同事曾经为了让邮件发送“更智能”,直接把发送动作也交给模型,结果某次模型把“请帮我发邮件”理解成了“发送测试邮件给全体员工”,差点出大事故。收回权力不是扼杀智能,而是让智能花费在值得的地方。
6. 进一步优化:数据回灌与自适应路由
当系统稳定之后,我开始考虑下一步:能不能让路由表自动更新,而不需要人工维护?这其实就是 Embabel 体系里比较进阶的一个方向。
6.1 日志回放工具:跑一遍历史请求找出可优化的点
做法是这样的:拦截所有请求与响应,统一存日志。日志回放工具可以模拟用户重放请求,检查当前版本的路由表与规则能否正确处理历史上的请求。每次改动 prompt 或代码后,跑一遍回放,对比新旧版本的表现差异。
我跑完一次回放后发现,某条请求在旧版本里意图识别错误,但新版本因为 prompt 示例增加了几个同义词,识别正确。类似的改进累计多了,系统就能保持持续进化的状态。这个机制解决了一个问题:LLM 决策权的迁移不是一锤子买卖,你得有工具来验证是否越迁越好。
6.2 引入“异常拒绝”模式与用户确认机制
还有一点非常重要:某些情况下,代码层判断意图枚举命中不够确定时,不要硬执行,而是主动向用户发起确认。这个设计可以在“用户体验”和“可靠性”之间找到平衡。用户说“帮我搞定它”,这个指代非常模糊,LLM 输出 confidence 只有 0.3。代码层不直接路由,而是返回给用户:“我理解您可能想做 A 或 B,请确认。” 用户点击确认后,再走后续链路。
我原以为引入确认机制会降低体验,但实际使用时发现,由于代码层会自动筛选出高置信度请求,80% 的请求根本不会触发确认,剩下的 20% 用户也愿意确认。反而避免了一堆错误操作后的解释成本。
6.3 用确定性规则“兜底”模型输出的新数据
当模型遇到新的说法、新的意图时,枚举里没有对应项。一种处理方式就是直接返回“无法识别”,但这太笨了。我的做法是:把所有无法识别的 intents 聚合成一个待人工标注池,每天抽时间查看一次,把高频新意图补充到枚举表里。这样路由表会越来越全,模型需要处理的模糊度也会越来越低。
这一步属于长期运维,但非常值得做。模型不是不可替代的,路由表才是。路由表越完整,模型在链路里承担的压力越小,系统越稳定。做 Agent 越到后面我越发现,真正沉淀下来最值钱的东西不是一个个 prompt 技巧,而是这个经过迭代的确定性决策表。
7. 给同样在做 Agent 的人几条掏心窝的建议
最后不写什么“总结”了,就聊几句我个人踩坑后最想对后来者说的话。
第一,把“模型输出 JSON”当成“模型输出建议”来对待。JSON 格式能约束字段形状,但约束不了模型“乱填”的行为。任何直接执行模型输出 JSON 的代码,都是在赌概率。要加校验层。校验层不是为了防模型,而是为了防你预期的所有意外。
第二,Prompt 再强也顶不过路由表。我有段时间沉迷优化 prompt,效果有,但边际效益越来越低。后来把精力放到整理意图枚举、配置映射表、写校验规则上,稳定性是质变。Prompt 可以提升模型的理解准确率,但无法消除风险。路由表加校验才是 Agent 的基石。
第三,不要把“重试”当成万能药。模型出错时,重试可能出现完全不一样的回答。在这个框架里,只有“参数缺失、校验失败”才适合重试;意图判定失败时,重试就是浪费账单并可能带来更大混乱。要给每个重试加上条件和上限。重试让人有种“系统还在努力”的错觉,但它未必是有效的努力。
第四,设计的时候就要想好怎么记录日志。拿回决策权之后,日志几乎是唯一能告诉你“哪一步出了错”的工具。没有日志,你就只能对着模型的原始输出发呆。每条日志至少要包含:模型原始输出、校验结果、路由结果、工具执行结果、耗时、失败原因。
第五,把 Agent 当成一个“能理解模糊指令的 API 网关”,而不是“全能的机器人”。网关的职责是理解指令、路由到右侧后端、校验响应、返回结果。它不创造数据,不修改规则,也不自己决定要不要跳过某个环节。这个类比帮我做了很多设计决策。
Embabel 这套思路实际操作起来并不复杂,难的是克制住“让大模型多干点”的冲动。把决策权从 LLM 手里拿回来,说到底不是在限制 Agent,而是让 Agent 真正变得可靠。我现在做的所有 Agent 项目,默认的第一版架构里几乎不会再出现“模型直接触发工具”这样的设计了。先定义一个稳妥的编排框架,再让模型在框架内部做它最擅长的高质量语义判断——这条路走下来,线上系统的稳定性有了保证,我半夜也不用再被告警短信叫醒了。