ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flowing:以状态流驯服复杂交互的轻量级智能体运行时

Flowing:以状态流驯服复杂交互的轻量级智能体运行时

上周有个朋友跟我吐槽,说最近用流行的Agent框架搭了一个客服机器人,单轮对话效果还挺好,但一旦进入"用户要求退款 → 机器人反问订单号 → 用户上传凭证 → 机器人核验 → 返回结果"这种多步骤流程,整个状态管理就直接失控了。我太懂这个感觉了。做智能体这两年,我最大的体会是:模型能力早就不是瓶颈,真正劝退人的是那些"不按顺序来"的复杂交互。今天想好好聊聊的Flowing,就是冲着这个问题来的——一个面向复杂交互的轻量级智能体运行时框架。

先说清楚它解决什么问题。如果你只是做"用户说一句话,AI回一句话"的简单问答,市面上任何框架都够用。但一旦涉及多轮确认、条件分叉、等待外部事件、任务挂起后再恢复,事情就变得非常棘手。Flowing把智能体的执行过程拆成"状态流",把"挂起"和"恢复"做成运行时的一等公民,让开发者可以用接近普通编程的方式组织复杂交互逻辑。这篇文章我会从设计理念讲到核心机制,再给一个完整的客服智能体实战案例,最后聊聊我在实际使用中踩过的坑和边界。

1. 复杂交互到底难在哪:普通编排框架的三个失控点

很多团队一开始用智能体框架,都是从工作流编排入手的。拖拽节点、连线、配置参数,看起来简单,但真做复杂交互时,三个问题会依次冒出来。

1.1 失控点一:多轮记忆不知道"该轮到谁"了

先看一个最简单的场景:客服对话中,用户问"我的订单为什么还没发货",机器人查了一下说"亲,您的订单在运输途中",用户接着问"那退款的话怎么操作"。到这里,模型还能勉强理解话题切换了。但如果用户说的是"对了,刚才那个快递单号你再发我一下",甚至"那个退款我还是想弄,不过先别退运费"——这类带有指代、回退、穿插的对话,模型的注意力会被分散,经常搞不清当前到底进行到哪一步。

我之前用普通方式实现时,"记忆"就是往上下文里塞聊天记录,让模型自己从一大段文字里推断当前状态。短对话还行,超过五轮就开始出现幻觉:模型以为用户在退款流程中,实际上用户早就切到咨询物流了。Flowing的思路完全不同——当前进行到哪一步是显式状态,是运行时维护的,而不是让模型去猜。状态从哪来、能往哪走,由开发者定义,模型只是在某个节点内部做语义判断。

1.2 失控点二:条件分支一旦变多,图就变成了意大利面

工作流画到二三十个节点、几十条连线之后,图基本就没法看了。想改一条分支的逻辑,你得先花十分钟搞懂连线是怎么走的。更麻烦的是,业务里的分支往往不是穷举的,用户表达方式千奇百怪,你画图的时候根本没办法把所有可能性都枚举出来。

我见过一个团队在可视化编排平台上搭了一个"订单售后处理流",里面用条件节点判断"是否是七天无理由""是否拆封""是否影响二次销售",还叠加了不少AI判断节点。最后线上出问题,排查了一个下午才定位到——有个判断节点的连线顺序不对,导致部分用户走了错误的分支。这个问题的本质在于:业务规则适合用代码表达,不适合用图表达。Flowing的做法是把流程控制权还给代码,状态流只是"当前在哪、下一步去哪"的骨架,分支逻辑写在普通函数里,可以写测试、可以调试、可以review。

1.3 失控点三:挂起任务与外部事件之间的桥接全靠手搓

这是最折磨人的一个场景。机器人说"请上传您的付款凭证",然后就结束了这一轮——可用户可能五分钟后才上传,也可能传错文件,还可能传一半不传了。你需要在外部事件(文件上传)和内部任务(核验凭证)之间建立一条通道。

用普通框架实现时,我见过各种手搓方案:有人用Redis存状态,轮询查询;有人用Webhook回调,结果回调地址写错了,任务直接丢失;还有人把整个对话历史塞进队列,让worker重新推理一遍。这些方案都能跑,但都极其脆。Flowing把"等待外部事件"做成了语言级原语——节点执行到某个位置主动挂起,运行时负责保存现场,事件到达后再精确恢复到挂起点。这个机制我会在第三章专门展开,是整套框架最值得研究的部分。

