从ReAct到事件驱动Workflow:智能体落地的工程实践与踩坑记录

📅 发布时间:2026/10/20 10:48:44
从ReAct到事件驱动Workflow:智能体落地的工程实践与踩坑记录
前阵子做客服工单自动化我陷入了一个误区以为智能体就应该是 ReAct 那种“让模型边想边干”的循环。直到线上积压了一批工单我才意识到真正的落地不一定靠自由循环而要靠事件驱动的 Workflow——把云端 API 当成流水线分段跑。这轮改造做完我的工单机器人从“能跑通”变成了“能扛量”今天把我的完整思路、设计决策和踩坑记录整理出来给正在纠结“该让模型自由发挥还是把流程写死”的朋友一个参考。1. 先说为什么我放弃“全自动循环式”的 ReAct 写法1.1 ReAct 没什么不对错在我把它当默认选择ReAct 的核心逻辑其实特别简单让大模型在每次推理时不仅输出下一步的思考还输出要调用的工具和参数然后把工具结果再喂回模型循环往复直到模型认为任务完成。这套思路在论文里很优雅在 Demo 里也很惊艳因为它把“决策权”完全交给了模型——你不需要预判所有路径模型自己会探索。我最初做智能体时也理所当然地把 ReAct 当成了默认架构一个 while 循环、一个工具列表、一段 system prompt感觉万事大吉。面对“帮我查一下订单状态”这种单轮查询任务ReAct 确实表现得不错模型调一两次工具就能给出答案。但一旦任务变复杂比如“帮我处理一批异常工单涉及分类、审核、退款、补发、通知”循环式的写法就开始失控。问题不在于模型笨而在于 ReAct 这种模式本质上是为“不确定路径”设计的。它假设每一步都可能有多种走向所以每一步都交给模型判断。但对那些路径已经被业务规则定义得很清楚的任务这种“每一步都重新思考”的做法就是巨大的浪费——既浪费 token也浪费用户的等待时间。1.2 我在积压工单场景里遇到的三类崩溃第一批真实压力来自一次线上积压业务方导入了 200 多条历史客服工单需要机器人先分类再按类型执行退款或补发。我用 ReAct 版本跑了一轮三个问题立刻暴露出来。慢。每个工单的平均处理时间在 15 秒以上因为模型在循环里动辄调用 4~6 次工具每次都是一轮大模型 API 往返。有个工单甚至因为模型反复确认“用户是否已经退款了”来回调用了 9 次查询接口耗时 40 多秒。200 条工单跑下来预计要一个多小时业务根本等不了。贵。ReAct 循环把大量 token 花在了“思考过程”上而那些思考内容对于最终结果几乎没有增量价值。我粗算了一下平均每个工单的实际有效输出不到三分之一其余全是循环里的中间推理。成本翻了两倍不止而且很多步骤的推理内容高度重复——模型在反复“确认自己刚才做了什么”。不可观测。最让人崩溃的是排错。ReAct 的所有状态都藏在循环内存里出错了你只能看到最终失败信息根本不知道模型在中间哪一步选错了工具、传错了参数。有一次模型把退款金额从字符串解析成负数我盯着日志看了两个小时才意识到是工具返回的格式没被正确清洗。这种“黑盒式”的执行过程在小规模 Demo 里可以接受一旦上了生产环境没人敢把业务核心交给一个不可观测的黑盒子。2. ReAct 到 Workflow把“思考”和“干活”拆开才是落地2.1 两条路线的本质差异经历过那次排错之后我开始重新审视 ReAct 和 Workflow 的关系。很多人把它们理解成“智能”和“死板”的对立但实际用下来我觉得它们解决的是完全不同层面的问题ReAct 解决的是“面对未知路径模型怎么自主决策”Workflow 解决的是“已知路径怎么稳定高效地执行”。两者的核心差异可以用一张表格说清楚维度ReAct 模式Workflow 模式决策位置模型在循环内每一步决策流程定义时已经定死模型只在个别节点参与判断状态管理全部压在大模型上下文里状态显式存储数据库/对象存储任何节点可恢复可观测性差中间步骤难追踪好每个节点有独立日志和指标失败恢复难重跑整个循环成本高容易单节点重试即可成本效率低大量 token 花在中间推理高模型只在必要的判断/生成节点被调用适用范围开放式、探索式任务规则明确、路径固定的业务任务这个表格不是理论推演是我两版代码跑出来的实测结论。同样是处理 100 条工单ReAct 版本消耗了约 1.2M token耗时约 25 分钟Workflow 版本消耗约 200K token耗时约 3 分钟。在流程固定的业务场景里Workflow 几乎全面碾压。2.2 拆分时的三个设计原则从 ReAct 改造到 Workflow不是简单地把循环拆成几个函数调用而是要在设计层面想清楚“到底哪些事该让模型做”。我总结出三个原则后面所有代码都围绕它们展开。第一个原则能被规则固化的步骤永远不要交给模型。比如工单格式校验、订单状态查询、金额计算这些都是确定性的操作用代码写死比让模型“思考”安全一万倍。模型只需要负责它真正擅长的两件事语义理解和内容生成——也就是“这段用户描述属于哪类问题”和“给用户一段什么样的回复”。第二个原则模型只出现在窄接口节点。Workflow 里模型的调用点应该像函数一样输入一段被严格 schema 约束的 prompt输出一段被严格校验的 JSON。不要给模型长列表的工具让它自己挑而是把“挑工具”这个动作拆成上游节点的输出。分类节点直接输出“退款”或“补发”下游节点不需要模型再判断直接调用对应的业务 API。第三个原则每个步骤必须可独立重试、独立超时、独立观察。事件驱动天然提供这种能力每个事件节点有独立的队列、独立的失败处理策略、独立的可观测性。这也是我最终选择“云端 API 事件驱动”而不是“本地写一个同步脚本”的根本原因。3. 基于云端 API 的事件驱动骨架怎么搭3.1 四层结构入口、队列、执行、状态当时我手头的技术栈是某云厂商的函数计算、消息队列服务和对象存储。整个事件驱动架构被我拆成了四层入口层、缓冲层、执行层、状态层。入口层负责接收外部事件比如客服系统推过来的工单消息缓冲层用消息队列削峰和解耦执行层由一个个云函数节点组成状态层用对象存储或数据库记录每个工单的处理进度。入口层我接的是 API 网关加 Webhook客服系统把工单数据 POST 过来网关简单校验签名后立刻返回 200让调用方不等待实际处理结果这很关键——工单处理是分钟级的事同步等待只会引发一堆超时重试。真正的工作被放进消息队列由队列驱动后续节点。执行层是重点。我不再把所有逻辑塞进一个函数里跑循环而是拆成三个独立的云函数分类函数、执行退款函数、补发函数、通知函数。每个函数监听一个消息队列的主题消费一条消息做自己的事再把结果投递到下一个主题。这样做的直接好处是任何一个函数崩溃了消息不会丢队列会按照策略重新投递业务不中断。状态层我选的是对象存储加一个简单的状态字段。每条工单有一个唯一 ID处理过程中每个节点完成后都会更新这个 ID 对应的状态值。比如“已分类”“退款中”“已退款”“已通知”。状态写入是幂等的同一条状态消息重复投递也不会造成错误覆盖。3.2 为什么事件驱动比同步 HTTP 更适合智能体同步 HTTP 的思路是调用方发出请求一直等等到智能体把完整流程跑完再返回结果。这套模式在 ReAct Demo 里很常见因为代码简单一个函数、一个循环、一个 return。但一旦涉及多个大模型调用、多个业务 API 调用同步模式就会遇到两个绕不过去的坎。第一个坎是超时。大模型 API 的响应时间波动很大高峰期一两秒拥挤时可能十几秒。一个工单可能有 3 次模型调用加 2 次业务 API 调用最坏情况总耗时能到一两分钟。HTTP 调用方的默认超时通常设置在 10 秒或 30 秒还没等智能体干完活调用方已经断了。你当然可以把超时调到五分钟但那等于把一个脆弱的长连接挂在链路上一抖动就全链路超时。第二个坎是重试。同步链路里任何一步失败整个流程都得从头再来。而我用事件驱动后每个节点天然具备“失败重试”的能力消费失败的消息会被队列自动重新投递只需要设置重试次数和退避策略。如果重试多次仍然失败消息会被路由到死信队列等待人工介入。这完全是生产环境要的可靠性。所以我的结论是智能体的落地形态不应该是一个“能回答问题的循环”而应该是一条“有状态的事件流水线”。模型只是流水线上一个擅长判断的处理节点而不是整条流水线的老板。4. 完整 Demo从 ReAct 版工单机器人改造成 Workflow4.1 场景自动处理“退款/补发”类客服工单我用一个简化但完整的场景来说明改造过程某电商客服系统会收到用户提交的工单内容是“我买的订单 12345 少发了一件红色 T 恤麻烦补发”或者“订单 23456 收到时碎了想退款”。智能体需要做三件事第一识别工单类型退款 or 补发 or 其他第二提取关键信息订单号、商品描述、退款金额第三执行对应的后续动作发起退款 / 创建补发单。同时考虑到大模型判断不一定百分之百准确我们要求当模型置信度低于 0.8 时不自动执行转人工处理。在 ReAct 版本里这个置信度逻辑非常难嵌入因为它属于“流程内的分支”模型未必会主动遵守。而在 Workflow 版本里这只是一个简单的比较语句。4.2 ReAct 版本代码没问题业务撑不住先看我当时写的 ReAct 版本的核心伪代码大概长这样def react_agent(user_message): messages [{role: system, content: SYSTEM_PROMPT_WITH_TOOLS}] messages.append({role: user, content: user_message}) while True: response call_llm(messages, toolsTOOL_LIST) if response.finish_reason stop: return response.content for tool_call in response.tool_calls: result execute_tool(tool_call.name, tool_call.arguments) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result), }) messages.append(response.message)这段代码本身没问题配合好的模型和工具描述Demo 效果很好。问题在于生产环境不买账没有超时控制、没有重试策略、没有状态持久化而且每次循环都是“开盲盒”——你不知道模型下一步会调哪个工具。我在线上就见过模型反复调用“查询订单状态”同一个工具三次因为它无法判断这个工具的结果是否已经足够支撑退款决策。4.3 Workflow 版本每个步骤变成独立事件节点改造后的 Workflow 版本被我拆成了四个节点每个节点对应一个云函数监听不同的队列主题。入口节点只做一件事接收工单事件防重然后把工单数据投递到ticket.new队列。def on_webhook(event): ticket_id event[ticket_id] if is_processed(ticket_id): return # 幂等已处理过的事件直接丢弃 enqueue(ticket.new, event)分类节点是唯一调用大模型的地方。它从队列里拿到工单内容让模型输出一个严格 JSON包含category、order_id、item_desc和confidence四个字段。然后做两件事一是用 JSON Schema 校验输出完整性二是根据置信度决定走自动线还是人工线。def classify_ticket(payload): prompt build_classify_prompt(payload[user_message]) raw call_llm_with_schema(prompt, CLASSIFY_SCHEMA) # 强制JSON输出 data validate_or_fix(raw, CLASSIFY_SCHEMA) # 校验修复 if data[confidence] 0.8: enqueue(ticket.manual, payload | {classify_result: data}) return if data[category] refund: enqueue(ticket.refund, payload | {classify_result: data}) elif data[category] reship: enqueue(ticket.reship, payload | {classify_result: data}) else: enqueue(ticket.manual, payload | {classify_result: data})执行节点是完全确定性的代码。退款节点拿到分类结果后调用支付系统的退款 API补发节点调用仓储系统创建补发单。这个环节没有任何模型参与只有校验、调用、确认三步。def execute_refund(payload): info payload[classify_result] result call_refund_api(order_idinfo[order_id], amountinfo[amount]) enqueue(ticket.notify, payload | {exec_result: result})通知节点最后调用大模型生成给用户的回复文案把处理结果发回客服系统。注意这里是第二次调用模型但用途是“生成回复内容”不是“决定下一步动作”——决策已经由分类节点完成了。4.4 改造前后对比对比维度ReAct 版本Workflow 版本大模型调用次数/工单4~9 次2 次分类 生成回复平均处理时长15~40 秒约 3 秒置信度阈值控制模型自觉不可靠代码里硬性判断单步失败恢复整循环重跑单队列节点重试日志排查黑盒每个节点独立日志上生产难度高低这个对比不是我统计出来“好看”而是真实跑到线上的结果。特别是“置信度阈值控制”这一行ReAct 版本中我即便在 prompt 里反复强调“低于 0.8 禁止操作”模型仍然偶尔会为了完成任务强行执行动作Workflow 版本从结构上消灭了这个问题——模型根本没有权限触发退款它只能输出建议真正的闸门在代码分支里。5. 两周线上运行后我踩过的四个坑5.1 大模型返回的 JSON 不能直接当数据用Workflow 架构成型后我以为最大的风险已经过去了结果上线第一天就出事。分类节点要求模型返回{category: refund, order_id: 12345, amount: 99.9}模型确实返回了 JSON但字段解析后amount是字符串99.9元order_id里混了一个订单号前缀。我当时的天真之处在于相信模型会严格遵守 schema 示例。解决方案是两层第一层用 JSON Schema 做严格校验类型不对就报错第二层加一个修复函数针对常见错误做正则清洗。比如从字符串中提取数字、移除“订单号”之类的前缀。不要试图让模型自己修复修复逻辑用代码写死更可靠。这个坑给我的教训是模型输出是自然语言不是数据库记录一切进入业务系统之前都要过一道“清洗层”。5.2 无限重试会造成重试风暴消息队列默认的重试策略是“失败后立即重试”如果你不设置退避和上限一个下游 API 故障会造成雪崩。我的退款节点第一次上线时就踩了这个坑支付 API 临时故障退款函数不断消费失败、重试、再失败、再重试把队列的积压量瞬间推高还占满了函数计算的并发配额导致其他工单节点全被拖慢。正确处理方式是指数退避加重试上限。我把重试次数设置为 3 次退避间隔设置为 1 秒、4 秒、16 秒。超过 3 次还没成功的消息直接投递到死信队列。我另外写了一个死信处理函数每天定时扫描一次把死信中的工单批量转人工。这样既保证了系统不会被重试打垮也保证了失败消息有最终去处。5.3 事件会重复投递幂等必须先做云厂商的消息队列一般承诺“至少一次”投递也就是说同一条消息理论上可能被投递两次以上。如果退款节点处理了两次同一条工单用户就会收到两份退款。我在设计入口节点时也写了一个is_processed判断但我犯了个低级错误用内存缓存做去重云函数实例一回收缓存就丢了。后来改成了用对象存储写一个幂等标记每条工单 ID 对应一个状态文件处理前先尝试创建“处理中”标记创建成功的才继续执行创建失败说明已经有实例在处理或已处理完成直接跳过。这个方式看起来土但在云端就是比内存缓存稳妥。处理完成后把标记更新为“已完成”同时记录处理结果用户主动查询时也能看到完整节点轨迹。5.4 超时预算不设一个慢请求拖垮全链路云端函数的默认超时通常是 60 秒但这并不意味着你该把整个函数超时设为 60 秒。我的分类节点里大模型 API 调用是核心耗时点但下游执行节点完全不需要等大模型那么久。如果分类函数 60 秒后才超时消息队列会在 60 秒后才重新投递中间这 60 秒用户的工单就卡在“处理中”状态毫无动静。我给每个节点设置了独立的超时预算分类节点的模型调用内部超时设置在 20 秒函数整体超时设置在 50 秒执行节点因为只有业务 API 调用函数超时设置在 30 秒API 调用超时设置在 8 秒通知节点的模型调用超时设置在 10 秒。这样任何一段卡住都不会拖慢后续工单的消费。更重要的是超时设置要和重试策略联动超时越短重试越快用户等待时间越短。6. 什么情况我会退回 ReAct或使用两者的混合方案6.1 我的判别标准Workflow 跑顺之后我没有彻底抛弃 ReAct。准确地说我现在会根据任务特性选型判别标准就三条路径是否确定、状态是否重要、是否允许事后补救。如果任务路径完全确定——比如“收到工单 - 分类 - 退款/补发 - 通知”——Workflow 绝对优先。任何把这条路交给模型自由发挥的行为都是给自己挖坑。但如果任务路径是开放的比如“帮我研究一下这个新的开源项目看看它能不能解决我们的某个问题给出评估报告”这种没有固定步骤的探索型任务ReAct 仍然是最合理的因为它允许模型根据中间结果自主调整下一步。第三条标准很实际错误代价高不高。退款做错了要跟用户解释、要走逆向流程代价很高所以路径必须固定、必须有闸门文章摘要写错了里面多一句话少一句话代价不高让模型自由发挥完全没问题。我的总体倾向是能 Workflow 就 Workflow万不得已才上 ReAct绝不把 ReAct 当默认选项。6.2 混合方案主流程 Workflow决策点内嵌 ReAct最近我在处理一类更复杂的工单时发现纯 Workflow 也有力不从心的时候工单分类里的“其他”类别模型判断不出来归属于退款还是补发但又不是简单的转人工能解决的它可能需要模型像一个人工客服一样去追问用户。为此我加了一个混合节点主流程仍然是事件驱动的 Workflow但在“分类节点”和“执行节点”之间加一个 ReAct 子循环专门处理低置信度但还有可能自动解决的边缘情况。这个 ReAct 子循环有严格限制最多循环 3 次每次只能调用两个工具查询订单、发送追问循环结束后仍然无法确定的转入人工队列。它就像一个“兜底处理单元”既有 ReAct 的灵活性又不会无限膨胀。从业务效果看这个混合设计把工单自动处理率从 82% 提升到了 89%剩下的 11% 仍然走人工但人工处理时已经有完整的上下文记录不需要重新问用户一遍。我在这次改造中最大的体会是不要把 ReAct 和 Workflow 当两个“派系”它们只是工程上的两种手段。落地智能体时先在纸上画出业务流程图把确定环节标成 Workflow 节点把不确定环节标成 ReAct 子循环然后加上事件队列、幂等去重、超时重试这些生产级要素整个系统才真正能上线。如果你也正在犹豫自己的智能体该用哪种模式可以按这个思路重新画一遍流程图答案其实很快就能浮出水面。