☰
告别LLM循环决策:用代码拿回Agent的流程控制权
2026/10/14 7:50:07 网站建设 项目流程

如果你也维护过一个跑在线上的Agent,我想我们有过类似的经历:某个凌晨,系统突然在没人触碰的情况下做出一个离谱决策,然后一脸无辜地把错误结果发给用户。我把这事复盘了好几天,最后发现根因不是prompt写得不够好,也不是模型不够聪明,而是我把“接下来该做什么”这种流程级决策权,交给了整个系统里最不可控的那个组件——LLM本身。

后来我花了两个星期啃Embabel这套思路,一边学一边把之前那个失败的Agent骨架重新搭了一遍。这篇文章不是Embabel的官方文档,而是我自己结合实践整理的学习笔记。核心就一句话:Agent的智能应该体现在理解用户意图上,而不是体现在自己决定下一步干什么上。如果你也被“模型循环决策”坑过,想知道怎么把流程控制权拿回代码手里,这篇笔记应该能给你一条可以直接落地的路径。

1. 一次让人背脊发凉的故障:我把流程控制权交给了“最不可控”的组件

1.1 故障是怎么发生的

去年我们在内部环境测试一个订单客服Agent(代号“某订单客服Agent”,场景细节我做了脱敏处理)。流程本身不复杂:识别用户意图、查询订单、缺订单号时追问、给结果。为了追求“自然对话”,我们把整个流程的决策权都交给了模型——让它自己判断下一步该追问还是该查库,自己判断用户是不是已经不耐烦,自己决定能不能跳过某个环节。

灰度测试中出了这么一件事。一个用户连续三次都没有提供订单号,模型突然在对话里自行判断“用户明显不满意,应该直接退款”,然后生成了一句“我已经为您申请退款”。问题在于,这个Agent根本没有退款权限,它只是生成了一段不存在的承诺发给用户。等到回访的时候,用户一脸错愕:退款在哪?根本没到账啊。

比错误本身更让人难受的是排查过程。我翻了半天日志,只看到模型输出里有一句“根据上下文判断用户情绪低落,为了避免客诉升级,应该优先处理退款”。没有触发条件,没有决策链路,没有版本号。我试着用同样的输入重放,第二次模型却又决定“继续追问订单号”。同一个输入,两个截然不同的决策——那一刻我意识到,这个系统的行为已经没有任何人能解释了。

1.2 复盘时我想明白的一个道理

我把这类问题归因成一句话:LLM的决策本质上是一种概率采样,它不是一块可靠的决策芯片。它像一个联想能力极强、但会顺着上下文越跑越远的对话者——对边界没有概念,对成本没有感知,对错误也没有记忆。

我把LLM做流程决策的硬伤归纳成三个:

  • 行为边界不可枚举。你想用prompt约束模型“不要执行退款”,但它不会像代码那样收到异常——它只会脑补出另一种“善意”行为,比如绕过状态机直接给出一个不存在的结论。提示词只能画一个模糊的圈,圈外的内容它还是会踩进去。

  • 成本和延迟随决策循环膨胀。之前的方案每轮对话平均要串行调用三次模型:一次想“当前该做什么”,一次想“怎么做”,一次组织自然语言回复。每一次都在烧token、堆延迟,而其中大部分“决策”其实用几十行代码就能完成。

  • 状态污染几乎无法根治。Agent的上下文里堆满了历史对话、中间结果、系统提示,模型为了做决策会去翻那些早该归档的字段,翻错是常态。哪怕每轮都做上下文总结压缩,也只是把风险从明处挪到暗处。

看个直观对比:

维度LLM做流程决策确定性代码做流程决策
行为边界模糊,靠提示词“约束”明确,非法路径直接被拒绝
可解释性靠模型输出反推意图查日志和分支条件即可
成本每轮多次调用,token不可控主要在意图识别阶段调用一次
故障恢复改prompt再赌一次改配置或代码,立即可回滚

想通这一点之后,我再也不敢让模型在“该做什么”这件事上随意发挥了。但问题来了:不靠模型发挥,多轮对话的灵活性从哪来?这正是Embabel这套思路给我的答案。

2. 换个视角:决策权不等于“能动脑”,而是要能“担责”

