ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter+LLM多智能体应用开发:从零构建RAG知识库问答助手

Flutter+LLM多智能体应用开发:从零构建RAG知识库问答助手 最近有朋友问我现在这个节点想做个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层。这里先说个结论FlutterLLM的组合真正的难点从来不在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 flutters 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和原生页面的互跳可以走MethodChannelFlutter侧调用原生的startActivity()方法原生侧通过MethodChannel返回结果给Flutter。这里有一个经验之谈一个应用里尽量只保留一个FlutterEngine实例避免创建多个引擎带来大量的内存开销。搜索词里的“flutter跳转原生activity”就是这么干的。4. 核心实战实现一个调度器两个智能体的最小可跑App4.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}); FutureAgentResponse 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如果发现不是最新的就丢弃。这个技巧看着简单但能让你避免大量“莫名其妙的回答错位”问题。Futurevoid 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的完整交互链路是用户在Flutter输入框里输入问题按发送。UI层调用Cubit的sendMessage方法Cubit立即emit一个ChatKnowledgeSearching状态页面显示“正在查阅知识库”。Cubit调用AgentOrchestrator的handleUserMessage编排器先请求LLM做意图路由。调度器返回SEARCH: xxx编排器把改写后的查询词传给RAG服务。RAG服务查询向量库返回Top K条文档片段和它们的来源。编排器把所有片段拼接成一段上下文连同用户原始问题一起发给生成智能体请求stream模式。生成智能体开始流式返回文本每返回一部分就通过回调把新文字追加到当前消息上UI层通过StreamBuilder把文字实时渲染出来。答案流式输出完成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应用调试跟传统程序最大的不同是它的错误往往不是程序崩溃而是“回答质量不对”。你看不到中间结果就永远只能对着一个错得离谱的答案瞎猜原因。能看到调度器改写了什么检索词、检索回了哪些文档、生成器用了几分力的上下文问题的根源会自己跳出来。
返回列表