2. 为什么叫"运行时"而不叫"平台":Flowing的设计定位

现在市面上标榜"智能体框架"的东西很多,其中相当一部分是平台。Flowing给自己的定位很明确——运行时。这个定位差别很大,决定了你怎么用它、它能给你什么。

2.1 轻量级不是"功能少",而是"边界清楚"

我最初用Flowing时,最大的意外是它的安装成本几乎是零。核心库没有任何UI、没有必须启动的独立服务、没有强制绑定的数据库,你可以在一个普通的Python进程里直接引入,作为你现有服务的一个模块来用。这跟"必须把应用部署到某个平台上去"是完全不同的体验。

轻量级的另一层意思是边界清楚。Flowing只做了三件事:管理状态流转、提供挂起恢复原语、处理事件路由。至于模型用哪个、要不要接向量库、Agent之间的通信用什么协议,它都不替你决定。这让它特别适合做一件事——作为你现有后端服务里的一个"大脑组件",嵌入式的智能体运行时。你的用户服务、订单服务照旧,需要智能体参与的逻辑调Flowing。

2.2 核心抽象:Node、State、Hook与挂起原语

Flowing的抽象层比我预想的少,四个概念基本够用。

  • Node(节点):一个交互语义单元。比如"识别用户意图""查订单状态""等待用户上传凭证",都是Node。Node之间通过返回值决定下一步走向。
  • State(状态):当前会话进行到哪一步、这一步携带什么数据。和普通代码里的对象不同,它可以被序列化保存,从而支持挂起和恢复。
  • Hook(生命周期回调):节点进入、节点退出、挂起、恢复、失败这些时机都可以挂接回调,用来做鉴权、打日志、埋点之类的横切逻辑。
  • 挂起原语(suspend/wait/emit):让节点执行到一半主动"停下来",把状态交给运行时,等待外部事件触发后继续。这是Flowing区别于大多数框架的核心能力。

我画过一张示意图,Flowing跑起来就像一个状态栈:当前节点就是一个"栈顶",它可以往下派生子节点,也可以把自己挂起然后从栈上退下来,等事件来了再把栈顶恢复回去。每个会话都有自己的状态栈,互不干扰。

2.3 与其他平台型框架的本质区别

这里说点得罪人的实话。Dify、Coze这类平台型框架,优势是"不写代码也能做智能体",对象是业务人员。Flowing这类运行时框架,对象是开发者,优势是"复杂逻辑依然在你的代码掌控之中"。两者不是替代关系,是分工关系。

维度Flowing这类运行时平台型智能体框架
部署方式作为库嵌入你的进程独立平台或云服务
复杂交互挂起、恢复、状态栈是一等公民通过可视化工作流拼装
状态存储可插拔,开发者自己接平台托管,难以迁移
定制能力代码级完全可控受平台能力边界限制
适用对象开发者、技术团队业务人员、快速验证

我自己现在做项目的习惯是:涉及到强业务逻辑、需要对接内部系统的智能体,用Flowing这类运行时;仅仅是做一个演示Demo、验证某个想法,才考虑平台型的工具。两者解决的问题不一样,硬放在一起比不公平。

3. 挂起与恢复:让智能体"等一等人"的运行时机制

这是Flowing真正值钱的地方,我单独拿出来讲。你想想,真实的业务交互从来不是一次性完成的。用户会犹豫、会去查资料、会等上级审批、会传错文件再传一次。"等一等"是复杂交互的常态,不是例外。

3.1 一个最简单的"等用户回包"到底要写多少代码

先说结论:在Flowing里,一个节点等待用户上传凭证,代码大概是这样的。

from flowing import node, suspend @node("wait_voucher") async def wait_voucher(ctx): # 告诉前端需要用户上传凭证 await ctx.emit("bot.ask", content="请上传您的付款凭证,支持jpg和png格式") # 挂起当前节点,等待事件 resp = await ctx.wait("user.voucher_uploaded", timeout=120) if resp is None: return "timeout" # 超时未上传,走超时分支 return "verify_voucher" # 收到凭证,进入核验节点