2.1 把“做什么”和“怎么做”拆开

Embabel的核心启发,是把Agent的执行过程拆成两个层次:任务编排层和内容生成层。“做什么”(意图路由、状态转移、流程控制)交给确定性代码;“怎么做”(听懂用户的话、抽取订单号、生成回复话术)交给LLM。

我用一个餐厅的类比来理解这件事。LLM是后厨里手艺最好的师傅,煎炒烹炸样样精通。但这家餐厅能不能点烤鸭、什么时候通知客人换桌、这桌菜按什么顺序上,是由传菜台和领班说了算的。把决策权从LLM手里拿回来,不是不用厨师,而是不让厨师兼职管理整家餐厅——否则客人一句“今天有点急”,厨师可能就把没做完的菜端上桌了。

落实到设计上,需要做到三件事:

  • 模型输出的动作必须是枚举值。枚举之外的行为在代码层不存在,模型没有第二条路可走。
  • 路由表、状态机、前置条件全部落在代码或配置里。每一步的合法性由程序判断,而不是由模型“感觉”。
  • 模型是唯一接触用户原始输入的那一层,也是唯一允许产生自由文本的那一层。它的输出经过代码校验后,才能触发下一步动作。

这套拆法听起来像常识,但真正把它落实到代码里,和嘴上说说完全是两回事。我们之前的系统之所以失控,就是因为“做什么”和“怎么做”全部混在模型的一次次生成里,没有任何一道闸门。

2.2 Embabel给我的三点启发

在学习过程中,我没有逐行抄它的官方配置,因为比起API细节,更值得吸收的是它的设计原则。总结下来有三点:

  • 控制流可视化。每一个决策点都被显式列出来,成为可读、可测、可配置的节点。这比在代码里藏着一堆隐式if逻辑要强得多——至少出了问题你知道往哪看。

  • 失败兜底制度化。模型不响应、超时、低置信度、格式非法,都有固定的默认通道,而不是让模型“硬决定一条路”。代码层永远有最后的兜底方案。

  • 智能与逻辑解耦。模型管语义,代码管逻辑,两者之间的接口是结构化的枚举和JSON,而不是自然语言。自然语言只存在于和用户交互的两端,不参与内部控制流的传递。

你可能会问:这么一拆,Agent会不会变得很死板?答案是:不会。死板的是路由逻辑本身——但路由逻辑本来就应该死板。灵活性应该体现在模型对用户意图的识别和回复的生成上,而不是体现在流程的任意跳变上。

3. 动手改造:我的极简“显式决策”骨架

3.1 第一层:意图识别只负责“听懂”,不负责“决定”

改造的第一步,是给模型一个严格的结构化输出约束。我要求它只返回一个JSON对象,其中intent字段必须是预定义枚举集合中的一个值,params里放抽取出的关键参数。代码只认这个JSON,模型想多说什么都不被允许。

# 示意代码:接入你常用的模型API即可 def call_llm_for_intent(user_text: str) -> dict: messages = [ {"role": "system", "content": ( "你负责从用户输入中识别意图。" "只允许输出如下 JSON 格式:" '{"intent": "query_order|cancel_order|chat", "params": {...}}' "意图不属于以上集合时,输出 chat。" )}, {"role": "user", "content": user_text}, ] # 实际项目中替换为你的模型调用,要求 response_format 为 json_object raw = your_model_fn(messages, response_format={"type": "json_object"}) return json.loads(raw)

关键点在于,模型输出的自由度被压缩到了最小。它不是一个可以自由行动的Agent,而是一个被限定在“识别意图+抽取参数”这个窄接口里的语义解析器。任何不在枚举集合里的意图,在代码层直接不存在。

这样做的理由很简单:意图识别恰恰是LLM最擅长、同时也最难被规则替代的部分。用户可能用一百种方式说“我要查订单”,规则引擎写不了那么全,但模型可以。反过来,识别完成之后“该往哪走”是确定性流程,根本不需要模型再发挥。

3.2 第二层:路由与状态机接管“下一步”

多轮对话的本质,是一个有限状态机。当前状态 + 用户意图,决定了下一步的合法动作。我用枚举定义状态,用一张转移表定义所有合法路径:

