ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

智能体运行时框架Flowing:用状态机与事件总线管理复杂交互

智能体运行时框架Flowing:用状态机与事件总线管理复杂交互

干智能体开发的同行应该都有过这种体验:功能demo跑得很顺,一上复杂交互就失控。多轮对话里上下文悄悄变脏,工具调用结果和决策逻辑纠缠在一起,一个分支状态漏处理,整个会话就像脱缰的野马。去年我做一个客服场景的智能体,代码从三千行膨胀到一万多行,一半时间都在救火——改A分支出错B状态,调C工具又污染了D轮对话的上下文。那时候我意识到,问题不在某个prompt写得不好,而是缺一个真正能管住“复杂交互”的运行时。

后来我花了几个周末捣鼓了一个叫Flowing的轻量级智能体运行时框架,核心思路很朴素:把状态流转、工具调用、流式输出、多智能体协作这些事从业务代码里剥离出来,交给一个专门搞交互管理的运行时去处理。这篇文章我把自己从设计到落地的完整思路、踩过的坑、以及一些关键代码片段整理出来,希望能给正在做智能体应用、或者纠结要不要自研框架的朋友一些参考。

1. 复杂交互到底复杂在哪:从单轮问答到有状态协作

1.1 一个客服场景里暴露出的状态灾难

先说我那个客服智能体的真实情况。表面看需求不复杂:用户提问、系统检索知识库、必要时调用订单查询和售后登记两个工具、最后生成答复。但实际跑起来,问题全藏在“轮次”和“分支”里。

用户可能说“帮我看看上个订单”,系统得记得“上个订单”指的是哪个;用户可能在工具调用中途打断说“算了先不查了”,系统得能撤销已经进入执行序列的工具调用;用户可能一句话里同时表达了查询和投诉两种意图,系统得决定是串行处理还是并行拆解。这些行为用传统的request-response模式根本描述不清楚——每一轮对话的真正含义,都依赖之前的状态积累。我的第一版代码用一堆if-else加全局变量硬撑,结果就是状态散落在各个回调函数里,Debug的时候根本不知道“当前系统到底处于什么阶段”。

这就是典型的“复杂交互”问题:它不只是多轮对话,而是多状态、多分支、多工具、可能还有多智能体参与的有状态协作。处理它不能靠堆业务代码,得靠一个能管理状态的运行时。

1.2 一个运行时框架要管好的四件事

当时我给自己列了一个问题清单,做完调研之后发现,智能体运行时框架至少要管好四件事:

第一,会话状态管理。谁在什么阶段、已经积累了哪些上下文、当前所处的状态节点是什么,这些必须有明确的数据结构承载,不能藏在调用栈里。

第二,工具调用的生命周期。工具注册、参数校验、调用执行、结果回填、超时处理、失败重试,每个环节都要有标准流程。否则工具一多,边界情况能把人折磨疯。

第三,流转控制。系统什么时候该调用大模型,什么时候该执行工具,什么时候该交给另一个智能体,什么时候该结束回合,这些要由运行时统一编排,而不是散落在prompt里靠大模型“自发”决策。

第四,交互通道。客户端到智能体的消息格式、流式推送、中断恢复、事件通知,这些需要一套稳定的协议。没有它,前面做得再好都很难接入真实业务。

这四个问题,就是我设计Flowing的出发点。整个框架的架构决策,几乎都是围绕“把这四件事变成可复用能力”展开的。

2. Flowing的核心抽象:状态机、事件总线和工具注册表

2.1 用状态机描述会话流转

我第一个决定性设计是用显式状态机来管理会话生命周期。所谓状态机,说人话就是把“智能体当前在干什么”归类到有限几个状态里,比如idle(空闲)、awaiting_input(等待用户输入)、processing(正在思考)、waiting_tool(等待工具结果)、responding(正在回复)。每个状态定义好合法迁移路径,非法迁移直接报错。

from flowing.core import State, StateMachine class AgentStates(StateMachine): initial = State.IDLE idle = State( name="idle", on_enter="reset_session_context", transitions={ "user_message": State.AWAITING_INPUT, }, ) awaiting_input = State( name="awaiting_input", transitions={ "receive": State.PROCESSING, "timeout": State.IDLE, }, ) processing = State( name="processing", transitions={ "tool_required": State.WAITING_TOOL, "response_ready": State.RESPONDING, "needs_human": State.AWAITING_INPUT, }, ) waiting_tool = State( name="waiting_tool", transitions={ "tool_success": State.PROCESSING, "tool_failure": State.PROCESSING, }, ) responding = State( name="responding", transitions={ "complete": State.IDLE, "error": State.IDLE, }, )

