1. 从一次失控的Agent任务说起
去年冬天,我接手了一个内部知识库问答系统的改造项目。需求听起来很朴素:让Agent根据用户提问,自动决定是查文档、调接口,还是直接回答。我一开始用的是纯LLM驱动的方案,把工具列表塞进系统提示词,让模型自己选。前两周跑得挺顺,第三周开始出幺蛾子——同一个问题,早上问它调接口,下午问它直接编答案,晚上问它又跑去查文档。更离谱的是,有一次它连续调了七次同一个接口,把下游服务打挂了。
那次事故之后,我花了整整两周时间翻各种Agent框架的源码,最后锁定了Embabel这个项目。它的核心主张特别对我的胃口:把Agent的决策权从LLM手里拿回来。LLM只负责它擅长的事——理解语义、生成文本,而"下一步该做什么"这个决策,交给确定性的状态机来管。这篇文章就是我这段时间的学习笔记和实操总结,适合正在被Agent不确定性折磨的开发者,也适合想理解"可控Agent"设计思路的技术人。
2. 为什么纯LLM决策会失控
2.1 LLM作为决策器的三个结构性缺陷
很多人第一次搭Agent,直觉就是把工具描述写进prompt,让模型输出"我要调用哪个工具"。这个方案在demo阶段几乎不会出问题,因为demo的输入是精心挑选的。但一旦上生产,三个缺陷会同时暴露。
第一个是概率性输出。LLM本质是下一个token的概率分布采样,同样的输入,温度参数不为零时输出就会漂移。你没法保证"用户问天气"永远映射到"调用天气接口",它可能今天映射到接口A,明天映射到接口B。这不是模型能力问题,是架构问题。
第二个是上下文污染。当对话轮次变多,历史消息里混入了工具返回结果、错误信息、用户纠正,模型对"当前该做什么"的判断会被这些噪声干扰。我实测过一个场景:前一轮工具调用失败返回了错误码,下一轮模型居然模仿错误码的格式来回答用户。
第三个是无法审计。纯LLM决策的Agent,你没法回答"为什么它这一步选了工具X而不是工具Y"。日志里只有模型的输出文本,没有决策依据。出了问题只能靠猜,这在生产环境是致命的。
2.2 决策权与生成权分离的核心思想
Embabel的设计哲学可以用一句话概括:决策是确定性的,生成是概率性的,两者必须分离。
打个比方。LLM像一个知识渊博但有点随性的顾问,你问他"这份合同该走哪个审批流程",他能给你分析得头头是道,但你不会让他直接去点审批按钮。真正点按钮的,是一套写死的流程引擎——什么条件下走一级审批,什么条件下走二级,规则是明确的、可审计的、可测试的。
Embabel把Agent的执行拆成两层:上层是状态机,定义"当前处于什么状态、满足什么条件时转移到什么状态";下层是LLM调用,只在状态机明确需要"理解语义"或"生成内容"时才被触发。决策权归状态机,生成权归LLM,各司其职。
这个分离带来的直接好处是:你可以对状态机写单元测试,可以画出完整的状态转移图,可以在日志里看到每一步的决策依据。Agent从"黑盒"变成了"白盒"。
2.3 什么场景适合这种架构
不是所有Agent都需要Embabel。如果你的任务只有一步——用户问,模型答,那直接用LLM就行,套状态机是过度设计。
但如果你符合以下任一特征,就值得考虑:任务需要多步执行且步骤之间有依赖;执行过程中需要调用外部工具且工具调用有副作用;业务上要求可审计、可回放、可测试;或者你已经被纯LLM方案的不确定性坑过。
我自己的判断标准很简单:如果这个Agent挂了会导致下游服务异常,或者出了问题需要向别人解释"为什么它这么做",那就该上状态机。
3. Embabel的核心概念拆解
3.1 State、Transition与Guard的三件套
Embabel的模型非常精简,核心就三个概念。
State(状态)表示Agent当前所处的阶段。比如"等待用户输入"、"已解析意图"、"工具调用中"、"生成最终回答"。状态是离散的、有限的,你可以把所有状态画在一张图上。
Transition(转移)表示从一个状态到另一个状态的路径。每条转移有一个触发条件,条件满足就执行转移。
Guard(守卫)是转移的触发条件。Guard可以是一个布尔表达式,也可以是一次LLM调用——但注意,LLM调用的返回值会被强制转换成布尔值或枚举值,而不是让LLM自由发挥。
这三件套组合起来,Agent的行为就完全确定了:给定当前状态和输入,Guard评估结果决定走哪条转移,到达新状态后重复这个过程。整个过程可以画成一张状态转移图,任何人看了都能理解Agent会怎么走。
3.2 Guard如何约束LLM的输出空间
这是Embabel最巧妙的地方。很多人担心"把决策权拿回来"会让Agent变笨,因为LLM的灵活性没了。但实际上,Guard里的LLM调用不是被削弱了,而是被约束了。
举个例子。纯LLM方案里,你让模型输出"下一步动作",它的输出空间是整个词表,可能输出任何东西。而在Embabel里,Guard里的LLM调用被要求输出一个枚举值,比如"INTENT_QUERY_DOC"、"INTENT_CALL_API"、"INTENT_DIRECT_ANSWER"三选一。
输出空间从"无限"压缩到"三选一",模型的准确率会大幅提升。我实测过,同一个意图分类任务,自由生成格式的准确率大概70%,约束成枚举后能到95%以上。这不是模型变强了,是任务变简单了。
更重要的是,Guard的返回值可以被校验。如果LLM输出了一个不在枚举里的值,状态机可以直接拒绝,走fallback分支,而不是让一个非法值继续往下传。
3.3 与主流Agent框架的定位差异
市面上主流的Agent框架,大多走的是"LLM规划+工具执行"的路线。LLM负责生成一个计划(plan),然后按计划执行。Embabel不一样,它不生成计划,它预定义计划。
这个差异决定了适用场景。如果你的任务步骤是固定的、可枚举的,Embabel更合适,因为它的行为完全可预测。如果你的任务步骤是开放的、需要模型临场发挥的,那纯LLM规划可能更合适。
但我的经验是,生产环境里90%的Agent任务,步骤都是可枚举的。真正需要模型临场发挥的场景,往往是因为需求没想清楚,而不是任务本身开放。
4. 手把手搭建第一个Embabel Agent
4.1 环境准备与依赖安装
我用的环境是Python 3.11,Embabel对3.10以上都支持。依赖安装很干净,没有一堆传递依赖。
pip install embabel-core如果你要用它内置的LLM适配器,再装对应的包。我用的是OpenAI兼容接口,装的是:
pip install embabel-openai装完之后,先跑一个最小示例验证环境。Embabel自带了一个echo agent的示例,跑通它说明环境没问题。
注意:Embabel的版本迭代比较快,我写这篇文章时用的是0.4.x。如果你装的是0.3.x,部分API可能有差异,建议先看对应版本的文档。
4.2 定义状态与转移的完整代码
下面是我写的一个最小可用的Agent,功能是:接收用户问题,判断是"查文档"还是"直接回答",查文档的话调用一个模拟的检索函数,最后生成回答。
先定义状态枚举:
from enum import Enum class State(Enum): START = "start" CLASSIFYING = "classifying" RETRIEVING = "retrieving" GENERATING = "generating" DONE = "done"然后定义Agent类,注册状态和转移:
from embabel import Agent, transition, guard class QAAgent(Agent): initial_state = State.START @transition(from_state=State.START, to_state=State.CLASSIFYING) def start_classify(self, ctx): return True @transition(from_state=State.CLASSIFYING, to_state=State.RETRIEVING) @guard(lambda ctx: ctx.intent == "QUERY_DOC") def go_retrieve(self, ctx): ctx.doc = retrieve_docs(ctx.question) return True @transition(from_state=State.CLASSIFYING, to_state=State.GENERATING) @guard(lambda ctx: ctx.intent == "DIRECT_ANSWER") def go_generate(self, ctx): return True @transition(from_state=State.RETRIEVING, to_state=State.GENERATING) def after_retrieve(self, ctx): return True @transition(from_state=State.GENERATING, to_state=State.DONE) def finish(self, ctx): ctx.answer = generate_answer(ctx) return True这段代码里,ctx是上下文对象,贯穿整个执行过程。intent字段是在CLASSIFYING状态里通过LLM调用填充的,但填充之后,走哪条转移完全由Guard的布尔表达式决定,LLM不再参与。
4.3 在Guard中调用LLM的正确姿势
CLASSIFYING状态需要调用LLM来判断意图。这里的关键是:LLM的输出必须被约束成枚举。
INTENT_PROMPT = """判断用户问题的意图,只输出以下三个词之一: QUERY_DOC - 需要查文档 DIRECT_ANSWER - 可以直接回答 UNCLEAR - 无法判断 用户问题:{question} """ def classify_intent(ctx): raw = llm.complete(INTENT_PROMPT.format(question=ctx.question)) raw = raw.strip().upper() if raw not in ("QUERY_DOC", "DIRECT_ANSWER", "UNCLEAR"): return "UNCLEAR" return raw注意最后那个校验:如果LLM输出了枚举外的值,直接归到UNCLEAR,走fallback分支。这个校验看起来简单,但它堵住了LLM不确定性的最后一道口子。
实操心得:prompt里一定要明确"只输出以下三个词之一",并且给出每个词的含义。我试过只写"判断意图",模型会输出一整段解释,解析起来很麻烦。
4.4 运行与调试的实操记录
跑起来之后,Embabel会输出完整的状态转移日志。这是我实测的一段:
[START] -> [CLASSIFYING] intent=QUERY_DOC [CLASSIFYING] -> [RETRIEVING] guard passed [RETRIEVING] doc retrieved, length=1204 [RETRIEVING] -> [GENERATING] [GENERATING] answer generated, length=356 [GENERATING] -> [DONE]这个日志的价值在于:任何一步出问题,你都能精确定位到是哪个状态、哪条转移、哪个Guard。纯LLM方案里,你只能看到模型的输入输出,中间过程是黑盒。
调试的时候,我习惯在Guard里加一行日志,记录Guard的评估结果。这样当Agent走了非预期分支时,能立刻看出是Guard判断错了,还是LLM分类错了。
5. 把决策权拿回来之后的收益与代价
5.1 可测试性带来的开发效率提升
这是最直接的收益。状态机是可以写单元测试的。我可以构造一个上下文,把intent设成QUERY_DOC,断言Agent会走RETRIEVING分支。这种测试跑起来是毫秒级的,不需要调用LLM。
我现在的做法是:状态转移逻辑用单元测试覆盖,LLM调用部分用mock替换。这样CI里跑几百个测试用例也就几秒钟。纯LLM方案做不到这一点,因为每次测试都要真实调用模型,成本高、速度慢、结果还不稳定。
5.2 可观测性:每一步决策都有据可查
生产环境出问题时,Embabel的日志能直接告诉你"Agent在CLASSIFYING状态把意图判成了DIRECT_ANSWER,所以没走检索分支"。你可以拿着这条日志去分析:是prompt写得不好,还是用户问题本身有歧义。
我遇到过一个问题:用户问"这个功能怎么用",Agent直接回答了,没查文档。看日志发现意图被判成了DIRECT_ANSWER。分析prompt后发现,"怎么用"这个表述在训练数据里经常对应通用知识问答,模型倾向于直接回答。后来我在prompt里加了一条规则:"涉及本系统具体功能的问题,一律判为QUERY_DOC",问题就解决了。
这种问题在纯LLM方案里也能修,但你不知道要修哪里,只能反复试prompt。Embabel让你知道问题出在哪个环节。
5.3 灵活性下降的应对策略
代价也很明显:状态机是写死的,遇到没预定义的情况就抓瞎。
我的应对策略是保留一个兜底状态。所有Guard都不满足时,走FALLBACK状态,在这个状态里调用LLM做自由决策。这样既保证了主流程的确定性,又保留了处理边缘情况的灵活性。
另一个策略是状态机分层。主状态机管大流程,子状态机管细节。比如RETRIEVING状态内部可以再套一个小子状态机,处理"检索失败重试"、"多路检索合并"这些细节。这样主状态机保持简洁,细节逻辑封装在子状态机里。
6. 踩过的坑与排查实录
6.1 Guard循环依赖导致的死锁
我遇到过一次Agent卡死。日志停在某个状态不动了。排查后发现是两条转移的Guard互相依赖:A转移的Guard需要B转移执行后设置的字段,B转移的Guard又需要A转移执行后设置的字段。结果两条都不满足,Agent就卡住了。
排查方法:Embabel有超时机制,卡住超过阈值会抛异常。看到超时异常后,检查当前状态的所有出边Guard,看是否有循环依赖。
修复方法:把互相依赖的字段拆开,或者引入一个中间状态打破循环。我现在设计状态机时,会刻意避免Guard依赖"未来才会设置的字段"。
6.2 LLM输出格式漂移的兜底方案
即使prompt里写了"只输出枚举值",模型偶尔还是会输出带解释的文本,比如"QUERY_DOC,因为用户问的是具体功能"。我的兜底方案是正则提取:
import re def extract_enum(raw, valid_values): for v in valid_values: if re.search(r'\b' + v + r'\b', raw.upper()): return v return "UNCLEAR"先尝试精确匹配,失败再用正则提取,再失败归到UNCLEAR。三层兜底下来,实测漂移率降到千分之一以下。
6.3 状态爆炸的预防经验
状态机设计最容易犯的错是状态爆炸。每加一个条件就加一个状态,最后状态图变成一团乱麻。
我的经验是:状态数量控制在7个以内。超过7个,人脑就很难在脑子里维护这张图了。如果确实需要更多状态,考虑拆成多个子状态机。
另一个经验是:状态命名用"动词+ing"形式,比如CLASSIFYING、RETRIEVING、GENERATING。这样一眼就能看出这个状态在做什么,比用名词命名清晰得多。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| Agent卡死不动 | Guard循环依赖 | 检查当前状态出边Guard | 打破循环或加中间状态 |
| 走了非预期分支 | Guard判断错误 | 看Guard评估日志 | 修正Guard表达式或LLM prompt |
| LLM输出无法解析 | 格式漂移 | 看原始输出 | 加正则提取兜底 |
| 状态图过于复杂 | 状态爆炸 | 数状态数量 | 拆分子状态机 |
| 测试跑得慢 | 真实调用LLM | 看测试是否mock | 用mock替换LLM调用 |
7. 从单Agent到多Agent协作的扩展思路
单个Agent跑通之后,自然会想扩展到多Agent。Embabel的多Agent协作模型很直接:每个Agent是一个独立的状态机,Agent之间通过消息传递通信。
我做过一个实验:一个"路由Agent"负责判断问题类型,然后分发给"文档Agent"或"数据Agent"。路由Agent的状态机很简单,只有CLASSIFY和DISPATCH两个状态。文档Agent和数据Agent各自有完整的状态机。
这种架构的好处是职责清晰。每个Agent的状态机都很小,容易维护。坏处是调试变复杂,一个问题可能跨越多个Agent,日志要串起来看。
我的建议是:先从单Agent做起,把单Agent的状态机跑稳,再考虑拆分。很多场景其实单Agent就够了,拆多Agent反而增加复杂度。
8. 我个人的一些使用体会
Embabel不是银弹。它解决的是"决策不确定性"这一个问题,不解决"LLM本身能力不足"的问题。如果你的任务需要模型做复杂推理,Embabel帮不上忙,该调模型还是得调。
但如果你被Agent的不确定性折磨过,Embabel值得一试。它最大的价值不是技术多先进,而是把Agent的设计从"调prompt"变成了"画状态图"。这个思维转变,比任何框架都重要。
我现在设计Agent的流程是:先在纸上画状态图,标出每个状态的进入条件、退出条件、Guard表达式。图画清楚了,代码就是翻译。图画不清楚,说明需求还没想明白,这时候写代码就是浪费。
最后分享一个小技巧:Embabel的状态转移日志可以导出成图。我习惯在每次状态机改动后,导出图看一眼,确认没有意外的转移路径。这个习惯帮我提前发现过好几次设计缺陷。