前一阵我想在手机上做一个自己的AI助手不是那种一问一答的玩具而是能把“帮我整理这周笔记并生成摘要”“根据知识库写一份产品简报”这类任务拆开、分给不同角色处理的App。Flutter是现成的跨平台框架LLM负责理解和生成剩下最大的问题是怎么让多个模型角色协作跑起来——这就是我入坑“FlutterLLM多智能体”的起点。这篇内容不是讲概念是我从零搭一套可运行方案的过程记录。适合刚开始接触Flutter和LLM、想绕过纯Demo、直接做一个带“多角色协同”能力的应用的开发者。你不需要先读懂什么高深的Agent论文跟着我把链路拆开把每一层该干什么、为什么这么干弄清楚就能在自己的工程里跑起来。1. 这个组合到底在解决什么问题从单轮聊天到协同干活1.1 我对Flutter、LLM、多智能体三者关系的朴素理解很多人把Flutter和LLM放在一起第一反应是“做个聊天界面”。这不叫AI应用这只是给模型套了一个壳。真正的AI应用至少要满足三件事能感知用户意图、能调用工具或查询数据、能把复杂任务拆给不同角色协作完成。我的理解是Flutter是“手和脸”负责界面交互和多端运行LLM是“脑”负责理解、生成、判断多智能体是“管理脑的神经系统”负责把一个复杂问题拆分、派发、汇总。三者组合在一起才能做一个真正的“助手”而不是一个“说漂亮话的接口”。在我之前做的一些简单接里LLM只是个文本进出管道用户输入一句话请求发出去响应显示在界面上。这种方式做Demo没问题但一遇到多步骤任务就露馅。比如“帮我对比三篇笔记里的观点然后写一段摘要”这种请求单次调用根本做不到要么靠模型硬编要么靠后端写一长串代码。而多智能体是把任务拆成三个角色一个读笔记、一个做对比、一个写摘要每个角色各干各的最后汇总。1.2 客户端接入LLM的三条路线对比在我动手之前我先列了一下现有的主流做法具体对比是这样的接入方式优点缺点适合场景Flutter直接调模型API实现最快代码量少适合验证想法对话状态、工具调用逻辑全部堆在客户端请求容易被拦截Demo、自用工具、轻量助手后端编排Agent状态管理、多模型协同、安全管控都放在服务端能力强要维护服务端成本高个人项目容易放弃生产级应用、团队项目客户端编排云端模型Flutter本地管理会话状态和Agent调度只把推理请求发给LLMAgent逻辑都在客户端依赖端上计算和本地存储中小型应用、离线和弱网场景我最后选了第三条。原因很直接我不想为了一个自用项目买一台服务器跑Agent框架也不想把用户对话状态全扔给后端去管。Flutter本地有状态管理、有数据库、有文件读写能力把一个简单的Agent调度器放在本地是完全可行的。1.3 为什么选择“客户端编排云端模型”选这条路不是因为它最好而是因为它最符合我的实际约束没有后端资源、想把App做成跨平台、同时希望后续能无缝接本地部署的模型。本地编排最大的好处是你能精确控制Agent之间的消息流转。比如我需要一个“总结Agent”读完文件后把结果传给“撰写Agent”在客户端可以直接用一个本地队列完成不需要网络请求。整个推理过程只有模型API是远程的其他一切都在本地调试起来非常直观。当然也有代价。第一API Key存在客户端有泄露风险自用没问题上架就要考虑托管中转。第二Flutter的Dart是单线程模型大量Agent并行调度时要注意异步编排不然界面会卡。第三多Agent涉及的上下文管理如果全部堆在客户端内存和存储压力会比后端大。这些坑后面我会详细说。2. 多智能体运行骨架把一个人的思考拆成一支队伍2.1 单Agent和多Agent的本质区别我之前一直以为多智能体就是把同一个模型调用好几遍。后来发现不是这个逻辑。单Agent是“一个脑子从头想到尾”它既要理解用户问题又要规划步骤还要写结果。一到复杂任务要么忘记前文要么上下文爆炸。多智能体是“多个脑子分工”每个Agent只需要关心自己的那一段职责。比如一个“任务规划Agent”只负责拆解步骤拆完之后把子任务发给“执行Agent”执行完再由“汇总Agent”收口。每个Agent的上下文都被限制在自己的职责范围内既省Token又不容易乱。我在实际项目里把Agent分成三类规划型Agent接收用户原始请求拆解任务清单。执行型Agent只处理单一子任务比如搜知识库、算结果、生成文案。审核型Agent对执行结果做检查不合格就打回重做。这三类角色由客户端的一个调度器统一管理消息格式做成统一的JSON结构。2.2 常用编排模式路由、编排者-工作者、群聊协作多智能体不是随便写个循环就叫多智能体编排模式决定任务质量和容错。我实测下来常用的有三种模式工作方式适用场景缺点路由器模式一个Agent分析任务类型分发给不同专用Agent任务类型分明、职责边界清晰无法处理复合型任务编排者-工作者模式编排Agent规划、派发、回收、校验工作者Agent只执行需要严格流程控制的任务编排Agent容易成为瓶颈群聊协作模式多个Agent自由发言通过消息协议互相衔接头脑风暴、创意生成结果不可控容易跑题我项目里最常用的是编排者-工作者模式。因为自用工具最怕跑题和失控编排者统一协调每个执行Agent收不到用户直接消息只处理结构化子任务输出也是结构化的最终由编排者校验。2.3 消息协议设计角色、任务、结果的结构化多智能体之间通信不能靠自然语言的自由发挥必须定义一套结构化协议。我参考了主流Agent框架比如LangGraph、AutoGen的思路简化成自己的三段式消息结构{ message_id: msg_001, conversation_id: conv_001, role: planner, task: { type: summarize_notes, params: { source_ids: [note_1, note_2], output_length: short } }, context: { history: [...], knowledge_base_ids: [kb_a] }, result: null }这里最关键的是role和task.type。Agent订阅感兴趣的task.type处理完把结果写回result字段再由调度器决定下一步发给谁。这样每一个Agent都是“无状态工人”状态统一放在context里由调度器管理。我踩过的一个坑是一开始让每个Agent自己携带全部对话历史结果几个Agent打完一轮消息体积翻了好几倍Token费用直接起飞。后来改成只传当前任务相关的上下文消息体积降了80%而且每个Agent的判断反而更准了。2.4 Token背后的Key/Query/Value逻辑我如何理解“我是谁、找什么、能提供什么”做多智能体绕不开Token和上下文但“Token”不要只理解成计费单位。我习惯用一套类比来设计Agent上下文Key是“我是谁”Query是“我在找什么”Value是“我能提供什么”。比如一个“知识库检索Agent”它的Key就是它的身份定位和职责描述告诉模型“你是检索助手只能查知识库”Query是它收到的问题和任务目标Value是它能调用的检索工具、能访问的文档索引。这个三元组定义清楚了Agent就不会越权也不会在无关问题上乱发挥。这套思路同样适用于RAG检索。我后面会把知识库里的文档切片也按这三个维度做索引检索时先匹配Query再结合Key做过滤最后返回Value。它不一定是传统深度学习里Attention的严格定义但对工程实践来说比模糊的“语义相似”好用得多。3. Flutter侧的Agent运行时状态、异步与线程细节3.1 数据流的分层UI层、Agent协调层、模型接入层把多智能体搬进Flutter最怕的是UI和Agent逻辑混在一起。我项目里的分层是这样的UI层只负责渲染监听状态变化不直接调LLM。Agent协调层维护Agent列表、消息队列、任务状态机对外暴露runTask()接口。模型接入层封装LLM API调用、流式响应解析、Token统计内部跟Dio或http打交道。UI层(Widget/Cubit) - Agent协调层(消息队列与状态机) - 模型接入层(LLM API)这样做的好处是模型接入层换成任何一家厂商的API或者从云端API换成本地OllamaUI层和Agent协调层都不需要改。我前后换过三次模型服务商只动了模型接入层一个文件。3.2 Cubit状态管理与Agent状态的映射Flutter的状态管理我选了flutter_bloc的Cubit因为它轻量适合把Agent状态机映射进UI。我的AgentCubit大致长这样class AgentCubit extends CubitAgentState { AgentCubit(this._coordinator) : super(AgentState.idle()); final AgentCoordinator _coordinator; Futurevoid startTask(TaskRequest request) async { emit(AgentState.running(taskId: request.taskId)); await for (final progress in _coordinator.runTask(request)) { emit(AgentState.progress( taskId: request.taskId, step: progress.step, detail: progress.description, )); } emit(AgentState.completed(taskId: request.taskId)); } }我特意不用Bloc的Event机制因为Agent的进度是天然流式的规划、执行、校验、汇总每一步都是一个中间态。Cubit配合Stream处理这种场景非常顺。状态设计上我只有五种idle、running、progress、completed、failed。多了没必要少了用户反馈不够。进度文案从Agent协调层上报UI层只是展示。3.3 Future的then回调真的在微任务队列吗异步顺序实测热搜词里有人在问“Flutter的Future.then回调是放入微任务队列吗”这个问题我在Agent调度器里真实踩过。Dart事件循环里有两套队列微任务队列和事件队列。Future.then注册的回调在Future完成时会被调度进微任务队列微任务队列处理完之前事件队列里的任务不会开始。这句结论不是背出来的是我跑实验验证的。我给Agent调度器连续派发三个子任务分别用await和.then处理打印输出顺序Futurevoid testQueueOrder() async { Future(() print(task1)).then((_) print(then1)); Future(() print(task2)).then((_) print(then2)); await Future(() print(task3)); print(after await); }实测输出是task1、then1、task2、then2、task3、after await。这说明.then回调确实按微任务规则执行但多个Future之间的调度顺序还得看它们本身的完成时机。在Agent协调层里我没有依赖then的调度来做任务顺序控制而是用了显式的队列一个任务完成后由协调器手动发下一个。因为Agent任务有依赖关系比如“规划Agent”必须先于“执行Agent”不能靠微任务顺序去赌那样迟早出竞态。3.4 流式输出的处理Stream加增量渲染LLM生成是流式的等全部生成完再显示用户会以为卡死了。我在模型接入层用Stream输出增量Flutter端用StreamBuilder逐段渲染。模型接入层的最小实现看起来是这样StreamString chatStream(ListChatMessage messages) async* { final response await _dio.post( /chat/completions, data: {messages: messages.map((m) m.toJson()).toList()}, options: Options( responseType: ResponseType.stream, ), ); final stream response.data as ResponseBody; await for (final chunk in stream.stream.transform(utf8.decoder)) { final content _parseDelta(chunk); if (content ! null) yield content; } }这里有个细节流式响应的解析不能写死单条JSON。SSE格式是每个事件一块数据中间有换行分隔我的_parseDelta实现会先做缓冲区拼接把不完整的JSON行缓存起来等下一块来了再拼成完整JSON解析。这个处理不对的话流式输出经常会在中间位置断掉或者抛出FormatException。4. 让Agent动手工具调用与Function Calling的落地细节4.1 模型是嘴工具是手函数调用的原理我之前一直有个疑问LLM只会输出文本凭什么能“调用工具”后来搞懂了模型不是自己执行代码而是“说出”想调用哪个函数、传什么参数由客户端去执行再把结果回传给它。这就是Function Calling也叫工具调用。整个流程其实是一个循环客户端把“有哪些工具可用、每个工具的参数是什么”拼进请求。模型判断需要调用工具时返回一个结构化的调用指令。客户端解析指令执行对应函数。把函数执行结果作为新消息发给模型。模型基于结果继续回答或决定是否再调用。在这个循环里工具是否好用的核心不在模型而在“工具描述写得好不好”。描述不清晰模型就会瞎编参数。4.2 定义工具的JSON Schema参数校验是重点我给每个工具定义一份JSON Schema模型看到的就是这个结构。以一个“查询知识库”工具为例{ name: search_knowledge_base, description: 在本地知识库中检索与问题最相关的文档片段返回片段内容和来源ID, parameters: { type: object, properties: { query: { type: string, description: 检索目标必须是与用户问题直接相关的关键词或语句 }, top_k: { type: integer, description: 返回的片段数量默认3最大5, minimum: 1, maximum: 5 } }, required: [query] } }参数schema里最容易忽略的是“描述”。同一个参数写“要搜索的内容”和写“必须是与用户问题直接相关的关键词或语句”模型执行出来的效果差别很大。因为模型不是按参数名理解语义主要是读描述。还有一个常见错误参数类型定义模糊。比如top_k我一开始没写minimum和maximum模型偶尔传一个20后面检索模块直接超时。加上边界约束之后这类问题基本消失。4.3 Flutter端工具注册表的实现在Flutter里我实现了一个简单的工具注册表本质是Map加一个执行包装器typedef ToolHandler Futuredynamic Function(MapString, dynamic args); class ToolRegistry { final MapString, ToolHandler _handlers {}; void register(String name, ToolHandler handler) { _handlers[name] handler; } Futuredynamic execute(String name, MapString, dynamic args) async { final handler _handlers[name]; if (handler null) { throw Exception(未知工具: $name); } return handler(args); } ListMapString, dynamic toSchemaList() { return _handlers.keys.map((name) _schemas[name]!).toList(); } }调度器拿到模型返回的tool_calls之后别直接执行。我的习惯是先做一次参数白名单校验只保留schema里声明的字段过滤掉模型瞎传的字段。这一步能防止脏参数污染工具函数。4.4 工具调用失败时最常见的错误schema和tool payload被拒绝我在接不同模型服务时经常遇到这样的报错llm request failed: provider rejected the request schema or tool payload。这个报错看起来像是网络问题实际上90%都是工具定义格式不符合供应商要求。我遇到过的原因有这几个用了供应商不支持的parameters格式有些厂商只接受strict模式或特定字段名。工具描述里带了空字符串或超长文本。tool_calls返回结果回传时没有按厂商要求的角色字段格式写。排查思路很简单先注释掉所有工具定义只发普通对话确认能通然后逐个加工具二元查找是哪个定义的问题。不要一次性把20个工具全怼上去那排查成本太高。5. 给模型装上记忆库RAG与知识库集成5.1 为什么纯对话模型回答不了私域问题LLM训练数据截止到某个时间点它不知道你的笔记内容、你的项目文档、你的私有资料。硬问它就胡编。解决方案不是换更大的模型而是给它一个“外挂记忆库”也就是RAG先把文档切分成片段、做向量化存储用户提问时先检索最相关的片段再把这些片段拼进提示词最后让模型基于检索结果作答。这套东西的工程链路过一遍之后你会发现它没有想象中神秘但细节决定效果好坏。5.2 检索增强RAG的最小可运行链路我落地RAG时链路简化成四步文档切片把长文档按固定长度比如500字切块并保留段落标题作为上下文前缀。向量化用嵌入模型把每个切片转成向量。检索用户提问时把问题向量化做余弦相似度检索取Top-K。拼接与生成把命中的切片内容和来源ID拼进提示词让LLM回答。切片这一步最影响质量。我一开始按字符硬切结果把一个段落从中间截断检索出来的片段语义不完整模型回答经常前言不搭后语。后来改成“按段落切段落太长再按句子断”效果明显改善。向量化我用的是现成的嵌入API成本不高。自用小工具完全够用。5.3 Wiki知识库、本体与GraphRAG从检索到推理单纯向量检索有个天花板它是“按语义找相似片段”但回答不了需要跨文档推理的问题。比如“过去三个月哪些笔记提到过性能优化方案”这种问题分散在多个文档里向量检索能各自命中但无法把关系串起来。这就是为什么现在大家讨论GraphRAG和本体。简单说GraphRAG在向量检索之上把文档里的实体和关系抽出来组成一张知识图谱查询时不只找片段还沿着实体关系找关联。本体的作用更偏“规范”它定义了这个领域里有哪些概念、概念之间是什么关系让检索器知道“优化方案”和“性能问题”是相关的概念。我在项目里没有一上来就上GraphRAG因为成本高、实现重。我的路径是先用纯RAG跑通然后给知识库加了一层很轻的“标签本体”每个切片手动或半自动打上“主题”、“来源”、“时间”等标签。查询时先按标签过滤再走向量检索。实测效果已经比纯向量好不少也够我用。等数据量大了再考虑GraphRAG。5.4 嵌入模型与向量库的取舍嵌入模型的选择我建议直接看Open LLM Leaderboard这类公开榜单作为初筛再看跑分不能只看总分要看检索相关任务的单项分。自用场景不求最强但求稳定。向量库方面Flutter端项目我推荐两种选择方案特点适合场景本地JSON加内存向量计算无额外依赖支持余弦相似度手写实现知识库小于几千条自用工具SQLite加向量插件数据落盘支持增删改查查询快知识库体量增长需要持久化我一开始用纯内存结果App重启之后知识库全丢还得重新加载。后来改成了SQLite方案把向量以二进制存库查询时取出来算相似度几百个片段的量级效率完全没问题。6. 桥接原生世界EventChannel、PlatformView与平台适配6.1 为什么Flutter应用还需要原生能力我的AI助手做了几个月后发现自己绕不开原生能力有的模型SDK只有原生版本没有Dart SDK有的系统级API比如扫描本地文件、读取系统剪贴板用Flutter插件做不灵活还有一些硬件能力必须走原生通道。Flutter的优势是UI和逻辑跨平台但和系统底层打交道的活还是得靠原生代码补。6.2 EventChannel做LLM请求的跨端通道我用的原生AI SDK只提供了安卓和iOS版本Dart这边直接用HTTP调不了。我的做法是用MethodChannel发起推理请求用EventChannel接收流式文本返回结果。原生侧发流式事件的核心代码逻辑如下// Kotlin侧通过EventChannel推送增量文本 private var eventSink: EventChannel.EventSink? null override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { eventSink events } fun pushToken(content: String) { eventSink?.success(mapOf(type to token, content to content)) } fun pushDone() { eventSink?.success(mapOf(type to done)) }Flutter侧接收端我是这样组织的// Dart侧原生流式事件直接接到Agent协调层的Stream EventChannel(agent_inference).receiveBroadcastStream().listen((event) { final map event as Mapdynamic, dynamic; if (map[type] token) { _inferenceController.add(map[content]); } else if (map[type] done) { _inferenceController.close(); } });这里有个非常容易踩的坑EventChannel必须在原生页面创建之后监听过早监听会丢失事件。我一开始把监听放在了App启动时结果原生侧消息发出来时Flutter监听还没注册丢了一堆token。后来改成在进入Agent页面时才创建EventChannel并注册监听。6.3 PlatformView嵌入原生地图、网页与扫描组件需要嵌入原生视图的场景Flutter也有方案就是用PlatformView。比如我的助手需要展示一篇原生渲染的Markdown网页预览直接用Flutter的WebView插件在部分机型上有兼容问题我是用PlatformView桥接的。安卓端用PlatformViewLink或者AndroidViewiOS端用UiKitView代码不算复杂但要注意生命周期同步原生视图的创建和销毁必须跟Flutter Widget的挂载和卸载对齐否则页面跳转后原生资源不释放内存会持续上涨。6.4 安卓原生项目嵌入Flutter页面的反向集成我现在这个项目不是纯Flutter工程而是安卓原生App里嵌入Flutter页面也就是热搜词里说的“安卓原生项目嵌入Flutter页面”。反向集成的坑比纯Flutter多一个Flutter引擎的创建时机。我的做法是在Application初始化时预先创建好FlutterEngine并缓存起来等需要跳转Flutter页面时直接用缓存的engine展示加载速度和体验会好很多。如果每次跳转都新建一个引擎冷启动感特别明显而且内存压力很大。// Java侧预创建FlutterEngine FlutterEngine flutterEngine new FlutterEngine(this); flutterEngine.getDartExecutor().executeDartEntrypoint( DartExecutor.DartEntrypoint.createDefault() ); FlutterEngineCache.getInstance().put(agent_engine, flutterEngine);页面跳转时FlutterActivity.withCachedEngine(agent_engine).build(context);用缓存引擎有个注意点多个Flutter页面共用一个引擎时路由状态是共享的需要自己在Dart侧处理页面栈不然从页面A回退时可能直接退到原生界面。6.5 Impeller渲染引擎与Web引擎启动慢的观察Flutter 3.44之后开发者讨论最多的渲染层变化就是Impeller。我升级后观察到的现象是iOS上的滚动和动画帧率更稳定了Shimmer、打字机这类特效也没问题。但Android上部分自定义着色器需要在Impeller下重新适配如果你项目里有复杂的自定义Shader升级前最好做一个图形预览的回归测试。另外Flutter Web的引擎启动慢问题我也处理过。原因多半是首次加载要拉取依赖的JS和CanvasKit。我做过两个优化一是开启--web-rendererhtml低版本或按目标平台选择合适渲染器大幅减少启动体积二是把资源改成延迟加载首页只加载核心入口AI助手模块真正打开时才加载相关的Dart包。7. 打包发布与版本刺头我遇到的坑和排查思路7.1 Gradle插件命令式apply报错热搜词里有句报错很直观“you are applying flutters main gradle plugin imperatively using the apply script”。这个我升级Flutter之后也撞上了。新旧版本的变化是新版Flutter要求使用声明式插件管理系统在settings.gradle里声明plugins { id dev.flutter.flutter-plugin-loader version 1.0.0 id com.android.application version 8.1.0 apply false }而不是在app/build.gradle里用apply plugin:命令式应用。这个问题的排查思路很简单按报错提示把Flutter插件从build.gradle迁移到settings.gradle统一声明同步一下。如果项目里引用了多个Flutter插件注意它们的版本别对不上一般报错信息会指明是哪个插件依赖冲突。7.2 Xcode版本过新导致Flutter包报版本低的问题我的Mac升级到新版Xcode之后跑Flutter项目经常提示“当前的Flutter SDK不被完全支持”之类的警告有的Pod安装还会失败。这个问题的本质是Flutter SDK有个支持上限Xcode版本超出后被判定为不兼容。排查思路是先升级Flutter SDK升级到支持该Xcode大版本的版本升级后如果还有问题清理Pod缓存和构建目录重新pod install。我不建议一上来就改Xcode版本除非你的项目依赖锁死了Flutter版本。这个问题在Flutter 3.44之后改善了不少但新Xcode预览版发布初期还是建议先观望再升级。7.3 组件通信丢失状态与TabBar点击动画异常做AI应用时我经常遇到页面切换后Agent状态丢失的问题。Flutter的Navigator默认会销毁非当前路由比如你从Agent页面跳到详情页再返回来Agent页面被重建状态没了。我试过几种方案用IndexedStack保住页面状态适合Tab切换场景。用StatefulShellRoute管理带有状态的Shell分支。用全局状态管理Cubit/Bloc把Agent状态提升到App层页面重建后重新订阅。我最后选了第三种Agent状态全部提升到AgentCubit页面只是状态的投影。这样无论页面怎么跳Agent执行进度不会丢返回页面时界面自动恢复。TabBar点击动画的问题则是另一个方向想要取消点击动画通常是在TabBar的animationDuration设为Duration.zero或者在Indicator设置里关掉动画。如果用的是自定义TabBar检查是否有隐式的AnimatedContainer在做动画。7.4 模型选型参考公开榜单与本地部署思路多智能体项目里模型选型会直接影响工具调用能力和多轮协同效果。我参考了Open LLM Leaderboard这类公开榜单做初筛但我的判断标准是工具调用能力看模型在Function Calling评测集上的表现这不是榜单总分能体现的。上下文长度多Agent协同需要长上下文至少8K起。本地部署可行性如果不想全部走云端API检查模型量化版本对内存的需求。7B量化版在16G内存的机器上可以跑13B级别以上建议上24G以上。实际测试时我会准备20个工具调用测试用例覆盖“正确调用”“不该调用时别乱调用”“参数边界”三类情况。跑一轮就知道模型能不能用不用看太多榜单。结尾一点个人体会这么一条链路跑下来我的最大体会是Flutter、LLM、多智能体单拎出来都有大量文档难的是把它们缝在一起。这里缝线的地方不在UI而在状态管理、消息协议、异步调度和原生桥接。你如果也打算做类似项目我建议从最简单的“一个Agent一个工具RAG归档”开始跑先把链路走通再加第二个Agent。我一开始贪多一口气设计了六个角色结果调试时一半的时间都在查是哪个Agent传了脏数据。如果你也想做一个自己的AI助手先把架构想清楚然后把每一步走稳这个方向能玩的深度绝对超乎你的想象。