刚开始会有人觉得这套设计多此一举——反正都是调大模型,状态机不是增加心智负担吗?但用下来我发现,状态机的真正价值不是“画流程图好看”,而是强制你思考每一个迁移分支。以前写if-else,漏掉分支是常态;现在状态定义里没有的迁移,运行时直接拒绝执行,等于有一双看不见的手在检查你的逻辑完整性。这个特性在排查问题和保证代码安全上,价值极高。

2.2 事件总线与响应式流转

光有状态机还不够,因为智能体运行过程中会产生大量异步事件:用户发了新消息、工具返回了结果、某个插件触发了外部回调、定时器到期了。这些事件如果都用“状态机主动去拉”的方式处理,代码就会变成一堆轮询和回调地狱。

Flowing里我实现了一个轻量级事件总线。所有IO、插件、工具调用都会发出事件,比如message.received、tool.executed、agent.started、stream.chunk。状态机监听它关心的事件,不关心的一律无视。这个设计借鉴了我以前写前端时的响应式思路——数据变化了,界面自动更新;对应到智能体里,就是状态机根据事件自动迁移。

这里有个实践经验值得分享:事件总线的消息尽量做成不可变对象,只在需要时做浅拷贝。智能体运行时往往要同时处理多个会话,事件如果被某处意外修改了,排查成本极高。Flowing里所有事件都用类似dataclass(frozen=True)的方式定义,既保证了线程安全的心智模型,也让调试的时候少了很多“这个字段怎么被改了”的疑难杂症。

2.3 工具注册与执行策略

工具管理这块我踩过不少坑。最初我把工具调用逻辑直接写在状态机的processing分支里,结果每加一个新工具就要改状态机的代码,维护成本直线上升。后来才决定增加工具注册表。开发者在注册时只需要声明工具的schema和执行函数,运行时负责和模型层的function calling协议对接,以及invoke时的生命周期管理。

from flowing.core import ToolRegistry, ToolResult registry = ToolRegistry() @registry.register( name="query_order", description="根据用户订单号查询订单状态", parameters={ "order_id": {"type": "string", "required": True}, "customer_level": {"type": "string", "enum": ["normal", "vip"]}, }, timeout=10.0, max_retries=2, ) def query_order(order_id: str, customer_level: str = "normal") -> ToolResult: # 真实场景这里会去调用订单服务 data = mock_order_service(order_id, customer_level) return ToolResult.ok(data=data)

在真实项目中,注册表的价值主要体现在三方面:一是统一了工具调用的超时和重试策略,不用每个工具自己写try-except;二是方便做工具调用的审计日志,每次调用都有标准化的Trace上下文;三是新工具接入成本降到最低——写一个函数、加一个装饰器,完了,状态机和prompt基本不需要动。

3. 轻量级设计原则:不堆功能,只做关键路径

3.1 运行时内核的最小集合

做框架的人最容易犯的毛病是功能越加越多,最后变成一个谁也用不动的大怪兽。我在设计Flowing时给自己立了一条规矩:内核只放那些“没有就玩不转”的能力,可做可不做的一律做扩展插件。

最终内核留下的东西就四件:状态机、事件总线、工具注册表、会话存储接口。其他像各种模型适配器、向量检索、缓存策略、多智能体通信协议,全部作为独立模块按需引入。当你需要跑一个最简单的demo时,只需要状态机和事件总线两份依赖,整个运行时内核代码量控制在几千行以内,启动成本几乎为0。

这么设计有一个非常实际的好处——出问题的时候排查范围小。如果智能体行为异常,先查状态机的迁移逻辑,再查事件处理函数,这两块是固定逻辑;模型输出不稳定导致的随机行为,单独归到适配层去看;工具调用异常,只需要盯着注册表里那个工具的执行函数。分层清晰了,生产环境的救火效率会高很多。

3.2 在轻量和功能完整之间找平衡

当然,“轻量”不是“功能少”的同义词。我的判断标准是:高频使用的核心路径必须开箱即用,低频的进阶场景允许通过几行代码接入。

比如默认情况下来一个大模型提供商,我支持OpenAI兼容格式,因为这是事实标准;你要接自家部署的国产模型,只要它提供了OpenAI兼容接口,同样三行配置搞定。再比如流式输出,这个我认为是今天智能体交互的刚需,所以放进内核,不是一个插件功能。而多智能体协作这种相对复杂的用法,则做成独立模块——你需要多智能体,就多导入一个依赖,用消息通道把各个运行时实例连起来;你不需要,就完全不会被这部分代码影响到。

