☰
Flutter+LLM多智能体应用开发:从零构建RAG知识库问答助手
2026/9/30 13:52:17 网站建设 项目流程

最近有朋友问我:现在这个节点想做个AI应用,前端到底该选什么。我的回答一般很直接——如果你的目标是快速覆盖手机和桌面、又要接入LLM和多智能体这套新东西,Flutter是当下性价比最高的选择。Flutter做UI层,LLM做推理层,多智能体做任务编排层,三样东西组合起来,就能在几天内跑出一个真正能用的智能助手原型。这篇文章就是把我从零开始踩坑、打通这三者的一套完整流程写出来,适合有Flutter基础、但没怎么碰过大模型和多智能体的开发者参考。

先说清楚这篇文章不是什么:不是讲怎么训练模型,不是讲Flutter官方文档的重复翻译,更不是纸上谈兵的架构图。是讲一个具体可跑的最小闭环——一个Flutter App里,用户输入一个问题,系统里有两个智能体分工协作:一个负责检索企业知识库,一个负责组织最终答案,中间还有一个调度器决定“这个问题要不要查资料”。整个过程从环境搭建到代码实现,再到联调踩坑,我会按我实际操作的顺序讲。

1. 为什么这三样东西放在一起,能解决单模型解决不了的问题

1.1 单次对话的LLM,本质上是个“不会翻书的全能实习生”

先聊一个根本问题:为什么要在LLM外面再套一层多智能体?直接用一个大模型不就行了吗?

我做了几个测试项目后的体感是——单个LLM的直接调用,适合写文案、改代码、翻译这种“一次生成”的任务。但一旦遇到两个场景,它就露馅了。第一个场景是需要结合你私有的知识库回答,比如问它“我们公司内部的报销流程是什么”,如果你不把流程文档喂进去,它只能编一个听起来很合理但完全不对的答案。第二个场景是任务需要分步骤完成,比如“查一下最近三天所有项目相关的讨论,整理成一份风险报告交给我”,这里既有检索、又有筛选、又有总结,让模型一口气做完,效果通常很一般,而且出了问题你根本不知道是哪一步错了。

我自己习惯把单模型比作一个全能但很浮躁的实习生:你问他什么他都能接话,但说完就忘,而且从来不记得上一步为什么要那么做。而多智能体的思路,其实就是把这个实习生拆成几个专职角色——有人专门负责查资料,有人专门负责动笔写,有人专门负责审核。每个角色只做一件事,做好为止。

1.2 Flutter在多智能体应用里的定位:不是“界面”,是“躯干”

很多做LLM后端的人对Flutter有误解:觉得前端不就是个聊天框嘛,随便写写不就完了?真不是。LLM应用的前端比传统App更复杂的地方在于:它要在同一个页面上同时呈现“流式输出的文字”、“正在检索的中间状态”、“可点击引用的知识来源”、“多轮对话的上下文”,如果产品涉及企业内部系统,还得把原生能力接进来——扫码登录、语音输入、消息推送、文件预览。

Flutter在这块的优势是:一套代码把移动端、Web端、桌面端全占了,对LLM项目来说意味着你可以先在Windows桌面调试业务逻辑,再顺手编译一个Android版给同事试用,不用维护两套前端。再加上Flutter内部的EventChannel、PlatformView这些桥接机制,可以很方便地把原生平台的语音识别、传感器、摄像头能力暴露给Dart层。这里先说个结论:Flutter+LLM的组合,真正的难点从来不在UI绘制,而在状态管理和异步通信,后面我会专门展开。

1.3 这篇教程的前置条件与最低要求

按照我实际跑通这套流程的经验,你不需要多深的底子,但有几个前置条件最好先满足:一是你熟悉Flutter的基础Widget、路由和Future的用法,能自己搭一个简单的页面;二是你手上有一个LLM的API访问方式,无论是OpenAI兼容的接口还是国内大模型服务商的接口都行,只要能按标准格式发HTTP请求;三是有基本的REST API调试经验,会用Postman或者直接用代码调试都行。

至于多智能体框架、LangChain、向量数据库这些东西,你不需要提前会——我的建议恰恰相反:入门阶段最好什么都别用,先用原生的HTTP请求把流程跑通,你才能真正理解多智能体是怎么工作的。用了框架之后哪里报错都不知道,会非常痛苦。

2. 动手前必须理顺的三大基础:Token、RAG检索、智能体编排

2.1 Token与上下文窗口:计费、上限和“三个点”的直观理解