就这么简单。ctx.wait这行代码背后的运行时逻辑,手动实现起来非常麻烦——而大多数人最初并不知道这一点有多复杂。

3.2 挂起语义背后:状态栈、续跑点与幂等恢复

ctx.wait执行的那一刻,运行时做了四件事。第一,把当前Node的执行栈、上下文数据、局部变量全部序列化成一个快照;第二,把快照和会话ID绑定,存到你配置的持久化存储里(Redis、数据库都行);第三,向事件中心登记一个等待者,声明"这个会话在等某个事件";第四,向调用方返回一个挂起状态,告诉外层服务"这轮任务还没结束"。

当事件到达时,运行时根据会话ID找到对应的等待状态,反序列化快照,把现场恢复出来,然后从挂起点继续往下执行。这个机制很像你在浏览器里填一个很长的表单,切出去干了别的事,回来刷新一下,表单数据还在原地等你——只不过Flowing对应的是智能体的执行栈。

恢复的时候有个很关键的工程问题:幂等。恢复后可能要继续调用外部API,如果不做防护,同一笔退款可能执行两次。我的做法是在快照里维护一个"已执行的副作用清单",节点调用外部接口前先登记,恢复时检查登记,已经调用过就直接跳过。这一步光靠框架不够,需要你配合设计,但对生产环境来说特别重要。

3.3 会话快照与恢复:进程重启后对话怎么续上

挂起和恢复还有一个容易忽视的应用场景——进程重启。智能体服务发布新版本、机器崩溃、容器重新调度,这些在生产环境中天天发生。如果你把智能体状态都放在内存里,重启一次所有进行中的会话全部丢失,用户就得从头再来。

Flowing的快照可以持久化,就意味着进程重启之后,加载会话ID,从存储里取出最近的快照,可以原封不动地恢复。但也别高兴太早,有两点要注意:一是快照里不能放不可序列化的对象。比如你持有某个数据库连接、某个内存锁,序列化时就会炸。二是恢复时需要检查内部节点的版本,如果代码逻辑改了,旧快照恢复过来可能跑不通。这个问题我会在第六章的"状态结构兼容"里详细讲。

4. 动手写一个会分叉、会等待、会回退的客服智能体

理论说再多,不如跑通一个真实的例子。我拿"订单售后客服"来演示。这个场景涵盖了分叉路由、等待外部事件、条件回退、多轮循环,正好是Flowing最擅长的领域。

4.1 先从交互状态流开始设计

拿到需求不要急着写代码,先画状态流。我的习惯是在纸上、白板上把"用户在什么状态、做了什么操作、会跳到哪个状态"写清楚。售后客服的状态流大概是这样的:

  • 入口节点接收用户消息,判断意图,分三条路:退款流程、订单查询、转人工。
  • 订单查询简单很多:查数据库,生成回复,结束。如果用户继续说新话题,重新进入意图判断。
  • 退款流程是重点:先查订单,然后进入等待凭证挂起节点。用户上传凭证后,调用视觉模型核验凭证,核验通过就进入退款确认,不通过就退回挂起点,告诉用户重新上传。
  • 转人工:创建工单,通知客服,结束当前自动节点,等待客服接管。

用户随时可能说"算了不退了""我还是先查物流吧",这类中途退出怎么处理?Flowing的每个挂起节点都可以监听"取消"事件,收到之后清理状态,回到入口节点重新做意图判断。挂起节点不是死等,它挂了也可以被"掐醒"。

4.2 节点实现与路由声明

每个节点是一个普通的async函数,用@node装饰器标记。返回值就是下一步的路由目标。

from flowing import Runtime, node, suspend, start, event @node("intent_route") async def intent_route(ctx): text = ctx.data.get("user_text", "") intent = await ctx.llm_judge( "判断用户意图", candidates=["refund", "query", "human", "cancel"], user_text=text ) return intent # 路由到同名节点,如 "refund" @node("refund") async def refund(ctx): order_id = await ctx.llm_extract("从用户消息中提取订单号", ctx.data.get("user_text")) order = await ctx.call("order_service.get", order_id=order_id) if order is None: await ctx.emit("bot.say", content="没有查到这个订单,请核对订单号") return "refund" # 回到自己,等用户重新提供订单号 # 订单有效,进入等待凭证节点 ctx.state["order"] = order return "wait_voucher"

