Agent状态机实战:多轮补参、二次确认与可恢复执行
发布时间:2026/9/26 23:39:47来源:尧图网络
1. 为什么Agent需要状态机从一问一答到可恢复执行做过Agent开发的人大概都有过这种体验用户说帮我订一张明天下午从北京到上海的高铁票Agent调用工具查完车次返回结果问您要订哪一趟用户回就那个最快的吧Agent又得重新查一遍——因为上一轮的上下文里车次列表已经丢了。更糟的是如果中间网络抖了一下工具调用超时整个会话直接崩掉用户得从头再说一遍需求。这就是典型的无状态Agent困境。它把每一轮对话都当成独立请求处理没有记忆、没有进度、没有恢复能力。而Agent状态机要解决的正是这三个问题多轮补参缺什么问什么问完接着走、二次确认危险操作前必须让用户点头、可恢复执行中断了能从断点续上而不是重头再来。我最早接触状态机是在嵌入式领域那时候写MCU上的按键扫描用三段式状态机现态、条件、次态处理长按短按组合键逻辑清晰到几乎不会出bug。后来转到Agent开发发现这套思路简直是天作之合——Agent的执行流程本质上就是一个状态迁移过程从理解意图到收集参数到等待确认到执行工具到返回结果每个状态都有明确的入口条件、出口条件和异常分支。提示状态机不是银弹。如果你的Agent只是简单的用户问天气→调API→返回结果那用不上状态机直接线性调用就行。状态机的价值在于流程有分支、有中断、有回退、有重试的场景。1.1 状态机给Agent带来的三个核心能力先把这个说清楚不然后面的实操你会觉得为什么要搞这么复杂。多轮补参的本质是参数收集状态的循环。用户第一次说帮我订票Agent识别出意图是订票但缺少出发地、目的地、时间三个必填参数。状态机进入COLLECTING_PARAMS状态检查参数槽位发现缺出发地生成追问您从哪出发等待用户输入填入槽位再检查缺目的地再追问……直到所有必填参数齐了才迁移到下一个状态。这个过程可以循环任意轮次而且每一轮的状态是持久的不会因为对话轮次增加而丢失已收集的参数。二次确认是一个独立的状态节点。当参数收集完毕、准备执行危险操作下单、支付、删除、发送之前状态机迁移到AWAITING_CONFIRMATION状态把即将执行的操作摘要展示给用户等待用户明确说确认或取消。用户说确认则迁移到EXECUTING说取消则迁移到CANCELLED并清理上下文。这个状态的存在把误操作的风险从执行后补救前移到了执行前拦截。可恢复执行依赖的是状态快照。每次状态迁移时把当前状态、已收集参数、工具调用结果、时间戳等序列化存储。如果执行过程中断了网络超时、服务重启、用户关闭页面下次请求进来时先查快照恢复到中断前的状态从断点继续。而不是让用户重新说一遍需求。1.2 一段式、两段式、三段式选哪种状态机热词里提到了一段式和两段式三段式状态机这是状态机设计的经典分类在Agent场景下同样适用。一段式状态机把所有逻辑写在一个always块或一个函数里用if-else嵌套判断当前状态和输入条件。优点是写起来快缺点是状态一多就变成面条代码可读性极差。我见过一个Agent项目把意图识别、参数收集、工具调用、结果格式化全塞在一个handle_message函数里两千多行改一个分支要通读全文。这种写法在状态少于5个时还能忍超过5个就是灾难。两段式状态机把状态迁移判断和状态输出分开。第一段根据当前状态和输入计算次态第二段根据次态执行对应动作。在Agent里这对应着路由层和执行层的分离路由层只负责决定下一步去哪个状态执行层只负责在当前状态做事。这种分离让逻辑清晰很多但状态迁移条件仍然散落在路由层里。三段式状态机是我最推荐的也是嵌入式领域的标准写法。它把状态机分成三部分现态寄存器存储当前状态、次态组合逻辑根据现态和输入计算次态、输出逻辑根据现态或次态产生输出。映射到Agent开发现态寄存器 会话上下文里的current_state字段次态组合逻辑 一个纯函数transition(current_state, event, context) - next_state输出逻辑 根据next_state决定是追问、确认、执行还是返回结果三段式的好处是可测试性极强。transition函数是纯函数输入输出确定可以写单元测试覆盖所有状态迁移路径。输出逻辑独立可以mock。现态存储独立可以持久化。这三者互不干扰改一个不影响另外两个。状态机类型代码组织可测试性适用场景Agent场景建议一段式单函数if-else差状态5个简单问答Agent两段式路由执行分离中状态5-10个中等复杂度Agent三段式现态次态输出好状态10个生产级Agent我个人的经验是只要你的Agent涉及工具调用和参数收集直接上三段式。前期多写几十行代码后期省几百行调试时间。2. 核心状态设计把Agent的执行流程拆成可管理的节点状态设计是状态机的灵魂。设计得好流程清晰、扩展容易设计得差状态爆炸、迁移混乱。我踩过的坑是一开始把状态分得太细比如追问出发地追问目的地追问时间各设一个状态结果状态数量爆炸迁移逻辑重复。后来改成参数收集一个状态内部用槽位检查循环处理代码量减少70%。2.1 最小可用状态集六个状态覆盖80%场景经过多个Agent项目的迭代我总结出一套最小可用状态集六个状态就能覆盖绝大多数工具调用型Agent的场景IDLE空闲会话初始状态等待用户输入。收到输入后先做意图识别识别成功则迁移到COLLECTING_PARAMS识别失败则留在IDLE并返回没听懂请再说一遍。COLLECTING_PARAMS参数收集检查意图对应的必填参数槽位缺哪个追问哪个。所有参数齐了判断是否需要二次确认需要则迁移到AWAITING_CONFIRMATION不需要则直接迁移到EXECUTING。AWAITING_CONFIRMATION等待确认展示操作摘要等待用户确认。用户说确认迁移到EXECUTING说取消迁移到CANCELLED说其他内容则留在本状态并重新提示。EXECUTING执行中调用工具执行操作。执行成功迁移到COMPLETED执行失败迁移到FAILED需要重试则留在本状态。COMPLETED已完成返回执行结果清理上下文迁移回IDLE。FAILED失败返回错误信息提供重试或取消选项。用户选择重试迁移到EXECUTING选择取消迁移到CANCELLED。这六个状态之外还可以根据业务需要扩展比如CANCELLED已取消、TIMEOUT超时、ESCALATED转人工。但核心骨架就是这六个。注意状态数量不是越多越好。每增加一个状态就增加N条迁移路径N是状态数。六个状态有36条潜在迁移路径十个状态就有100条。所以能用状态内循环解决的不要新增状态。2.2 状态迁移表把逻辑从代码里抽出来状态迁移逻辑如果写在代码里就是一堆if-else。我习惯先画一张状态迁移表把当前状态事件→次态动作列清楚再照着表写代码。这样逻辑清晰而且这张表可以直接作为测试用例的输入。当前状态事件次态动作IDLE意图识别成功COLLECTING_PARAMS初始化参数槽位IDLE意图识别失败IDLE返回没听懂COLLECTING_PARAMS参数未齐COLLECTING_PARAMS追问缺失参数COLLECTING_PARAMS参数已齐需确认AWAITING_CONFIRMATION展示操作摘要COLLECTING_PARAMS参数已齐不需确认EXECUTING调用工具AWAITING_CONFIRMATION用户确认EXECUTING调用工具AWAITING_CONFIRMATION用户取消CANCELLED清理上下文AWAITING_CONFIRMATION用户其他输入AWAITING_CONFIRMATION重新提示确认EXECUTING执行成功COMPLETED返回结果EXECUTING执行失败FAILED返回错误COMPLETED结果已返回IDLE清理上下文FAILED用户重试EXECUTING重新调用工具FAILED用户取消CANCELLED清理上下文这张表就是状态机的宪法。代码里的transition函数严格按这张表实现任何不在表里的迁移都视为非法直接抛异常。这样能避免意外状态迁移导致的诡异bug。2.3 上下文数据结构状态机跑起来的燃料状态机本身只是骨架真正让它跑起来的是上下文数据。我设计的上下文结构包含四部分context { session_id: sess_abc123, current_state: COLLECTING_PARAMS, intent: book_train_ticket, slots: { departure: 北京, destination: None, date: 明天下午, train_number: None }, required_slots: [departure, destination, date], tool_results: {}, retry_count: 0, created_at: 2025-01-15T10:30:00Z, updated_at: 2025-01-15T10:30:05Z }slots是参数槽位required_slots是必填参数列表两者配合实现多轮补参。tool_results缓存工具调用结果避免重复调用。retry_count控制重试次数防止无限重试。created_at和updated_at用于超时判断和快照管理。这个结构可以直接序列化成JSON存Redis或数据库实现可恢复执行。我一般用Redis设置30分钟过期既保证恢复能力又不会无限堆积。3. 多轮补参的实操槽位填充与追问策略多轮补参是状态机最常用的功能也是最容易做砸的功能。做得好用户觉得这Agent真懂我做得差用户觉得怎么老问重复的问题。差别就在槽位管理和追问策略上。3.1 槽位填充的三种模式模式一顺序追问。按required_slots列表顺序缺哪个问哪个。优点是实现简单缺点是用户体验僵硬。比如用户已经说了明天下午北京到上海Agent还问您从哪出发用户会抓狂。模式二批量追问。一次性把所有缺失参数列出来问请提供出发地、目的地和时间。优点是轮次少缺点是用户可能只回答一部分还得再追问。模式三智能追问。先从用户输入里抽取所有能抽取的参数填入槽位然后只追问真正缺失的。如果缺失参数多于两个用批量追问如果只缺一个用顺序追问。这是我推荐的方式。实现智能追问的关键是参数抽取。用户说明天下午北京到上海你需要从中抽出date明天下午、departure北京、destination上海。这可以用LLM做也可以用规则NER做。我一般用LLMprompt里给出槽位定义和示例让模型输出JSON格式的抽取结果。def extract_slots(user_input, slot_definitions): prompt f 从用户输入中抽取以下参数输出JSON格式。 参数定义{slot_definitions} 用户输入{user_input} 如果某个参数未提及值为null。 result llm.invoke(prompt) return json.loads(result)抽取完成后用抽取结果更新slots然后检查required_slots里还有哪些是None只追问这些。3.2 追问话术的设计技巧追问话术看似简单其实有很多细节。我总结了几个原则原则一一次只问一个。如果缺三个参数不要一次问三个用户容易漏答。但可以在追问时提示还需要以下信息然后只问第一个。原则二带上已知信息。追问时把已收集的参数复述一遍让用户知道Agent记住了。比如您明天下午从北京出发请问到哪个城市原则三提供默认值或选项。如果参数有常见值直接给选项。比如问时间时可以说您是要上午、下午还是晚上原则四允许用户修正。用户可能在追问过程中说不对我改一下出发地这时候要能识别出这是修正意图更新对应槽位而不是当成新参数。实操心得追问话术里不要用请提供XX这种命令式用请问XX是或您想XX更自然。另外追问超过三轮还没收集齐考虑转人工或提供更明确的引导。3.3 槽位校验别等执行了才发现参数不对参数收集齐了不代表参数有效。用户说明天下午但明天是哪天用户说北京但系统里只有北京市这个选项。所以槽位填充后要做校验。校验分三层格式校验日期格式、城市名格式、范围校验日期不能是过去、城市必须在服务范围内、业务校验该线路是否有票、该用户是否有权限。校验失败时不要直接报错而是把校验结果作为追问的一部分。比如您说的明天下午我查到明天下午没有余票您看上午可以吗这样用户知道为什么被追问而不是觉得Agent在刁难。def validate_slots(slots): errors [] if slots[date] and slots[date] today: errors.append({slot: date, message: 日期不能是过去}) if slots[departure] and slots[departure] not in CITY_LIST: errors.append({slot: departure, message: f暂不支持{departure}出发}) return errors校验失败时状态机留在COLLECTING_PARAMS把校验错误信息作为追问内容。4. 二次确认的实现把危险操作拦在执行之前二次确认是Agent安全性的重要保障。没有二次确认的Agent就像一个没有刹车片的车——平时没事出事就是大事。我见过一个Agent用户说帮我清空购物车它直接调了清空接口用户后悔都来不及。4.1 哪些操作需要二次确认不是所有操作都需要确认否则用户会被烦死。我一般按操作影响范围和可逆性两个维度判断操作类型影响范围可逆性是否需要确认查询类无不涉及不需要创建类小可删除视情况修改类中可回滚建议需要删除类大不可逆必须需要支付类大不可逆必须需要发送类中不可撤回必须需要简单说只要操作不可逆或影响范围大就必须二次确认。查询类操作不需要因为查错了没损失。4.2 确认摘要的写法二次确认的核心是让用户清楚知道即将发生什么。所以确认摘要要包含操作类型、关键参数、预期结果、风险提示。比如订票场景的确认摘要即将为您执行以下操作 - 操作预订高铁票 - 车次G1234 - 出发北京南 14:00 - 到达上海虹桥 18:30 - 座位二等座 - 票价553元 确认预订请回复确认取消请回复取消。这个摘要把用户需要知道的信息都列出来了用户看一眼就能判断对不对。如果只写即将为您订票确认吗用户根本不知道订的是哪趟、多少钱确认了也不放心。4.3 确认状态的超时与重试AWAITING_CONFIRMATION状态不能无限等待。我一般设置5分钟超时超时后自动迁移到CANCELLED并返回确认超时操作已取消。这样避免用户忘了确认操作一直挂着。如果用户回复了非确认非取消的内容比如多少钱来着状态机留在AWAITING_CONFIRMATION重新展示确认摘要并补充回答用户的问题。这样用户不用退出确认流程就能获取信息。def handle_confirmation(user_input, context): if user_input in [确认, 是, 好的, yes]: return EXECUTING elif user_input in [取消, 不, 算了, no]: return CANCELLED else: # 用户问了其他问题回答后重新确认 answer answer_question(user_input, context) return AWAITING_CONFIRMATION, answer注意确认词要覆盖多种表达。确认是好的可以行都算确认取消不算了不用都算取消。但好的有歧义可能是确认也可能是敷衍我一般把好的算确认但会在摘要里再强调一次操作内容。5. 可恢复执行的落地快照、恢复与幂等可恢复执行是状态机的高级能力也是生产级Agent的必备能力。没有它用户网络一抖、服务一重启整个会话就丢了用户体验极差。5.1 快照存储存什么、存哪里、存多久快照存储的核心是存什么。我一般存四类数据状态数据current_state、上下文数据slots、intent、执行数据tool_results、retry_count、元数据session_id、created_at、updated_at。存哪里Redis是首选因为读写快、支持过期、支持原子操作。如果对持久性要求高可以Redis数据库双写Redis做热存储数据库做冷备份。存多久我一般设30分钟过期。太短了恢复不了太长了浪费存储。30分钟足够覆盖大多数中断场景网络抖动、页面刷新、服务重启。def save_snapshot(context): key fagent:snapshot:{context[session_id]} redis.setex(key, 1800, json.dumps(context)) def load_snapshot(session_id): key fagent:snapshot:{session_id} data redis.get(key) return json.loads(data) if data else None5.2 恢复流程从断点续上而不是重头再来恢复流程分三步查快照、校验有效性、恢复执行。查快照根据session_id从Redis加载快照。如果没有快照说明是新会话走正常流程。校验有效性检查快照是否过期、状态是否合法、参数是否完整。如果快照过期或状态非法丢弃快照走正常流程。恢复执行把快照里的current_state和context恢复到内存然后根据当前状态决定下一步动作。比如快照里状态是AWAITING_CONFIRMATION恢复后直接展示确认摘要等待用户确认。def resume_session(session_id, user_input): snapshot load_snapshot(session_id) if not snapshot: return start_new_session(session_id, user_input) if is_expired(snapshot): return start_new_session(session_id, user_input) context snapshot next_state, action transition( context[current_state], user_input, context ) context[current_state] next_state save_snapshot(context) return action5.3 幂等性恢复执行时别重复下单可恢复执行有个大坑如果工具调用已经成功但快照还没更新就中断了恢复后会再次调用工具导致重复下单。这就是幂等性问题。解决方案是给每次工具调用分配唯一ID工具端根据ID去重。比如订票时生成order_request_id传给订票接口接口先查这个ID是否已处理已处理则直接返回上次结果未处理才真正下单。def execute_tool(tool_name, params, context): request_id context.get(request_id) or generate_request_id() context[request_id] request_id # 先查是否已执行 cached redis.get(ftool:result:{request_id}) if cached: return json.loads(cached) # 执行工具 result call_tool(tool_name, params, request_id) # 缓存结果 redis.setex(ftool:result:{request_id}, 3600, json.dumps(result)) return result这样即使恢复后重复调用工具端也能识别出是同一个请求不会重复执行。实操心得幂等ID要在进入EXECUTING状态时生成并存到快照里。这样恢复后用的是同一个ID工具端能正确去重。如果每次调用都生成新ID幂等就失效了。6. 常见问题与排查技巧实录状态机跑起来之后总会遇到各种诡异问题。我整理了一份常见问题速查表都是实际踩过的坑。问题现象可能原因排查方法解决方案状态卡在COLLECTING_PARAMS不迁移必填参数列表为空或槽位名不匹配打印required_slots和slots对比检查槽位定义一致性追问重复问同一个参数槽位填充后未更新context打印每次追问前的slots确保填充后立即save_snapshot确认后直接执行未等用户确认状态迁移条件写错检查transition函数确认状态必须等用户输入恢复后重复下单幂等ID未持久化检查快照里是否有request_id进入EXECUTING时生成并存储状态迁移抛异常迁移表中无此路径打印current_state和event补充迁移表或加默认分支快照过大导致Redis慢存了工具返回的大对象检查快照大小只存必要字段大对象存引用6.1 状态迁移的非法路径处理状态迁移表不可能覆盖所有情况。用户可能在任何状态说任何话产生迁移表里没有的当前状态事件组合。这时候不能直接崩要有兜底逻辑。我的做法是transition函数最后加一个默认分支返回(current_state, 抱歉我没理解您的意思请重新描述)。这样非法迁移不会崩而是留在原状态并提示用户。def transition(current_state, event, context): # 查迁移表 key (current_state, event) if key in TRANSITION_TABLE: return TRANSITION_TABLE[key] # 兜底留在原状态 return current_state, 抱歉我没理解您的意思请重新描述6.2 超时与重试的控制EXECUTING状态可能因为工具超时而卡住。我一般设置工具调用超时10秒超时后迁移到FAILED并允许用户重试。重试次数上限3次超过则建议用户稍后再试或转人工。def execute_with_retry(tool_name, params, context): max_retries 3 for i in range(max_retries): try: result call_tool(tool_name, params, timeout10) return result except TimeoutError: context[retry_count] 1 if context[retry_count] max_retries: raise MaxRetryExceeded() return None注意重试要有退避策略不要立即重试。我一般用指数退避第一次等1秒第二次等2秒第三次等4秒。这样给下游服务恢复的时间避免雪崩。6.3 状态机的可观测性状态机跑在生产环境必须可观测。我一般埋三类日志状态迁移日志记录每次迁移的现态、事件、次态、工具调用日志记录调用参数、耗时、结果、异常日志记录异常类型、堆栈、上下文快照。这些日志用结构化格式JSON输出方便后续用ELK或类似工具分析。比如状态迁移日志{ event: state_transition, session_id: sess_abc123, from_state: COLLECTING_PARAMS, to_state: AWAITING_CONFIRMATION, trigger: params_complete, timestamp: 2025-01-15T10:30:05Z }有了这些日志排查问题时可以还原整个会话的状态迁移路径快速定位卡在哪个状态、为什么卡住。7. 从状态机到Agent框架什么时候该自己写什么时候该用现成的聊到这里可能有人会问现在Agent框架这么多LangGraph、AutoGen、CrewAI都支持状态管理为什么还要自己写状态机我的观点是简单场景用框架复杂场景自己写。框架的优势是开箱即用劣势是抽象层太厚出问题难排查而且定制化成本高。如果你的Agent流程是标准的意图识别→参数收集→工具调用→返回结果用LangGraph的StateGraph完全够用。但如果你的流程有特殊的分支逻辑、特殊的恢复策略、特殊的确认机制自己写三段式状态机反而更可控。我自己在项目里的做法是核心状态机自己写外围能力用框架。状态迁移、槽位管理、快照恢复这些核心逻辑自己实现保证可控性和可测试性。LLM调用、工具注册、日志埋点这些外围能力用现成库省时间。另外状态机的思想不仅适用于Agent也适用于任何有流程、有分支、有中断的系统。我见过用状态机管理订单流程的、管理审批流程的、管理游戏AI行为的。核心思想是一样的把复杂流程拆成有限状态用明确的迁移规则连接用上下文数据驱动。最后分享一个我踩过的坑不要过早优化状态机。我一开始设计状态机时想着要支持各种复杂场景设计了十几个状态结果大部分用不上反而增加了维护成本。后来改成最小可用状态集按需扩展先用六个状态跑通主流程遇到新需求再加状态。这样迭代速度快代码也干净。状态机不是越复杂越好而是刚好覆盖业务需求最好。六个状态能解决的问题不要用十个状态。能用状态内循环解决的不要新增状态。这是我在多个项目里验证过的原则。
网站建设高端定制企业官网