这种取舍需要清醒的自我认知。框架不是越全能越好,而是要在“开箱即用”和“按需扩展”之间找到清晰的边界。我为每一个计划中的功能都列过一个表:使用频率高不高?加入内核会不会影响主路径性能?有没有成熟的替代方案?三关全过的,才配进内核。

3.3 性能基准与资源占用

还有人在技术群里问我,用状态机做会话管理会不会性能开销很大?我自己做了一组基准测试,场景是模拟一个包含4次工具调用的完整电商售后会话,对比Flowing和一组自己手写的async逻辑代码。

  • 纯消息分发开销:Flowing的耗时在15毫秒以内,手写版本大概8毫秒,差距主要在事件总线的序列化环节。
  • 带完整会话持久化的场景:Flowing整体耗时约28毫秒,手写版本因为各模块接口不统一,反而花了很多时间做数据格式转换,总耗时35毫秒朝上。
  • 内存方面,单个会话对象占用的额外内存约为3-5KB,这在大模型Token开销面前基本可以忽略。

Flowing的框架开销大概在全体耗时的5%-8%之间。这个数不算最低,但换来的是统一的状态管理、事件追踪和工具处理流程,我认为非常值。也建议大家在做选型时,别只盯着框架的运行时开销,智能体应用的大头永远是模型推理和外部IO,框架只要不出现指数级的恶性损耗,都可以接受。

4. 流式输出实战:从SSE封装到客户端恢复

4.1 为什么流式响应是智能体交互的刚需

做过聊天机器人的朋友应该都有体会,如果用户提问后要等5秒才看到完整回复,体验会非常糟糕。这里不仅仅是“等待时间长”的问题,更重要的是心理感知——流式输出让用户觉得系统“已经懂了”并且在“认真思考”,哪怕实际总耗时一样,主观体验也完全不同。

智能体的流式输出和普通大模型直连的流式输出还有些不同。普通场景只需要往外吐token字符串,智能体场景则要把“过程”也流式通知出去。具体来说,我处理的几类事件包括:智能体开始处理某个阶段、准备调用某个工具、工具返回结果、模型生成了一段文本、最终回复完成。这些事件按顺序推给客户端,前端就能渲染出“正在理解→正在查单→已查到→正在生成回答”的过程状态。

4.2 Flowing里的事件协议设计

我做了一个非常简单的SSE协议封装。每个事件通过data:字段推给客户端,字段类型如下:

事件类型说明关键字段
agent.status智能体状态变化state,例如processing、waiting_tool
tool.started开始调用某个工具name、arguments
tool.completed工具执行完成name、result_summary
message.delta大模型生成的文本增量content
message.complete一条完整消息生成完毕message_id、content
error运行过程出错code、message、recoverable

这段协议配套一个简单的Python封装,核心代码如下:

async def sse_publish(event: str, data: dict, request_id: str): payload = { "event": event, "request_id": request_id, "data": data, } await publisher.publish( f"event: {event}\ndata: {json.dumps(payload)}\n\n" )

这个协议我后来在好几个项目里复用了,可以说直接帮我省掉了前端联调时各种“消息格式对不上”的撕扯。建议所有做智能体应用的朋友,哪怕是临时demo,也尽早规范好事件协议,不然后面每次改需求,前端后端都难受。

4.3 流式连接的断线与恢复处理

如果要给流式输出挑一个最麻烦的问题,断线重连一定排第一。用户的网络不会一直稳定,服务端也可能在生成过程中重启,如果断线就丢弃整个会话,体验同样糟糕。

我的处理思路是:流式消息全部落一个事件日志,客户端带了last_event_id重连时,服务端从断点续推。这有点类似消息队列里的消费组机制,实现上也只是加了一层游标记录。同时我会在SSE连接的心跳包里带上request_id和event_sequence,让客户端能判断自己有没有漏消息。

这个设计有个容易被低估的细节:流式输出的消息要尽量设计成幂等可重放的。客户端收到重复的事件,应该能直接忽略,而不是把重复文本追加渲染出来。Flowing里每个事件都分配了全局递增的sequence,客户端用last_event_id做去重,实测下来重连后的UI渲染基本不会出乱子。

5. 多智能体协作与外部系统接入

5.1 单智能体的边界,以及什么时候该拆

很多业务场景单靠一个智能体做不完。比如企业客服场景,一个负责售前咨询,一个负责订单处理,一个负责质检和风控,同时在线。继续把所有职责塞进一个智能体,prompt会越来越长,状态管理会越来越混乱,模型输出的稳定性也会随着职责增多而明显下降。

