看到这个标题点进来的朋友,估计都是想把手里的Flutter技能和大模型接到一起,但又不知道多智能体那一堆名词到底怎么落地。我最近带一个小组做了一个内部用的LLM问答工具,前后用了两个周末从Flutter端到Agent调度全部打通,中间踩的坑能写满一页A4纸。这篇文章就是把这条实战路线重新走一遍,把我验证过的思路、踩过的坑、以及为什么这么设计的原因讲清楚。如果你是一个已经会写Flutter页面、但还没碰过LLM,或者反过来刚玩过LLM但不知道怎么把它嵌进移动端的开发者,这篇内容应该能帮你省掉一大半试错时间。
1. 先把概念理清:Flutter、LLM和多智能体到底怎么串起来
1.1 Flutter在应用里扮演什么角色
如果是刚接触的人,很容易把Flutter和LLM的关系理解成“用Flutter写个大模型出来”,这当然不对。Flutter是客户端框架,跑在Android、iOS、Web、Windows等终端上,负责UI和交互逻辑;LLM是服务端大脑,通常以API形式提供推理能力。你的Flutter应用负责把用户的话传过去,把模型返回的内容展示出来,还要负责管理整个对话上下文、多轮状态、甚至触发多个Agent协同工作。所以这条链路里Flutter是“前台”,LLM是“后台”,多智能体是“后台的业务逻辑”。
具体到一个App,Flutter端的职责大概是:收集输入、维护会话上下文、渲染流式输出、调用RAG检索接口、接收Agent执行结果、展示工具调用状态(比如思考中、调用搜索、生成答案)。这些事看起来很杂,但技术上都不难,难的是把异步流程和状态管理做得让用户不觉得卡、不觉得乱。我见过一些人直接拿WebView套网页版聊天工具来“跨界”,没有说不行,但那样你拿不到原生的性能、推送、离线能力,也不容易做出多智能体的自定义交互。用Flutter原生来实现,后面扩展性会好很多。
1.2 LLM与“多智能体”不是一回事
LLM是模型本身,它擅长做“给定一段文本,预测下一个token”这件事。但单个模型不行的地方在于:它不能稳定地执行复杂多步任务,容易在中间忘掉目标;它无法主动决定“什么时候该查资料,什么时候该调工具”;它面对一个涉及多个领域的问题时,容易把所有事情揉在一起回答。多智能体其实是一种应用层架构:把一个大任务拆成若干子任务,分配给多个不同的角色去处理,每个Agent可以有自己的Prompt、自己的工具、自己的知识库,再通过一个协调器或消息总线把它们串起来。这里Agent不一定是独立的模型,很多时候只是“不同的Prompt加工具调用配置”共享同一个LLM。用多个Agent的好处是职责单一、上下文干净、可分别调试。
所以你可以这样理解:LLM是发动机,多智能体是车队调度系统。我们做的是用Flutter去开这个车队,而不是去造发动机。
1.3 一个最小可落地的系统架构
我们项目最终的架构可以简化为三层。第一层是Flutter客户端,负责界面、本地状态、网络请求。第二层是Agent服务端,自建或者跑在云函数上,它接收客户端消息,维护会话,调用LLM API,并负责路由到不同的Agent。第三层是LLM API加知识库,模型推理能力来自这里,知识库可以是向量数据库或Wiki索引,用于RAG增强。
为什么要把Agent调度放在服务端而不是Flutter端?因为服务端可以集中管理Prompt、密钥、日志,也方便迭代。Flutter端如果直接接LLM,密钥会暴露在客户端,而且多智能体需要多次串行调用,完全在手机上跑容易失控。当然,如果你只是做一个本地单机Demo,也可以直接用Dart调LLM API,后面我会讲到。这个架构看着简单,但把通信协议、状态同步、错误处理做好并不容易,下面我们就从Flutter侧开始逐步实现。
2. 从零搭环境到第一个LLM请求:Flutter侧准备
2.1 Flutter环境搭建与版本选择的避坑
如果你还没装Flutter,先去官网下载SDK。但版本选择上我要多说两句:Flutter 3.40以后,Windows桌面端稳定性提升明显;我自己用的3.44,实测在安卓真机和Windows上跑LLM流式响应都挺稳定。如果你是macOS加新版本Xcode的组合,可能会遇到很多Flutter包提示“版本低”的问题,这不是你的错,是这些第三方包还没适配最新Xcode,建议优先使用稳定版Flutter而不是最新版。
创建项目有两种方式:用IDE新建,或命令行执行flutter create。很多新手会卡在“如何用Android Studio创建Flutter项目”,其实关键点是先装好Flutter和Dart插件,然后在New Project里选Flutter Application。这里有个我踩过的坑:默认创建的项目是用Kotlin和Gradle构建的,如果你在根目录的android/app/build.gradle里手写apply script: "flutter.gradle"时用了apply命令,会收到“you are applying flutter's main gradle plugin imperatively using the apply script”这类警告。正确做法是使用新版的plugins DSL方式,让工具自动应用Flutter Gradle插件,而不是手写apply。
另一个常见警告是“current configured Flutter SDK is not known to be fully supported”,这通常是因为你的项目锁定了某个SDK版本,而你本地却用了另一个版本。解决办法是把项目的pubspec.yaml和android配置里的SDK版本统一到同一个版本,或者直接升级项目里的sdk约束。出现这种警告时不要直接忽略,很多时候它会导致后面打包时出现难以追踪的Gradle错误。
2.2 用Dart发起LLM API请求的三种常见方式
在Flutter里调用LLM API,我试过三种方式。第一种是直接用http包发POST请求,适合快速验证,请求体就是一个JSON,包含模型名、消息数组和是否流式参数。但要注意大模型接口一般不接受裸字符串,需要你先把用户输入组装成messages数组,并且把历史记录一起带上。第二种是使用官方或社区SDK,比如dart_openai这类包,好处是封装了鉴权、错误码和流式解析,坏处是第三方包更新可能不及时,遇到接口新特性会滞后。
第三种是直接用dart:io的HttpClient做流式解析。如果你要做一个打字机效果,建议用这种方式,因为http包默认会把整个响应读到内存,流式响应需要特殊处理。实际项目中我在Flutter端写了一个简单的流式解析函数,按换行切分JSON,然后通过Stream传给UI层。这里有个关键点:LLM的请求与响应都是围绕messages结构,你要自己维护一个List<Map<String, String>>上下文。记住每次请求都带上完整的历史消息,模型才能“记得”聊过什么,但也别无限携带,否则Token很快爆掉。
2.3 Token、Key、Query、Value这些概念到底什么意思
很多新手看到Token就头疼。其实可以这样理解:Token是模型处理文本的最小单位,一句中文可能被切成几个Token。你调API时,输入会占Token,输出也会占Token,总额就是计费依据。所以做对话系统时,要控制历史长度,只保留最近几轮,不然账单飞涨。
网上有个很好的比喻:LLM内部有三组向量——Key、Query、Value。你可以把它们想象成图书馆检索:Query是“我在找什么”,Key是“每本书的标签”,Value是“书的内容”。模型通过Query和Key的匹配度,决定从哪些Value里提取信息生成回答。这个机制是Transformer注意力的核心。虽然写Flutter代码时用不到这些数学细节,但当你调试为什么模型会“答非所问”时,理解这一点会帮你想到:很可能是上下文里Key没有覆盖到用户真正的Query。
3. 多智能体的实际玩法:角色拆分与协作流程
3.1 什么是多智能体:以“组织一个团队”为类比
想象你开了一家公司:有前台接待、资料管理员、行业分析师、法务审核。客户提出一个需求时,前台先接单,判断该转给谁;分析师需要资料时让资料管理员去查;法务最后把把关。每个角色只干自己擅长的事,而且有明确分工。多智能体就是把这套流程搬到代码里。
在实现上,每个Agent就是一段系统提示词,加上它可以使用的工具列表,以及一组输入输出格式。LLM会扮演这个角色,根据用户请求决定要不要调用工具、按什么顺序调用。多个Agent协同有两种常见模式:一种是“路由模式”,由一个协调者把任务分发给不同Agent;另一种是“管道模式”,Agent A的输出作为Agent B的输入。实际项目里往往是两种模式混合着用,协调者负责整体调度,管道用于数据加工。
3.2 一个实际案例:需求拆解、工具调用、结果汇总
我们做的是一个内部知识库问答工具,用户会问“帮我总结一下第二季度运营报告里关于用户增长的章节,并对比上季度数据”。如果只丢给单个LLM,它要么说“我没有数据”,要么瞎编。所以我们设计了三个Agent:拆解Agent把用户需求拆成“找文档、提取信息、分析对比、生成总结”四步;检索Agent调用RAG接口,在向量库或Wiki里检索相关文档片段;分析Agent把检索到的片段和用户问题组织成Prompt,交给LLM生成最终答案。
实际流程是:Flutter端把用户消息发给服务端协调器,协调器先给拆解Agent,它返回一个JSON计划;然后协调器调用检索Agent,拿到片段;最后分析Agent在片段基础上生成答案,同时把引用的文档标题返回给前端展示。这个流程的运行时间通常比单次LLM调用长,所以Flutter端要做好“中间状态”的展示,比如显示“正在检索文档”“正在生成回答”,否则用户以为卡死了。
3.3 在Flutter端如何管理和调度多个Agent
虽然调度逻辑在服务端,但Flutter端也要管理“当前处于哪个阶段”的状态。我用了flutter_cubit来管理页面状态,把状态分为idle、routing、searching、generating、done、error几种。UI根据状态显示不同的加载文字,同时可以展示Agent间的消息流。
这里要特别提一下:很多人在Flutter页面跳转时用Navigator.push,结果回来发现状态清空了,这是因为页面被重建了。如果你在Cubit里保存状态,需要把Cubit放在更高的层级,比如父级或全局,而不是写在某个页面的initState里。我们后来把Cubit实例放在MultiBlocProvider的顶层,才解决了切换页面丢状态的问题。另外,Agent之间的调度消息如果需要在UI上实时滚动显示,可以用StreamBuilder监听协调器的流式事件,这个我在后面的实操记录里会提到。
4. Flutter与LLM联动的核心技术要点
4.1 异步和微任务:Future、Isolate,别把UI线程卡死
LLM请求是网络IO,天然异步。Flutter的Future是基于事件循环和微任务队列的。你调用await http.post(...)时,不会阻塞UI。但有一个坑:如果你把大量JSON解析或加密操作直接放在then回调里,这些同步计算会占用UI线程,导致掉帧卡顿。解决方法是把重计算放到Isolate里。
Dart里创建Isolate有几种方式,最简单的是用compute函数。你只需要传入一个顶层函数和数据,它就会在单独的隔离区执行,然后把结果传回来。比如解析大模型返回的大段JSON,用compute(parseJson, response.body)就可以。关于微任务队列,一句话总结:Future.then的回调会被调度到微任务队列,在事件循环处理完当前任务后立即执行。合理使用微任务可以让状态更新更及时,但不要在微任务里再嵌套大量then,那会形成回调地狱。
4.2 Flutter组件通信与跨页面状态管理
组件通信在Flutter里是老生常谈:父子之间通过构造函数传参,兄弟之间常用InheritedWidget、Provider、Bloc或Cubit。如果你要在多个页面共享LLM会话上下文,推荐用Bloc或Cubit。另一个技术点是EventChannel。它是Flutter与原生平台双向通信的机制,和MethodChannel的区别是:MethodChannel是“一次请求一次响应”,适合调用原生功能;EventChannel是“持续的事件流”,适合从原生侧推消息给Flutter。
比如你用原生写了一个网络模块,或者要接收原生推送的LLM流式数据,可以考虑EventChannel。但如果你走的服务端是WebSocket或流式SSE,直接Dart侧接就行,不用绕道原生。在纯Flutter项目里,MethodChannel和EventChannel使用频率没有想象中那么高,但一旦遇到“安卓原生项目嵌入Flutter页面”这种混合开发需求,它们就变得很重要。
4.3 原生与Flutter通信:PlatformView与EventChannel的适用场景
有朋友问,能不能把原生的IM控件或者地图嵌到Flutter里?这就要用到PlatformView。它可以在Flutter页面上嵌入Android View或iOS UIView,适合那些Flutter没有等价实现的复杂控件。但要注意,PlatformView有额外的性能开销,而且在滚动时容易有白屏问题,能用Flutter自己实现就尽量不要用。
如果你的场景只是“安卓原生项目嵌入Flutter页面”,那做法是反过来:在原生Activity里用FlutterEngine和FlutterFragment或FlutterView承载Flutter页面。如果你需要从原生往Flutter传用户态数据,用EventChannel很顺手;如果只是Flutter调原生方法,用MethodChannel更简单。在我们的LLM项目里,其实没用到PlatformView,整个界面都是Flutter控件画的。但我在给一个已有原生应用嵌入Flutter模块时,发现EventChannel用来传递登录态和Token特别方便,避免了重复登录。
4.4 Web端与移动端的差异:引擎启动慢、Impeller渲染
Flutter Web现在能跑,但在LLM聊天场景里有个明显问题:首次启动加载慢。原因是要下载渲染引擎和Dart编译产物。解决办法是开启适合的Web渲染器,或者用Impeller的Web支持来优化,具体要看你用的Flutter版本。
Impeller是Flutter的下一代渲染引擎,早期在iOS上替换了Skia,在3.7以后默认启用。在Android部分版本也支持Impeller。它的好处是解决了Skia的“渲染第一帧慢”和“Shader编译卡顿”问题。如果你在真机上发现页面动画掉帧,可以检查是否启用了Impeller。但有些第三方包还不兼容Impeller,会闪退,这时可以通过设置关闭Impeller。对Web场景,我个人的建议是:如果主要用户是移动端,优先做好Android和iOS;Web端可以后补,用加载遮罩加预加载减轻体验问题。
5. 实操记录:一个“LLM Wiki知识库问答助手”的演进过程
5.1 初始版本:单Agent简单问答
第一天我们只做了最简版本:Flutter页面一个输入框、一个消息列表,服务端一个Node.js路由,转发请求到LLM API。Flutter端用http包POST到/api/chat,拿到完整响应后一次性渲染。这个版本跑通后,我们发现两个问题:一是回答太慢,几秒内只有转圈;二是没有引用来源,用户无法验证答案。
于是第二天改成流式输出。服务端用流式事件向Flutter推数据,Flutter端用StreamBuilder逐字刷新。这里踩了个坑:如果直接用setState逐字更新整个消息列表,会导致文本选择跳动和列表反复重建。后来我们给每一条消息单独抽成StatefulWidget,只更新增量文本,配合ListView的itemExtent,流畅多了。这一步是聊天类应用体验的关键,值得花时间调。
5.2 升级:RAG知识库与多智能体协作
有了基础对话后,我们接入了一个wiki知识库。做法很简单:把公司文档切分成小块,做成向量索引;用户提问时,先从向量库召回Top K相关内容,拼进Prompt。这就是所谓的RAG。我们对接了不同的知识来源,做成“LLM Wiki”的概念,其实就是一个可检索的文档库。
但纯RAG也有问题:用户问题可能涉及多份文档,简单的检索加拼接容易“淹没”关键信息。所以我们引入了前面说的三个Agent。实现时其实不需要很重的框架,用一段JSON配置加一个for循环就能把流程串起来。关键点是每个Agent要定义清晰的职责描述、可调用的工具、输出格式。我在实践中发现,给Agent一个“不超过200字的工具返回结果”约束,能有效防止协调器被无关内容带偏。
5.3 避坑:schema报错、工具调用失败等典型问题
在Agent工具调用过程中,我遇到最多的错误就是“LLM request failed: provider rejected the request schema or tool payload.”。这个翻译过来就是:你定义的工具参数schema不够严谨,或者LLM生成的工具调用参数不符合声明。比如你定义了一个date参数却用了字符串,模型给了数字。解决办法:一是把参数类型写严格,尽量用string类型并在描述里标明格式;二是加一层校验,收到工具调用后先验证再执行,不合法就要求模型重新生成。
另一个坑是“工具执行结果超长”。Agent把一整篇文档塞回给LLM,导致Token超限。我通常会对工具结果做截断,只保留与问题最相关的段落。如果你的工具是RAG检索,可以让检索Agent先返回文档标题和摘要,再由分析Agent决定要不要获取全文,这就是多智能体比单Agent省Token的地方。
6. 常见问题与排查经验快查表
6.1 Flutter侧典型报错与解决
我整理了一张表,方便你照着排查:
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
打包报java.lang.AssertionError: could not close I... | Android Gradle插件或资源文件问题 | 清理构建缓存,重新执行flutter clean和Gradle Sync |
| 新Xcode环境下很多包报版本低 | 插件未适配新工具链 | 升级Flutter到稳定版或等待插件更新 |
| 项目创建时警告不支持当前Flutter SDK | 项目锁定版本与本地版本不一致 | 统一pubspec.yaml中的sdk约束 |
apply script方式被废弃 | 使用了旧的Gradle插件应用方式 | 改用DSL方式自动应用插件 |
| 切换页面后LLM聊天记录没了 | Cubit实例被页面销毁 | 将Cubit提升到全局或路由父级 |
| Web端首屏加载慢 | 渲染引擎初始化耗时 | 使用加载占位,考虑开启Impeller |
| 流式输出时列表不停抖动 | 整个列表被setState重建 | 把消息行做成独立Widget,增量更新 |
6.2 LLM侧典型报错与解决
| 报错 | 含义与处理 |
|---|---|
context length exceeded | 输入Token太长,截断历史或使用摘要 |
provider rejected the request schema | 工具schema不合法,检查类型与描述 |
rate limit exceeded | 请求频率超限,加退避重试或排队 |
stream timeout | 流式响应超时,调大客户端超时时间并做心跳 |
| 模型回答“编造” | 可能RAG检索不到相关内容,检查知识库索引和召回率 |
6.3 我的三个实操心得
第一,别一开始就上复杂框架。多智能体最朴素的实现就是“一个协调函数加N个带Prompt的LLM调用”。先用最笨的方式跑通,再考虑用现成框架。第二,Flutter端的“中间状态”必须做。LLM一次请求三秒以上是常态,如果没有加载动画和阶段提示,用户会直接杀掉App。我们用Cubit做状态机后,整个体验提升了一个档次。第三,日志是你最好的调试工具。无论是Flutter侧还是服务端,把每次LLM请求的Prompt、返回的JSON、工具调用的参数全部记录下来,这样出问题时能迅速定位到是模型的问题、参数的问题,还是网络的问题。
最后再分享一个小技巧:如果你想快速做原型,可以在Flutter端用dart:io的HttpClient直接连一个支持OpenAI兼容协议的本地LLM服务,这样不用先写服务端,就能体验流式输出和多智能体流程。等逻辑稳定了再迁到云端,成本很低。