注意refund节点里的循环:用户给的订单号不对,就回到自身节点再来一次。状态流天然支持这种"回到自己"的循环,不需要额外处理。

4.3 触发外部事件:凭证上传与动态恢复

接下来是等待凭证的节点,以及事件接入的代码。

@node("wait_voucher") async def wait_voucher(ctx): await ctx.emit("bot.ask", content="请上传付款凭证") resp = await ctx.wait("voucher_uploaded", timeout=120, cancel_events=["user.cancel"]) if resp is None: await ctx.emit("bot.say", content="等待超时,您可以随时回复'继续'重新上传") return "suspend_forever" # 这个节点进入长挂,不占用异常路径 ctx.state["voucher"] = resp.get("file_url") return "verify_voucher" @node("verify_voucher") async def verify_voucher(ctx): result = await ctx.call("vision_service.verify", url=ctx.state["voucher"]) if result["pass"]: return "confirm_refund" await ctx.emit("bot.say", content="凭证不清晰,请重新上传") return "wait_voucher" # 回退到挂起点,让用户重新传 @node("confirm_refund") async def confirm_refund(ctx): await ctx.call("refund_service.create", order_id=ctx.state["order"]["id"]) await ctx.emit("bot.say", content="退款申请已提交,预计1-3个工作日到账") return "done"

事件接入的部分,在服务启动时注册一个入口函数:

runtime = Runtime() @event("voucher_uploaded") async def on_voucher_uploaded(session_id, payload): # 校验文件合法性,然后交给runtime await runtime.resume(session_id, "voucher_uploaded", payload)

这个resume就是核心调用:外部系统接收用户上传后,调它一声,运行时内部找到对应的等待者,恢复快照,继续执行。整个链路下来,等待、恢复、回退、再等待都通过状态机管理,没有一行手搓的回调逻辑。

5. 一些实测里才看得见的边界与取舍

好用归好用,但Flowing也不是银弹。跑了几个项目之后,我陆续碰到了一些有意思的边界情况,分享出来帮大家提前避坑。

5.1 挂起任务多了之后的内存与存储开销

挂起节点会产生快照。每个挂起中的会话,快照要保存当前的状态数据、事件等待信息、调用栈上下文。我大致估算过,一个中等业务复杂度的客服会话,快照序列化之后大约5到20KB。1000个并发挂起会话,就是10到20MB的存储占用——这个量级对Redis完全不是问题,但如果你的应用是单机内存模式,挂在内存里开销就会累积。

我的建议是:生产环境一定接持久化存储,不要用默认内存模式;给挂起会话设总数量上限,超过上限直接拒绝新挂起任务,否则存储会慢慢被撑爆;定期清理超时且已被放弃的会话,那些超时后用户再也不回来的挂起节点,就是泄漏的状态。

5.2 循环、重试与死锁:状态机也救不了所有错

状态流的分叉逻辑很清楚,但它不会自动识别"死循环"。比如用户一直输入不匹配的订单号,节点会不断回到自身。虽然每轮都要用户输入,逻辑上不算死循环,但资源消耗是实打实的。我会在每个会话上统计节点执行次数,超过阈值比如30次,直接拉起人工兜底。

还有一种坑是事件永远不会到达的"死等"。一个会话挂起在等待用户上传凭证,但用户早就关掉页面走了。虽然设了timeout,但timeout之后的处理分支如果也挂起等待别的事件,就可能形成事实上的状态泄漏。我的规则是:任何等待节点都必须有明确的超时分支,超时分支最终必须落到"结束会话"或"转移给人"两个方向之一。

5.3 什么场景不建议用Flowing这样的轻量运行时

做技术选型时知道"什么时候不用"比"什么时候用"更重要。我个人的判断标准是这样的:

  • 纯线性任务、没有挂起、没有复杂分支的简单问答,用Flowing是杀鸡用牛刀,普通的多轮对话库就行。
  • 需要强人工审批、复杂会签逻辑的BPM场景,这是专门的工作流引擎的地盘,Flowing这种偏Agent的运行时不适合。
  • 对"可解释性"要求极其严格的金融风控场景,状态流虽然能显式表达流程,但模型判断仍然有概率性,你需要的可能是完全确定性的规则引擎。
  • 高频事件驱动的数据管道,比如实时聚合、窗口计算,直接上流处理框架,没有必要让智能体参与。