from enum import Enum class Intent(str, Enum): QUERY_ORDER = "query_order" CANCEL_ORDER = "cancel_order" CHAT = "chat" class State(str, Enum): IDLE = "idle" COLLECTING_ORDER_ID = "collecting_order_id" CONFIRM_CANCEL = "confirm_cancel" DONE = "done" # 状态转移表:当前状态 -> 用户意图 -> 下一状态 TRANSITIONS = { State.IDLE: { Intent.QUERY_ORDER: State.COLLECTING_ORDER_ID, Intent.CANCEL_ORDER: State.CONFIRM_CANCEL, Intent.CHAT: State.IDLE, # 闲聊不改变状态 }, State.COLLECTING_ORDER_ID: { Intent.QUERY_ORDER: State.COLLECTING_ORDER_ID, Intent.CANCEL_ORDER: State.CONFIRM_CANCEL, }, State.CONFIRM_CANCEL: { Intent.CANCEL_ORDER: State.DONE, Intent.QUERY_ORDER: State.COLLECTING_ORDER_ID, }, }

为什么用状态机而不是一堆if-else?因为状态机的所有合法路径都在这张表里,测试时可以枚举穷尽,出问题时可以拿着“旧状态+意图+新状态”来复现。if-else写多了之后,隐藏在嵌套分支里的逻辑会让代码变成一团乱麻——这恰恰违背了“拿回决策权”的初衷。

我把Agent的主循环压缩成下面这个简化版本:

class Agent: def __init__(self): self.state = State.IDLE self.order_id = None def handle(self, user_text: str) -> str: try: payload = call_llm_for_intent(user_text) intent, params = validate_intent_payload(payload) except Exception: # 决策权在代码里的体现:模型失败时走固定兜底,而非自行发挥 return "抱歉,我刚才没听清,您可以再说一次吗?" next_state = TRANSITIONS[self.state].get(intent) if next_state is None: # 非法路径直接被否决 return f"抱歉,当前状态下不能执行这个操作。" if intent == Intent.QUERY_ORDER: self.order_id = params.get("order_id") or self.order_id if self.order_id is None: self.state = State.COLLECTING_ORDER_ID return "请提供订单号。" self.state = State.DONE return f"正在为您查询订单 {self.order_id}。" if intent == Intent.CHAT: # 闲聊分支不改变状态,交给另一个生成模型处理 return chat_model_reply(user_text) if intent == Intent.CANCEL_ORDER: if self.state == State.CONFIRM_CANCEL and params.get("confirmed"): self.state = State.DONE return "订单已为您取消。" self.state = State.CONFIRM_CANCEL return f"确认要取消订单 {self.order_id or ''} 吗?" self.state = next_state return "好的。"

3.3 一个完整示例:订单查询场景是怎么跑通的

跑一个具体场景你就理解了。

用户第一次说:“我想查一下我的订单。”模型返回{"intent": "query_order", "params": {}}。Agent当前状态是IDLE,查转移表,QUERY_ORDER在IDLE下允许,目标状态是COLLECTING_ORDER_ID。但params里没有order_id,所以Agent追问:“请提供订单号。”

用户第二次说:“订单号是A10086。”模型返回{"intent": "query_order", "params": {"order_id": "A10086"}}。Agent当前状态是COLLECTING_ORDER_ID,转移表里这个状态下QUERY_ORDER仍然允许,但此时order_id已经拿到了,直接进入DONE并返回查询结果。

用户第三次说:“顺便帮我把这个订单取消吧。”模型返回{"intent": "cancel_order"}。但Agent当前状态已经是DONE,转移表里DONE状态没有出边。代码直接回复“当前状态下不能执行这个操作”——而不是让模型自己决定“DONE状态下能不能取消,我说了算”。

整个过程里,模型的自由意志只出现在第一次JSON输出那一瞬间,后面的每一步都是代码在做判断。

实测下来,同一个场景在旧方案里需要四次模型调用(想一轮、做一轮、复核一轮、回复一轮),新方案只需要两次:一次意图识别,一次闲聊或生成类的补充调用。路由的延迟和成本直接归零。

4. 把决策权拿回来之后,哪些事明显变好了

