1. 从一次失控的Agent任务说起
去年冬天,我接手了一个内部知识库问答系统的改造项目。需求本身不复杂:让一个智能体(Agent)根据用户提问,自动决定是去检索文档、调用计算工具,还是直接回答。团队里几个人一开始都挺乐观,觉得无非是写几个提示词、接几个工具函数的事。结果上线第一周就翻车了——同一个问题,早上问和下午问,Agent给出的决策路径完全不同;更离谱的是,有些明显该走检索的查询,它偏偏选择“自信地胡说八道”。
那段时间我反复盯着日志看,慢慢意识到问题的根子不在提示词写得好不好,而在于决策权完全交给了大语言模型。模型每次生成下一步动作,本质上是在做一次概率采样,温度、上下文长度、甚至前后请求的细微差异,都会让它的选择发生漂移。对于一个需要稳定、可审计、可复现的业务系统来说,这种不确定性是致命的。
后来我接触到了Embabel这个框架,它的核心主张一下子就击中了我:把Agent的决策权从LLM手里拿回来。这句话听起来有点抽象,但落到工程上非常具体——LLM不再负责“决定做什么”,而是退回到它最擅长的事情上:理解语义、生成内容、抽取结构化信息。至于“下一步该调用哪个工具”“满足什么条件才继续”“失败了怎么回退”,这些交给显式的、可测试的、确定性的代码逻辑来管。
这篇笔记就是我这段时间学习和实践的整理。我会从设计思路、核心机制、实操落地、踩坑排查几个角度,把Embabel这套“夺回决策权”的做法讲清楚。如果你也在做Agent相关的系统,被LLM的不稳定性折磨过,或者单纯想搞清楚“确定性编排”到底怎么落地,那这篇内容应该能给你一些可以直接抄作业的东西。
2. 为什么要把决策权从LLM手里拿回来
2.1 LLM做决策的三个天然缺陷
先说清楚问题,才能理解方案。LLM作为决策中枢,在工程上有三个绕不开的缺陷。
第一个是不确定性。哪怕你把temperature设成0,由于浮点运算、批处理顺序、上下文拼接方式等因素,同一个输入在不同时刻仍可能得到不同的工具选择。这在演示环境里看不出来,但在生产环境里,一次错误的工具调用可能就意味着一次错误的数据写入。
第二个是不可解释。当Agent选择调用工具A而不是工具B时,你很难从模型那里得到一个可靠的解释。它给出的“理由”往往是事后编造的,和真实的决策依据未必一致。这在需要审计和追责的场景里是硬伤。
第三个是难以测试。传统代码你可以写单元测试,输入X断言输出Y。但LLM的决策路径是概率性的,你没法用断言去覆盖所有分支。结果就是Agent系统的测试覆盖率极低,回归全靠人工点点点。
2.2 确定性编排的核心思想
Embabel的思路,用一句话概括就是:把“做什么”和“怎么说”分开。
“做什么”是控制流,包括状态判断、条件分支、循环、异常处理、工具调用顺序。这部分用普通代码写,可以测试、可以调试、可以版本管理。
“怎么说”是内容生成,包括理解用户意图、抽取参数、生成自然语言回复。这部分交给LLM,发挥它的语义能力。
打个比方,以前的Agent像是一个既当导演又当演员的人,你给他一个剧本大纲,他现场决定怎么演。Embabel的做法是:导演的活交给一个固定的分镜脚本(代码),演员的活交给LLM。演员可以在台词上自由发挥,但走位、出场顺序、什么时候该哭什么时候该笑,全部由脚本决定。
这样做的好处很直接:控制流是确定的,所以系统行为可预测;LLM只负责它擅长的语义任务,所以输出质量更稳定;控制流是代码,所以可以写测试、可以做代码审查、可以灰度发布。
2.3 什么场景适合这种模式
不是所有Agent场景都需要确定性编排。如果你做的是一个创意写作助手,让LLM自由发挥反而更好。但以下几类场景,确定性编排几乎是刚需:
- 业务流程自动化:比如订单处理、工单流转,每一步都有明确的准入条件和后续动作。
- 多工具协同:需要按特定顺序调用多个工具,且后一个工具的输入依赖前一个的输出。
- 需要审计的系统:金融、医疗、法务等领域,决策路径必须可追溯。
- 高并发生产环境:不能接受因为模型采样波动导致的偶发错误。
我自己的项目属于第一类和第四类的结合,所以Embabel这套思路几乎是量身定做的。
3. Embabel的核心机制拆解
3.1 动作(Action)与状态(State)的分离
Embabel里最基础的概念是动作和状态的分离。一个Agent的运行过程,被建模成一系列状态迁移,每次迁移由一个动作触发。
动作是显式定义的,它声明自己需要什么输入、产生什么输出、在什么条件下可以执行。状态则是动作执行后系统所处的快照,包含当前已知的所有信息。
这种建模方式的好处是,整个Agent的运行过程变成了一张有向图。你可以清楚地看到从初始状态到目标状态有哪些路径,每条路径上经过哪些动作。LLM在这个图里的角色,是帮助填充某些动作的输入参数,或者生成某些动作的输出内容,但它不决定图的走向。
我刚开始理解这一点时,总觉得少了点什么。后来想明白了:以前是LLM在迷宫里自己找路,现在是我们在迷宫上方画好了路线图,LLM只负责在指定路口辨认一下路标。
3.2 条件分支的显式表达
条件分支是决策权的核心体现。在传统Agent里,“如果用户想查天气就调用天气工具,否则走通用问答”这个判断是LLM做的。在Embabel里,这个判断被写成显式的条件。
条件可以是基于状态的布尔表达式,也可以是基于LLM分类结果的枚举值。关键在于,分支的走向由代码决定,LLM只提供分类信号。
举个例子,你可以定义一个动作叫“意图分类”,它调用LLM把用户输入归类到“查询类”“操作类”“闲聊类”三个标签之一。然后代码里写三个分支,分别处理这三类。LLM负责打标签,代码负责根据标签走不同的路。这样即使LLM偶尔分类错误,你也能通过日志立刻定位到是哪一步出了问题,而不是面对一个黑盒。
3.3 工具调用的参数校验与回退
工具调用是Agent最容易出问题的地方。LLM生成的参数可能格式不对、类型不对、甚至缺少必填项。Embabel的做法是在工具调用前加一层显式的参数校验。
校验规则用代码写,比如“日期参数必须符合ISO格式”“数量参数必须是正整数”“必填参数不能为空”。校验不通过时,不是直接抛错,而是触发一个回退动作——可以是让LLM重新生成参数,也可以是走默认值,或者直接返回一个友好的错误提示。
这一层校验看起来不起眼,但实际用下来,它挡掉了大部分因为参数问题导致的工具调用失败。而且因为校验逻辑是代码,你可以针对每种失败情况写测试,确保回退路径也是可靠的。
3.4 与纯LLM决策方案的对比
为了更直观地说明差异,我整理了一个对比表:
| 维度 | 纯LLM决策 | Embabel确定性编排 |
|---|---|---|
| 决策依据 | 模型概率采样 | 显式代码逻辑 |
| 可复现性 | 低,同输入可能不同输出 | 高,同状态同分支 |
| 可测试性 | 难以写断言 | 可单元测试每个分支 |
| 可解释性 | 事后编造理由 | 日志直接记录分支走向 |
| 开发门槛 | 提示词工程为主 | 需要代码建模能力 |
| 适合场景 | 创意、探索类 | 业务、生产类 |
这张表不是要否定LLM决策,而是说明两种模式各有适用面。Embabel的价值在于,它给需要确定性的场景提供了一个工程上更可靠的选项。
4. 实操落地:从零搭一个确定性Agent
4.1 环境准备与依赖安装
我用的是一台普通的开发机,Python 3.11环境。Embabel本身是一个Python库,安装很直接:
pip install embabel-core如果你要用它内置的LLM适配层,还需要装对应的客户端库。我这边用的是通用的OpenAI兼容接口,所以额外装了:
pip install openai建议用虚拟环境,避免和系统里的其他包冲突。我习惯用venv:
python -m venv venv source venv/bin/activate装完之后,先跑一个最小示例验证环境没问题。Embabel的文档里有一个hello world级别的例子,大概十几行代码,跑通它再往下做。
4.2 定义第一个动作与状态
先定义状态。状态就是一个数据类,包含Agent运行过程中需要传递的所有信息:
from dataclasses import dataclass, field from typing import Optional @dataclass class AgentState: user_input: str = "" intent: Optional[str] = None tool_result: Optional[str] = None final_answer: Optional[str] = None retry_count: int = 0然后定义动作。动作是一个函数,接收状态,返回更新后的状态:
def classify_intent(state: AgentState) -> AgentState: # 这里调用LLM做意图分类 intent = llm_classify(state.user_input) state.intent = intent return stateEmbabel的编排层会负责按顺序执行这些动作,并根据条件决定是否跳过或重复。
4.3 编排流程的代码实现
编排的核心是定义一个流程。Embabel提供了装饰器风格的API,也支持显式的流程对象。我用的是后者,因为更直观:
from embabel import Flow, Step flow = Flow() flow.add_step(Step(classify_intent, next_step="route")) flow.add_step(Step(route_by_intent, branches={ "query": "retrieve", "action": "execute_tool", "chat": "generate_reply" })) flow.add_step(Step(retrieve, next_step="generate_reply")) flow.add_step(Step(execute_tool, next_step="generate_reply")) flow.add_step(Step(generate_reply, next_step=None))这段代码定义了一个五步流程:分类意图、根据意图路由、检索或执行工具、生成回复。每一步的走向都是显式的,LLM只在分类和生成两个环节参与。
4.4 参数校验与异常回退的配置
参数校验我单独抽了一个模块,用装饰器的方式挂在工具调用动作上:
from embabel.validators import validate_params @validate_params({ "date": {"type": "iso_date", "required": True}, "count": {"type": "positive_int", "required": False, "default": 10} }) def execute_tool(state: AgentState) -> AgentState: params = extract_params(state.user_input) result = call_external_tool(params) state.tool_result = result return state校验失败时,Embabel会触发一个默认的回退动作,把错误信息写回状态,并允许流程重新走一遍参数抽取。我设置了最大重试次数为2,超过就返回友好提示。
4.5 一次完整运行的日志追踪
跑一次完整流程,日志大概长这样:
[Step 1] classify_intent: input="帮我查一下上周的销售数据" [Step 1] LLM classify result: intent="query" [Step 2] route_by_intent: branch="query" -> retrieve [Step 3] retrieve: query="上周销售数据", top_k=5 [Step 3] retrieve result: 3 documents found [Step 4] generate_reply: context_length=1200 [Step 4] final answer generated [Flow] completed in 4 steps, total tokens=850每一步的输入、输出、分支走向都清清楚楚。出问题时,你不需要去猜LLM想了什么,直接看日志就知道流程在哪一步走偏了。
5. 常见问题与排查技巧实录
5.1 意图分类不准导致路由错误
这是最常见的问题。LLM把“查询类”误判成“闲聊类”,导致本该检索的问题直接走了生成回复,答案自然不靠谱。
我的处理办法是加一层置信度阈值。让LLM分类时同时输出一个置信度分数,低于阈值的走一个“澄清”分支,反问用户确认意图。这样虽然多了一轮交互,但避免了错误路由。
另外,分类的标签体系不要设计得太细。我一开始设计了八个标签,结果LLM经常混淆。后来压缩到三个,准确率明显提升。
5.2 工具参数抽取失败的处理
参数抽取失败通常有两种原因:一是用户输入本身信息不全,二是LLM抽取格式不对。
对于第一种,我的做法是在校验失败时,不是直接报错,而是生成一个追问,让用户补充缺失的信息。比如用户说“帮我订一张票”,缺少日期和目的地,系统就追问“请问您要去哪里,什么时候出发”。
对于第二种,我会在抽取动作后加一个格式规范化步骤,用正则或简单的字符串处理把LLM的输出转成标准格式。这一步用代码做,比让LLM重新生成更可靠。
5.3 状态膨胀与上下文管理
Agent跑多轮之后,状态对象会越来越大,如果每次都把完整状态塞进LLM的上下文,token消耗会失控。
我的做法是状态分层:核心状态(当前意图、关键参数、已执行动作列表)始终保留,辅助状态(历史对话、中间结果)按需裁剪。Embabel支持在动作定义里声明需要哪些状态字段,编排层会自动做裁剪。
5.4 排查速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 流程卡在某一步 | 动作抛异常未捕获 | 检查该动作的异常处理 |
| 分支走错 | 分类结果错误 | 查看分类日志和置信度 |
| 工具调用失败 | 参数校验不通过 | 检查校验规则和实际参数 |
| 回复质量差 | 上下文缺失或过长 | 检查状态裁剪配置 |
| 重复执行同一步 | 回退逻辑死循环 | 检查重试次数上限 |
这张表是我踩坑之后整理的,基本覆盖了八成以上的常见问题。
6. 一些个人体会与后续扩展方向
用Embabel这套思路重构完项目之后,最直观的变化是线上事故率降了一个数量级。以前每周都要处理几起Agent“发疯”的问题,现在基本没有了。因为大部分决策都是代码做的,代码不会今天心情好就走这条路、明天心情差就走那条路。
另一个意外收获是开发效率反而提高了。以前调提示词是个玄学活,改一个字可能影响一大片。现在控制流是代码,改分支逻辑就是改几行代码,改完跑测试,心里有底。
后续我打算在这几个方向继续扩展:一是把动作的注册做成插件化,方便不同业务线复用;二是给编排流程加可视化,让非技术同事也能看懂Agent的决策路径;三是探索在部分环节引入LLM做动态规划,但保留代码的最终否决权。
如果你也在做Agent系统,被LLM的不确定性困扰过,我建议你试试把决策权拿回来这个思路。不一定非要用Embabel,自己用状态机或者工作流引擎也能实现类似效果。核心就一句话:让LLM做它擅长的事,让代码做它该做的事。