在写任何LLM代码之前,先把Token这关过了。Token是模型处理文本的最小单位,中文通常一个汉字对应一个或多个Token,英文一个单词往往被拆成一两个Token。你的每一次请求,本质上就是发一段带Token上界的文本给模型,模型返回的也是Token。计费按Token算,模型能接受的输入长度也按Token算——这就是所谓的上下文窗口。

网上关于“LLM的Token三个点”有个说法我挺认同:每个知识条目在系统里,可以拆成三个维度来理解——“我是谁”(这个内容的身份标识,比如标题或ID)、“我在找什么”(用户的查询意图)、“我能提供什么”(知识条目本身的内容向量)。当你文本库里存的是“报销流程.pdf”的某个切块,它的“身份”是“财务板块第3节”,“内容向量”是那几段有关发票和审批的文字。用户query是“怎么报销打车费”,系统做的事情就是拿这个query去向量库里匹配“能提供什么”的那部分,再把匹配结果连同“我是谁”一起交给LLM。

这段基础必须建立起来,因为多智能体系统里,每条消息、每个工具的返回结果都在消耗Token,你得大概清楚一次完整的问答要花多少Token,才不会让上下文窗口莫名其妙爆掉。

2.2 RAG与LLM Wiki:企业知识库问答的基石

接着Token的话题说RAG。RAG全称Retrieval-Augmented Generation,检索增强生成。为什么要RAG?因为模型的知识止步于训练数据,你公司的内部Wiki、最近更新的产品文档、用户手册,它一概不知道。你想让LLM基于这些材料回答问题,又不能把几千页文档一次性塞进上下文——Token不够,噪声也太大。

LLM Wiki就是这类项目里比较有代表性的一个:把一堆文档(比如Markdown格式的团队Wiki)切块、清洗、生成向量索引,用户提问时先检索相关段落,再让LLM基于检索结果生成答案。搜索词里还有一个“本体RAG”,这算进阶玩法,就是事先定义好文档里的实体和关系结构(比如“项目-人员-任务”),让检索不再只是字符串或向量相似度匹配,而是带语义关系的结构化检索。入门阶段用简单的向量检索就够了,但你要知道这层演进路径。

2.3 多智能体的本质:角色分工、状态传递和结果汇总

多智能体这个词最近特别热,被很多人说得神乎其神。我用自己的话说:多智能体不是多个模型在那里互相聊天,它是一个编排系统,把一个大任务拆成几个子任务,每个子任务由不同角色(Agent)完成,这些角色共享上下文或分阶段传递上下文,最后汇总结果。

最简的多智能体系统长什么样?我认为就三个角色:调度器(Planner/Router)、检索智能体(Retriever Agent)、生成智能体(Generator Agent)。调度器接收用户的原始问题,判断问题是否需要查资料,如果需要,就把问题改写成更利于检索的Query,交给检索智能体;检索智能体只负责从知识库里捞回Top K条相关内容,不负责组织语言;生成智能体拿到这些资料后,结合对话历史生成最终回答。三个角色可以共用同一个LLM模型,区别仅仅在于System Prompt和给它开放的工具有哪些。明白这一点,你也就明白为什么很多人说“多智能体本身没有引入新的模型能力,它引入的是架构能力”。

3. 从零搭建Flutter工程:环境、分层和原生桥接

3.1 环境搭建与几个版本的坑(Windows/Mac都适用)

Flutter环境搭建本身不复杂,但在搜索词里出现频率很高的几个坑我得提前说。

第一,Windows下安装Flutter SDK,官网下载zip压缩包后,一定要把解压路径放到一个没有中文和空格的目录,比如D:\flutter。然后配置环境变量,再在Android Studio里安装Flutter和Dart插件,新建Flutter项目时选择对应的SDK路径。搜索词里提到“flutter windows 3.47.5下载”,这其实是个很新的版本,我的建议是:别追新,用稳定版(stable channel)就行。

第二,创建项目时有一个很有意思的警告:“you are applying flutter's main gradle plugin imperatively using the apply s……”。这是说项目里的android/build.gradle还在用旧的apply plugin写法,而新版Flutter模板建议改用pluginsDSL。不是致命错误,项目照样能跑,但升级依赖或打包时容易出幺蛾子,有了这个提示尽早把settings.gradle里的pluginManagement配置对齐官方模板。

第三,Android Studio新建Flutter项目时会让选组织名和项目名,项目名记得全小写加下划线,否则后面生成包名时会报错——这个我吃过一次亏。

3.2 分层设计:把UI、状态、服务、原生桥接分开