我个人的经验是,从这些迹象出现时就应该考虑拆分智能体:提示词超过两三千字、依赖的工具超过几十个、希望不同职责有不同的响应语态和权限边界。拆成多个智能体,每个专注单一职责,接收明确格式的上下游消息,维护成本会直线下降。

5.2 协作模式与消息路由

Flowing的多智能体协作采取的思路是每个智能体一个运行时实例,之间通过message channel交换消息。这种设计的好处是隔离性极强——一个智能体挂了不会拖垮整个系统,新加一个智能体也无非是启动一个新实例,再订阅消息通道即可。

from flowing.contrib.multi_agent import MessageChannel, AgentNode billing_agent = AgentNode("billing", message_channel=channel) support_agent = AgentNode("support", message_channel=channel) @channel.on("support.need_billing") async def handle_billing_handoff(message): # 把客服智能体收集到的订单信息传递给账单智能体 billing_agent.send({"session_id": message.session_id, "order_id": message.order_id})

路由规则我们一般不用太复杂的语义判定,就用简单的event prefix匹配。比如support.*的消息给客服智能体,billing.*的消息给账单智能体。复杂路由逻辑让运行时自己猜,猜错的代价太高,不如在接入层就定死。

5.3 和外部平台工具对接的技巧

智能体不能永远只活在内部沙箱里,总要调用真实业务系统的接口。这里最实用的建议是:工具适配层要写成薄薄的一层,别把业务逻辑塞进来。Flowing里我推荐的做法是,工具函数只做参数格式化、发起调用、返回标准化的ToolResult,业务校验留在工具后面的真实服务里面。

这样设计的好处是工具可以被不同会话、不同智能体复用。同样的订单查询接口,客服智能体可以调,账单智能体也可以调,因为参数语义是通用的。只要工具层面没有和某个具体的prompt耦合,后续维护和升级都会很从容。

6. 容错与可观测性:生产环境必须面对的事

6.1 模型输出不规范时的容错策略

做智能体开发必然会遇到一个经典问题——让大模型输出的JSON突然不符合schema了。有时候多了一个字段,有时候把字符串写成了裸文本。处理不好,整个工具调用链路就会中断。

我采用的是校验与重试的容错策略。工具调用参数先做本地schema校验,失败后会把校验错误和原始输出拼进一条修正prompt,让模型自己纠错,最多重试2次。实测下来的效果是:在比较复杂的电商场景里,一次通过率从刚开始的80%左右提升到95%以上,重试1次就能解决绝大多数问题。别盲目重试3次以上,开销太大且收益递减,2次是性价比很合适的上限。

还有一类更讨厌的情况是:模型跑偏了,开始输出和当前场景无关的内容。我后来在状态机里加了异常分支迁移——模型输出解析器发现无法映射到任何合法迁移时,会触发error状态,并返回一条“我可能没完全理解,请换个说法”的消息。这个过程不需要业务代码干预,运行时自动兜底。

6.2 追踪与日志:一次请求的完整链路

智能体调试最头疼的是链路太长,一次用户请求,可能经历了“大模型→工具→大模型→另一个智能体→工具→客户端”,哪一环出问题都不好定位。我一开始靠print大法,后来发现根本不够用——要复现一个生产环境的case,缺太多上下文了。

后来我在Flowing里加入了trace机制,核心是给每个session维护一个全局Trace上下文,记录关键节点的事件和耗时。结构大概长这样:

session_id: 8f3a2b... chain: - event: message.received content: "帮我查一下上个订单" ts: 12:00:01.120 - event: tool.started name: query_order elapsed_ms: 68 - event: tool.completed status: ok elapsed_ms: 210 - event: llm.started model: qwen-plus input_tokens: 1820 - event: message.delta content: "您上一个订单" ts: 12:00:03.450

每个环节的耗时、Token数、工具参数、返回摘要都记下来。生产环境出问题时,我直接拉一份trace出来,十有八九一眼就能看出瓶颈在哪。框架只做结构化记录,分析靠人或者后续接一个可视化工具,这样避免过度设计。

6.3 超时、限流与资源释放

智能体跑起来之后的资源释放问题也值得专门说一说。最容易被忽视的是流式生成过程中,客户端断开连接,服务端的大模型生成任务还在继续跑,浪费Token不算,还可能把状态机卡在responding状态。

Flowing里我加了生成任务的超时控制和连接状态检测。SSE连接断开超过一定秒数(一般设30秒),运行时就会取消当前的大模型调用,把状态机强制迁回idle,并清理会话缓存。多个会话同时跑的时候,用信号量做一个并发限制,避免某个热点时段把后端服务打垮。这些细节听起来不酷,但实际撑起生产环境稳定性的,恰恰就是这些最土的办法。

