1. 为什么我选择 Flutter + LLM 多智能体这条技术路线
1.1 从单机 App 到“会思考的 App”,端侧智能正在换挡
过去两年我一直在做 Flutter 跨端项目,从电商、工具类到企业内部系统都趟过一遍。真正让我决定把精力转向Flutter + LLM 多智能体这条线的,是去年接的一个需求:客户希望 App 里能有一个“懂业务、能自己查资料、能分步骤办事”的助手,而不是那种只会关键词匹配的假 AI。当时我第一反应是用纯后端方案,把大模型调用全放在服务端,Flutter 只做展示层。但实测下来问题很明显——网络往返延迟高、上下文状态难维护、离线场景直接歇菜,而且一旦要做多角色协作(比如一个负责理解意图、一个负责查数据、一个负责生成回复),后端逻辑会迅速膨胀成一坨难以维护的“面条代码”。
于是我开始认真研究多智能体(Multi-Agent)这套思路。简单说,它不是让一个大模型包办所有事,而是把任务拆给多个各司其职的 Agent,每个 Agent 有自己的角色设定、工具集和记忆,彼此通过消息协作。这跟现实中的团队分工是一个道理:你不可能让一个人同时当产品经理、程序员和测试,Agent 也一样。而 Flutter 作为客户端框架,天然适合承载“编排层”和“交互层”——它跨平台、UI 响应快、能直接调用本地能力(相机、文件、传感器),把 LLM 的推理能力和端侧的真实操作结合起来,这才是“会思考的 App”该有的样子。
这篇文章面向的是有一定 Flutter 基础、想切入 AI Agent 方向的开发者。如果你还在纠结“Flutter 能不能做 AI”,我的答案是:不仅能,而且端侧多智能体编排恰恰是 Flutter 被低估的一块战场。下面我会把整套思路、核心细节、实操过程和踩过的坑全部摊开讲。
1.2 多智能体到底解决什么问题,别被概念忽悠
先泼盆冷水。市面上很多“多智能体”教程,本质就是把一个 Prompt 拆成三段分别调用,然后美其名曰“协作”。这不是多智能体,这只是串行调用。真正的多智能体要解决三个核心问题:任务分解、状态共享、冲突消解。
任务分解指的是,一个复杂请求进来(比如“帮我整理上周的会议纪要并生成待办”),系统要能判断这需要几个步骤、每步交给谁。状态共享指的是,Agent A 查到的信息,Agent B 得能拿到,不能各说各话。冲突消解指的是,当两个 Agent 给出矛盾结论时,得有个仲裁机制。这三点在纯 Prompt 层面很难做好,必须落到工程架构上。
我在 Flutter 里实现时,用的是“编排器 + 专家 Agent”的结构。编排器(Orchestrator)本身也是一个 Agent,但它不干具体活,只负责拆任务、派活、汇总。专家 Agent 各自绑定不同的工具和知识库。这个结构的好处是,新增能力只需要加一个 Agent,不用动主流程,符合开闭原则。而且 Flutter 的Cubit状态管理特别适合做这件事——每个 Agent 的状态可以是一个独立的 Cubit,编排器通过监听多个 Cubit 的状态变化来决定下一步动作,天然解耦。
提示:不要一上来就追求“全自动多智能体”。我建议先用“固定流程 + 单 Agent”跑通,再逐步引入第二个、第三个 Agent。一次性堆五个 Agent,调试时你会怀疑人生。
2. 核心架构拆解:Flutter 端如何承载多智能体
2.1 分层设计:UI 层、编排层、Agent 层、模型层
我把整个系统分成四层,每层职责清晰,避免耦合。
UI 层就是普通的 Flutter Widget,负责展示对话、工具调用结果、Agent 状态。这里有个细节:多智能体运行时,用户需要看到“现在是谁在处理”,所以我在消息气泡上加了 Agent 标识和状态指示(思考中、调用工具中、已完成)。这层不碰任何业务逻辑。
编排层是核心,用 Cubit 实现。它维护一个任务队列和当前执行上下文,决定下一个该哪个 Agent 上场。编排层不直接调用 LLM,而是通过 Agent 层暴露的接口。
Agent 层每个 Agent 是一个独立类,包含角色定义(System Prompt)、可用工具列表、私有记忆。Agent 之间不直接通信,全部通过编排层中转,这样便于日志追踪和权限控制。
模型层负责实际的 LLM 调用。这里我做了抽象,支持切换不同的模型提供方。因为实际项目中,你可能需要根据任务复杂度选择不同模型——简单意图识别用小模型,复杂推理用大模型,成本和延迟都能优化。
| 层级 | 职责 | 技术选型 | 关键注意点 |
|---|---|---|---|
| UI 层 | 展示与交互 | Flutter Widget + Cubit | 状态指示要实时 |
| 编排层 | 任务调度与仲裁 | Cubit + 状态机 | 避免死循环 |
| Agent 层 | 角色逻辑与工具 | 纯 Dart 类 | 记忆隔离 |
| 模型层 | LLM 调用 | HTTP + 流式解析 | 超时与重试 |
这个分层最大的好处是可测试。Agent 层可以脱离 UI 单独跑单元测试,编排层可以 Mock 掉模型层做逻辑验证。我实测下来,这套结构在后期加功能时改动量极小。
2.2 Agent 的 Token 三要素:Key、Query、Value 怎么设计
热词里有个很有意思的说法:LLM 的 token 三个点——key 我是谁、query 我在找什么、value 我能提供什么。这其实是把注意力机制的概念借用到 Agent 设计上,我觉得特别贴切。
在 Agent 里,Key(我是谁)对应 System Prompt 里的角色定义。这个必须写得极其具体,不能是“你是一个助手”这种废话。我通常写成“你是一个专门处理订单查询的客服 Agent,你只能访问订单数据库,不能回答与订单无关的问题,遇到超出范围的问题必须转交编排器”。角色边界越清晰,Agent 越不容易“越权”。
Query(我在找什么)对应 Agent 接收到的任务描述。这里的关键是任务描述要结构化,不能是一句模糊的自然语言。我会把任务封装成一个对象,包含意图类型、参数、上下文引用。这样 Agent 拿到后能快速判断自己能不能处理。
Value(我能提供什么)对应 Agent 的工具集和知识库。一个 Agent 的能力上限,取决于它挂了多少工具、知识库覆盖多广。我踩过的坑是:一开始给一个 Agent 挂了十几个工具,结果它经常选错工具。后来我改成每个 Agent 最多挂 3-5 个高度相关的工具,准确率立刻上来了。
注意:System Prompt 里一定要明确“不知道就说不知道”。我见过太多 Agent 因为被要求“必须回答”而开始胡编,这在生产环境是灾难。
2.3 工具调用与 Function Calling 的端侧实现
多智能体要真正“办事”,必须能调用工具。Flutter 端调用工具分两类:本地工具(读写文件、访问传感器、操作 UI)和远程工具(调后端 API、查数据库)。
本地工具直接用 Dart 实现,注册到 Agent 的工具表里。远程工具通过 HTTP 调用,但要注意:不要把 API Key 硬编码在客户端。我的做法是客户端只发意图,真正的敏感调用走后端代理,客户端拿到的只是结果。这既是安全考虑,也是因为客户端网络环境不可控。
Function Calling 的解析是另一个坑。不同模型提供方返回的工具调用格式不完全一样,有的返回 JSON,有的返回特定标记。我在模型层做了统一适配,把各家格式归一化成内部结构,Agent 层只认内部格式。这样换模型时不用改 Agent 代码。
// 工具注册的简化示例 class AgentTool { final String name; final String description; final Future<dynamic> Function(Map<String, dynamic> args) execute; AgentTool({required this.name, required this.description, required this.execute}); } // 注册一个查询订单的工具 final orderTool = AgentTool( name: 'query_order', description: '根据订单号查询订单状态,参数为 orderId', execute: (args) async { final orderId = args['orderId']; // 实际调用后端接口 return await api.getOrder(orderId); }, );工具描述(description)的写法直接决定模型能不能选对工具。我的经验是:描述里要包含“什么时候用”和“参数是什么”,最好再给一个例子。这比单纯写功能说明有效得多。
3. 实操过程:从零搭一个可运行的多智能体 Demo
3.1 环境搭建与项目初始化
先说环境。Flutter 版本我用的是 3.24 之后的稳定版,Impeller渲染引擎默认开启,动画流畅度比老版本好很多,这对多智能体运行时频繁的状态切换体验提升明显。创建项目就是标准的flutter create,但有几个配置要提前改。
pubspec.yaml里我加了几个关键依赖:http或dio做网络请求,flutter_cubit做状态管理,uuid生成会话 ID。如果你要做流式输出,还需要处理 SSE(Server-Sent Events),我用的是http的流式响应配合手动解析。
dependencies: flutter: sdk: flutter flutter_cubit: ^1.0.0 dio: ^5.4.0 uuid: ^4.2.0Android 端要注意minSdkVersion,我设成 21 以上,避免一些兼容问题。iOS 端记得在Info.plist里配置网络权限。这些是基础,不展开。
提示:如果你遇到 Gradle 插件相关的报错,大概率是 Flutter SDK 版本和 Gradle 版本不匹配。我的做法是先用
flutter doctor -v确认环境,再对照官方文档调整 Gradle 版本,不要盲目升级。
3.2 模型层封装:统一接口与流式解析
模型层是整个系统的地基,必须做厚。我定义了一个抽象接口LLMProvider,包含chat和chatStream两个方法。不同提供方实现这个接口,上层不关心具体是谁。
abstract class LLMProvider { Future<String> chat(List<Message> messages, {List<AgentTool>? tools}); Stream<String> chatStream(List<Message> messages, {List<AgentTool>? tools}); }流式解析是重点。大模型返回的是一个个 token,客户端要边收边渲染。我用的方案是:收到一个 chunk 就解析出增量文本,通过 Cubit 的emit更新 UI。这里要注意背压问题——如果模型吐字太快,UI 渲染跟不上,会卡顿。我的处理是加一个小的缓冲,每 50ms 批量更新一次 UI,实测流畅度提升明显。
超时和重试也必须做。LLM 调用失败是常态,尤其是网络不稳定时。我设置了 30 秒超时,失败后重试 2 次,重试间隔指数退避。如果三次都失败,就降级到本地兜底回复,而不是让用户干等。
3.3 编排器实现:任务队列与状态机
编排器我用了一个简单的状态机。状态包括:idle(空闲)、planning(规划中)、executing(执行中)、aggregating(汇总中)、done(完成)。每次用户输入进来,编排器先进入planning,调用规划 Agent 拆解任务,然后进入executing,按顺序或并行调用专家 Agent,最后aggregating汇总结果。
任务队列用List<Task>维护,每个 Task 包含目标 Agent、输入参数、依赖的前置 Task。这里有个关键设计:支持并行执行无依赖的任务。比如“查天气”和“查日程”互不依赖,可以同时跑,节省时间。我用Future.wait实现并行,但要注意并发数控制,别一次发太多请求把模型限流了。
Future<void> executeTasks(List<Task> tasks) async { final independent = tasks.where((t) => t.dependencies.isEmpty).toList(); final results = await Future.wait( independent.map((t) => _runAgent(t)), ); // 处理依赖后续任务... }状态机的转移条件要写死,不能靠模型判断。我见过有人让模型决定“下一步该干嘛”,结果模型陷入循环,一直重复调用同一个 Agent。流程控制必须由代码负责,模型只负责内容生成,这是铁律。
3.4 一个完整案例:会议纪要整理 Agent 的落地
拿一个真实案例串一遍。需求是:用户上传一段会议录音转写的文本,系统自动整理出纪要、提取待办、识别负责人。
我设计了三个 Agent:摘要 Agent负责提炼核心内容,待办提取 Agent负责找出行动项,负责人识别 Agent负责匹配人名和任务。编排器先并行调用摘要和待办提取,等两者都完成后,把待办列表传给负责人识别 Agent。
实测下来,这个流程比单个大模型一次性处理准确率高不少。原因是每个 Agent 的 Prompt 更聚焦,上下文更干净。单模型处理时,它要同时兼顾摘要、提取、匹配三件事,容易顾此失彼。
注意:Agent 之间的数据传递要用结构化格式(JSON),不要传自然语言。自然语言在传递过程中会丢失精度,而且解析起来容易出错。
4. 常见问题与排查技巧实录
4.1 Agent 不按预期调用工具怎么办
这是最高频的问题。表现是:明明注册了工具,模型却不用,或者用错工具。排查思路分三步。
第一步,检查工具描述是否清晰。我遇到过工具描述写成“查询数据”,模型根本不知道查什么数据。改成“根据用户 ID 查询订单列表,返回订单号、金额、状态”后,调用准确率大幅提升。
第二步,检查 System Prompt 是否给了调用工具的引导。如果 Prompt 里只说“你是一个助手”,模型可能觉得直接回答就行。要明确写“当需要实时数据时,必须调用工具,不要凭记忆回答”。
第三步,检查工具数量。前面说过,超过 5 个工具后准确率下降。如果确实需要很多工具,考虑拆成多个 Agent,每个 Agent 管一小撮工具。
| 问题表现 | 可能原因 | 解决方法 |
|---|---|---|
| 完全不调用工具 | Prompt 未引导 | 明确要求必须调用 |
| 调用错误工具 | 描述模糊 | 补充使用场景和参数说明 |
| 调用参数错误 | 参数格式未说明 | 在描述里给示例 |
| 频繁调用同一工具 | 陷入循环 | 加调用次数上限 |
4.2 流式输出卡顿与状态丢失
Flutter 里做流式输出,最容易遇到两个问题:UI 卡顿和页面切换后状态丢失。
UI 卡顿的根因通常是每收到一个 token 就setState一次,导致频繁重建。我的解法是用 Cubit 管理消息列表,配合ListView.builder只重建变化的那条消息。另外,前面提到的批量更新(50ms 一次)也很关键。
状态丢失是因为 Flutter 的 Widget 在页面切换时可能被销毁。如果你把 Agent 状态放在 StatefulWidget 里,切走再切回来就没了。正确做法是把状态提升到 Cubit 或全局 Provider,Widget 只做展示。我实测过,用 Cubit 后,即使页面被销毁重建,对话历史和 Agent 状态都能完整恢复。
提示:如果你用 Navigator 切换页面后发现状态丢了,先检查状态是不是存在了 Widget 内部。这是新手最常踩的坑。
4.3 模型返回格式不稳定怎么兜底
LLM 返回的 JSON 偶尔会多一个逗号、少一个引号,直接解析就崩。我的兜底策略是三层:第一层,用容错的 JSON 解析库;第二层,解析失败时用正则提取关键字段;第三层,都失败就触发一次“修复调用”,把错误内容发回模型让它重新输出。
这套机制上线后,格式错误导致的崩溃基本归零。虽然多了一次调用成本,但比起用户体验受损,这点成本值得。
4.4 多 Agent 协作时的死循环与超时
死循环的典型场景是:Agent A 把任务转给 Agent B,Agent B 又转回 Agent A。防呆机制是给每个任务加一个“转交次数”计数器,超过 3 次就强制终止并返回兜底回复。同时,整个任务链要有总超时,我设的是 60 秒,超时后返回已完成的部分结果,而不是全部丢弃。
5. 我在这条路上踩过的坑与经验总结
5.1 不要过早优化,先跑通再谈性能
我一开始就想着做“完美架构”,结果花了两周设计抽象层,真正跑起来发现很多设计根本用不上。后来我改成先用最土的办法跑通一个单 Agent,再逐步重构。能跑的烂代码胜过不能跑的好设计,这话在 AI 应用开发里尤其成立,因为模型行为本身就有不确定性,你很难提前设计出完美架构。
5.2 Prompt 工程是持续迭代,不是一锤子买卖
我见过太多人写完 Prompt 就不管了。实际上,Prompt 需要根据实际 badcase 不断调整。我的做法是建一个测试集,每次改 Prompt 都跑一遍,看准确率有没有下降。这个习惯帮我避免了很多“改好一个、弄坏三个”的情况。
5.3 端侧 LLM 的取舍:不是所有场景都适合本地跑
热词里有llm wiki、llm 网关这些,说明大家都在关注模型部署。我的经验是:端侧跑小模型适合隐私敏感、离线场景,但复杂推理还是得靠云端大模型。混合方案最实际——简单意图识别本地做,复杂任务上云。Flutter 的跨平台能力在这里体现得很明显,一套代码同时覆盖两端,端侧能力调用也统一。
5.4 关于 Agent 记忆管理的一点心得
Agent 的记忆不能无限增长,否则上下文会爆。我的做法是分层记忆:短期记忆保留最近 10 轮对话,长期记忆把关键信息摘要后存本地数据库。每次调用时,短期记忆全带上,长期记忆按相关性检索 top 3 条。这样既控制了 token 消耗,又保留了关键上下文。
这套 Flutter + LLM 多智能体的组合,我目前已经在两个内部项目里落地,稳定性比预期好。后续我打算把编排层再抽象一层,做成可配置的 DSL,这样非开发人员也能定义 Agent 流程。如果你也在做类似的事,欢迎交流踩坑经验——这个领域变化太快,一个人闷头搞容易走弯路。