说实话,我一开始对“Flutter + LLM + 多智能体”这个组合是持怀疑态度的。Flutter再强也只是个UI框架,LLM再聪明也就是个聊天接口,把两者接起来不难,难的是“多智能体”这三个字——听起来像学术界的概念,跟移动端实战似乎隔着几层。但当我真把一个对话式信息管理工具拆成“规划Agent + 执行Agent + 校验Agent”三个角色跑起来之后,才意识到这套组合不是噱头:Flutter负责把复杂的交互结果以跨端形式呈现,LLM负责把自然语言变成结构化指令,多智能体则解决了单次问答在复杂任务上的乏力感。
这篇文章把我从零开始趟过的路完整记录下来:从Flutter环境搭建、LLM请求封装、多智能体调度骨架,到Flutter与原生平台之间的通信通路,再到异步微任务、状态丢失、渲染引擎这些实战里必踩的坑。适合已经会写基础Flutter页面、但对LLM应用层还比较陌生的人;如果你是从Web后端转过来、只懂接口不懂移动端协作,这篇也能帮你快速补上那块拼图。
1. 这套组合到底在解决什么问题:先想清楚“为什么”
在动手写代码之前,我花了整整两天想一个问题:Flutter + LLM 已经很常见了,为什么要再引入多智能体?
1.1 单次问答的“单线程困境”
一个没有多智能体参与的LLM应用,本质上就是“用户输入一句话,模型输出一段话”。这在简单问答场景下没问题,但一旦任务开始复杂,比如“帮我把这个季度所有项目按风险等级排序,并输出一份需要重点关注的名单”,单次问答模型经常答非所问——它记住的上下文太长,会在细节里迷失。
我做的信息管理工具就碰到了这个情况。用户问的自然语言看似简单,背后其实包含了好几个子任务:解析查询意图、检索知识库、对比数据、生成结论。让一个LLM调用去完成所有环节,结果就是每一个环节都不够精确,尤其是当提示词塞进大量背景信息时,模型甚至会“忘记”初始指令。
1.2 LLM的token三要素:key、query、value
在做多智能体消息设计时,我反复被一个概念点醒:把每一次LLM请求拆成三个问题——你是谁(key)、你在找什么(query)、你能提供什么(value)。最初我以为这只是简单的Prompt工程,后来发现这种拆法直接影响了多智能体之间的消息结构。
在实际代码里,我给每个Agent定义了一个标准化的消息信封,无论内部逻辑多复杂,对外暴露的结构都是这三个维度:
class AgentMessage { final String key; // 消息发出者身份,比如 planner final String query; // 请求的目标,比如 “查询风险数据” final String value; // 携带的上下文或返回内容 }这个设计让我在调试多智能体协作时省了大量时间。因为每个环节的消息都是结构化对象,我一眼就能看出是哪个Agent发出的消息、它在找什么、它提供了什么,而不是挤在一堆纯文本里翻来翻去。
1.3 多智能体不是“多个LLM调用”,而是“分工协作”
这里我要纠正一个误区:多智能体不等于“在代码里调用三次LLM接口”。真正有效的多智能体架构,是让不同角色承担不同的职责,并通过一套调度机制让它们像团队一样协作。
我的做法是三个角色:
- 规划Agent(Planner):把复杂任务拆解成可执行的子步骤
- 执行Agent(Executor):负责具体的工具调用、数据检索
- 校验Agent(Validator):检查执行结果是否满足初始目标,不满足则打回重做
这三个角色之间的协作不是我在代码里写死逻辑,而是通过一套循环调度器,让LLM自己决定下一步该调用哪个角色。这就是业内常说的ReAct模式(思考-行动-观察循环)。不过我们在Flutter里落地时把它简化成了事件循环,核心代码不到100行。
1.4 从这里开始,你的技术选型才有依据
想清楚了问题边界,再回头看技术选型就很清晰了:Flutter负责全平台的交互界面;LLM接口负责语义理解与生成;多智能体调度负责把复杂任务拆解、分发、汇总。三者各司其职,不重叠、不混乱。
2. 环境准备与最小LLM请求运行
现在进入实操阶段。这部分包含两个目标:跑通Flutter环境,以及让Flutter发出的第一条LLM请求成功返回。
2.1 Flutter环境搭建中容易被忽略的细节
Flutter环境搭建本身不难,大多数人的坑都出在版本和工具链的配合上。比如我一开始用Android Studio直接创建Flutter项目,结果一直报错。后来才发现,用命令行创建更干净:
flutter create ai_multi_agent_demo这个命令会在当前目录生成完整的跨端项目骨架,iOS、Android、Web、Windows、macOS、Linux平台目录一应俱全。创建完先跑一次flutter doctor,把缺少的依赖项补齐,再开始写代码。
这里提醒一个我在版本上踩的坑:如果你用的是较新的Xcode(比如热搜里提到的Xcode 27),很多Flutter依赖包可能报“版本低”的错误。这通常不是Flutter本身的问题,而是CocoaPods里的依赖没有升级。解决办法是先升级Flutter SDK到最新稳定版,再执行pod update让Podfile重新解析依赖。千万别一上来就改Podfile里的版本号,那样反而会把依赖树搞乱。
2.2 选一个兼容性最强的LLM接入方式
LLM服务的选型会直接影响后续代码的写法和维护成本。目前国内外的LLM服务大多提供了OpenAI兼容接口,也就是/v1/chat/completions这个路径加上Authorization: Bearer <token>的认证方式。这个事实让迁移成本变得很低:你今天用一个服务商,明天换另一个,代码改动可能就是改一个baseUrl和API Key的问题。
我在Demo里用Dart写了这样一个最小请求封装:
import 'dart:convert'; import 'package:http/http.dart' as http; class LLMClient { final String baseUrl; final String apiKey; final String model; LLMClient({required this.baseUrl, required this.apiKey, required this.model}); Future<String> chat(String systemPrompt, String userMessage) async { final resp = await http.post( Uri.parse('$baseUrl/chat/completions'), headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer $apiKey', }, body: jsonEncode({ 'model': model, 'messages': [ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': userMessage}, ], 'temperature': 0.3, }), ); if (resp.statusCode == 200) { final data = jsonDecode(utf8.decode(resp.bodyBytes)); return data['choices'][0]['message']['content'] as String; } else { throw Exception('LLM request failed: ${resp.statusCode} ${resp.body}'); } } }这里有几个参数值得解释一下。temperature设成0.3是为了在多智能体协作时让输出尽量稳定,毕竟我不希望同一个任务每次拆出来的子步骤天差地别。如果是面向用户的创意写作场景,可以调高到0.7以上。
2.3 流式响应的接入:让打字机效果真正跑起来
如果只做普通请求,上面那十几行代码就够用了。但多智能体协作有一个特点,就是每个Agent处理都要花时间,如果等待期间UI一动不动,用户体验会非常差。这时候需要流式响应(SSE,Server-Sent Events)。
Flutter侧处理SSE其实不难,用http.Client发请求时设置response.stream,然后逐块解析。关键点是要把LLM返回的数据块按data:前缀切分,因为OpenAI兼容接口的每个数据块都以这个前缀开头:
import 'dart:convert'; import 'package:http/http.dart' as http; Stream<String> streamChat(String apiKey, String baseUrl, String model, List<Map<String, String>> messages) async* { final req = http.Request('POST', Uri.parse('$baseUrl/chat/completions')) ..headers['Content-Type'] = 'application/json' ..headers['Authorization'] = 'Bearer $apiKey' ..body = jsonEncode({ 'model': model, 'messages': messages, 'stream': true, }); final resp = await req.send(); await for (var chunk in resp.stream.transform(utf8.decoder)) { // 按行解析,处理 SSE 格式 final lines = chunk.split('\n'); for (final line in lines) { if (line.startsWith('data:')) { final data = line.substring(5).trim(); if (data == '[DONE]') return; final json = jsonDecode(data); final delta = json['choices'][0]['delta']['content']; if (delta != null) yield delta; } } } }这一段代码配合Flutter的StreamBuilder,就能实现一个逐字输出的对话界面。我在多智能体调度日志面板里用到了这个能力,每个Agent的思考过程都能像打字机一样实时展示出来,调试体验比看一坨静态JSON好太多了。
2.4 模型选型的参考:公开榜单只能帮你走到这一步
关于模型选型,很多人会盯着Open LLM Leaderboard之类的公开榜单选模型。这类榜单确实能让你快速看到各类开源模型的基准测试分数,但我的经验是:榜单分数只能证明模型的“知识广度”,不能证明它在你的垂直场景下的“实际可用性”。尤其多智能体场景对模型的指令跟随能力要求很高,如果一个模型在榜单上数学题很强,但在工具调用的JSON输出上频繁格式错误,那它就不适合这个场景。
我做了个简单的对比实验,用同一套多智能体Prompt维度去测试三到四个主流模型,最后选了一个在“指令遵循 + JSON格式稳定”上表现最好的,而不是分数最高的。
3. 多智能体协作的项目骨架:角色定义与调度循环
这部分是整套系统的核心。我先把结论放出来:在Flutter里实现多智能体,不需要引入LangChain那样的重量级框架,自己写一个调度循环完全够用,而且可控性更高。
3.1 用Ontology思维定义Agent角色
前面提到过token的三要素,角色定义则是从本体论(Ontology)的角度出发。说白了就是:在让多个Agent协作之前,你得先明确地告诉每个Agent——这个世界里有哪些东西、你负责什么、你的能力边界在哪里。
我用一个Dart抽象类来约束所有Agent的能力:
abstract class Agent { String get role; // 角色名,如 planner、executor、validator Future<AgentMessage> process(AgentMessage message); }这个设计的妙处在于:LLM模型本身是通用的,它今天可以扮演规划者,明天可以扮演执行者,但你的系统需要稳定的契约。通过定义Agent抽象类,每一个角色的行为都在代码层面被约束住了,而不是靠Prompt里的一句“你是一个规划者”来约束。
3.2 ReAct调度循环:思考、行动、观察
多智能体调度器的核心是一个循环,其结构可以简化为三个步骤:先让规划Agent思考,再让执行Agent行动,最后让校验Agent观察结果。
class AgentOrchestrator { final List<Agent> agents; final LLMClient llm; AgentOrchestrator({required this.agents, required this.llm}); Future<AgentMessage> run(String task) async { var currentMsg = AgentMessage(key: 'user', query: task, value: ''); for (var i = 0; i < 5; i++) { // 1. 规划Agent拆解 final plan = await agents[0].process(currentMsg); // 2. 执行Agent执行 final result = await agents[1].process(plan); // 3. 校验Agent检查 final verdict = await agents[2].process(result); if (verdict.value == 'PASS') return result; currentMsg = verdict; // 打回重做 } throw Exception('Agent orchestration failed after max rounds'); } }这个循环虽然简单,但它带来了一个重要的能力变化:任务的每一步都是可观察、可干预的。某个Agent跑偏了,你可以直接在日志里看到,并针对性地调整它的Prompt,而不是在一大段最终回答里猜哪里出了问题。
3.3 工具调用的Schema冲突排查
多智能体要真正“做事”,免不了要调用外部工具,比如查数据库、发请求。这就涉及到LLM的Function Calling(工具调用)机制。我在早期集成时频繁遇到一个报错:LLM request failed: provider rejected the request schema or tool payload.
这个问题的根因通常有两种:一是工具JSON Schema格式与模型服务商要求的格式不完全一致,比如OpenAI要求parameters字段必须是严格的JSON Schema,而你又传了$schema或者非规范的额外字段;二是工具Payload中包含了模型服务商禁止的内容类型。排查方法很简单,先去掉所有非必要字段,最小化Schema,再用服务商提供的API文档逐一对照。
3.4 多Agent之间的消息传递
在Flutter这种有明确生命周期和状态管理概念的框架里,Agent之间的消息传递不要用全局静态变量,那样会在页面重建时丢失状态。我建议把AgentOrchestrator作为应用级状态对象,放进Provider或者Cubit里,让整个组件的生命周期可靠可控。用Cubit管理Agent状态,UI层通过BlocBuilder监听状态变化,这是我在项目里验证过最稳妥的做法。
4. Flutter与原生平台之间的通路:EventChannel、MethodChannel与PlatformView
多智能体系统如果只停留在“对话 + 显示”,价值有限。真正落地到产品里,往往会碰到这类需求:LLM说要调用系统能力,比如打开地图、录制音频、读取本地文件。这时候就需要一套跨语言通道,让Dart代码和原生代码互相对话。
4.1 三条通道各有各的使用场景
Flutter和原生之间的通信,常见的有三个名字:MethodChannel、EventChannel、PlatformView。很多新手混着用,出问题就不知道该查哪边。
| 通道名称 | 使用场景 | 数据方向 | 典型例子 |
|---|---|---|---|
| MethodChannel | 一次性请求-响应 | 双向 | 调用原生TTS播报 |
| EventChannel | 持续的数据流 | 原生到Dart | 接收传感器数据、推送事件 |
| PlatformView | 原生UI嵌入Flutter | 双向+UI | 嵌入原生地图、WebView |
在多智能体应用里,最常用的场景是:Executor Agent分析出“需要调用地图组件”,Flutter侧通过MethodChannel通知原生打开地图,然后把结果回调给Agent继续处理。EventChannel则适合用在整个调度过程的状态推送——原生侧的某个耗时长任务完成后,主动把结果推给Dart侧,而不是让Dart侧一直轮询。
4.2 安卓原生项目嵌入Flutter页面
热搜词里有一句“安卓原生项目嵌入Flutter页面”,这是存量App集成Flutter的一个高频需求。如果你所在团队已经有一个安卓原生App,想把多智能体对话界面作为其中一个模块加进去,没必要把整个项目重写成Flutter,直接用Flutter官方提供的add-to-app方案即可。
大概流程是:在原生项目的build.gradle里添加Flutter依赖,然后用FlutterEngine和FlutterFragment去承载Flutter页面。有个细节要注意:多个Flutter页面共用同一个引擎时,状态是共享的,页面切换不会重新加载;但如果你每次都新建引擎,内存开销非常大,得不偿失。
4.3 Flutter Web引擎启动慢的问题
我的Demo同时发布了Web版,结果发现首次加载特别慢,用户打开页面要等很久才能看到内容。查了一轮之后发现,Flutter Web默认使用Canvaskit渲染,而Canvaskit需要从远端加载大量Wasm资源。
解决办法有两个方向:一是使用--web-renderer html切换到HTML渲染器,加载速度快一些,但渲染复杂动画的能力不如Canvaskit;二是把Canvaskit相关资源打包到本地静态目录,或者用CDN就近分发。考虑到多智能体界面动效不多,我的项目直接切到了HTML渲染器,加载速度提升明显,动画也够用。
5. 实战中绕不开的坑:微任务、状态丢失、动画与打包链路
这段内容是我整篇最想分享的部分。因为这些坑不是从文档里读来的,全部是我在真实项目中一行行代码定位出来的。
5.1 Future的then回调,到底是不是微任务
这个问题看起来基础,但它在多智能体调度里会直接影响任务的执行顺序。Dart是单线程事件循环模型,所有异步任务分为两类:微任务(Microtask)和事件队列(Event queue)。Future.then的回调默认是微任务,这意味着它会在当前同步代码执行完之后、下一个事件(比如I/O事件)处理之前执行。
看这段代码:
void main() { Future(() => print('A')); // 事件队列 Future.microtask(() => print('B')); // 微任务 print('C'); } // 输出顺序:C, B, AFuture()构造函数默认把任务放入事件队列,所以A必须等当前事件循环结束才执行;而Future.microtask放进微任务队列,在当前同步代码执行完就立刻执行。在AgentOrchestrator里,这意味着不同Agent的调用顺序可能因为你用了不同的Future构造方式而悄然改变。我处理的方式是:调度循环内所有Agent调用统一用await,静默的任务调度则放进微任务队列,不让它在事件循环里排长队。
5.2 Navigator切换页面后,真的会丢失状态吗
很多人在Flutter里用Navigator.push跳转新页面,返回后发现上一页的状态变了,就认为是“状态丢失”。其实这里要分清楚:页面Widget被销毁了,页面里的State对象也跟着销毁了,但状态数据如果保存在上层对象(比如Cubit、Provider)里,数据还在。
我多智能体Demo里遇到的情况是:用户从对话页跳到历史记录页,再返回对话页,对话内容全没了。排查后确认原因是我把对话状态存在了页面内部State里,页面销毁状态就没了。解决办法是把对话数据迁到应用级Cubit中管理,页面每次打开时从Cubit读取数据。
一个更轻量的方案是使用IndexedStack,让多个页面在切换时不被销毁。IndexedStack会同时保留所有子页面的State,代价是内存占用略有上升。对于对话这种状态很重的页面,我更推荐Cubit + 结构化数据管理,数据与UI生命周期解耦才是一劳永逸的做法。
5.3 TabBar点击还想取消动画?有办法
有段时间我在调试多智能体的分类筛选页,发现每次用户点击Tab标签,内容切换动画太慢,影响连续快速点击时的体验。Flutter的TabBar默认自带切换动画,想取消常见思路是自定义一个TabController,在点击时把动画时长设为0:
tabController.animateTo(index, duration: Duration.zero, curve: Curves.easeOut);但如果你用的是默认的DefaultTabController,没法直接拿到controller,就得先把TabBar包在TabController里。要注意的是,TabBar组件内部的TabController必须通过SingleTickerProviderStateMixin创建动画驱动。网上很多帖子说直接tabController.index = index就行,那是不对的,那不会触发实际的页面切换。实测下来,用animateTo+Duration.zero才是正解。
5.4 打包阶段的高频报错处理
多智能体项目最终要交付时,打包环节容易集中爆发一批问题,我把热搜里出现频率最高的几类统一在这里复盘一下。
第一类是Gradle插件应用方式报错,提示You are applying Flutter's main Gradle plugin imperatively using the apply。这是Flutter新版对Gradle插件应用方式给出的警告,沿用老写法虽然能编译,但会逐渐不被支持。处理方法是把apply plugin: 'com.flutter.gradle'改成plugins { id 'com.flutter.gradle' }这种声明式写法。
第二类是The current configured Flutter SDK is not known to be fully supported。这个一般在Flutter SDK版本太新或太旧,与项目里依赖的插件版本不匹配时出现。解决方法是把SDK固定到项目创建时对应的稳定版本,或者在pubspec.yaml里调整依赖版本与SDK匹配。
第三类是Impeller(Flutter 3.7之后的新渲染引擎)在部分安卓设备上导致的渲染异常。可以在AndroidManifest.xml里通过meta-data关闭Impeller来验证是否它导致的问题。不过这不是长期方案,确认是渲染引擎兼容性问题后,应该优先升级Flutter版本等待官方修复。
第四类是打包时的java.lang.AssertionError: java.lang.Exception: could not close i问题。这个我一开始也摸不着头脑,后来在社区里看到解决方案是大同小异的:先执行flutter clean清理缓存,然后删除根目录的build文件夹和安卓目录下的.gradle文件夹,重新打包。这通常能解决Gradle增量编译缓存异常导致的问题。
6. 一个完整的Demo:让两个智能体协作回答“周末安排”
前面讲的都是知识和坑,最后做个完整的实战串联。我实现了一个非常小的多智能体Demo,两个Agent:一个规划者(Planner),一个执行者(Executor)。用户输入一句话,两个Agent协作给出结果。
6.1 Demo目标与设计
用户输入示例:“帮我安排一个周六的户外活动,需要避开人多的地方。”
规划Agent拿到任务后,拆成三个子任务:
- 查找周六可能的户外活动类型
- 筛选人流密度低的场所
- 生成一份简短的行程建议
执行Agent拿到子任务后,去调用一个模拟数据接口(在真实场景中可以是地图API、景区人流API),返回候选结果。由于Demo中没有接入真实API,我用了一个MockOutdoorService来模拟查询。
6.2 关键代码实现
首先是规划Agent:
class PlannerAgent extends Agent { final LLMClient llm; PlannerAgent(this.llm); @override String get role => 'planner'; @override Future<AgentMessage> process(AgentMessage message) async { final resp = await llm.chat( '你是一个行程规划专家。将用户的请求拆解为最多3个可以并行执行的子任务。' '只输出JSON数组,不要多余说明。', message.value, ); // 解析 resp 中的 JSON 数组,包装为子任务列表 return AgentMessage(key: role, query: message.query, value: resp); } }执行Agent:
class ExecutorAgent extends Agent { final MockOutdoorService service; ExecutorAgent(this.service); @override String get role => 'executor'; @override Future<AgentMessage> process(AgentMessage message) async { final tasks = jsonDecode(message.value) as List<dynamic>; final results = <String>[]; for (final task in tasks) { final result = await service.query(task['keyword']); results.add(result); } return AgentMessage(key: role, query: message.query, value: results.join('\n')); } }UI层我用StreamBuilder来订阅调度器产生的流转日志,界面从上到下会依次显示“规划中 -> 执行中 -> 完成”,每个Agent的中间结果都实时可见。这个效果比单次问答体验好得多,因为用户能直观感受到系统“正在做什么”,而不是面对一个空白对话框干等。
6.3 运行效果与结论
实测下来,两个Agent协作回答同一句话,比单次LLM调用更稳定、更可控。原因在于:每个Agent只负责一个小环节,上下文短,模型不容易被无关信息干扰;而且中间结果可以单独调试,哪里出错就调哪里。代价也很明显:多次LLM调用带来了更高的延迟和token消耗,如果对响应速度要求极高,需要在Agent并发或模型选择上做额外的优化。
7. 根据个人项目体会:不要急着上重型框架
最后聊一点个人感受。在我把Flutter + LLM + 多智能体这条路跑通之后,最深的体会就是:不要一上来就引入LangChain这类重型框架。先用最朴素的Dart代码手写调度器跑通流程,你会对每一步有充分掌握力;等业务复杂度真的上来了,再考虑迁移到框架或者引入更成熟的Agent编排方案。手写版本还有一个好处,就是当LLM服务商换了接口或者模型换了行为方式,你能在第一时间定位到是调度问题、Prompt问题,还是工具Schema问题,而不是在框架的层层封装里迷失。
另一个体会是,多智能体系统的调试,日志格式决定了你的排查效率。我在每个Agent的入口和出口都打印结构化消息(key、query、value三个字段一目了然),这比看纯文本日志要快一个数量级。建议你从第一个Demo开始就养成这个习惯。
这个项目的后续扩展空间其实很大。往小做,可以给每个Agent加上工具调用能力,接入真实的外部数据源;往大做,可以引入RAG(检索增强生成)或者GraphRAG,让Agent基于企业知识库作答,搜索词里的LLM Wiki、本体等概念到时候就会派上用场。但不管往哪个方向扩展,Flutter这块跨端壳子、LLM这块智能内核、多智能体这块调度骨架,三者各司其职的架构不会变。顺着这条路一直走下去,你会发现这个组合能做的事情远比想象中多。