7. 对比“平台拖拽”和“代码自建”:框架适合谁

7.1 平台类工具的优势与局限

现在市面上一堆可视化智能体平台,拖拖拽拽就能搭一个知识库问答机器人,确实方便。我试用过几个,优点是上手快、插件生态全、非技术同学也能玩。但真到了复杂业务场景,问题就出来了:

  • 平台的编排能力是有边界的,复杂条件分支、多轮状态依赖、动态工具选择,平台的节点类型不一定能覆盖。
  • 很难进行代码级定制。你拿不到状态机里那个关键分支的控制权,只能接受平台抽象出来的有限选项。
  • 结构化日志和链路追踪通常比较弱,出问题排查困难。
  • 跨系统深度对接时,平台的连接器不一定支持你要的协议。

结论很明确:平台适合快速验证和标准场景,当你的交互复杂度超过平台节点类型能表达的边界时,就该考虑自建植根于代码的运行时了。

7.2 借助Flowing这类代码框架的正确姿势

和平台相比,Flowing这类框架的思维方式更接近“自己在写业务系统时引入一套流程管理工具”。入门确实比拖拽平台陡峭,因为它要求开发者理解状态机、事件、工具注册这些概念。但一旦吃透,可控性就会大幅提升:

你可以自己控制状态迁移的每一处分支;每接入一个新工具基本就是十几行代码的事;日志、监控、容错策略全都可以走统一通道。如果你本身就是做代码开发的,团队也具备长期维护智能体中台的能力,那直接拥抱代码框架是长期来看更顺手的路。

7.3 团队决策的一些实用标准

如果你正在纠结选型,我建议按这几个维度打分:交互复杂度高不高?是否要求深度定制?是否需要全链路可观测?开发团队是技术型还是业务型?有没有长期迭代和维护的预算?如果前面几个全部选中,那大概率你需要的是一个代码级运行时框架。

还有一点很重要:不要一开始就追求大而全的完整平台。可以先从一个小场景入手,用代码框架把这套状态管理和工具调用的基础能力跑通,再逐步把其他业务搬进来。这个路径的风险最小,踩坑成本也最低。

8. Flowing落地过程中的关键优化

8.1 上下文压缩与长期会话

智能体会话时间长了,上下文膨胀是必然的,Token成本会一路走高。一个真实跑了几十轮的售后会话,把全部聊天记录都塞进prompt,大几千Token是常事。

我用的策略是分级压缩。最近的几轮对话保持完整;早期对话按轮次抽取关键信息,比如订单号、用户诉求、已提供的答复,摘要成结构化的上下文。这块实现并不复杂,核心是一个摘要器和上下文组装器。实测在一个高频客服场景里,长期会话的Token消耗下降了四成,而关键信息的召回基本没受什么影响。

这个经验也说明了运行时框架的价值:当你把上下文管理、状态管理、工具管理都收拢到固定层时,做优化就不用到处改业务代码了。统一改上下文装配逻辑,所有会话自动生效。

8.2 会话持久化与水平扩展

生产环境里,智能体服务必须要考虑会话持久化和水平扩展,不然重启即失忆。我最初用内存存储,一切正常;一上多实例,发现问题来了——一个请求落到A实例,下一个请求落到B实例,状态完全对不上。

解决办法是给会话存储增加一个可插拔接口,生产环境落Redis,开发环境落内存。框架层面只依赖一个Storage协议,不关心具体实现。这样每条线程中各实例共享同一份会话状态,用户连续对话的粘性也就有了保障。多智能体场景下,同一个session的消息通道也基于Redis消息订阅实现,跨实例的AgentNode联系就顺畅了。

8.3 模型能力差异巨大的应对方案

最后说一个容易让人上火的坑:不同大模型的能力参差不齐,工具调用格式、输出稳定性、生成长度都差很多。同一套prompt在模型A上表现得很好,换到模型B上就开始胡言乱语。

我现在的思路是三层隔离。第一层是适配器层,负责把不同模型的调用接口和返回格式统一成Flowing自己的标准格式;第二层是策略层,允许为不同模型配置不同的工具调用提示词模板和重试参数;第三层是路由层,简单的任务放便宜的小模型,复杂任务走大模型。这样整体成本下来了,稳定性也上去了。如果你们团队也在做智能体产品,建议尽早把“模型路由”放进架构图里,别等账单爆炸才想起这回事。

RELATED READING

延伸阅读

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