1. 为什么我押注 Flutter 加 LLM 多智能体这条技术路线
移动端做 AI 应用,过去一年我试过至少三种组合:原生 Android 加云端接口、React Native 加本地小模型、以及 Flutter 加 LLM 多智能体。前两种要么在跨端适配上反复填坑,要么在状态管理和流式输出上体验割裂。最后让我决定长期投入的,是 Flutter 的渲染一致性和多智能体架构的解耦能力——这两者凑在一起,恰好能撑起一个“能跑在手机上、还能自己分工干活”的 AI 应用。
这篇文章面向的是已经写过一点 Flutter、但对 LLM 和多智能体还停留在“听说过”阶段的开发者。我会从整体架构讲到具体落地,包括 Flutter 侧怎么搭骨架、LLM 怎么接、多个 Agent 怎么协作、并发怎么扛、以及我在真机上踩过的那些坑。读完你至少能自己跑起来一个带三个 Agent 的移动端 Demo,并且知道每一步为什么这么做。
先说清楚一个前提:多智能体不是把一个大模型拆成几个就完事了。它的核心价值在于职责分离和上下文隔离。一个 Agent 负责理解用户意图,一个负责检索知识,一个负责生成最终回复,每个 Agent 只拿到自己需要的那部分上下文,这样既省 token,又能让每个环节的提示词更聚焦。Flutter 这边要做的,就是把这些 Agent 的输入输出用一套干净的 UI 和状态流串起来。
2. 整体架构设计与技术选型思路
2.1 三层结构:UI 层、Agent 编排层、模型接入层
我最终定下来的架构是三层。最上面是 Flutter 的 UI 层,负责对话界面、流式文字渲染、Agent 状态指示。中间是 Agent 编排层,用 Dart 写一个轻量的调度器,管理多个 Agent 的注册、路由和消息传递。最下面是模型接入层,统一封装 LLM 的 HTTP 调用,处理 token 计算、重试、超时和流式解析。
为什么把编排层放在 Dart 而不是丢给后端?因为移动端场景下,很多轻量任务(比如意图分类、简单问答)完全可以在本地判断后直接走对应 Agent,不必每次都绕一圈服务器。而且把编排逻辑放在客户端,调试时你能直接在 IDE 里打断点看每个 Agent 的输入输出,比在后端日志里翻要快得多。
三层之间通过抽象接口通信。UI 层不关心底下是哪个模型,编排层不关心 UI 怎么渲染,模型层不关心谁在调用它。这种解耦带来的直接好处是:我想把某个 Agent 从云端模型换成端侧小模型时,只改模型层的一个实现类,上层代码一行不动。
2.2 为什么选 Flutter 而不是原生或 RN
Flutter 在这个项目里有三个不可替代的优势。第一是渲染一致性,流式输出的文字逐字追加时,Flutter 的 Widget 重建机制配合ValueNotifier能做到非常平滑的刷新,不会像某些方案那样出现闪烁或跳动。第二是状态管理可控,我用的是 Cubit(来自 flutter_bloc 生态),它把每个 Agent 的状态封装成独立的 Cubit,Agent 之间互不干扰,调试时能清楚看到哪个 Agent 在什么时刻发出了什么状态。第三是平台插件生态,后面要接语音输入、本地存储、推送时,EventChannel 和 PlatformView 能让我在需要时无缝调用原生能力。
至于 Impeller 渲染引擎,从 Flutter 3.10 之后在 iOS 上默认开启,Android 上也在逐步铺开。实测下来,对话列表快速滚动时 Impeller 的帧率稳定性明显好于旧的 Skia 路径,尤其是列表项里带渐变和阴影的时候。如果你还在用老版本,建议至少升到 3.16 以上再开始这个项目。
2.3 多智能体的职责划分原则
我见过很多人一上来就搞五六个 Agent,结果调试时根本分不清是谁出的错。我的建议是从三个开始:一个 Router Agent 负责判断用户这句话该交给谁,一个 Knowledge Agent 负责检索和整理事实性内容,一个 Response Agent 负责组织最终的自然语言回复。
这三个的分工逻辑是这样的:Router 只看用户输入,输出一个意图标签;Knowledge 拿到意图标签和原始问题,去查本地知识库或调用检索接口,输出结构化的候选内容;Response 拿到候选内容和对话历史,生成最终回复。每个 Agent 的提示词都只聚焦自己那一段,token 消耗比让一个大模型从头做到尾要低不少,而且出错时能快速定位是哪个环节的问题。
注意:Agent 数量不是越多越好。每多一个 Agent,就多一次模型调用,延迟和成本都会上升。三个是移动端场景下比较平衡的起点,等跑通了再按需拆分。
3. Flutter 侧核心实现细节
3.1 项目初始化与环境搭建
创建项目这一步看似简单,但有几个参数选错了后面会很麻烦。我用的命令是:
flutter create --org com.yourdomain --platforms android,ios --template app multi_agent_demo--org一定要填你自己的域名反写,否则后面接推送、地图等需要包名唯一性的插件时会冲突。--platforms明确指定 android 和 ios,避免生成一堆用不上的桌面和 Web 目录。
环境方面,Dart SDK 建议 3.2 以上,Flutter 3.16 以上。如果你在 Windows 上开发,注意 Flutter 3.47.5 这个版本号是社区里常被提到的稳定分支,下载时认准官方渠道的归档页面。Android 侧 Gradle 插件现在推荐用声明式写法,如果你看到you are applying flutter's main gradle plugin imperatively using the apply这类警告,说明还在用旧的 apply 方式,改成 plugins 块声明即可。
依赖清单我列一下核心的几个:
dependencies: flutter_bloc: ^8.1.3 dio: ^5.4.0 equatable: ^2.0.5 shared_preferences: ^2.2.2dio用来做 LLM 的 HTTP 调用,它比 http 包更好的一点是支持拦截器,我可以在拦截器里统一处理 token 计数和错误重试。equatable让状态对象的值比较更省事,Cubit 判断状态是否变化时直接用它。
3.2 用 Cubit 管理每个 Agent 的状态
每个 Agent 对应一个 Cubit。以 Router Agent 为例,状态定义是这样的:
abstract class RouterState extends Equatable { const RouterState(); @override List<Object> get props => []; } class RouterIdle extends RouterState {} class RouterThinking extends RouterState {} class RouterDone extends RouterState { final String intent; const RouterDone(this.intent); @override List<Object> get props => [intent]; }Cubit 里只做一件事:接收用户输入,调用模型层,把结果转成状态发出去。
class RouterCubit extends Cubit<RouterState> { final LlmClient client; RouterCubit(this.client) : super(RouterIdle()); Future<void> route(String userInput) async { emit(RouterThinking()); final intent = await client.classifyIntent(userInput); emit(RouterDone(intent)); } }这样写的好处是,UI 层只需要BlocBuilder监听状态,RouterThinking时显示加载指示器,RouterDone时把 intent 传给下一个 Agent。整个链路是单向数据流,不会出现状态互相覆盖的问题。
3.3 流式输出的 UI 处理
LLM 的流式返回是逐 token 追加的,UI 要跟着实时更新。我的做法是在 Response Agent 的 Cubit 里维护一个StringBuffer,每收到一个 chunk 就 append 然后 emit 新状态。UI 侧用ListView加AnimatedList或者简单的setState配合SelectableText都能实现,但要注意不要每来一个 token 就重建整个列表,只重建最后那一条消息的 Widget。
实测下来,用ValueListenableBuilder包住单条消息的文本区域,比整页setState的帧率表现好很多。另外流式过程中如果用户快速滚动,记得用ScrollController判断是否在底部,只有在底部时才自动滚到最新,否则会打断用户的阅读。
提示:流式输出时给文字加一个闪烁的光标效果,用户体验会好很多。用一个
AnimatedOpacity循环切换透明度就行,不需要引入额外依赖。
3.4 平台通道与原生能力对接
当需要调用系统语音识别或本地文件存储时,EventChannel 是首选。它适合持续性的数据流,比如语音识别的中间结果。PlatformView 则适合嵌入原生地图或相机预览这类需要原生渲染的场景。
我踩过的一个坑是:在 Android 上通过 PlatformView 嵌入原生页面时,如果原生页面里有输入框,键盘弹出会遮挡。解决办法是在原生侧监听键盘高度变化,通过 MethodChannel 把高度传给 Flutter,Flutter 侧动态调整 padding。这个细节在文档里不会写,但真机上一定会遇到。
4. LLM 接入与多智能体协作落地
4.1 统一模型接入层的封装
模型层我定义了一个抽象类LlmClient,里面有三个方法:classifyIntent、retrieve、generate。每个方法对应一个 Agent 的需求。具体实现类HttpLlmClient用 dio 发请求,关键是要处理好三件事:token 计算、超时重试、流式解析。
token 计算我用的粗略估算:中文按每字 1.5 token,英文按每 4 字符 1 token。精确计算需要引入 tokenizer,但移动端为了包体积考虑,粗略估算够用了。超时设置成连接 10 秒、读取 60 秒,因为流式生成时首 token 可能来得慢,但后续会持续输出。重试策略是只对 5xx 和超时重试,最多两次,且第二次重试前等待 1 秒。
流式解析用 dio 的ResponseType.stream,拿到ResponseBody后监听stream,按 SSE 格式逐行解析。每解析出一个完整的 data 块就提取 content 字段,通过回调传给上层。
4.2 Router Agent 的意图分类实现
Router 的提示词我打磨了很多版,最终稳定下来的是这个结构:
你是一个意图分类器。用户会输入一句话,你需要判断它属于以下哪一类: - QA:事实性问题,需要查知识库 - CHAT:闲聊,直接回复即可 - TASK:需要执行某个操作,比如设置提醒 只输出类别名称,不要输出其他内容。 用户输入:{query}关键点是只输出类别名称,这样解析起来零成本。如果让模型输出 JSON,还得处理它偶尔多写几个字的情况。温度设成 0,保证分类结果稳定。
4.3 Knowledge Agent 的检索与整理
Knowledge Agent 拿到问题后,先去本地知识库检索。本地知识库我用的是简单的关键词匹配加向量相似度混合。向量部分如果不想引入端侧模型,可以先把文档向量化后存在本地,查询时算余弦相似度。文档量不大的话(几百条以内),暴力计算完全够快。
检索出候选片段后,Knowledge Agent 的提示词是:
根据以下资料回答用户问题。如果资料中没有相关信息,直接说"资料中未找到"。 资料:{context} 问题:{query}这样能有效抑制模型编造内容。实测下来,加了这句约束后,幻觉率明显下降。
4.4 Response Agent 的最终生成
Response Agent 拿到 Knowledge 的输出和对话历史,负责组织语言。它的提示词里会带上人设和语气要求。这里有个技巧:把对话历史截断到最近 5 轮,再往前的用摘要代替。否则上下文会越来越长,token 成本飙升。
三个 Agent 的串联在编排层里就是一个 async 方法:
Future<String> handleUserInput(String input) async { await routerCubit.route(input); final intent = (routerCubit.state as RouterDone).intent; if (intent == 'CHAT') { return await responseCubit.generate(input, []); } final context = await knowledgeCubit.retrieve(input); return await responseCubit.generate(input, context); }4.5 并发场景下的 Agent 调度
移动端虽然不像服务端那样面对高并发,但用户快速连发多条消息时,Agent 之间还是会出现竞争。我的处理方式是给每个会话加一个队列,同一会话的消息串行处理,不同会话并行。用 Dart 的StreamController或者简单的Future链都能实现。
如果要做更复杂的并发控制,比如限制同时进行的模型调用数量,可以用Pool模式。Dart 里没有现成的信号量,但用Completer加计数器手写一个也就二十行代码。核心思路是:请求前检查当前活跃数,超过阈值就 await 一个 Completer,等有请求完成时 complete 它。
注意:不要在 UI 线程做重计算。向量相似度计算如果文档量大,放到
Isolate里跑,否则会卡住界面。
5. 常见问题与排查技巧实录
5.1 流式输出中断或乱码
最常见的原因是 SSE 解析时没有正确处理跨 chunk 的行。一个 data 块可能被 TCP 拆成两个 chunk 到达,如果你按 chunk 直接解析就会出错。正确做法是维护一个 buffer,每次 append 新数据后按换行符切分,最后一行不完整就留在 buffer 里等下一个 chunk。
另一个原因是编码问题。确保请求头里Accept: text/event-stream,响应按 UTF-8 解码。中文乱码基本都是解码方式不对导致的。
5.2 Agent 之间状态不同步
如果 UI 上显示 Router 已经完成但 Knowledge 还在转圈,大概率是 Cubit 的 emit 顺序有问题。检查一下是不是在 await 之前就 emit 了完成状态。另外,多个 Cubit 之间如果需要共享数据,不要直接互相引用,而是通过一个上层 Coordinator 来中转,避免循环依赖。
5.3 真机上首次请求特别慢
首次请求慢通常是 DNS 解析加 TLS 握手导致的。解决办法是在 App 启动时就预热连接,发一个轻量的 HEAD 请求到模型服务端点。另外,如果用的是自建服务,检查一下服务端的 TLS 配置,启用会话复用能明显减少后续请求的握手时间。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 流式文字卡顿 | 每 token 重建整页 | 只重建最后一条消息 Widget |
| 中文乱码 | 解码方式错误 | 确认 UTF-8 解码 |
| 请求超时 | 首 token 延迟高 | 增大读取超时,预热连接 |
| Agent 结果错乱 | 状态 emit 顺序错误 | 检查 await 与 emit 的先后 |
| 内存持续增长 | 对话历史未截断 | 限制历史轮数,用摘要替代 |
| 键盘遮挡输入框 | PlatformView 未处理键盘 | 原生侧传键盘高度给 Flutter |
5.5 我踩过的三个坑
第一个坑是在 Cubit 里直接持有 BuildContext。这在页面销毁后会导致内存泄漏,正确做法是把需要导航的操作通过状态暴露给 UI 层,由 UI 层决定怎么跳转。
第二个坑是忽略 Impeller 在 Android 上的兼容性。早期版本在某些机型上 Impeller 会导致文字渲染异常,解决办法是在AndroidManifest.xml里按需关闭,等 Flutter 版本升级后再开启。
第三个坑是token 估算过于乐观。我一开始按每字 1 token 算,结果实际消耗高出 50%,导致成本超预期。后来改成中文 1.5、英文 0.25 每字符,才比较接近真实值。建议在模型层加一个累计计数器,每次请求后打印实际用量,跑几天就能校准出适合自己场景的系数。
6. 后续可以继续深挖的方向
这套骨架跑通之后,我接下来打算做两件事。一是把 Knowledge Agent 的检索换成端侧向量模型,彻底摆脱对云端检索接口的依赖,这样离线也能用。二是给 Agent 之间加一个简单的消息总线,让它们能互相订阅事件,而不是只能串行调用。比如 Response Agent 生成到一半发现需要补充事实,可以反向触发 Knowledge Agent 再查一次。
另外,Agent 的记忆管理也值得单独做一层。现在对话历史是简单截断,后面可以引入摘要加关键信息提取,把长期记忆和短期上下文分开存储。这块如果做得好,多轮对话的体验会有质的提升。
如果你也在做类似的东西,建议先把三个 Agent 的链路跑通,别急着加功能。链路通了之后,每个环节的优化方向自然就清晰了。