![免责声明:以上判断基于我在业务落地时的经验,具体选型还是要结合你们团队的技术栈和业务特征。]

6. 接入生产环境前,值得先想清楚的三个工程问题

框架本身跑通了Demo只是第一步,接入真实业务时,我强烈建议先把下面三个问题想清楚,不然后面排错会非常痛苦。

6.1 可观测性:把状态流转日志捞出来

智能体跟普通接口最大的不同是:它的执行链路很长、跨越的时间很长(可能挂起几分钟甚至几小时)。没有可观测性,出了问题根本没法查。我在项目中统一给每个会话打了trace_id,从入站消息开始贯穿全链路。Flowing每个状态转移都打JSON结构化日志,内容包括:trace_id、session_id、from_state、to_state、事件类型、耗时、节点返回值。

举个例子,排查"用户为什么一直收不到退款确认"时,只需要查这个会话的完整状态流转记录:

{"trace_id":"ab12","session_id":"s_88","event":"state_transition","from":"intent_route","to":"refund","cost_ms":320} {"trace_id":"ab12","session_id":"s_88","event":"state_transition","from":"refund","to":"wait_voucher","cost_ms":1250} {"trace_id":"ab12","session_id":"s_88","event":"suspend","node":"wait_voucher","reason":"voucher_uploaded_timeout","wait_ms":120000}

看到最后一行,就知道问题出在用户根本没上传凭证——不是核验环节出了问题。这种排查效率,远远高于翻聊天记录猜状态。

6.2 并发控制与超时熔断

并发控制方面,框架本身会处理不同会话之间的并行,但同一个会话的多个事件同时到达时,需要防止状态错乱。我的做法是"一个会话对应一个执行队列",所有事件入队串行处理,保证同一时刻只有一个执行栈在跑。这套模式跟消息队列的消费模型很像,实现成本不高,但能避免很多灵异Bug。

超时熔断我建议分两层。一是挂起等待的超时,等待外部事件不要无限期等下去;二是节点内部调用的超时,比如调用LLM或者视觉服务,必须设置单次调用超时,超时之后有降级策略。我之前遇到过最糗的情况:外部视觉服务响应慢了,导致整个退款流程卡住,后续所有依赖这个节点的会话全部排队,活活堵了十分钟。

6.3 平滑升级:状态结构变更时的兼容策略

这是最容易被忽略的工程问题。你线上跑着一批挂起中的会话,它们的状态结构是按照旧版代码序列化的。这时候你发了一个新版本,改了某个节点的数据结构——事情就来了。旧快照恢复出来,新代码按新结构去读,直接空指针。

我的解决方案是给状态快照加version字段。版本不匹配时,走迁移逻辑:

MIGRATIONS = { 1: migrate_v1_to_v2, # 把旧结构转成新结构 2: migrate_v2_to_v3, } async def restore_snapshot(snapshot): ver = snapshot.get("schema_version", 1) while ver < CURRENT_VERSION: snapshot = MIGRATIONS[ver](snapshot) ver += 1 return snapshot

如果旧快照已经破到无法迁移,我会选择放弃那个会话,给用户发一条"抱歉,之前的流程已超时,请重新发起"的消息。线上运行时宁可干净地放弃,也不要让脏状态留在系统里。


说点个人的真实体会。用了Flowing这类运行时框架之后,我做智能体的开发方式改变挺大的。以前拿到需求,我的第一反应是"数据结构怎么设计";现在拿到的第一反应是"状态流怎么画"——入口在哪、分几支、哪一步需要挂起等待、超时走哪条路、用户取消怎么优雅退出。这种思考方式逼着我把业务逻辑在动手写代码之前就理透,反而省了很多返工的时间。

如果你也正在被智能体的"复杂交互"折磨,我的建议是别急着继续堆逻辑了,先换个思路:把交互画成状态流,把挂起变成一等公民,让运行时帮你兜住状态。等习惯了这个模式,你会发现自己写的智能体,不再是一个脆弱的"聊天记录+模型推理"缝合怪,而是一个结构清楚、可以测试、可以运维的正式服务。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进