ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter 与 LLM 多智能体架构:移动端状态管理与编排实战

Flutter 与 LLM 多智能体架构:移动端状态管理与编排实战 1. 为什么把 Flutter 和 LLM 多智能体放在一起做移动端做 AI 应用最容易踩的坑不是模型效果差而是架构选错了。我见过太多团队一开始把大模型调用直接塞进 Widget 的onPressed里跑 Demo 没问题一旦要处理多轮对话、工具调用、状态回滚代码就烂成一锅粥。Flutter 的响应式 UI 和 LLM 多智能体的异步、有状态特性天然存在一层阻抗失配这层失配不解决后面全是返工。这篇内容面向的是已经会写 Flutter 页面、但对 LLM 智能体编排还停留在调个 API 拿段文字阶段的开发者。我会从零搭一个能跑通的多智能体骨架把 Flutter 侧的状态管理、LLM 侧的 Agent 编排、以及两者之间的通信协议讲透。核心不是教你调某个具体模型的接口而是让你理解为什么多智能体在移动端必须这样分层以及每一层该放什么、不该放什么。先说清楚多智能体在这里指什么。它不是指同时跑好几个大模型而是指把复杂任务拆成若干职责单一的 Agent——比如一个负责理解用户意图的路由 Agent、一个负责查资料的检索 Agent、一个负责生成最终回复的写作 Agent。每个 Agent 有自己的系统提示词、自己的工具集、自己的上下文窗口。它们之间通过消息传递协作而不是共享一大坨全局状态。这个定义很重要因为它直接决定了 Flutter 侧该怎么设计数据流。Flutter 这边我强烈建议用Bloc/Cubit来管理 Agent 的会话状态而不是setState或者 Provider。原因后面会展开但一句话概括Agent 的执行是事件驱动的、可能长时间挂起的、需要中途取消的这三点恰好是 Bloc 的强项。至于 LLM 框架我不绑定具体某一个而是讲清楚选型时要看的几个维度你按自己的场景挑。提示本文所有代码都是骨架级别的可运行示例重点在结构和思路具体模型 SDK 的调用细节请对照你所用框架的官方文档替换。2. 多智能体在移动端的架构分层与职责边界2.1 三层结构UI 层、编排层、模型层把整个系统切成三层是我试过最不容易乱的做法。UI 层只做两件事把用户输入变成事件发出去把编排层吐回来的状态渲染出来。它不知道有几个 Agent也不知道 Agent 之间怎么传消息。这一层用 Flutter 的 Widget 树实现状态通过 Bloc 暴露的state订阅。编排层是核心跑在 Dart 侧。它负责维护会话上下文、决定下一个该哪个 Agent 出场、处理 Agent 之间的消息路由、管理工具调用的结果回填。这一层不直接碰网络请求的细节而是通过一个抽象的LlmClient接口去调用模型。模型层是真正跟大模型打交道的地方。它可以是本地推理也可以是远程 API。关键是把这一层做成可替换的——今天用 A 框架明天换 B 框架编排层代码一行不用改。这么分的好处是当你发现某个 Agent 的提示词需要调整时你只动编排层当你发现 UI 卡顿时你只动 UI 层。职责边界清晰调试时能快速定位问题出在哪一层。2.2 为什么编排层不能放在 UI 层我踩过这个坑。早期图省事把 Agent 的调度逻辑写在StatefulWidget的initState里结果遇到两个致命问题。第一Widget 会被重建。用户旋转屏幕、切换 Tab、系统回收内存State对象可能被销毁重建而 Agent 的执行是跨越多轮对话的长过程状态一丢整个会话就断了。你可能说用AutomaticKeepAliveClientMixin保活但那只是延缓问题不是解决。第二Agent 执行需要取消能力。用户发了消息模型还在生成用户突然想改问题你得能中断当前执行。在 Widget 里做取消你得把CancelToken一路透传代码耦合得没法看。而 Bloc 天然支持事件取消——新事件进来旧事件的处理可以被emit覆盖或者显式取消。所以编排层必须独立于 Widget 生命周期存在。我通常把它做成一个单例或者通过依赖注入提供的长生命周期对象Bloc 只是它的一个视图适配器。2.3 Agent 之间的消息协议设计Agent 之间传什么这个协议设计不好后面扩展会非常痛苦。我的经验是消息体至少包含这几个字段字段类型说明roleString发送方 Agent 的标识如router、retriever、writercontentString消息正文可以是自然语言也可以是结构化 JSON 字符串toolCallsList本次消息触发的工具调用列表没有则为空metadataMap附加信息如时间戳、token 消耗、置信度关键是content允许结构化。很多人一上来就把所有消息都当自然语言处理结果 Agent 之间传递参数时得靠正则去解析脆弱得不行。正确的做法是需要精确传递的数据用 JSON需要模型理解的内容用自然语言两者在同一个消息体里共存。举个例子路由 Agent 判断用户意图后不是输出用户想查天气而是输出{intent: weather_query, city: 北京, date: today}。下游 Agent 直接读字段不用猜。3. Flutter 侧的状态管理Bloc 与 Cubit 的取舍3.1 会话状态该长什么样先定义状态结构这决定了后面所有代码的写法。一个多智能体会话的状态我通常拆成这么几块class AgentSessionState { final ListAgentMessage messages; // 完整对话历史 final AgentStatus status; // idle / thinking / toolCalling / error final String? currentAgent; // 当前活跃的 Agent 标识 final MapString, dynamic scratchpad; // 中间结果暂存区 final String? errorMessage; }messages是给 UI 渲染的scratchpad是给 Agent 之间传中间结果的。为什么要分开因为 UI 不需要展示 Agent 内部的推理草稿但下游 Agent 需要。混在一起会导致 UI 渲染逻辑变得复杂而且用户看到一堆内部消息体验很差。status字段很关键。它让 UI 能准确反映当前系统在干什么——是在等模型返回还是在执行工具调用还是出错了。没有这个字段你只能靠isLoading这种布尔值粒度太粗。3.2 用 Cubit 还是完整 Bloc这个问题我被问过很多次。我的判断标准是如果你的状态转换逻辑超过 5 个分支用 Bloc否则 Cubit 够用。多智能体场景下状态转换其实不算特别复杂——无非是收到用户输入→路由→执行→返回这个循环。但每个环节都可能出错错误处理的分支会比较多。我倾向于用 Cubit把复杂逻辑封装在编排层的服务类里Cubit 只负责调用服务、接收回调、emit新状态。这样做的理由是Bloc 的 Event 类会随着功能增加而膨胀每个 Event 都要写一个类维护成本高。而 Cubit 的方法调用更直接配合编排层的服务类代码量能少三分之一。class AgentSessionCubit extends CubitAgentSessionState { final AgentOrchestrator _orchestrator; AgentSessionCubit(this._orchestrator) : super(AgentSessionState.initial()); Futurevoid sendMessage(String userInput) async { emit(state.copyWith(status: AgentStatus.thinking)); try { await for (final update in _orchestrator.run(userInput)) { emit(state.merge(update)); } } catch (e) { emit(state.copyWith(status: AgentStatus.error, errorMessage: e.toString())); } } void cancel() { _orchestrator.cancel(); emit(state.copyWith(status: AgentStatus.idle)); } }注意_orchestrator.run返回的是一个Stream。这是关键设计——Agent 的执行过程是渐进的每完成一步就吐出一个状态更新UI 可以实时反映进度而不是等全部跑完才刷新。用户能感知到系统正在查资料而不是干等。3.3 状态更新的粒度控制emit太频繁会导致 UI 过度重建太稀疏又会让用户觉得卡。我的做法是按用户可感知的阶段来 emit而不是按 Agent 的内部步骤。具体来说这几个时机必须 emit用户消息入队、路由完成、开始调用工具、工具返回、最终回复生成完毕、出错。至于 Agent 内部在拼提示词、在解析 JSON 这些步骤不 emit避免无谓刷新。还有一个细节copyWith要小心处理null。Dart 的copyWith如果参数是null默认会保留原值但有时候你确实想把某个字段置空。我的做法是给需要置空的字段加一个bool clearXxx参数显式控制。4. LLM 编排层的核心Agent 循环与工具调用4.1 Agent 执行循环的本质不管用什么框架Agent 的核心都是一个循环思考→行动→观察→再思考。这个循环在代码里长这样StreamAgentUpdate run(String userInput) async* { var context _buildInitialContext(userInput); var step 0; while (step maxSteps) { final decision await _llmClient.decide(context); if (decision.isFinalAnswer) { yield AgentUpdate.finalAnswer(decision.content); break; } if (decision.hasToolCalls) { yield AgentUpdate.toolCalling(decision.toolCalls); final results await _executeTools(decision.toolCalls); context context.appendToolResults(results); yield AgentUpdate.toolResults(results); } step; } }maxSteps是必须的。没有这个上限模型可能陷入死循环反复调用同一个工具。我一般设 8 到 10 步超过就强制返回当前最好的结果并提示用户任务较复杂已尽力处理。4.2 工具调用的注册与分发工具是 Agent 能力的延伸。在 Dart 侧我建议用一个注册表来管理工具而不是写一堆if-else。abstract class AgentTool { String get name; String get description; MapString, dynamic get parametersSchema; FutureString execute(MapString, dynamic args); } class ToolRegistry { final MapString, AgentTool _tools {}; void register(AgentTool tool) _tools[tool.name] tool; AgentTool? lookup(String name) _tools[name]; ListMapString, dynamic get schemas _tools.values.map((t) { name: t.name, description: t.description, parameters: t.parametersSchema, }).toList(); }schemas这个 getter 很重要——它生成的就是传给模型的工具描述。模型根据这个描述决定调哪个工具、传什么参数。所以description要写得让模型能理解parametersSchema要严格符合 JSON Schema 规范。我踩过的坑是工具描述写得太简略模型不知道该在什么场景用结果该调工具的时候不调不该调的时候乱调。后来我把每个工具的description都写成什么时候用这个工具的说明而不是这个工具是什么准确率明显提升。4.3 上下文窗口的管理策略多轮对话跑久了上下文会超出模型的窗口限制。这时候必须做裁剪。我的策略是分层保留系统提示词永远保留不可裁剪。最近 N 轮对话完整保留N 取 5 到 8。更早的对话做摘要压缩用一个小模型或者规则方法把多轮对话压成一段话。工具调用结果如果结果很长只保留关键部分或者存到外部上下文里只放引用。摘要压缩这一步很多人省掉了结果要么超窗口报错要么被迫丢弃早期上下文导致 Agent失忆。花点时间做摘要长期收益很大。注意裁剪上下文时不要从中间截断一条消息。要么整条保留要么整条移除。截断会导致模型看到不完整的 JSON 或半句话输出质量急剧下降。5. 从零跑通第一个多智能体完整实操链路5.1 环境准备与依赖选择Flutter 环境搭建这块网上教程很多我只说几个容易出问题的点。Flutter SDK 版本建议用 3.24 以上因为低版本在异步流处理和 isolate 通信上有一些已知问题。Dart SDK 随之升级到 3.5 以上。依赖方面核心就三个dependencies: flutter_bloc: ^8.1.0 # 状态管理 http: ^1.2.0 # 网络请求或换成你用的模型 SDK uuid: ^4.0.0 # 消息 ID 生成不需要引入太重的框架。LLM 调用用http直接发请求就行除非你用的框架有官方 Dart SDK。我见过有人为了调一个模型接口引入五六个依赖最后版本冲突搞得焦头烂额。能少则少。5.2 定义第一个 Agent意图路由路由 Agent 的职责很简单读用户输入输出一个结构化的意图标签。它的系统提示词我通常这么写你是一个意图分类器。根据用户输入判断其意图类别。 可选类别weather_query, general_chat, tool_required, unknown。 只输出 JSON格式{intent: ..., confidence: 0.0-1.0} 不要输出任何其他内容。注意最后那句不要输出任何其他内容。模型很爱在 JSON 前后加解释性文字加了这句能大幅减少解析失败。即便如此解析时还是要做容错——用正则把第一个{到最后一个}之间的内容抠出来再解析。MapString, dynamic parseIntent(String raw) { final start raw.indexOf({); final end raw.lastIndexOf(}); if (start -1 || end -1) { return {intent: unknown, confidence: 0.0}; } try { return jsonDecode(raw.substring(start, end 1)); } catch (_) { return {intent: unknown, confidence: 0.0}; } }这个容错逻辑看着简单但能救你无数次。模型输出格式不稳定是常态不要假设它永远听话。5.3 串联检索 Agent 与写作 Agent路由完成后根据意图决定下一个 Agent。以tool_required为例流程是检索 Agent 调用工具拿数据写作 Agent 把数据组织成自然语言。检索 Agent 的系统提示词要强调只负责找数据不负责组织语言。写作 Agent 则相反强调基于给定数据回答不要编造。两个 Agent 之间通过scratchpad传递数据。检索 Agent 把结果写进scratchpad[retrieved_data]写作 Agent 从那里读。这样职责清晰也方便单独测试每个 Agent。Futurevoid _runRetriever(BuildContext ctx) async { final tool _registry.lookup(search); final result await tool!.execute({query: ctx.userInput}); ctx.scratchpad[retrieved_data] result; } Futurevoid _runWriter(BuildContext ctx) async { final data ctx.scratchpad[retrieved_data]; final prompt 基于以下数据回答用户问题\n$data\n\n用户问题${ctx.userInput}; final answer await _llmClient.complete(prompt); ctx.finalAnswer answer; }5.4 在 Flutter 页面里消费 Agent 流UI 侧订阅 Cubit 的状态根据status渲染不同内容。消息列表用ListView.builder注意加reverse: true让最新消息在底部。BlocBuilderAgentSessionCubit, AgentSessionState( builder: (context, state) { return Column( children: [ Expanded( child: ListView.builder( reverse: true, itemCount: state.messages.length, itemBuilder: (_, i) MessageBubble(state.messages[i]), ), ), if (state.status AgentStatus.thinking) const LinearProgressIndicator(), InputBar(onSend: (text) context.readAgentSessionCubit().sendMessage(text)), ], ); }, )LinearProgressIndicator在thinking状态显示给用户即时反馈。别小看这个细节没有它用户会以为应用卡死了。6. 实测中暴露的五个典型问题与处理6.1 模型返回的 JSON 解析失败这是最高频的问题。除了前面说的正则容错我还会在提示词里加 few-shot 示例给模型看一两个正确的输出样例。实测下来加了示例后解析失败率从 15% 降到 3% 左右。如果还是失败做一次重试。重试时把上次的失败输出和错误信息一起塞回提示词让模型看到自己错在哪。这个技巧对格式类错误特别有效。6.2 工具调用参数类型不匹配模型经常把数字传成字符串或者把数组传成逗号分隔的字符串。解决办法是在工具执行前做一层参数校验和转换。MapString, dynamic coerceArgs(MapString, dynamic args, MapString, String types) { final result String, dynamic{}; args.forEach((key, value) { final expected types[key]; if (expected int value is String) { result[key] int.tryParse(value) ?? 0; } else if (expected list value is String) { result[key] value.split(,).map((s) s.trim()).toList(); } else { result[key] value; } }); return result; }这层转换代码不多但能避免大量运行时异常。6.3 长对话导致的内存增长messages列表无限增长跑几十轮后内存占用明显上升。我的做法是设一个上限比如保留最近 100 条超出的归档到本地存储。UI 上做分页加载用户往上滑才加载更早的消息。归档用sqflite或者简单的文件存储都行看你的数据量。关键是别让内存里的列表无限膨胀。6.4 用户快速连发消息导致的状态错乱用户手快连点发送多个 Agent 循环并发跑状态互相覆盖。解决办法是在 Cubit 里加一个正在处理的锁新消息进来时如果还在处理要么排队要么取消前一个。我倾向于取消前一个因为用户连发通常意味着他改主意了。取消逻辑通过StreamSubscription.cancel()实现编排层要暴露一个cancel()方法。6.5 网络异常时的降级体验模型调用失败是常态不能让它直接崩。我的处理是捕获异常后如果messages里有历史就让用户看到网络异常请重试同时保留输入框内容不清空。如果连续失败三次提示用户检查网络。千万别在失败时把整个会话状态清空那会让用户想砸手机。7. 关于 Agent 编排框架选型的几点个人判断选框架这件事我的核心观点是移动端场景下轻量级自研编排往往比引入重型框架更划算。重型框架比如那些主打自主 Agent的通常假设你跑在服务器上有充足的算力和稳定的长连接。移动端网络不稳定、内存受限、用户随时可能切后台这些假设都不成立。引入重型框架后你会发现大量代码在跟框架的假设做斗争。我建议的路径是先用本文这种轻量编排跑通核心流程等业务复杂到编排逻辑本身成为瓶颈时再考虑引入框架。而且引入时优先选那些能部分采用的——只用它的工具调用模块或者只用它的记忆模块而不是全盘接管。判断一个框架是否适合移动端看三个指标包体积增量、是否依赖特定运行时、能否优雅处理中断。这三个都过关才值得考虑。至于具体用哪个模型我的经验是别过早绑定。编排层通过LlmClient抽象接口调用模型换模型时只改一个实现类。这样你可以在开发期用便宜的小模型快速迭代上线前再切到效果更好的模型做最终验证。8. 我在这套架构上踩过的几个真实坑第一个坑是在 Agent 之间共享可变状态。早期我图方便让所有 Agent 共用一个Map当上下文结果 A Agent 改了某个字段B Agent 读到的是被污染的数据排查了半天才发现是共享引用的问题。后来改成每个 Agent 拿到的是上下文的不可变快照要修改就返回新副本问题消失。这个教训让我彻底理解了为什么函数式编程强调不可变。第二个坑是提示词里塞了太多工具描述。工具一多系统提示词就长模型注意力被分散该调工具时不调。后来我把工具按场景分组路由 Agent 先判断场景只把该场景相关的工具描述传给下游 Agent。提示词短了准确率反而上去了。第三个坑是忽略了 token 计费。开发期用大模型随便跑上线后发现成本高得离谱。后来加了 token 计数和预算控制超过阈值就降级到小模型或者直接返回缓存结果。这个功能上线前就该做别等账单来了才后悔。第四个坑是没有做请求去重。用户问了个问题Agent 内部可能对同一个工具调用两次因为模型在两轮里都决定调它。加一层结果缓存相同参数的调用直接返回上次结果能省不少 token 和时间。这些坑的共同点是它们都不会在 Demo 阶段暴露只有真实用户、真实流量下才会浮现。所以我的建议是架构设计时就把这些防御性机制考虑进去别等出了问题再补。最后分享一个调试技巧把每次 Agent 循环的完整上下文、模型原始输出、工具调用参数和结果都打到日志里用一个开关控制。出问题时打开开关复现一次日志一看就知道哪一步错了。这个日志系统我每做一个 Agent 项目都会先搭投入产出比极高。
返回列表