ARTICLE DETAIL

资讯详情

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

Flutter+LLM多智能体实战:从架构设计到API对接全指南

Flutter+LLM多智能体实战:从架构设计到API对接全指南 我说句实在话在手机App里接入大模型这件事目前不少团队做的还停留在“套壳聊天”的层面。如果你想更进一步——让App里的大模型能自己查知识库、能调系统工具、能把一个复杂任务拆成几个步骤分别处理——这就进入 Flutter LLM 多智能体的实战领域了。我最早接触这个组合是因为一个实际需求做一个跨端的AI助手既要跑在Android和iOS上又希望它不只是一问一答而是能像一个小团队一样分工协作。折腾了小半年踩了不少坑今天把整套入门路径整理出来从架构设计、Flutter端实现、多智能体编排到API对接和打包上线一次讲透。这篇文章适合两类人一是已经会基础Flutter、想在App里接入LLM能力的开发者二是会调LLM API但不知道怎么把多智能体架构落到客户端的同学。文章中不会堆概念基本是我当时怎么想、怎么设计、怎么写、怎么排查的记录代码你直接拿去改就能用。1. 为什么要在Flutter里做LLM多智能体——先想清楚场景再动手1.1 移动端AI应用的价值空间与硬性限制先说个反直觉的结论LLM应用的核心能力不一定在模型而在应用层。大模型本身是通用推理引擎但一个真正能用的AI助手至少还需要本地数据访问、系统级能力录音、通知、日历、离线缓存、私有知识库。这些恰恰是移动端的强项。Flutter的价值在于一次编写、双端运行UI层面和业务逻辑可以高度复用后端只要提供LLM API即可客户端就是一个轻量但完整的智能体运行环境。但移动端做LLM应用有硬性限制我踩过一轮才深刻体会到。第一是内存与包体积本地跑模型不现实主流方案还是走云端API第二是网络不稳定弱网环境下的流式输出体验如果没做好用户会直接流失第三是token成本多智能体之间的消息传递如果设计得不好一次请求烧掉几百K token很容易。下面这张表是我在实际选型时反复权衡的维度分享出来供参考。对比维度云端API方案本地小模型方案首包体积影响无模型在服务端增加60-200MB取决于量化等级推理延迟100-500ms受网络影响20-100ms受手机性能影响隐私数据需要传云端有合规成本数据不出设备多智能体复杂度服务端做编排客户端只管展示客户端需要自行管理内存与功耗入门成本低一个API Key就能跑高需要掌握Ollama或ONNX Runtime典型适用场景通用问答、复杂推理、联网搜索关键词抽取、指令路由、离线兜底我的建议是入门阶段老老实实走云端API把业务逻辑跑通、架构理顺之后再回头考虑本地小模型兜底。因为多智能体架构的调试复杂度已经够高了没必要一开始就叠加模型部署的变量。1.2 为什么单一大模型Prompt撑不住复杂业务一开始我也天真过不就是做一个AI助手吗把所有指令塞进一个System Prompt不就完了实际上当业务复杂度上来之后单Prompt方案会连续踩三堵墙。第一堵墙是指令膨胀。当助手需要同时具备查天气、设提醒、检索知识库、生成日报这些能力时System Prompt会不断堆砌很快超过模型的有效注意力范围。实测下来超长指令下模型的行为会变得不稳定经常漏掉某些工具该调的不调不该调的乱调。第二堵墙是上下文污染。不同能力的指令混在一起模型在回答一个简单问题时也可能把其他场景的规则带进来产生幻觉。第三堵墙是更新困难。你改了其中一个功能的描述哪怕只改一句话也要对整个Prompt做回归测试——因为改A功能可能莫名影响B功能的输出这个维护成本实在太高。多智能体方案的本质是专业分工。给每个Agent单独的系统提示词和工具集由一个调度者决定哪个Agent上场。这样每个Prompt都很短更新互不影响测试可以精准到单一Agent维度。我在实际项目中把原来的6000字巨型Prompt拆成了Planner、Researcher、Calculator、Reviewer四个角色每个角色的Prompt控制在500字以内效果反而明显提升。1.3 先明确智能体的边界哪些该上多智能体哪些是过度设计不是所有应用都需要多智能体架构。如果你只是做一个产品手册问答工具一个检索增强生成的单Agent就够了强行上多智能体只会增加延迟和token消耗。我的判断标准是三个有没有多个独立能力域、这些能力是否需要按顺序或条件执行、结果是否需要多轮校验。三个都满足才值得上多智能体架构。举个例子我做过的智能行程助手用户输入明天下午3点约张总开会地点在我公司附近会议需要一份上季度的销售数据摘要。这个需求里至少有时间解析、地点查询、知识库检索、文档生成四个独立能力而且有依赖顺序所以是典型的多智能体场景。反过来一个记账App的语音记一笔功能用单Agent就能完成不需要拆分。2. 先画架构再写代码三层结构与一次请求的完整旅程2.1 三层架构设计原则我最终定的架构是三层UI展示层、LLM代理层、Agent编排层。UI展示层就是Flutter页面负责输入捕获、流式渲染和结果展示这一层不直接与任何模型代码打交道。LLM代理层是客户端里封装的LLM服务负责HTTP/SSE通信、API Key管理、模型路由、超时重试相当于一个轻量网关。Agent编排层则是整个系统的核心管理各Agent的角色定义、工具注册、消息路由和循环控制。为什么把编排层放在客户端而不是服务端两个原因。第一入门阶段把编排放客户端调试起来最直观你可以在IDE里单步看Agent之间如何传递消息也能直接在本地加日志不用在服务端和客户端之间来回翻。第二很多工具的调用比如打开本地日历、写入剪贴板天然依赖设备能力放客户端可以减少一次网络往返。后面如果业务量大了再把编排层整体后移客户端只保留展示层和轻量代理架构依然成立。2.2 一次请求的完整数据流我建议把所有Agent之间的协作收敛到一类循环模型里业内称为ReAct风格思考、行动、观察。一次用户请求的完整旅程是这样走的。用户输入先进入PlannerPlanner解析意图后输出一份任务清单每个任务标注了需要的Agent角色。然后Router按清单逐个调度Executor类型的Agent每个Executor在执行时可能会调用工具比如调知识库RAG检索、调天气API工具返回结果后再由该Agent决定是继续调用下一个工具还是结束。最后所有子任务的输出汇总到ReviewerReviewer检查结果的一致性和完整性发现问题就打回重做没有问题就把最终答案组装给UI层。整个过程有一个轮次上限我通常设5轮防止Agent陷入死循环烧token。这里有个关键设计消息对象不要用单纯的字符串要结构化。我在Dart里定义了统一的ChatMessage类型包含role、content、toolCalls、toolCallId、timestamp等字段。为什么因为Agent编排过程中模型返回的内容除了普通文本还有工具调用请求没有结构化字段的话你只能在字符串里自己解析极易出bug。2.3 状态管理选型为什么我用Riverpod而不是BlocFlutter里的状态管理方案多到让人选择困难但我们的场景非常特殊要处理的是持续到达的流式数据。用户发出请求后LLM的响应是一块一块到达的UI要实时刷新还要同时处理Agent各阶段的中间状态正在规划、正在检索、正在生成。这个场景下我推荐Riverpod。Bloc的Event/State模型本身没有问题但在流式数据下每个chunk都要发一个Event代码会变得非常啰嗦。Riverpod的StreamProvider可以直接把LLM服务的返回流映射到UI配合AsyncValue的when方法处理loading、data、error代码量能减少一半以上。而且Riverpod原生支持依赖注入测试时可以直接替换LLM服务为模拟实现。我的Flutter项目里整个LLM会话状态就是一个StreamProviderUI层只用StreamBuilder订阅展示逻辑非常清爽。3. Flutter端核心实现流式渲染、状态保持与原生通道3.1 流式渲染把SSE流变成UI进度条LLM API通常以SSEServer-Sent Events或WebSocket方式返回流式数据。我在Dart里封装了一个LLMService核心接口就是返回一个Stream 。客户端统一走这个抽象不管是接DeepSeek、通义千问还是自建网关只要实现同一个接口就行。class LLMChatService { final http.Client _client; StreamString chatStream({ required ListChatMessage messages, required ListToolDefinition tools, }) async* { final request http.Request(POST, _endpoint) ..headers[Content-Type] application/json ..headers[Authorization] Bearer $_apiKey; final body _buildRequestBody(messages: messages, tools: tools); request.body jsonEncode(body); final response await _client.send(request); if (response.statusCode ! 200) { throw LLMException(await response.stream.bytesToString()); } final lines utf8.decoder .bind(response.stream) .transform(const LineSplitter()); await for (final line in lines) { if (line.startsWith(data: )) { final data line.substring(6); if (data [DONE]) break; final json jsonDecode(data); final delta json[choices]?[0]?[delta]?[content]; if (delta ! null) yield delta.toString(); } } } }UI层用StreamBuilder消费这个Stream核心逻辑只有几行StreamBuilderString( stream: _service.chatStream(messages: currentMessages, tools: tools), builder: (context, snapshot) { if (snapshot.hasData) { _currentAnswer snapshot.data!; return Text(_currentAnswer); } if (snapshot.hasError) return Text(出错了: ${snapshot.error}); return const CircularProgressIndicator(); }, )这里有一个性能陷阱不要把每次chunk都setState而是让StreamBuilder直接响应流事件。如果你在Builder里把字符串存在State里再setState频繁重建Widget会掉帧。我刚开始没注意打字机效果一顿一顿的后来改成局部接收整体更新才顺滑。3.2 页面切换不丢状态Navigator的坑与解法这个坑经常有人问Flutter里Navigator推入新页面后原页面的状态会丢失吗直接回答不会除非你用了pushReplacement或者页面被销毁。但在多智能体场景下我们面临的是另一个问题——用户切到后台再回来或者切换到Tab又切回来LLM的输出流可能已经被销毁了。这比Navigator的状态丢失更隐蔽。最佳实践是主界面不用Navigator.push来切换功能页而是用IndexedStack加BottomNavigationBar的组合让所有主要页面常驻内存状态全部保留。如果一定要用Navigator就用StatefulWidget配合AutomaticKeepAliveClientMixin在wantKeepAlive里返回true这样页面被推入下层时State不会销毁。class AgentChatPage extends StatefulWidget { const AgentChatPage({super.key}); override StateAgentChatPage createState() _AgentChatPageState(); } class _AgentChatPageState extends StateAgentChatPage with AutomaticKeepAliveClientMixin { override bool get wantKeepAlive true; }为什么这个对LLM场景格外重要因为一次多智能体任务可能持续几十秒甚至几分钟如果用户中途切去看了个通知回来发现整个会话上下文被Flutter的导航栈回收了那种体验基本等于白等。保持页面常驻成本低但收益明显。3.3 EventChannel与MethodChannel什么时候需要原生通信我一开始天真地以为Flutter全Dart就能搞定所有事后来被现实教育了。以下场景绕不开原生通道播放TTS语音需要调用系统的TextToSpeech引擎本地模型推理需要接入原生推理框架后台通知需要原生代码注册录音与前端实时音频处理更是必须走原生。MethodChannel适合一次性请求-响应EventChannel适合持续事件流。我推荐的做法是LLM的音频输出不直接走MethodChannel而是走EventChannel。因为TTS在长文本时是持续的事件流一条一条发MethodChannel需要回调套回调难维护得多。// 原生侧Kotlin val eventChannel EventChannel(registrar.messenger(), tts_channel) eventChannel.setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { ttsEngine.setListener { text - events?.success(text) } } override fun onCancel(arguments: Any?) { ttsEngine.clearListener() } })// Flutter侧 class TTSChannel { static const _eventChannel EventChannel(tts_channel); StreamString get ttsStream async* { yield* _eventChannel.receiveBroadcastStream().map((e) e.toString()); } }踩过的坑EventChannel记得在页面dispose时做事件取消不然会出现页面关了但原生还在往Flutter发事件的内存泄漏。排查半天日志最后发现是旧页面还在接收TTS事件界面却已经换走了。3.4 用part拆分大型Agent类目Dart的组织方式项目跑起来之后Agent类目会越来越多一个agent_orchestrator.dart文件很快就会膨胀到两千多行这时就触及Dart里的part机制了。part和part of是Dart官方支持的代码拆分方式允许你把一个库拆到多个文件里但保持它们属于同一个库共享私有成员。具体用法是主文件里声明part agent_planner.dart;被拆分文件里写part of agent_orchestrator.dart;。// orchestrator.dart part agent_planner.dart; part agent_researcher.dart; part tool_registry.dart; class AgentOrchestrator { ... }// agent_planner.dart part of agent_orchestrator.dart; class PlannerAgent { // 可以直接访问AgentOrchestrator里的私有方法 }我的经验是能用独立库import/export解决的问题就不要用part。part的问题是重构时明明看着像独立文件实际却耦合在同一个命名空间里IDE识别有时会慢。只有当多个类确实共享大量私有实现细节、又必须放在同一个库时才值得用part。Agent的角色定义和编排者之间确实存在这种强耦合所以我最后选择用part拆分。4. 多智能体编排从单模型聊天到多角色协同系统4.1 什么是真正的Agent模型提示词工具记忆很多教程把调用LLM API叫Agent这是误导。一个真正的Agent至少包含四个要素模型推理引擎、系统提示词角色设定、工具可执行的外部能力、记忆上下文管理。没有工具的记忆Agent只能聊天没有记忆的工具Agent无法连续执行多步骤任务。移动端的多智能体系统本质就是把多个Agent组合在一个编排器里让它们在不丢失上下文的前提下协作完成一个复杂目标。这里必须做一个务实的决定移动端不要多个模型实例而是一个模型引擎多个角色配置。后者在API模式下就是一套Prompt工具集映射不需要在客户端并发维护多个模型连接token消耗也能通过调度策略控制在合理范围。所谓多智能体在大多数业务场景下是多角色、多工具、多记忆子系统的组合而不是同时跑几个大模型。4.2 角色设计Planner/Researcher/Calculator/Reviewer我在智能行程助手项目里最终沉淀了四个角色你完全可以复用或扩展。Planner的任务是把用户的模糊意图拆解为可执行子任务并输出一个有序清单。Researcher负责检索外部信息包括知识库RAG、天气API、地图API。Calculator处理所有数值计算和结构化数据操作避免模型直接做加减乘除——你知道的LLM做算术容易翻车。Reviewer负责任务完成后一致性校验发现逻辑漏洞会标记并打回重做。角色核心职责System Prompt关键要点典型工具Planner意图拆解、任务排序只输出任务清单不要执行动作无Researcher信息检索与收集区分事实与猜测返回来源search_knowledge_base、get_weatherCalculator参数计算与数据规整只做计算不做主观判断run_calculationReviewer结果审核与纠错输出问题列表和修正建议validate_result这四个角色共享同一个LLM接口但每次请求的System Prompt和可用工具不同。我在编排器里用一个Map维护角色配置每次调用时取出对应配置组装消息。4.3 工具定义与Function Calling为什么不裸让模型输出JSON第一次做Agent时我让模型直接输出JSON格式的工具调用结果一天崩三次JSON转义错误、字段缺失、参数类型不对。后来老老实实用Function Calling也叫Tool Calling。它的好处是API层面就保证参数是结构化JSON模型只会输出你定义过的函数不会凭空捏造工具名。一个规范的Function Calling工具定义长这样{ type: function, function: { name: search_knowledge_base, description: 在内部知识库中检索与问题相关的文档片段, parameters: { type: object, properties: { query: { type: string, description: 搜索关键词 }, top_k: { type: integer, description: 返回片段数量, default: 5 } }, required: [query], additionalProperties: false } } }Dart侧把工具定义组织成列表请求时统一传给APIfinal toolDefs [ ToolDefinition( name: search_knowledge_base, description: 在内部知识库中检索与问题相关的文档片段, parameters: { type: object, properties: { query: {type: string}, top_k: {type: integer, default: 5}, }, required: [query], additionalProperties: false, }, ), ];工具注册时我封装了一个ToolRegistry接收ToolCall后找到对应的执行函数拿到结果再以role: tool的消息格式回传给模型。这一步可以用现成Dart包做JSON Schema校验避免执行函数收到意外参数。4.4 一个可运行的编排循环带轮次上限的ReAct核心编排代码不需要很复杂但要有几个关键保护机制最大轮次限制、异常恢复、工具结果长度截断。我写的简化版本如下实际项目中你在每个分支加日志即可。class AgentOrchestrator { static const maxRounds 5; FutureAgentResult run(String userInput, ListAgentRole roles) async { var messages _buildInitialMessages(roles, userInput); var currentRole roles.first.name; for (var round 0; round maxRounds; round) { final response await _llm.chatCompletion( messages: messages, tools: _toolsForRole(currentRole), ); if (response.toolCalls.isEmpty) { // 没有工具调用说明当前角色任务完成进入下一个角色 currentRole _nextRole(currentRole); if (currentRole RoleEnd) break; messages.add(_roleSwitchMessage(currentRole)); continue; } messages.add(response.toMessage()); for (final call in response.toolCalls) { final result await _toolRegistry.execute(call); messages.add(ChatMessage.toolResult(call.id, result)); } } return AgentResult(messages: messages); } }为什么要加轮次上限因为模型在tool调用时偶尔会钻进死循环比如反复调用同一个搜索工具不收敛没有上限分分钟把预存款烧完。我设的5轮是基于真实需求的统计96%的任务在4轮内完成。4.5 上下文管理移动端省钱的关键多智能体最容易烧token的地方是上下文无限累积。每个角色看到的历史消息如果都带全量记录请求体越来越大成本线性上升模型判断也会被无关历史干扰。我的方案是三步走滑动窗口、摘要压缩、关键结果保留。滑动窗口只保留最近K条消息默认K20。摘要压缩是指当历史超长时触发一个压缩Agent把旧消息概括成500字内的摘要替换掉原始消息。关键结果保留则是工具调用的返回值要单独缓存不参与普通消息窗口淘汰。比如知识库检索结果每次都会进系统提示词上下文但检索详情只保留最新一次。这套策略实施后我的单次多智能体任务平均token消耗从4.2K降到2.1KDirect的效果很直观——账单几乎减半。5. 对接LLM API的常见问题从被拒payload到断线重连5.1 provider rejected the request schema or tool payload最常见的工具调用报错这个报错是所有接Function Calling的人一定会遇到的。我第一次看到时一脸懵排查了三个小时才明白。这个错基本发生在请求工具参数结构不符合provider要求时通常在以下三个细节之一。第一个细节是strict mode。很多服务商在tool_choice为required或指定函数时强制要求additionalProperties: false你没写就拒绝。第二个细节是parameters对象里漏了type: object。有些SDK会自动补全但如果你手写HTTP请求就会踩坑。第三个细节是参数描述里含有非法字符或层级嵌套过深某些严格校验的实现会直接拒掉。排查方法分享给你把每次请求的原始body打进日志文件复制到服务商提供的调试工具里手动发送逐步删减字段定位问题。我后来直接在Dart层写了一个工具定义校验函数在请求前主动检查additionalProperties和type字段是否齐全。5.2 流式响应的超时、乱序与断连流式响应最烦人的是断连网络一抖动连接断开用户界面停在半句话很尴尬。我的策略是把连接超时和读超时分开设置。连接超时控制在10秒读超时控制在60秒。SSE收到心跳事件时重置读超时计时器这样服务端只要在推送内容连接就不会被误杀。final response await _client .post(uri, body: body, headers: headers) .timeout(const Duration(seconds: 60));断线后如果直接失败让用户从头再来多智能体的多轮历史就要重跑一遍很浪费。我实现了一个轻量级的会话恢复请求时携带一个session_id断线后重新连接时在messages末尾追加一条你的上一条输入已经收到请继续的系统提示并把最后一条模型输出作为新请求前缀。实测在弱网环境下恢复成功率提升到80%以上。5.3 请求日志与降级策略产品上线前必须处理的事上线前最重要的不是功能而是可观测性。我花了一周时间做了一套Dart侧的请求日志系统记录每次请求的模型、token数、耗时、工具调用序列、错误类型。这样用户反馈回答太慢时你能直接翻日志找到是哪一环慢而不是靠猜。降级策略也很有必要。多智能体系统链路长任何一个环节出错都要兜得住。我的方案是Planner失败时直接降级为全量任务列表让用户选择工具调用失败时自动包装为某工具暂不可用请用已有信息继续的系统提示递给模型。保证核心体验不中断而不是一报错就红色大叉。6. 环境搭建与打包上线的避坑清单6.1 从创建项目到第一个编译通过很多新手会在Android StudioAS里新建Flutter项目时卡住因为要注意的选项不少。AS创建Flutter项目的步骤File - New - New Flutter Project选择Flutter SDK填项目名全小写下划线、包名建议用com.yourname.project格式Organization别和默认的com.example混淆。生成完项目先别急着写代码先跑一次flutter run确认环境通。国内网络环境下载依赖慢的问题我在环境变量里配置了镜像地址。Flutter SDK本身如无法从官网下载也可以从镜像站获取然后在PATH里配好。检查环境的命令依然是三件套flutter doctor flutter upgrade flutter pub getflutter doctor会告诉你缺少哪些依赖包括Android SDK、JDK、Xcode工具链。我第一次只装了Android SDK没装JDK编译时卡了半天。6.2 Gradle与Android打包常见报错做Flutter Android打包最容易遇到的报错是英文长句you are applying flutters main gradle plugin imperatively using the apply。这句话的意思是你还在用旧式的apply plugin方式引用Flutter的Gradle插件而新版Flutter要求用plugins DSL。旧写法在Flutter 3.16以后会抛警告后续版本直接失败。解决办法是把android/settings.gradle里这样写// 旧写法不推荐 // apply plugin: com.flutter.gradle.plugin // 新写法 plugins { id com.flutter.gradle.plugin }另一个常见打包错误是java.lang.AssertionError: could not close I/O stream遇到过的人都想摔键盘。这个错大概率是三种原因build目录下残留损坏产物、磁盘空间不足、杀毒软件锁定文件。处理方式就是依次排查flutter clean清一次看磁盘剩余临时退出杀毒软件然后再打包。我在Windows开发机上遇到过两次清空Gradle缓存目录后解决。还有那个the current configured flutter sdk is not known to be fully supported的警告一般是Flutter版本和某依赖的版本兼容范围不匹配。认真对待这个警告别忽略。我一次忽略后打包出来的App在某些Android版本上点击TextInput直接闪退。建议使用flutter upgrade把SDK升到稳定版并保证pubspec里的依赖版本同步更新。6.3 iOS侧的版本坑与新Xcode的兼容性iOS打包有自己的脾气。Xcode升级到新版本后很多Flutter包会报版本太低的错。本质是Xcode自带的Swift工具链更新了而旧版Flutter导出的Podfile还在用旧的platform版本或Swift版本。解决办法有两个一是升级Flutter到适配新Xcode的版本二是手动更新Podfile里的platform :ios, 13.0并把Pod解析器的Swift版本调高。platform :ios, 13.0 # 如果报Swift版本问题 project Runner, { IPHONEOS_DEPLOYMENT_TARGET 13.0 }真机调试时看Flutter日志最方便的方式不是看Xcode控制台而是命令行运行flutter run -v。关闭应用后日志还是会输出很稳定。iOS真机调试还有一步去Apple Developer后台把设备UDID加进Provisioning Profile里新手经常漏。6.4 性能优化Impeller、Web引擎与包体控制Flutter 3.7以后的版本默认启用Impeller渲染器iOS和Android上的UI性能都有明显提升尤其在滚动场景和文本渲染上。如果你的目标机型较老遇到渲染花屏或异常可以在AndroidManifest.xml里配置关闭Impeller回退到Skia作为应急方案但正常新版本不建议关闭。如果做Flutter Web版本启动慢是老大难问题。Flutter Web默认用CanvasKit渲染首次加载需要下载几百KB的wasm文件没有CDN加速时用户等得抓狂。解决办法是自托管CanvasKit文件在web/index.html里通过重写uiCanvasKit指向你自己的CDN路径。我迁移了一次自建CDN后首次可交互时间从11秒降到了3.6秒。包体控制上多智能体应用往往要打包TTS资源、图标、字体。我用flutter build apk --split-per-abi分ABI打包再配合尺寸分析工具APK从85MB压到38MB。不必要的原生SDK尽量按需引入。最后分享一个我反复用过、非常管用的工作方法在写Flutter UI之前先在纯Dart环境里把多智能体编排逻辑以命令行方式跑通。这样你可以专注调试Agent逻辑、工具调用、上下文管理不用被Widget构建和热重载干扰。等编排逻辑稳定了再套Flutter UI壳你会发现开发速度快一倍。这个组合越往后做越有意思把编排层后移成独立服务、接入RAG知识库、让不同Agent共享记忆每个方向都是一片新空间。
返回列表