移动端 AI 应用正在从调一次接口拿一次结果的形态快速演进到多个角色分工协作、自主规划、互相校验的形态。Flutter 负责把这套能力落到 iOS 和 Android 上LLM 负责提供语言理解与决策多智能体负责把一件复杂的事拆成若干可执行的小任务。这三者叠在一起就是我现在做的这套东西一个能在手机上跑起来的多智能体协作应用。它适合已经写过一点 Flutter、想往 AI 应用方向走的开发者也适合做后端或算法、想把模型能力包装成产品的人。下面我把自己从零搭这套东西的完整思路、踩过的坑、以及每个关键决策背后的理由尽量讲透。1. 先把多智能体这件事在脑子里拆干净很多人一上来就问用哪个框架、哪个模型其实真正卡住进度的是没想清楚多智能体到底多在哪。如果这个概念没拆明白后面写出来的代码大概率只是一个循环里连续调了几次 LLM那不叫多智能体那叫批处理。1.1 单 Agent 和多 Agent 的分界线在哪我自己的判断标准很简单是否存在多个拥有独立上下文、独立目标、并且能互相影响决策的执行单元。一个 Agent 本质上就是一个带角色设定的 LLM 调用循环——它有自己的系统提示词、自己的对话历史、自己的工具集。当你把三个这样的循环放在一起让 A 的输出成为 B 的输入、B 的结论又能反过来修正 A 的判断这才算多智能体。举个具体例子。用户输入帮我规划一次三天的周边游。单 Agent 的做法是一个提示词里塞进查天气、排行程、算预算模型一次性吐出一段文本。多 Agent 的做法是拆成三个角色规划 Agent负责拆解需求产出需要哪些信息的清单检索 Agent拿着清单去查天气、查景点开放时间、查交通汇总 Agent把前两者的结果整合成最终行程并做预算核算关键在于汇总 Agent 发现预算超了可以回头让规划 Agent 重新排——这个回头的动作就是多智能体区别于单次调用的核心。1.2 三种主流协作拓扑以及我为什么选主管制落到工程上多智能体的协作结构基本就三类我列个表对比一下这也是我选型时的真实依据拓扑结构工作方式优点缺点适用场景流水线式A→B→C 顺序执行实现简单、可控无法回退、错误会累积步骤固定的流程主管制一个主管 Agent 调度若干工人 Agent灵活、可动态分配主管容易成为瓶颈任务类型多、需动态决策去中心化Agent 之间自由通信理论上最强调试地狱、成本失控研究性质、实验场景我最终选了主管制。原因很实际移动端算力和网络都不稳定去中心化那种Agent 之间互相喊话的模式一旦某个环节超时整个链路就乱了而且 token 消耗根本压不住。主管制的好处是有一个明确的大脑所有调度逻辑集中在一处出问题好定位成本也好估算。提示新手最容易犯的错是一上来就搞去中心化觉得那样更智能。实测下来主管制能覆盖 90% 的实际需求而且代码量少一半。1.3 一个 Agent 的最小构成要素不管用什么框架一个能跑的 Agent 拆开来看就四样东西我在 Flutter 里也是按这四块来组织的角色定义System Prompt决定它是谁、能干什么、输出格式是什么上下文Context它记得的对话历史注意这里要控制长度不能无限堆工具Tools它能调用的外部能力比如查数据库、调 API、读本地文件循环控制Loop什么时候继续、什么时候停、最多跑几轮这四样里循环控制最容易被忽略但恰恰是移动端最要命的。手机上没有服务端那种可以随便跑几十轮的环境我给自己定的硬规则是单个 Agent 最多 5 轮整个任务链路最多 15 轮超了就强制返回当前最优结果并提示用户。这个数字不是拍脑袋来的是我统计了几十次真实任务后的经验值——超过这个轮数要么是任务本身有问题要么是 Agent 陷入了循环。2. Flutter 侧到底承担了什么角色搞清楚概念之后下一个问题就是为什么用 Flutter而不是原生或者 React Native以及 Flutter 在这套架构里具体管哪一块2.1 选 Flutter 的真实理由不是跨平台三个字跨平台这个理由太泛了任何跨平台框架都能这么说。我选 Flutter 的真实原因有三个都是踩过坑之后才体会到的第一UI 和逻辑的分离足够干净。多智能体应用的状态变化非常频繁——某个 Agent 正在思考、某个工具调用返回了、某个环节失败了要重试。这些状态如果用命令式 UI 去更新代码会乱成一团。Flutter 的声明式 UI 配合状态管理天然适合这种状态驱动的场景。我只要把 Agent 的状态建模成一个状态机UI 自动跟着变。第二Dart 的异步模型和 LLM 调用很搭。LLM 调用本质是网络请求是 IO 密集型的。Dart 的Future和Stream处理这类场景非常顺手尤其是流式输出模型一个字一个字往外吐用Stream接简直是为它设计的。第三热重载对调试 Agent 逻辑帮助巨大。调 Agent 的时候我经常要改提示词、改循环条件然后立刻看效果。热重载让我改完一秒就能看到结果这个效率提升是实打实的。至于 Impeller 渲染引擎Flutter 新的渲染后端它在多智能体应用里的价值主要体现在长列表和复杂动画上——比如聊天记录很长的时候滚动要跟手。这个后面会细说。2.2 Flutter 层要处理的四类状态我把 Flutter 侧的状态明确分成四类分开管理绝不混在一起会话状态当前有哪些 Agent、各自在干什么、消息历史网络状态请求进行中、成功、失败、重试中UI 状态输入框内容、滚动位置、展开收起持久化状态本地缓存的对话、用户配置为什么要分这么细因为它们的更新频率和生命周期完全不同。会话状态可能一秒变好几次UI 状态可能只在用户操作时变。如果全塞进一个全局状态里任何一点变化都会触发整棵树重建性能直接崩。我吃过这个亏——早期版本聊天一长就卡后来把状态拆开只让需要更新的部分重建问题就解决了。2.3 组件通信EventChannel 和 MethodChannel 的分工Flutter 和原生或者和本地跑的推理引擎通信绕不开 Platform Channel。这里有个很多人搞混的点MethodChannel 和 EventChannel 该用哪个。简单说MethodChannel 是你问我答——Flutter 调原生一个方法原生返回一个结果一次性的。EventChannel 是我持续告诉你——原生那边有数据就推给 Flutter是流式的。在多智能体场景里我的分工是这样的MethodChannel用来做一次性操作比如初始化模型查询当前配置取消某个任务EventChannel用来接收流式数据比如模型逐字输出的 token、Agent 状态变化的事件// EventChannel 接收流式 token 的典型写法 class AgentStreamHandler { static const _channel EventChannel(com.example.agent/token_stream); StreamString get tokenStream { return _channel.receiveBroadcastStream().map((event) { // 原生侧推过来的是 Map取出 token 字段 return (event as Map)[token] as String; }); } }这里有个坑我必须提醒EventChannel 的广播流如果没人监听原生侧还在推数据会出问题。我早期的做法是页面一销毁就取消订阅但原生侧不知道还在傻推。后来改成用 MethodChannel 显式通知原生我开始监听了我停止监听了原生侧据此开关数据源才彻底干净。注意EventChannel 在页面切换时一定要记得取消订阅否则会造成内存泄漏而且原生侧的资源也不会释放。3. LLM 接入层把模型能力包装成 Agent 能用的工具Flutter 本身不跑大模型除非你用端侧小模型所以 LLM 接入层本质是怎么把远程模型的能力包装成 Agent 可以稳定调用的接口。这一层做得好不好直接决定整个应用的稳定性。3.1 提示词工程在代码里怎么落地提示词不是写在文档里的是要写进代码、能版本管理、能动态替换的。我的做法是把每个 Agent 的提示词做成独立的模板文件用占位符注入动态内容class PromptTemplate { final String role; final String task; final String outputFormat; String render(MapString, String variables) { var result role; variables.forEach((key, value) { result result.replaceAll({{$key}}, value); }); return result; } } // 使用示例 final plannerPrompt PromptTemplate( role: 你是一个任务规划专家负责把用户需求拆解成可执行的子任务。, task: 用户需求{{userInput}}\n可用工具{{tools}}, outputFormat: 请以 JSON 数组输出每个元素包含 task 和 tool 两个字段。, );为什么要模板化因为调 Agent 的时候提示词要改几十上百次。如果硬编码在逻辑里改一次要翻半天代码。模板化之后改提示词就是改一个字符串甚至可以做 A/B 测试——同一批任务用两套提示词跑看哪套效果好。3.2 结构化输出让 Agent 之间能对话多智能体最大的工程难点是Agent 之间怎么传递信息。如果 A 输出一段自然语言B 要理解这段自然语言再干活那不确定性太大了。我的做法是强制所有 Agent 之间的通信走结构化格式JSON只在最终面向用户时才转成自然语言。这里有个技巧在提示词里明确给出 JSON schema并给出一个完整的示例。实测下来给了示例之后模型输出合法 JSON 的概率从大概七成提升到九成五以上。剩下的那百分之几用代码做容错——解析失败就重试一次再失败就降级处理。// 解析 Agent 输出的容错逻辑 MapString, dynamic? parseAgentOutput(String raw) { try { // 先尝试直接解析 return jsonDecode(raw); } catch (e) { // 失败则尝试提取 JSON 片段模型有时会加前后缀说明 final match RegExp(r\{[\s\S]*\}).firstMatch(raw); if (match ! null) { try { return jsonDecode(match.group(0)!); } catch (_) { return null; } } return null; } }3.3 Token 成本控制移动端必须算的账服务端跑 Agent 可以不太在乎 token但移动端应用面向的是普通用户成本直接关系到能不能活下去。我做了三件事来控制成本第一上下文裁剪。每个 Agent 的对话历史不能无限增长。我的策略是保留最近 N 轮完整对话更早的做摘要压缩。摘要本身也是一次 LLM 调用但比保留全文便宜得多。第二工具结果缓存。很多工具调用结果是可复用的比如查某城市天气五分钟内重复查没必要再调一次。我加了一层带过期时间的缓存命中率大概能到三成。第三模型分级。不是所有 Agent 都需要最强的模型。规划、汇总这种需要推理的用强模型格式转换、简单抽取这种用轻量模型。分级之后整体成本能降一半左右。控制手段实现方式大致效果上下文裁剪保留近 N 轮 早期摘要降低 30%-50% 输入 token工具结果缓存带 TTL 的本地缓存减少约 30% 重复调用模型分级按任务复杂度选模型整体成本降约 50%3.4 流式输出在 Flutter 里怎么接才不卡流式输出是体验的关键——用户看到字一个个蹦出来会觉得它在思考而不是干等。但流式输出在 Flutter 里如果处理不好会非常卡。我踩过的坑是每收到一个 token 就setState一次结果一秒钟触发几十次重建界面直接卡死。正确的做法是做节流——把 token 先攒在缓冲区每隔一个很短的时间间隔比如 50 毫秒批量更新一次 UI。class StreamBuffer { final _buffer StringBuffer(); Timer? _timer; void add(String token, void Function(String) onFlush) { _buffer.write(token); _timer?.cancel(); _timer Timer(const Duration(milliseconds: 50), () { onFlush(_buffer.toString()); _buffer.clear(); }); } }这个 50 毫秒是我实测出来的平衡点。太短了没效果太长了用户能感觉到卡顿。当然这个值可以根据设备性能动态调整低端机可以放宽到 80 毫秒。4. 主管 Agent 的调度逻辑整套系统的心脏前面都是铺垫这一节是真正的核心——主管 Agent 怎么决定下一步让谁干活。4.1 调度不是 if-else是让模型做决策新手最容易写成一大坨 if-else如果用户问天气就调天气 Agent问路线就调路线 Agent。这种写法在任务类型少的时候能用一旦任务复杂起来就崩了——因为真实需求往往是帮我规划行程顺便看看天气再算算预算你没法用 if-else 穷举。我的做法是把调度本身也交给 LLM。主管 Agent 的提示词里写清楚你有哪些工人 Agent、每个工人能干什么、当前任务是什么然后让模型输出下一步调用哪个工人、传什么参数。class SupervisorAgent { final ListWorkerAgent workers; final LlmClient llm; FutureDispatchDecision decide(String task, ListMessage history) async { final workerDescriptions workers .map((w) - ${w.name}: ${w.capability}) .join(\n); final prompt 你是任务调度主管。当前任务$task 可调度的工人 $workerDescriptions 请决定下一步调用哪个工人传入什么参数。 如果任务已完成返回 {action: finish, result: ...}。 ; final response await llm.complete(prompt); return DispatchDecision.fromJson(parseAgentOutput(response)!); } }这样做的好处是新增一个工人 Agent 只需要在列表里加一项调度逻辑自动就能用上不用改任何代码。4.2 循环终止条件三种情况必须停调度循环最怕的就是停不下来。我设了三个终止条件满足任意一个就停主管明确返回 finish任务完成正常结束达到最大轮数我设的是 15 轮超了强制结束并返回当前结果连续两轮无进展如果主管连续两次调同一个工人、传同样的参数说明卡住了直接停第三个条件特别重要是我踩坑之后加的。有一次测试主管 Agent 陷入了一个死循环——它一直让检索 Agent 去查同一个信息因为检索结果它不满意。如果没有这个保护这个循环能一直跑下去烧钱又烧时间。4.3 失败重试与降级策略移动端网络不稳定工具调用失败是常态。我的重试策略分三层瞬时失败网络抖动立即重试最多 2 次间隔递增持续失败服务不可用跳过这个工具让主管 Agent 知道这个能力暂时不可用它会尝试别的路径整体失败返回一个友好的错误提示并把已经完成的部分结果展示给用户这里有个设计上的取舍要不要让 Agent 知道工具失败了。我一开始的做法是静默重试失败了也不告诉 Agent。后来发现这样不行——Agent 会一直等一个永远不会来的结果。改成显式告知之后Agent 反而能灵活应对比如天气查不到那就建议用户出行前自行确认。提示让 Agent 感知失败比替它隐藏失败更有效。模型处理信息不全的能力比我们想象中强。5. 实测中那些文档不会告诉你的坑这一节是我最想写的部分。前面讲的都是应该怎么做这里讲的是实际做的时候会怎么翻车。5.1 安卓原生嵌入 Flutter 页面的初始化顺序问题如果你的应用是原生为主、Flutter 为辅比如老项目加 AI 功能会用到安卓原生项目嵌入 Flutter 页面。这里有个非常隐蔽的坑FlutterEngine 的预热时机。如果等用户点了按钮才去创建 FlutterEngine第一次打开会明显卡顿因为引擎初始化要几百毫秒。正确做法是在应用启动时就预热一个 FlutterEngine 缓存起来用户点的时候直接复用。// 在 Application 里预热 class MyApp : Application() { lateinit var flutterEngine: FlutterEngine override fun onCreate() { super.onCreate() flutterEngine FlutterEngine(this) flutterEngine.dartExecutor.executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() ) FlutterEngineCache.getInstance().put(agent_engine, flutterEngine) } }另外Gradle 插件那块也容易出问题。如果你看到类似applying Flutters main Gradle plugin imperatively的警告说明插件应用方式过时了新版要用声明式的方式在settings.gradle里配置。这个警告不影响运行但会在构建日志里刷屏建议顺手改掉。5.2 状态管理选型我为什么最后没用最流行的方案Flutter 状态管理方案一大堆Provider、Riverpod、Bloc、GetX……我一开始跟风用了最流行的那个结果发现对多智能体场景不太合适。问题在于多智能体的状态是高度动态的——Agent 数量不固定、每个 Agent 的状态结构可能不同、状态之间的依赖关系复杂。那种强调强类型、编译期检查的方案在这种动态场景下反而束手束脚改一个字段要动好几个地方。最后我用了一个更轻量的方案核心状态用一个可监听的对象管理局部状态用 StatefulWidget 自己管。听起来很土但实测下来最灵活。我的原则是全局共享的才放全局只在一个页面用的就放页面里别为了架构优雅把简单问题复杂化。5.3 长列表性能聊天记录一长就卡怎么办多智能体应用的聊天记录会非常长因为每个 Agent 的思考过程、工具调用、结果都要展示。列表一长滚动就卡。我做了三件事第一用ListView.builder而不是ListView。前者是懒加载只构建可见项后者会一次性构建所有项。这个是最基础的但很多人会忘。第二固定 item 高度。如果每个 item 高度不固定列表要动态计算性能会差很多。我把消息卡片的高度做成固定的超出部分折叠滚动立刻顺滑。第三折叠中间过程。默认只展示最终结果中间的 Agent 思考和工具调用折叠起来用户点开才看。这样列表项数量直接少了一大半。配合 Impeller 渲染引擎这套组合下来即使几千条记录滚动也能保持跟手。5.4 那些让人抓狂的报错信息怎么读调试过程中会遇到各种报错我挑几个高频的说说怎么定位The current configured Flutter SDK is not known to be fully supported这通常不是错误是提示你当前 SDK 版本比较新某些工具还没适配。一般不影响运行除非你确实遇到了功能异常否则可以忽略。Agent execution terminated due to error这种泛泛的错误八成是某个工具调用抛异常了但没被捕获。我的做法是在每个工具调用外面包一层 try-catch把真实错误记下来而不是让它冒泡成一个笼统的错误。LLM request failed: provider rejected the request schema or tool payload这个基本是结构化输出格式不对模型返回的 JSON 不符合你声明的 schema。检查你的 schema 是不是太复杂或者示例给得不够清楚。注意遇到报错先别急着搜先看错误信息里有没有具体的字段名或行号。大部分时候错误信息本身就说清楚了问题在哪只是我们习惯性地跳过它。6. 从能跑到好用还差哪些工程化的事一个 demo 能跑起来和一个应用能上线中间隔着大量的工程化工作。这一节讲讲我为了让这套东西能用做的额外工作。6.1 可观测性Agent 在想什么你得看得见多智能体最麻烦的地方是黑盒——出问题了不知道是哪个 Agent 的哪一步出了错。我加了一套简单的日志系统把每个 Agent 的输入、输出、耗时、token 消耗都记下来。class AgentLogger { void log({ required String agentName, required String input, required String output, required Duration elapsed, required int tokens, }) { // 结构化日志方便后续分析 print(jsonEncode({ agent: agentName, input: input, output: output, elapsed_ms: elapsed.inMilliseconds, tokens: tokens, timestamp: DateTime.now().toIso8601String(), })); } }有了这些日志我才能回答为什么这次任务花了 30 秒为什么这个 Agent 老是失败这类问题。没有可观测性调优就是瞎猜。6.2 离线降级没网的时候怎么办移动端一定会遇到没网的情况。我的降级策略是有缓存就用缓存之前查过的信息没网时直接返回缓存结果并标注这是缓存数据能本地算就本地算简单的格式转换、文本处理不依赖模型也能做实在不行就明确告知不要假装在思考然后超时直接告诉用户当前网络不可用最忌讳的是让用户干等。宁可快速失败也不要让用户对着转圈等半分钟。6.3 提示词版本管理改坏了能回滚提示词是这套系统的源代码但它比代码更容易改坏——因为改提示词没有编译期检查改完可能表面上还能跑但效果悄悄变差了。我的做法是给提示词加版本号每次修改都记录改了什么、为什么改。上线新版本前用一批固定的测试用例跑一遍对比新旧版本的效果。如果新版本更差立刻回滚。这套流程听起来重但真的能救命。我有一次改了一个 Agent 的提示词本地测试没问题上线后发现它在某个边界情况下会输出非法 JSON导致整个链路失败。因为做了版本管理我五分钟就回滚了。6.4 用户预期管理别把 Agent 吹得太神最后说个软性的但很重要的点用户预期。多智能体听起来很酷但实际能力是有边界的。如果 UI 上把它包装成无所不能的 AI 助手用户一旦遇到它做不到的事失望感会特别强。我的做法是在交互上做引导明确告诉用户这个助手擅长什么、不擅长什么任务进行中展示正在做什么而不是一个笼统的思考中失败了给出具体原因而不是出错了。这些细节不涉及技术但直接决定用户觉得这东西好不好用。从我自己反复调试的体会来看多智能体应用真正的门槛不在模型而在工程——怎么把不确定的模型输出包装成用户觉得可靠的产品体验。模型能力是给定的但工程上的每一处细节都是可以打磨的。我现在的做法是每上线一个功能都盯着真实用户的使用日志看一周把那些卡住重试放弃的节点一个个找出来优化。这个过程没有终点但每优化一处产品就扎实一分。