别让大模型当状态机:Agent协议减负与状态流转实战
发布时间:2026/10/1 14:27:33来源:尧图网络
先交代一个背景上个月我把一个售后工单自动分类加流转的 Agent 上线跑了不到两周差点被线上告警打崩。问题不在模型能力也不在工具调用而是我把整个业务状态机——什么“待分配”“处理中”“待复核”“已关闭”——全部写进了提示词里让大模型在每一步都自行判断“现在处于哪个状态下一步该进哪个状态”。结果就是Token 用量高得离谱并发一上来就乱回退稍复杂的工单路径甚至出现死循环。后来我把状态机从提示词里拆出来做成一个轻量级的 Agent 协议层让模型只负责“意图识别”和“参数抽取”让协议层负责“状态流转”和“动作校验”。这个改动上线后Token 成本降了六成回退率近乎为零。这个思路其实不复杂就是一句话别把大模型当状态机。但真正做到“协议减负”里面有大量细节值得记录。这篇我就把这套协议的设计过程、封包格式、三种实现路径、源码级的核心循环以及我踩过的坑完整拆开来讲希望对正在做 Agent 工程化的朋友有参考价值。大模型在被设计之初是个语言模型它擅长的是把一段模糊的输入映射成一段有结构的输出。而状态机恰恰是最不适合交给语言模型的事情——状态机要求绝对的确定性、穷举的路径覆盖和幂等的回退机制这跟语言模型天生的概率化生成是冲突的。你越是试图在提示词里定义“如果A且B则进入C状态”模型就越容易在边界情况跑偏。1. 大模型不适合做状态机一次真实踩坑复盘先把当时的病根说透。我第一次做这套工单 Agent 时用的是一种很“天真”的实现把业务状态、状态转移规则、每一步的工具调用权限全部写进 System Prompt。看起来逻辑很清晰实际上隐患从一开始就埋下了。1.1 病根路径当时是怎么把状态机写进提示词的当时的提示词大概是这个风格你是工单处理助手。 系统状态如下 - pending待分配 - processing处理中 - review待复核 - closed已关闭 当前状态pending 规则 1. 若用户提交完整信息状态从pending进入processing。 2. 若信息缺失则保持pending并追问。 3. 若用户主动要求关闭则进入closed。 4. processing状态下可调用 assign_agent 工具。 5. review状态下若人工确认无误则进入closed。 ...表面看这套提示词实现了状态流转。但真实跑起来之后问题迅速暴露。第一个问题是状态必须在对话流里靠模型“记住”。多轮对话后模型自己就开始摇摆明明已经 assign 完 agent下一轮它又认为还在 pending。第二个问题更致命——用户消息稍微绕一点模型就会“自创”一条不在规则里的转移路径把工单状态搞到逻辑上不可达的位置。我后来统计过一周的数据在纯提示词方案下工单状态转移的准确率大约只有 87%。听起来似乎还能接受但放在真实业务里剩下的 13% 就是线上事故。工单流到错误状态后要么卡死无人处理要么重复派单造成客诉排查成本远高于预期。1.2 三个坚持不住的理由Token、幂等与并发如果只看准确率还能靠调提示词勉强撑住。真正让我痛下决心重构的是下面三个工程问题Token 消耗失控。为了让模型“记住”状态机每一步都必须把完整的状态规则塞进上下文。工单场景还好但换到一个稍微复杂的业务流程比如保险理赔状态数可能膨胀到几十个转发规则上百条。一轮推理的上下文就大得吓人成本线性增长延迟也跟着涨。状态回退不可控。状态机最强调幂等性一个动作要么成功要么失败不存在“好像成功又好像失败”的中间态。但大模型不是这样。某个工具调用超时后模型在下一轮可能认为“已经调用过了”也可能认为“还没调用”于是重复执行或者漏执行。这两种情况都不可接受。并发条件下乱套。工单任务天然是高并发的。提示词方案要求每个对话独立维护一份“状态上下文”但模型本身没有会话隔离的问题问题出在状态转移的时机上。两个并发请求如果同时读到“pending”同时做“processing”转移轻则重复派单重则状态被覆盖。提示词方案里完全没有锁和事务的概念。1.3 结论业务状态与语言智能是两码事踩完这些坑我得出一个很朴素的结论大模型负责“听懂用户”业务系统负责“保证状态”两者之间用协议沟通。具体来说用户说“帮我分配一下工单”大模型不直接改状态而是输出一个结构化指令TRANSITION(service_ticket, processing, assign_agent)再由协议层去校验合法性并且执行真正的状态转移。它的本质是让模型从“控制者”降级为“翻译官”。模型不再决定状态怎么走而只负责把用户的自然语言转化成协议字段。状态转移的合法性判断、动作顺序、幂等控制全部交给协议层这套确定性的代码去处理。这套模型识别意图、协议确认状态的架构我内部称呼是“模型出题协议改卷”。2. 协议减负的核心设计思路模型只出题协议来改卷很多做 Agent 的朋友有个误区觉得只要用了 Agent 框架写几个工具函数把流程描述放在 System Prompt 里就是“工程化”了。真正做深了你就会发现Agent 跑得好不好核心在模型和业务系统之间的那一层契约——这一层就是协议。2.1 什么是协议减负为什么可以理解为“减排”我说“协议减负”你可以把它理解成环保领域里的“减排”。不是要求工厂完全不排放而是把污染控制在合规的管道里。同样的道理不是要求大模型不去理解状态而是让它不需要去维护状态把这个负担转移到代码和协议层。模型只做一件事情把用户的模糊输入翻译成协议里的结构化字段。剩下的事情协议层用确定性的方式完成。这套方案改完后我最大的感受是提示词变得异常干净。System Prompt 里不再有几十条状态规则只有一段约 200 字的说明你是一名工单助手你的任务是解析用户意图输出合法指令不要自行判断工单状态。这句话很重要它告诉模型边界在哪里。事实也证明模型在明确边界后反而更“听话”了因为它的任务变单纯了。2.2 和 MCP 的关系MCP 是总线这里做的是应用层协议现在聊 Agent 绕不开 MCPModel Context Protocol。MCP 解决的是“模型怎么调用工具”这个连接问题它把工具暴露成统一的资源让模型可以理解并调用。但 MCP 不解决“业务状态怎么流转”的问题。MCP 是总线是基础设施而我这次做的协议是跑在总线上面的业务规则是应用层逻辑。打个比方MCP 像是家里的电路布线告诉你哪个房间有插座而我的协议是“家里的电器使用规矩”——空调不能在浴室开电磁炉不能在卧室开。两者层级不同可以共存。我在实践里就是让模型通过 MCP 访问工具同时通过我的协议访问状态机两者互相配合最终达到“连接复用、业务可控”的效果。2.3 设计原则自然语言只做意图协议负责确定性这套协议在设计时我给自己定了三条原则第一模型不存状态。任何一轮推送进模型上下文的只有当前必要的信息不包含完整的业务状态机规则。模型必须输出的是明确的操作类型、目标实体和关键参数。第二状态转移必须校验。协议层维护一张合法的状态转移表。模型提交转移请求后协议层先查表再决定执行、拒绝或要求补充。状态转移表是代码写死的不随模型波动。第三失败必须可回退。任何状态变更前先记录前置状态变更失败就回滚到前置状态。这个保障模型给不了只有代码能稳稳兜住。3. Agent 协议封包设计实操Header、Payload 与签名有了设计原则下一步就是把想法落地成封包格式。这里我直接展示我在生产环境里使用的协议结构并解释每个字段为什么存在。3.1 封包总览Header 与 Payload 双层结构给 Agent 用的协议和给 MCU单片机用的协议在思路上异曲同工——都需要把信息编码成结构化的字节流或文档都需要有明确的“请求—响应”语义。我参考了轻量级消息传输的常用做法把一次 Agent 交互封装成 Header Payload 的结构。Header 部分主要存协议元信息。Payload 部分则是模型完成一次意图解析后要输出的业务内容。两个部分分别处理一个管“怎么说”一个管“说什么”。字段类型说明protocol_versionint协议版本号便于迭代升级msg_typestring消息类型intent_request / intent_response / ackmsg_idstring单条消息唯一 ID用于追踪和幂等timestampint毫秒时间戳statusint200 成功 / 400 非法状态转移 / 500 服务异常Header 里的 msg_id 一开始我没太在意后来排查问题时才发现它的价值。有了它你可以把一次交互的完整链路串起来从用户输入到模型输出再到状态机最终执行全部落到日志检索里定位问题效率提升一个量级。3.2 Payload 字段设计角色、状态机、指令、参数的职责拆解Payload 是协议的核心它承载模型要表达的意图。我在实践里把 Payload 设计成四块角色、目标状态机、操作指令、操作参数。role当前参与者的身份可能是“用户”或“系统”。在工单场景里有时候用户要发起转移有时候是系统定时触发区分开后续权限校验才方便。state_machine目标状态机名称。因为这个系统里不止工单一个有状态业务售后、物流、退款都有自己的状态机必须显式指定操作对象。action操作类型。我定义了四个基础动作create创建状态实例、transition状态转移、rollback回退、query查询当前状态。模型不需要输出其他复杂动作四种足够覆盖绝大多数业务。params操作参数。比如转给哪个处理人、执行哪条指令、目标状态是什么。参数在协议层会被二次校验不合法就直接拒绝。3.3 ReqRepNum 与 RelayNodeList 的作用请求应答对齐与链路跟踪这两个字段在通信协议里很常见我借用到 Agent 场景里后解决了一个很头疼的问题模型输出和工具执行结果的对齐。req_rep_num是请求应答序号。我的架构里模型生成一条指令后Agent 会并行调用多个工具比如同时查库存、查物流、查用户信用。工具返回的顺序是乱序的如果没有序号对齐你根本不知道哪个结果对应哪条指令。加上序号之后协议层会自动按序号分组先到先匹配等待超时再重发。relay_node_list是链路中转列表。Agent 调用工具时可能跨多个内部服务每个服务处理完就往这个列表里追加自己的节点信息。出现故障时这个列表可以直接还原调用链不用再靠日志硬拼。3.4 代码示例一个开箱即用的 AgentMessage 实现下面这份代码是我项目里实际在用的最小实现只保留核心逻辑方便你直接看结构import json import time import uuid from dataclasses import dataclass, asdict dataclass class AgentMessage: protocol_version: str 1.0 msg_type: str intent_request msg_id: str timestamp: int 0 status: int 200 role: str user state_machine: str action: str params: dict None req_rep_num: int 0 relay_node_list: list None def __post_init__(self): if not self.msg_id: self.msg_id uuid.uuid4().hex if not self.timestamp: self.timestamp int(time.time() * 1000) if self.params is None: self.params {} if self.relay_node_list is None: self.relay_node_list [] self.validate_action() def validate_action(self): allowed {create, transition, rollback, query} if self.action not in allowed: self.status 400 raise ValueError(f非法action: {self.action}) def to_json(self): return json.dumps(asdict(self), ensure_asciiFalse) classmethod def from_json(cls, payload: str): data json.loads(payload) return cls(**data)这段代码看起来短但我建议你不要简化掉validate_action。这个校验是协议层的第一道防线——模型哪怕输出了actiontransfer_now协议层也会直接 400 拒绝而不是傻乎乎往下执行。正因为有了这道校验我后来在提示词里写“只允许输出四种动作”时才真正有底气。3.5 为什么封包推荐用 JSON 而不是 Python 字典直传很多刚接触 Agent 协议的人有个疑问既然模型输出本身是 JSON为什么我还要强行定一个AgentMessage类不能直接拿模型输出的字典去处理吗我的经验是字典是无约束的类是有约束的。模型输出的 JSON 天然是松散的你永远不知道它会多出什么字段、少掉什么字段。而 dataclass 通过__post_init__做了结构校验一进协议层就能发现异常。JSON 在这里扮演的是“传输格式”而 dataclass 是“运行期契约”。另一个原因是可扩展性。真实业务里你不可能只有一个状态机把协议统一成一份类定义后续维护新业务时就能复用同一套解析和校验逻辑不用为每一个业务单独写脏代码。4. 三种实现路径对比DSL、函数调用、状态机引擎协议封包设计完接下来要考虑的是状态机本身放在哪里执行。这一步的选型直接决定了整个系统的复杂度和维护成本。我实际对比过三条路下面这条细节很值得展开。4.1 方案 ADSL 描述状态机让模型生成状态表第一种思路是把状态机和转移规则写成 DSL领域特定语言比如 YAML 或 JSON 结构然后让模型去生成或修改这份 DSL。听起来进阶但我在实践中发现它水土不服。DSL 的好处是表达能力极强坏处是模型生成 DSL 时容易“自由发挥”。YAML 这种格式对缩进、键值对顺序极其敏感模型偶尔多一个空格或少一个冒号整个 DSL 解析就崩了。更麻烦的是DSL 一旦允许模型修改就意味着模型还是间接参与了状态机定义那跟直接写提示词没有本质区别只是把风险从运行期挪到了生成期。4.2 方案 B函数调用Function Calling直接传工具参数第二种方案走的是标准化路径模型不输出自定义 DSL而是调用预定义的工具函数通过工具参数来传递动作意图。这个方向跟 OpenAI 的 Function Calling 天然契合很多团队会直接用。这个方案的优点是很成熟生态好主流模型都支持。但缺点也很明显函数调用解决的是“模型怎么表达”没有解决“业务怎么流转”。工具函数本身是无状态的它并不知道工单现在处于什么状态、下一步应该找谁。模型如果不做状态判断工具就不知道该调哪个函数模型做了状态判断就又回到了起点——把状态机交给模型。4.3 方案 C把状态机引擎挂在 Agent 外面我最终的选择这套方案的思路是模型、状态机引擎、业务工具三个模块各司其职。模型负责从自然语言里抽参数然后把参数打包成协议指令状态机引擎接收协议指令执行合法性校验和状态流转业务工具执行具体的动作并把结果回传给状态机引擎。优点非常明显——状态流转彻底脱离了模型的概率控制。状态机引擎是纯代码实现有明确的表驱动逻辑和锁机制天然可以支撑并发和回退。模型的角色降级成“意图解析器”它对状态机的理解不构成影响。4.4 三方对比表与选型结论对比维度方案ADSL描述方案B函数调用方案C外挂状态机引擎模型参与状态定义直接参与风险高间接参与风险中不参与风险低并发控制能力弱弱强可用锁和事务回退机制需要额外实现依赖工具逻辑内置事务回滚实现成本中低中高适合场景业务流程相对简单快速原型验证生产级复杂流程我的选型结论是快速原型可以选方案 B生产系统直接上方案 C。不要因为方案 C 初期实现量更大就想走捷径。你省下的每一步实现后面都会用线上事故加倍偿还。5. Agent 核心循环与源码级实现导入导出与超时兜底确定方案后就要开始写真正的代码了。这一节我拆解 Agent 核心循环里最关键的几个函数包括主循环结构、导入导出策略、缓存与超时处理。每一块都是我实际调过坑之后调整出来的版本。5.1 主循环关键代码意图解析与协议校验的交替流转Agent 主循环的核心逻辑很简单读消息 → 调模型解析 → 协议校验 → 执行动作 → 回填状态。但真正写好的关键是每个环节都要有明确的异常出口。class AgentLoop: def __init__(self, state_machine, model_client, tools): self.state_machine state_machine self.model_client model_client self.tools tools async def run(self, raw_text: str, msg_id: str): try: intent await self.model_client.parse_intent(raw_text, msg_id) except ModelTimeoutError: return {status: 500, msg: 模型解析超时, msg_id: msg_id} if not intent.is_valid(): return {status: 200, action: need_more_info, msg: intent.feedback_prompt, msg_id: msg_id} action_msg AgentMessage( actionintent.action, state_machineintent.state_machine, paramsintent.params, msg_idmsg_id, ) try: result self.state_machine.execute(action_msg) if result.status 200: tool_result await self.tools.execute(result.action_chain) return {status: 200, data: tool_result, msg_id: msg_id} else: return {status: result.status, msg: result.reject_reason, msg_id: msg_id} except RollbackException as e: return {status: 500, msg: f状态回滚失败: {e}, msg_id: msg_id}这个循环里有一个细节值得特别说明intent.is_valid()这一步放在模型输出之后、状态机执行之前。它做的事很基础——检查模型输出的 action 是否在合法清单里params 里的必填字段是否齐全。别小看这一步它挡住了大约 40% 的模型幻觉输入。5.2 导入导出策略系统快照与增量同步状态机引擎跑起来后最怕两种情况重启丢状态、多实例状态不一致。我的做法是引入“系统快照 增量同步”双机制。快照机制每隔 5 分钟把状态机的全量状态序列化存储一份。增量同步每次状态转移成功就把转移事件推送到消息队列供其他实例消费。增量同步的重点在于事件顺序。状态转移是有先后依赖的如果两个实例按不同顺序应用增量事件状态就错乱了。解决方案是给每个事件加一个全局递增的序列号消费者必须按序列号顺序处理。这个方案实现成本不高但让状态机引擎在重启后只用回放增量事件就能恢复状态准确率基本有保障。5.3 缓存与超时处理防止模型延迟拖垮整个状态机模型调用往往是最耗时也最不稳定的环节。如果状态机引擎同步等待模型返回一个慢请求就能阻塞整个链路。我的处理方式有两层第一层是超时保护。模型解析上限设置为 10 秒超过就返回“模型解析超时”由上游重试或降级为人工处理。第二层是临时缓存。针对高频固定的用户模板比如“请把工单 #1234 转给张三”这类做意图解析结果的短时缓存TTL 30 秒。这样同一类指令在并发高峰时不需要每次都打模型协议层直接复用上次解析结果压力瞬间小很多。处理方式适用场景实现要点超时保护全部模型调用设置总超时时间和单次解析超时阈值意图缓存高频相似请求TTL 30 秒状态机层自动失效人工降级模型连续失败标记任务进入人工处理队列避免硬撑6. 常见故障与排查实录并发、超时、日志盲区协议层上线后肯定不是一劳永逸的。这里我把上线后遇到的高频故障整理成速查表再拆一个最典型的并发事故的现场帮大家提前避坑。6.1 故障速查表症状可能原因快速诊断解决方案状态停在不该停的位置模型请求被缓存命中错误查意图缓存 key 是否包含完整业务参数缓存 key 加入工单ID和操作类型并发状态下状态被覆盖状态机引擎缺锁查日志中同msg_id状态变更次数增加分布式锁或乐观锁模型频繁返回非法 action提示词边界不清晰抽查模型输出的原始 JSON在提示词中增加“只输出合法动作”的约束状态机恢复后数据不一致增量事件乱序查事件序列号是否全局递进强制按序列号顺序消费增量事件协议层返回“非法状态转移”模型跳步骤操作查转移表是否遗漏合法路径复核状态机定义补齐转移边6.2 一次并发事故复盘Modal 超时引发的状态覆盖悲剧那是在上线后的第二天流量高峰两个用户同时对同一个工单进行“分配处理人”操作。用户 A 的操作生成TRANSITION(pending - processing)用户 B 的操作生成TRANSITION(processing - review)。理想情况下A 执行完 B 再执行状态机最终变成 review。但当时 B 的请求先到达状态机引擎——因为 A 的模型解析超时了B 反而先进入执行。状态机引擎没有锁B 在 pending 状态下执行processing - review的转移给出的响应是“非法状态转移”然后 B 的任务失败。等 A 的超时恢复后A 又执行成功把状态从 pending 变成 processing。最终结果该工单的处理人分配好了但后续审核流程断掉了。复盘后我认为核心原因有两个一是状态机引擎缺少事务锁二是我没有做超时后自动回退。修复方式给状态机实例增加乐观锁版本号字段每次转移前检查当前状态版本号同时模型解析超时后任务直接标记失败不做延迟重试避免乱序执行。6.3 排查建议用好 msg_id 把系统日志串起来排查 Agent 问题时最大的坑是日志太散。模型日志、工具日志、状态机日志各自为政出了问题根本连不上。我的排查效率大幅提升靠的是强制要求所有环节输出同一组msg_id。在进入 AgentLoop 入口时先为本次交互生成代码里唯一的 msg_id调用模型时把 msg_id 放进请求调用工具时也把 msg_id 附加进上下文状态机引擎执行时记录同样的 msg_id。排查时只用一条查询语句按 msg_id 检索就能还原完整的调用链和状态转移过程。这几乎是零成本的改动但对线上排查效率的提升是质变的。如果你现在做 Agent 系统还没有统一的链路 ID我建议你把这条规则列为重构的第一优先项。结尾一点个人体会与后续方向这一轮实践做下来我最深的体会是Agent 的成熟度不在于大模型有多聪明而在于工程系统为它兜住了多少不确定性。协议层的核心使命就是把模型的“自由发挥”压缩到一个有限、合法、可控的操作空间里。这就是所谓的“协议减负”——不是限制大模型的能力而是把不适合它做的确定性部分接过来。我目前还在做两件扩展一是把协议层逐步抽象成一个通用 Agent State Machine 中间件不再是工单场景专用二是分析状态转移失败样本反过来做一次小样本微调让模型在意图解析这一步的准确率再往上提几个点。这种东西属于慢工细活但方向我是确定的。希望这篇实战笔记能给你带来一点参考。如果你也在做 Agent 工程化欢迎按这套协议先搭一个最小版本跑一跑尤其是把超时逻辑和链路 ID 做好你会明显感觉到运维压力降了一大截。
网站建设高端定制企业官网