跑通Demo之前先想清楚代码分层,后面调试会轻松很多。这是我这几次实际项目中总结的一个相对顺手的分层方式:

  • UI层:Widget组件,只负责渲染和用户交互,不做任何网络请求,不持有LLM Client。
  • 状态管理层:用Cubit(后面会说为什么用Cubit)来持有对话列表、当前任务状态、错误信息。
  • 服务层:封装LLM请求客户端、RAG检索客户端、Agent编排器的调用入口。
  • 桥接层:所有与平台相关的调用都走这里,比如调用原生模块扫码、使用PlatformView嵌入Web页面、通过EventChannel接收原生事件。

这样分有几个立竿见影的好处:你可以在没有真机的情况下,用一组Mock数据单独测试UI;也可以在纯Dart环境里把Agent编排逻辑跑一遍,不依赖任何手机。多智能体系统最怕的就是业务逻辑和UI耦合在一起——一旦智能体之间出现问题,你调试时看到的全是UI卡顿或者数据不对,根本没有头绪。

3.3 原生项目嵌入Flutter页面:混合开发里做LLM功能模块

搜索词里有个“安卓原生项目嵌入Flutter页面”,这个场景在做企业级AI助手时特别常见——已有的原生App(比如IM办公软件)里要加一个智能助手模块,总不能整个重写。我的做法是,在原生Android工程里创建一个FlutterEngine作为智能助手页面的运行载体,用FlutterEngineCache缓存预加载的引擎,然后通过FlutterEngine.getDartExecutor().executeDartEntrypoint()把Flutter页面作为原生Activity中的一个Fragment或View来展示。

Flutter和原生页面的互跳可以走MethodChannel:Flutter侧调用原生的startActivity()方法,原生侧通过MethodChannel返回结果给Flutter。这里有一个经验之谈:一个应用里尽量只保留一个FlutterEngine实例,避免创建多个引擎带来大量的内存开销。搜索词里的“flutter跳转原生activity”就是这么干的。

4. 核心实战:实现一个调度器+两个智能体的最小可跑App

4.1 用System Prompt定义角色,用代码定义协作关系

理论讲完了,开始写代码。下面这个例子是我照着真实的项目结构简化过的。首先定义两个智能体的System Prompt:

  • 调度器的Prompt:“你是任务调度器。根据用户问题,判断是否需要检索知识库。如果用户问的是实时信息或私有文档内容,输出SEARCH: <改写后的检索词>;如果可以直接回答,输出DIRECT: <回答>。”
  • 检索智能体(RetrieverAgent):负责调用RAG服务,把用户query转成向量,从向量库检索Top K文档段,只返回结果和来源。
  • 生成智能体(GeneratorAgent):职责是根据“检索结果+对话历史”,生成最终回答。

在Dart里我建了一个很简单的Orchestrator类:

