ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flutter 加 LLM 多智能体:移动端 AI 应用架构与落地实践

Flutter 加 LLM 多智能体:移动端 AI 应用架构与落地实践

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.2

dio用来做 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 的链路跑通,别急着加功能。链路通了之后,每个环节的优化方向自然就清晰了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进