4.1 成本和延迟:一组可以自己算的估算公式

拿回决策权最直接的收益,是模型调用次数大幅下降。我习惯用一个很粗糙的估算公式来量化:

月调用token总数 ≈ 月活跃用户数 × 平均每用户月对话轮数 × 每轮对话模型调用次数 × 每次调用平均token数

以前每轮对话需要3次模型调用(决策、执行、组织回复),每次平均4000 tokens。改造成“意图识别一次、必要时生成一次”之后,平均调用次数降到1.2次,每次平均1500 tokens。

做个量级估算:假设月活10万用户,每用户月均10轮对话。

  • 改造前:100,000 × 10 × 3 × 4,000 = 120亿 tokens
  • 改造后:100,000 × 10 × 1.2 × 1,500 = 18亿 tokens

同样的业务量,token消耗下降了约85%。虽然每家场景不同,数字会有浮动,但趋势是一致的:流程越复杂,原来被“循环决策”吃掉的调用次数越多,拿回决策权后的收益越明显。

延迟方面就更明显了。以前一个多轮任务平均要串行走完三次模型调用,每次至少一两秒,用户感受到的是明显的“一顿一顿”。现在大部分轮次只有一次模型调用,剩下的都是毫秒级的代码路由,体感上流畅很多。

4.2 可测试性和可恢复性:故障定位从“猜”变成“查”

改造之前,我们对Agent做测试主要靠人工对话,因为它每一步的行为取决于模型的“临场发挥”。改造之后,Agent本质上变成了一个普通程序:给定初始状态、用户输入、意图识别结果,输出和状态转移完全确定。

这意味着我可以像测普通代码一样给它写测试用例:

  • 输入“查订单”,断言状态变为COLLECTING_ORDER_ID。
  • 未提供订单号时追问,断言回复包含关键词“订单号”。
  • 在DONE状态收到CANCEL意图,断言返回“当前状态禁止操作”。

回归测试从“人工陪聊”变成“跑断言”,这是质的区别。

故障定位同样如此。旧方案排查要猜“模型当时在想什么”,新方案的日志只需记录三件事:旧状态、意图、新状态。开发人员直接看代码分支就知道问题出在哪。路由规则错了,改配置重新上线,半小时就能解决,完全不需要调prompt碰运气。

4.3 边界意识:把LLM关进“笼子”不是贬义

我一开始担心这套做法会让Agent失去灵活性,后来发现担心是多余的。把LLM关进“笼子”,关掉的是它的权限边界,不是它的生成能力。

在意图识别层,模型仍然要理解千变万化的人类表达;在回复生成层,模型仍然可以用自然、有温度的方式组织语言。只是当涉及到对用户做出承诺、触碰业务状态、变更数据这些有后果的动作时,必须经过程序化授权。

这就好比给模型戴了一副“手套”:它可以在笼子内自由发挥,但碰不到笼子外的任何按钮。这不叫削弱,这叫负责。

5. 哪些决策该拿回来,哪些该留在LLM手里

5.1 一个简单的判断标准:输出是否可枚举

拿回哪些决策权,不是拍脑袋决定的。我给自己定了一套判断标准,每次都问三个问题:

  1. 这个决策的结果是不是有限集合?比如“下一步调用哪个工具”“当前状态允不允许做这件事”“是否该结束会话”,答案都有限,完全可以用枚举和分支穷举。
  2. 这个决策错了能不能追溯?如果错了,你需要一个定位点。代码分支天然可追溯,模型的“灵光一现”无法追溯。
  3. 这个决策是否需要极低延迟、极低成本?流程中的高频决策,哪怕每次省一次模型调用,放大到百万次对话里都是可观收益。

三个问题里命中两条,这个决策就应该从LLM手里拿回来。

5.2 我整理的一份任务分类速查表

决策/任务类型放哪里原因
工具选择(用哪个API/哪个处理器)代码有限集合,错了可追溯
状态跳转(是否继续追问、是否结束会话)代码天然的状态机逻辑
停止条件(什么情况下终止流程)代码需要确定性,防止模型“自作主张”
意图识别、槽位抽取半拿半留LLM产出,代码校验后路由
开放域自由问答留LLM输出无限多样,代码无法穷举
摘要/改写/情感判断留LLM没有唯一正确答案
复杂歧义的意图消解留LLM但约束输出需要上下文推理,但结果仍要用枚举限定