class AgentOrchestrator { final LlmClient llm; final RagClient rag; AgentOrchestrator({required this.llm, required this.rag}); Future<AgentResponse> handleUserMessage(String userMessage) async { // 第1步:让调度器判断意图 final routeResult = await llm.chat([ _systemPrompt('router'), _userMessage(userMessage), ]); if (routeResult.startsWith('SEARCH:')) { final searchQuery = routeResult.replaceFirst('SEARCH:', '').trim(); // 第2步:检索智能体去查知识库 final documents = await rag.search(searchQuery, topK: 5); // 第3步:生成智能体基于资料回答 final finalAnswer = await llm.chat([ _systemPrompt('generator'), _userMessage( '知识库相关内容:$documents\n\n用户问题:$userMessage', ), ]); return AgentResponse(answer: finalAnswer, sources: documents); } // 直接回答分支 final answer = await llm.chat([ _systemPrompt('direct_answer'), _userMessage(userMessage), ]); return AgentResponse(answer: answer, sources: const []); } }

你看到的这段代码,本质上就是多智能体最核心的骨架:调度器决定下一步,检索智能体提供材料,生成智能体产出最终结果。整个系统只有几十行。

4.2 用Cubit管理对话状态和智能体的运行阶段

为什么在这个场景里选Cubit而不是Bloc或Provider?因为LLM应用的交互流程是“异步+多阶段”的,用户发出一个问题后,要经历“请求中—检索中—生成中—完成/失败”这些阶段,每个阶段UI都要有明确反馈。Cubit的emit足够轻量,我直接定义一个状态类:

sealed class ChatState { const ChatState(); } class ChatInitial extends ChatState { const ChatInitial(); } class ChatLoading extends ChatState { const ChatLoading(); } class ChatKnowledgeSearching extends ChatState { const ChatKnowledgeSearching(); } class ChatAnswerStreaming extends ChatState { const ChatAnswerStreaming(); } class ChatError extends ChatState { const ChatError(this.message); final String message; }

前端页面用一个BlocBuilder来监听这些状态,对应展示不同的控件:搜索状态显示“正在查阅知识库”,生成状态显示一个打字机效果的文字流,错误状态显示重试按钮。这个设计的好处是,Cubit层完全不关心UI,你可以在没有界面的情况下单独跑测试逻辑。

4.3 异步陷阱:Future回调放进微任务队列后,旧请求可能覆盖新请求

接着说说一个隐蔽的坑。搜索词里有一个非常好的问题:“Flutter Future的then回调是放入微任务队列吗?”答案是:是的,Future.then注册的回调默认会被调度到微任务队列,在当前同步代码执行完之后按注册顺序执行。这本来没什么问题,但在LLM应用里会引发一种很恶心的bug:用户快速连续输入两个问题,前一个请求的检索结果还没回来,第二个请求已经发出去了;如果两个响应几乎同时到达,后注册的回调可能让旧答案覆盖新答案。

我的解决办法是引入一个requestSeq自增ID,每次用户发起新请求时把ID加1,回调回来时比对当前ID,如果发现不是最新的就丢弃。这个技巧看着简单,但能让你避免大量“莫名其妙的回答错位”问题。

Future<void> sendMessage(String message) async { final seq = ++_requestSeq; emit(const ChatKnowledgeSearching()); final result = await orchestrator.handleUserMessage(message); if (seq != _requestSeq) { return; // 说明这不是最新的请求,直接丢弃 } emit(ChatAnswerStreaming()); ... }

4.4 完整链路:从输入到渲染的每一步

把上面的内容串起来,一个最小App的完整交互链路是:

  1. 用户在Flutter输入框里输入问题,按发送。
  2. UI层调用Cubit的sendMessage方法,Cubit立即emit一个ChatKnowledgeSearching状态,页面显示“正在查阅知识库”。
  3. Cubit调用AgentOrchestrator的handleUserMessage,编排器先请求LLM做意图路由。
  4. 调度器返回SEARCH: xxx,编排器把改写后的查询词传给RAG服务。
  5. RAG服务查询向量库,返回Top K条文档片段和它们的来源。
  6. 编排器把所有片段拼接成一段上下文,连同用户原始问题一起发给生成智能体,请求stream模式。
  7. 生成智能体开始流式返回文本,每返回一部分就通过回调把新文字追加到当前消息上;UI层通过StreamBuilder把文字实时渲染出来。
  8. 答案流式输出完成,Cubit把状态置为完成,同时展示引用来源列表。

这个链路里每一个环节出错都可能导致最终结果不对,所以我在做Demo时加了一个调试面板:把路由结果、检索结果和生成器Prompt的原文全部展示在页面上。这样一旦结果不对,你能立刻看到是哪一步出的问题。

5. 联调实测中的坑:从请求被拒到状态丢失

5.1 工具调用和Schema:为什么Provider会拒绝你的请求

搜索词里有句报错信息非常典型:“llm request failed: provider rejected the request schema or tool payload.” 我在第一次接工具调用时遇到过一模一样的情况。当时的场景是:我想让模型在处理用户问题时自动调用一个查询内部数据库的工具,按照文档写好了tools参数,结果请求直接被拒。

排查下来发现是“Schema”问题:新版的大模型API对工具参数要求非常严格,JSON Schema里少一个description字段、多一个additionalProperties: false、或者函数参数类型写错,都会被拒。具体排查方法我建议这样:先把工具调用功能关闭,只留一个普通的System Prompt,看请求能不能通;能通之后,再定义一个最简单的不带参数的工具;通了,再加参数、加required、加enum。一层层加出来,你就知道是哪里卡住了。

5.2 上下文管理和记忆滑动窗口:做多轮对话必须解决的事

多智能体系统里,每一次任务都不只是“消耗一次请求”那么简单。一次完整的调用会包含:系统提示词、前几轮的对话历史、当前问题的改写结果、检索回来的文档片段、最终生成的回答。这些Token加在一起,很容易在七八轮对话后就把上下文窗口挤爆。

我采用的方案是滑动窗口机制:在把对话历史发给编排器之前,只保留最近N条消息,更早的消息要么直接丢弃,要么压缩成一段摘要以较低优先级放进上下文。对于检索片段,我一般在RAG服务侧就限制单次返回的总字符数,比如控制在1500个Token以内,防止知识库内容喧宾夺主。

5.3 Navigator切换页面后,Cubit状态为什么丢了

这个问题我朋友踩过,原话是:“Flutter Navigator切换页面后,会丢失状态吗?”如果用的路由方式是Navigator.push,原来的页面并没有直接被销毁,只是不可见了,理论上状态还在。但如果你是把Cubit实例创建在页面Widget内部,一旦这个页面被系统回收内存,或者你用了某些框架做了状态清理,Cubit跟着就没了,对话上下文也就没了。

对LLM应用来说,这是重灾区:用户已经聊了十轮,中途切到别的页面看了一下设置,回来发现上下文被清空,甚至会当场崩溃。我的建议是:对话的Cubit实例要放到页面上层的ProviderScope或者根级注入里,别放在页面内部。另外一个笨但有用的办法是,把对话历史持久化到本地SQLite或SharePreferences,每次App冷启动后从本地恢复对话——这比什么高级状态管理方案都可靠。

5.4 打包与构建:Gradle、缓存和Web引擎的启动性能

到发布的环节,也有几个顺手能避开的坑。Flutter打包Android时如果遇到“could not close input stream”这类异常,多半是Gradle依赖缓存损坏,清理~/.gradle/caches和项目里build目录后重试,概率立刻下降一半。新版Flutter工程如果用旧的“imperative apply”,也建议尽快改用官方模板推荐的标准写法。

再就是如果选择把App部署成Flutter Web,必须有心理准备:首次加载时引擎启动偏慢,因为浏览器要下载CanvasKit渲染器。搜索词里提到的“flutter impeller”其实是一个相关的概念——它是以图形引擎的方式替换掉原来的渲染路径,在移动端上滚动和动画性能提升明显,Web端也在适配中。想用Web做演示,可以先做资源压缩、按需拆分,至少让首屏快一些。

6. 从最小原型走向可落地的知识型产品

6.1 知识库不是“切一切文档”那么简单:本体、GraphRAG与更新策略

Demo阶段你可以用现成的向量库把文档切块就完事了。但一旦进入生产环境,几个问题就来了:文档更新后向量索引是否需要重建?多个文档版本共存时如何保证LLM引用的是最新版?跨文档的实体关系(比如“文档A提到项目X,项目X的负责人在文档B”)检索时怎么办?

这时候就用到前文说的“本体”和GraphRAG思路了。本体就是先定义一套领域概念模型,比如一个企业知识系统里有“员工、项目、文档、流程”四类实体,每类实体有哪些属性,实体之间有哪些关系。让LLM从原始文档里抽取这些结构化信息存到图数据库,检索时不仅能做向量相似度匹配,还能顺着关系去查。GraphRAG在涉及“多跳问答”时表现明显比纯向量检索好,但构建成本高,适合复杂知识域,不适合扔给所有人一上来就用。

6.2 加一个LLM网关,统一管理Key、限流和日志

搜索词里有一个“LLM网关”,这词听上去高级,实际作用非常朴素:统一管理多个模型的API Key、做请求转发、加统一的限流与重试、记录每次请求的Token和耗时、把成功和失败的结果都留到日志里。直连Provider写Demo没问题,但一旦有多个客户端(Flutter App、Web端、后台任务)同时使用模型,没有一个集中式的网关,你很快就不知道该去哪找日志和排查计费了。

网关不需要自己写开源轮子,有几个成熟方案直接用。但不管选哪个,最基本的三件事得有:按接口维度配置限流、失败自动重试并做指数退避、每条调用日志带requestId和耗时字段。这样你的多智能体系统出了问题,才有迹可查。

6.3 扩展新智能体和工具:让智能体能从“回答问题”升级到“执行动作”

最后说扩展方向。Demo里我只有检索和生成两个角色,产品化之后,可以加一个“行动智能体”,它能调用企业内部系统的API,比如创建工单、订阅项目、查询内部系统数据。在这种架构里,“工具”是定义给特定Agent的——生成智能体没有调用企业API的权限,行动智能体才有。

Flutter端这边的联动方式依然是老路子:Dart层通过HTTP或WebSocket调自己后端的Agent网关,Agent网关内部再去调企业系统和LLM服务。如果你需要读取原生平台的传感器数据或者操作剪贴板,就走EventChannel/MethodChannel。我自己做完这个Demo后最大的体会是:多智能体不是技术门槛问题,而是组织问题——你把“做什么、查什么、答什么”分清楚了,代码自然就出来了。

最后分享一个实打实的调试习惯:把每一步Agent返回的原始输出都打到界面上,哪怕丑一点。LLM应用调试跟传统程序最大的不同是,它的错误往往不是程序崩溃,而是“回答质量不对”。你看不到中间结果,就永远只能对着一个错得离谱的答案瞎猜原因。能看到调度器改写了什么检索词、检索回了哪些文档、生成器用了几分力的上下文,问题的根源会自己跳出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询