这个标题初看像是把 Flutter、LLM、多智能体三个热门词硬拼在一起,但真上手做了一圈之后,我发现这个组合不是噱头,而是一条很值得走一遍的技术路径。Flutter 解决的是“一套代码跑到 iOS、安卓、桌面和 Web”的多端体验问题,LLM 解决的是“自然语言理解和生成”的问题,多智能体解决的是“复杂任务怎么拆解、角色怎么协作”的问题。三者拼起来,就是一个完整的 AI 应用闭环:客户端交互、模型推理、任务编排。如果你已经会写一点 Flutter,又对 RAG、工具调用、Agent 协作这些概念只有零散认知,这篇文章就是我这次实战的完整复盘,从架构设计到端侧实现再到排查记录,一条线讲完。
我这次带着团队做的 Demo 是一个“多智能体编码助手”App:用户自然语言描述一个需求,系统里的多个 Agent 分工完成需求拆解、代码实现、代码审查、知识库检索,最后用流式方式把结果推回 Flutter 客户端。这个项目不算复杂,但足够把 Flutter 端的状态管理、原生通信、Wepack 打包问题,和 LLM 端的提示词设计、Function Calling、RAG、统一网关全部串起来。下面我按实战顺序拆开讲。
1. 先搞清楚:这个组合到底做什么事,每个角色负责什么
1.1 项目边界与核心需求拆解
这个项目要解决的痛点很明确:单一聊天式 AI 助手在处理“帮我写一个登录页面并加上状态校验”这类真实开发需求时,输出质量不稳定。原因不是模型不行,而是任务太宽泛、约束条件太多。如果能让多个智能体各司其职,先做需求拆解,再写代码,再有人对着代码挑毛病,最后按规范整改,输出质量会明显更可控。
所以项目核心目标有三个。第一,在 Flutter 客户端提供统一的对话与任务看板界面,支持流式输出,这部分考验 UI 组织和状态管理能力。第二,在服务端完成 LLM 接入与多智能体编排,模型负责理解,编排逻辑负责让多个模型协作符合流程。第三,让知识库介入,使得智能体在回答时能引用真实可靠的 Flutter 官方文档和团队规范,而不是全靠模型记忆。
这个 Demo 不适合做的事情也要说清楚。它不适合用来跑大规模并发的高可用系统,也不适合替代成熟商业产品做生产级模型管理。入门项目的主要价值在于把一个完整链路走通,让你理解架构中每个环节为什么存在。
1.2 用“公司结构”理解多智能体
很多人第一次接触“多智能体”会觉得玄乎,我习惯用一个“公司”类比:单 Agent 相当于一个人既当产品经理又当程序员又当测试,多智能体则是把不同职责拆给不同团队,大家在同一项目上下文里协作。
Flutter 客户端在这里相当于“前台接待”,负责收集用户诉求并展示结果。LLM 相当于“核心大脑”,所有智能体的内核都是同一个或几个大模型,只是给不同 Agent 配了不同角色提示词、不同工具权限、不同上下文片段。比如 Planner Agent 提示词里写的是“负责拆解需求,输出步骤”,Coder Agent 的提示词里写的是“负责写具体代码”,Reviewer Agent 的提示词里写的是“负责审查代码规范与潜在 Bug”。
这里要记住一个关键点:LLM 多智能体不是多个模型在并行运行,而是多个“带角色配置的模型执行实例”在围绕共享任务上下文轮流工作。共享上下文通常由一个任务对象或消息队列来承载,这也是整个系统结构设计的核心。
2. 整体架构设计与知识库方案选择
2.1 客户端、服务端与模型层的分层关系
这个项目的架构我分成三层。客户端层是 Flutter,负责聊天界面、任务状态展示、原生能力调用,通过 HTTP/ WebSocket 与服务端通信。服务端层是一个轻量 API 服务,我用 FastAPI 写的,装了三样东西:智能体编排引擎、LLM 网关、知识检索服务。模型层则是对接具体的大模型服务商。
客户端和模型之间必须隔一层服务端,原因是多智能体编排涉及多轮模型调用、状态维护、工具执行,放在端上既费流量又难以调试,也容易被模型 API 的异步响应卡住。服务端把编排过程封装成一个 HTTP 长连接或 SSE 流,Flutter 端只负责愉快地接收增量文本,这个设计在入门阶段最省心。
至于 LLM 网关,我一开始觉得没必要,但接两个不同模型供应商后马上发现问题了:每家请求格式、超时设置、鉴权方式都不一样,业务代码里到处是 if-else。用网关统一收口之后,客户端只感知一套内部接口,网关负责把请求按规则转发给不同模型供应商,还能顺带做限流、日志、成本统计。常见的开源方案有 LiteLLM、One-API,自己写一个也不难,核心就是封装“模型名称到供应商端点”的路由表。
2.2 RAG 与 GraphRAG:知识库到底怎么选
“LLM Wiki”这个概念在这个项目里落地成一个团队内部的知识库:把 Flutter 官方文档、团队编码规范、常见踩坑记录整理成条目。直接把这些文档塞给模型是不现实且浪费的,所以要用 RAG(检索增强生成)来做“按需取用”。
RAG 最基础的实现流程是:先离线把文档切成片段,用 Embedding 模型把每个片段转成向量,存入向量数据库;查询时把用户问题也转成向量,做相似度检索,把最相关的片段连同问题一起交给大模型生成答案。这个流程能解决模型“记得不准”和“知识陈旧”的问题。我用的向量数据库是本地文件模式的 Chroma,对入门项目来说零运维、够用。
GraphRAG 则是在 RAG 基础上增加一层“实体关系图谱”。当知识库不是零散的 QA 片段,而是有明显的实体关系时,比如 Flutter 中 Widget、State、BuildContext、Element 之间的关系,GraphRAG 可以先抽取实体和关系,再把用户的查询映射为图检索。效果是回答时能自动带上“为什么这个 API 需要挂在 BuildContext 上”这种上下文。代价是构建图的过程复杂很多,需要调用 LLM 做实体抽取和关系抽取,还要存储三元组。
我的选择原则是:如果知识库是“Q&A 风格的规范文档”,普通 RAG 足够;如果知识库要支持“关系推理”类问题,再考虑 GraphRAG。入门阶段先用普通 RAG 把链路跑通,后续再升级图检索,不必一上来就全套上。
2.3 多智能体协作模式:编排器模式与共享上下文
多智能体的协作模式,我这套项目用的是“编排器模式”,也叫路由器模式。一个 Orchestrator Agent 作为总控,先接收用户原始需求,判断需要哪些专业技能,然后按顺序调度子 Agent。
实际流程是用户提问后,Orchestrator 先调用规划模块,生成任务清单;每项任务交给负责该领域的子 Agent,子 Agent 可以调用工具,比如查知识库、执行代码搜索;产出结果后写回共享上下文;最后 Orchestrator 汇总各结果,生成最终回复。这个过程中最容易被忽略的是共享上下文的长度控制。多智能体每次把全部历史上下文传给模型会非常烧 token,我在实现时做了一层“上下文压缩缓存”:只保留完整任务的摘要,每个子 Agent 的完整输入输出按需加载。
协作还有另一种常见模式是“辩论模式”,让多个 Agent 对同一问题提出不同见解,最后投票或综合。这种模式适合方案选型和代码审查,但成本更高、时延更长,入门阶段不推荐优先实现。
3. Flutter 端核心实现要点
3.1 状态管理选型:Bloc 还是 Cubit
Flutter 端最需要想清楚的是状态管理方案。说句心里话,在这个 AI 聊天类应用里,状态结构是“多屏共享一份会话状态 + 实时追加消息”,并不复杂,但确实需要明确的自动化状态更新。
Bloc 和 Cubit 的核心区别在于事件层。Cubit 直接用函数调用来变更状态,代码量少;Bloc 要求先定义 Event,再通过 Bloc 内部映射 Event 到 State,优点是所有状态变化都有迹可循、方便测试和埋点。我的建议是:如果只是 Demo 入门,直接用 Cubit 就够了。下面是我在项目里写的一段简化示例。
class ChatCubit extends Cubit<ChatState> { ChatCubit(this._apiClient) : super(const ChatState()); final ChatApiClient _apiClient; Future<void> sendMessage(String text) async { emit(state.copyWith(sending: true)); try { final reply = await _apiClient.streamReply(text); emit(state.copyWith( messages: [...state.messages, ChatMessage(text: reply, fromUser: true)], sending: false, )); } catch (e) { emit(state.copyWith(sending: false, errorMessage: e.toString())); } } }这段代码里我在状态对象里直接维护了 messages 列表、发送状态和错误信息,UI 层通过BlocProvider获取实例,再用BlocBuilder监听变化。比使用 setState 规范,也比直接套用 Bloc 的 Event 机制少写一半样板代码。
3.2 流式输出与原生通信:Stream 和 EventChannel
聊天应用的体验关键在“结果是一点点冒出来的”,而不是等一整段回答后才显示。服务端用 SSE 协议推送增量文本,Flutter 端用StreamChannel或dart:convert解析数据流。
我在 Demo 里直接使用package:http的http.Client().send获取StreamedResponse,逐行读取响应并按 SSE 格式解析。这里有个细节容易被忽略:SSE 字段名是data:,但模型输出里可能故意包含多余的换行,解析时一定要按空行切分事件块,再摘出 data 字段,否则中文标点会被意外截断。
原生通信方面,这个项目需要在安卓端读取设备型号与系统版本作为智能体诊断代码问题的辅助信息,这时候就用到 EventChannel 了。Dart 侧定义一个通道名,原生 Kotlin 侧注册同一个名字的 StreamHandler,Dart 订阅这个流就能持续收到原生推送的数据。
static const _deviceChannel = EventChannel('app/device_info'); Stream<String> watchDeviceInfo() { return _deviceChannel.receiveBroadcastStream().cast<String>(); }EventChannel(flutterEngine.dartExecutor.binaryMessenger, "app/device_info") .setStreamHandler(object : StreamHandler { override fun onListen(args: Any?, events: EventChannel.EventSink?) { events?.success(Build.MODEL + "/" + Build.VERSION.RELEASE) } override fun onCancel(args: Any?) {} })3.3 工程组织与原生页面嵌入:part/part of 的正确姿势
项目代码量上来之后,单一文件很难维护。Dart 提供了part机制来把一个库拆成多个文件。很多人写part会踩坑,最常见的是不知道part of里要加库名还是 Uri。Dart 3 推荐写法是part of 'chat_bloc.dart';,也就是带源头文件的 Uri,旧写法只写库名虽然能跑,但已经被官方建议淘汰。
写法如下,在chat_bloc.dart里声明库并包含分文件:
// chat_bloc.dart part 'chat_state.dart'; class ChatBloc extends Bloc<ChatEvent, ChatState> { // ... }// chat_state.dart part of 'chat_bloc.dart'; class ChatState { final List<ChatMessage> messages; const ChatState({this.messages = const []}); }用part组织的代码在 IDE 里跳转和重构都不如独立库文件方便,所以我只在同一个功能模块内部使用,模块与模块之间仍然用 export 和 import。另外还有一个很常见的实际需求是“安卓原生项目里嵌入 Flutter 页面”。如果你们公司已有安卓原生工程,想加入 Flutter 页面,思路不是把 Flutter 包成 AAR 塞进去,而是用 FlutterEngine 预加载和 FlutterFragment 承载页面。关键点是原生侧要先创建并缓存一个FlutterEngine,避免每次跳转都重新启动引擎。
3.4 UI 细节:TabBar 动画、Navigator 状态、Web 引擎启动慢
这三个问题是我实测被问得最多的,因为看起来简单,坑都藏在细节里。
第一个,TabBar 点击时总是带动画,想去掉。Flutter 的 TabBarView 内部是 PageView,自带滑动和跳转动画,直接设置动画曲线并不能全局禁用。我用的方式是把 TabController 的动画时长设定为 Duration.zero,或者重写 TabBar 的 indicator 动画。另一个更直达的方案是在 PageView 上设置物理效果和控制器偏移。
第二个,Navigator 切换页面后状态丢失。原因是默认push新路由后,老路由里的 State 可能被销毁,回来后整套状态重建。解决方法有三个:使用AutomaticKeepAliveClientMixin让列表保持在内存;使用IndexedStack同时保留多个子页面状态;或使用 Flutter 3.7 之后的StatefulShellRoute管理多 Tab 页的状态。我的项目里聊天页面采用了 IndexedStack 方案,简单稳定。
第三个,Flutter Web 首屏启动慢。这几乎是 Web 端没法绕开的问题,但可以优化:前端包体积里 CanvasKit 占了很大部分,你可以把CanvasKit的加载放到 CDN,并让loader.html里的初始脚本尽早执行;代码热更新时禁用 debug 版;如果场景不复杂,尽量使用flutter build web --release,并开启 Tree Shake Icons。我在实测里,改用 CDN 加载 CanvasKit 后首屏时间大概优化了 30%。
4. LLM 层选型与实现
4.1 模型怎么选:云端大模型还是本地小模型
这个项目里我用了两层模型策略:打开 App 时先用本地小模型做“意图分类”,判断用户诉求属于代码生成、知识问答还是普通聊天;然后根据分类结果,再由服务端路由到对应的云端大模型做后续生成。
你可能想问:LLM 和深度学习到底什么关系。直接说结论:LLM 是深度学习的产物,本质是一个大规模 Transformer 模型,通过海量文本训练获得通识能力。理解这一点后,你在选择模型时就不会被营销词汇带偏。
云端大模型优势是能力全面、中文表现好、工具调用支持稳定。劣势是数据合规、成本、时延。本地小模型可以用 ONNX 部署,真正跑在手机或者笔记本 CPU 上。我在实验里用 ONNX Runtime 跑了一个几百 MB 的量化模型做意图识别,效果足够,而且 QoS 非常稳定。如果你也想试,流程是:用 HuggingFace 下载模型 → 转成 ONNX → 在 Python 或端侧用 ONNX Runtime 加载 → 把输出做 softmax 分类。移动端只放一个 100M 以下的分类模型,体验上完全可接受。
4.2 Function Calling 与 Tool Schema:provider 拒绝 payload 的真相
多智能体要“动手做事”,比如查知识库、调用计算器、读取本地文件,靠的是模型平台的 Function Calling 能力。使用时最关键的是把工具描述写成一个符合平台要求的 JSON Schema。
我踩过的最大一个坑就是热词里那条报错:llm request failed: provider rejected the request schema or tool payload.最开始我以为是自己参数名拼错了,排查到最后发现原因很气人:我的工具描述太长,里面有一个字段的 description 写了一整段文档,超过了模型供应商对单字段描述长度的限制。另一个常见原因是工具里声明了type: "object"但 properties 里少写了一个必填字段,或者参数名用了模型保留的顶层级function。修复思路很朴素:精简工具描述、每个工具只保留最核心参数、用 JSON Schema 在线校验工具定义。给每个工具写字段描述时,保持“一句话能说清楚”的粒度,这对模型选择正确工具很有帮助。
除了参数问题,真正稳定生成 tool call 的前提是模型版本要支持。如果你用的开源模型版本太老,那它可能根本没有原生 Function Call 能力,这种情况下你再怎么调 schema 都没用,要么升级模型,要么走“把工具描述拼进提示词,让模型输出固定 JSON”的兼容方案。
4.3 统一网关与模型路由:多智能体场景下的基础设施
多智能体编排里,一次完整答复可能要调用 5 次以上的模型接口。如果每次都在编排代码里直接写供应商 SDK,后期换模型供应商就是灾难。我在 FastAPI 里加了一个“内部网关”,所有模型调用统一走Post /v1/model接口,参数是model_name和messages,网关内部查表得到供应商、API Key、模型别名。
网关逻辑里最有价值的部分是熔断和降级:如果某个供应商超时超过 3 次,自动把请求切换到备用供应商。这个机制在多智能体场景下特别重要,因为一个子 Agent 调用失败,整个编排流程就得掐断。实测中备用切换让成功率从 91% 提到了 99%。
网关还能统一做令牌统计,我把每次调用的输入输出 token 数记录到日志表,按 Agent 角色聚合,很快就能看到哪个 Agent 最烧钱、哪段提示词过长,这些是普通入门教程不会提到的治理细节。
5. 多智能体实战:AI 编码协作助手的完整流程
5.1 角色定义与提示词设计
我在 Demo 里定义了三个核心 Agent 角色。Planner Agent,负责理解用户需求并输出执行计划,提示词里有“只能输出计划,不能写代码”的硬性限制。Coder Agent,负责按照计划实现代码,提示词里有 Flutter 编码规范、组件选型约束、以及“不确定的 API 要标记出来”。Reviewer Agent,负责审查生成的代码,提示词里有“检查状态管理是否合理、是否有内存泄漏风险、是否有冗余代码”之类的要点。
角色提示词是 Agent 最核心的部分,它的质量直接决定 Agent 输出差异。我写了三个“铁律”:角色定义里必须声明边界,越界能力直接拒绝;工具列表要固定,不能一个 Agent 都给全部工具;输出格式要结构化,比如所有输出必须带summary和code_snippet字段,方便下一个 Agent 解析。
5.2 规划-执行-审查:协作循环怎么跑起来
在一次典型请求里,完整流程是:用户说“帮我写一个带表单验证的登录页面”,Planner 输出三步计划;Coder 根据计划调用知识库检索 Flutter 的 Form 和 TextFormField 官方用法,生成代码;Reviewer 审查代码,发现缺少空值保护后返回改进建议;Coder 根据建议改一版;最后 Orchestrator 把最终代码和解释一起交给 Flutter 展示。
整个编排循环用一个简单的 Python 类维护,核心状态对象是TaskContext,里面存当前阶段、所有中间结果、已完成步骤列表。子 Agent 执行完毕,把自己的输出写回 TaskContext,下一个 Agent 从 TaskContext 读取所需内容。这样做的好处是流程可回放、可中断、可调试,多智能体项目里一定要把“上下文对象”当成一等公民看待。
5.3 多智能体强化学习:从经典概念到 LLM 时代的入门理解
聊到“多智能体”这个热词,很多人会联想到“多智能体强化学习”,这里必须做个区分。经典多智能体强化学习(MARL)研究的是多个智能体在共享环境里学会协作或竞争策略,比如多个机器人协同搬运物体。智能体通过环境反馈的奖励信号更新策略,最后学会复杂协作。
在我这个 LLM 多智能体项目里,Agent 的“智能”来自大模型,而不是自己从环境里学出来的策略。这两个概念在落地层面可以结合:例如用强化学习来调“何时让 Reviewer 介入、何时直接放行”这类编排策略。入门阶段我建议你先跑通 prompt-based Agent,再回头读一点 MARL 的基础论文,理解“奖励设计”“通信协议”这些概念后,再看生产级 Agent 系统会顺很多。
6. 常见问题与排查技巧实录
6.1 Flutter 构建与版本问题速查
我这次实战和组员一起踩过非常多 Flutter 工具链问题,集中整理成下面这张速查表,基本覆盖绝大多数入门项目会碰到的坑。
| 现象 | 直接原因 | 我的解法 |
|---|---|---|
Flutter 打包报java.lang.AssertionError | 通常是插件与 Gradle 版本不匹配,或 Java 缓存损坏 | 先flutter clean,再清空 Gradle 缓存~/.gradle/caches,更新 JDK 到 17 并同步配置项目compileSdk |
| Flutter 工程报 “current configured Flutter SDK is not known to be fully supported” | 本机 Flutter SDK 版本比项目要求的版本低 | 使用 FVM 锁定项目 Flutter 版本,不要简单粗暴升最新版 |
| 安卓原生模型里 Gradle 报错 “applying Flutter’s main gradle plugin imperatively” | 项目用了老式的apply plugin方式 | 按新模板改成id "com.android.application"和 settings 插件管理方式 |
| Xcode 26/27 下很多 Flutter 包报版本低 | CocoaPods 与 Flutter 插件兼容性滞后 | 锁定 Xcode 版本或给插件打补丁,多数情况用pod install --repo-update解决 |
| Web 端引擎启动慢 | CanvasKit bundle 过大 | CDN 加载 CanvasKit、开启 Tree Shake、避免 debug 模式部署 |
| EventChannel 一直收到 null | Dart 侧类型声明与原生侧返回值类型不一致 | 统一使用字符串类型,并在原生侧做好toString() |
| Navigator 切页面后状态丢失 | State 被销毁,列表滚动位置丢失 | 使用 IndexedStack 或 AutomaticKeepAliveClientMixin |
6.2 LLM 接口与工具调用问题全文记录
这一节是全文中含金量最高的部分,因为我遇到的每个报错背后都有个废话少说直接看报错的排查过程。
llm request failed: provider rejected the request schema or tool payload.的完整排错路线是:先把工具列表清空,看请求是否恢复正常;然后逐个加回工具,确认是哪个工具出问题;对那个工具做 JSON Schema 校验,重点检查 required 字段是否都定义在 properties 里。这个报错很少是模型供应商“心情不好”,基本都是定义不够严格。
还有一个经验是要注意 Agent 上下文里的角色冲突。多个 Agent 共享同一段历史时,如果上一个 Agent 的输出是 HTML,下一个 Agent 的提示词却要求它当作纯文本读取,结果会诡异。我的做法是在共享上下文里为每个 Agent 的输出包一个role标签,比如<planner_output>...</planner_output>,让模型清醒地知道数据边界。
6.3 性能与成本控制:多智能体烧 token 的补救方法
多智能体最大的隐性成本是重复传递上下文。比如三个 Agent 每个都收到完整历史,一轮交互就消耗数万 token。我用两个办法缓解:第一个是“摘要替代法”,Agent 交接时把上一阶段完整内容压缩成摘要,摘要由一个小模型生成;第二个是“按需注入知识库”,把知识库检索结果只注入给真正需要它的 Agent,比如 Coder Agent 需要 Flutter 文档,Planner 则不需要。
这两个方法听起来很简单,但实际收益很大,我对比过同一场景优化前后,单轮交互 token 消耗下降了约 55%。如果项目未来要规模化,可以再接一层“语义缓存”,把相同或相似的问题直接命中缓存,跳过模型调用,这是后续扩展方向。
7. 实操总结与后续扩展
这套组合做下来,最大的体会是“单点知识再多,不如跑通一条链路”。Flutter 端的细节、模型调用的问题、多智能体的编排逻辑,单独看都有文档,但真正把它们组合到一起时,你会被迫理解“为什么状态管理决定流式输出顺不顺”“为什么工具 schema 会直接影响模型稳定性”“为什么上下文压缩能决定成本是否能接受”。这些认知是纯看教程很难获得的。
如果要给后来者一个明确的路径建议,我会这么排学习顺序:先做一个单 Agent 的 Flutter 聊天应用,跑通 SSE 流式响应;再给模型接上一个知识库工具,理解 RAG;然后把一个 Agent 拆成三个角色,理解多智能体编排;最后再把原生嵌入、Web 部署、网关治理这些工程问题逐个补上。每一步都有明确验证标准:第一步看流畅度,第二步看回答引用是否准确,第三步看复杂需求是否拆解清晰,第四步看治理手段是否让维护成本下降。
最后再分享一个小技巧:多智能体项目调试时,一定要给每次 Agent 调用记录一个 trace-id,方便把一次完整请求的所有模型调用日志串起来。我用这个方式定位问题的时间从一小时缩短到五分钟,是真的好用。