表格右边那三行是LLM真正的舒适区:输出空间巨大、没有唯一标准、无法用规则穷举。强行把这些收编成代码逻辑,只会得到一个无比笨拙的规则引擎。所以“把决策权拿回来”绝对不是“把所有决策都拿回来”。

5.3 折中方案:置信度阈值和人工接管

有时候你会遇到边界情况:意图能枚举,但模型自己也不确定用户到底想干嘛。这时候硬路由反而会放大错误。我加了一道保险丝——让模型在返回意图的同时返回一个置信度分数,低于阈值的输入不进路由,先走澄清对话。

def handle(self, user_text: str) -> str: payload = call_llm_for_intent(user_text) intent, params = validate_intent_payload(payload) # 置信度低于阈值时,不硬路由,先澄清 if float(payload.get("confidence", 0.0)) < 0.7: return "我理解得还不太确定,您是想查订单还是取消订单?" # 进入正常路由逻辑...

这一行代码,比你在prompt里写十句“如果你不确定就问我”都管用。因为“不确定”这件事不再依赖模型自觉,而是变成了一个程序可以判断的数值条件。

如果澄清之后模型置信度仍然很低,说明用户的话可能超出了当前Agent的职责范围。此时应该走人工接管通道,而不是让模型硬着头皮挑一个意图。高层决策经常需要人拍板,这是因为在可枚举的意图之外,还存在个体差异极大的边缘case——这种情况交给真人处理是成本最低的选择。

6. 啃Embabel思路时踩过的坑和我的使用建议

6.1 坑1:把开放对话也强行收编,机器人直接变“人工智障”

我第一次改造时犯了矫枉过正的错误。我把所有输入都强制映射到那几个业务意图上,结果用户说“今天天气不错”也被路由到了QUERY_ORDER,机器人一本正经地回复“请提供订单号”。

那画面真的挺蠢的。后来我在枚举里保留了CHAT作为兜底意图:模型判断不属于任何业务意图时输出chat,路由层不改变状态,直接把输入丢给另一个专门做闲聊的生成模型。业务Agent只负责业务,闲聊交给闲聊——各司其职。

6.2 坑2:状态机粒度过细,维护成本爆炸

第二次栽跟头是把状态机写得太细。我把“收集订单号”“确认用户身份”“确认取消意愿”全拆成独立状态,还加了一堆中间状态,结果跑了几轮之后转移表膨胀到二十多个状态和五十多条边。改一个流程要动十几处配置,同事看着这张表直摇头。

后来我学到的教训是:用粗粒度状态 + 细粒度参数。比如COLLECTING_ORDER_ID状态内部维护一个收集器对象,把订单号、用户名这些槽位都放在参数里收集,而不是为了每一个槽位创建一个状态。状态机只描述“阶段”,不描述“具体字段”。这样转移表稳定在四五个状态以内,维护成本立刻降下来。

6.3 给同样想尝试的朋友的几点建议

如果这篇文章让你产生了改造自己项目的冲动,我的建议是别急着全量重构。先把试点范围缩到一个故障率最高、成本最高的流程上,然后列出它的完整决策点清单。清单里每一行包含四个要素:输入条件、原来由谁决策、改造后由谁决策、失败时的兜底方案。

列完清单之后你会发现,很多步骤的答案一目了然:能枚举的、要追溯的、高频率的,统统收编;开放式的、生成式的,继续留给模型。然后逐个替换,并行灰度,不要一口气重写整个系统。

日志里也建议强制记录路由原因:旧状态、识别到的意图、置信度、新状态。哪怕现在不看,将来排查线上问题时会感谢自己。

最后分享一个我个人的体会:把决策权从LLM手里拿回来,本质上是给Agent立规矩。模型在规矩之内依然可以发挥聪明才智,但规矩本身不能由模型自己说了算。Embabel让我看到的不是一套神乎其神的框架,而是一个朴素的工程取舍——智能用在刀刃上,逻辑落在代码里。这套思路你同样可以用在自己的项目上,值得试一次。

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

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

立即咨询