做这个 flutter 3.32.8 版本的 ai 对话聊天基础项目,起因其实很简单:我想在自己的 App 里塞一个能正经聊天的 AI 入口,又不想自己维护一套推理服务、也不想承担 GPU 成本。硅基流动 API 恰好提供了按 token 计费的大模型在线接口,把对接成本直接压缩到一个 HTTP 请求的规模。这个项目的目标就是把整条链路跑通——从环境搭建、密钥申请、请求报文格式,到 Flutter 端如何做流式接收、状态管理和界面渲染,最终在手机上能和模型正常对话。这里的“基础”两个字不代表粗糙,反而意味着把最缠人的协议细节和 SDK 适配都提前趟平了。如果你刚接触 Flutter,或者第一次接大模型 API,这篇把每一步怎么落地的思路和代码都摆出来,直接照着抄就行。
表面上看,这个项目只是一个“HTTP 请求 + 列表展示”,但真正动手会发现环环相扣:版本不一致导致构建失败、密钥泄露风险、流式响应不处理会卡界面、上下文越长 token 越贵。下面我就从头把整套实现过程拆开讲。
1. 项目概述与技术选型思考
1.1 需求拆解与功能边界
在动手之前,我先把“聊天基础版”的功能边界划清楚。一个最小可用的 AI 对话应用需要满足这几件事:
- 用户输入一句话,点击发送后能立即看到消息进入列表;
- 请求大模型接口,把返回的内容追加到聊天记录中;
- 支持多轮上下文,模型能记得用户前面说的话;
- 请求期间有加载状态,不能让用户干等着以为应用死了;
- 消息列表可以滚动,视觉上区分用户消息和 AI 回复。
在基础版里,我不做用户系统、不做会话历史持久化、不做多模型切换、不做语音输入。这些功能后面扩展不迟,但第一版如果功能堆得太满,调试时反而分不清问题是出在网络协议上还是界面上。先跑通链路,再加花样,这是我做这类项目一贯的顺序。
1.2 为什么选 Flutter 3.32.8 搭配硅基流动 API
Flutter 的版本选择上,我直接锁定了 3.32.8。这个版本已经默认启用 Impeller 渲染引擎,在 Android 真机上滚动列表和刷新消息时明显比旧版的 Skia 渲染更稳定,不容易出现掉帧和毛刺。很多人在网上搜“flutter impeller”相关的性能问题,其实版本到位以后大部分已经解决。3.32.8 的另一个优势是 Gradle 插件版本和 Android 构建工具链的匹配度更好,新建项目后直接能跑起来的概率高很多,不会一上来就被各种构建报错劝退。
模型 API 服务商的选择,硅基流动 API 是我目前综合下来比较省心的一家。它提供的接口协议和 OpenAI 的 chat/completions 格式兼容,这意味着 Flutter 端不需要引入特殊的 SDK,直接用标准 HTTP 请求就能完成通信。平台上有 DeepSeek、Qwen、GLM 等多个开源模型的在线推理版本,按 token 计费,还有面向新用户的免费体验额度。对个人开发者和独立项目来说,这比自己在服务器上部署一个 70B 大模型要现实得多——后者光是显存成本和推理延迟就能拖垮项目进度。
1.3 整体架构与数据流设计
这个项目的架构虽然简单,但我还是分成了四层,避免后续扩展时牵一发动全身。
- UI 层:聊天页面和消息气泡组件,负责渲染数据和接收用户输入;
- 状态管理层:基于 Provider 维护消息列表、请求状态和错误信息;
- 服务层:封装硅基流动 API 的调用逻辑,包括请求头、参数序列化、流式解析;
- 配置层:集中管理 API Key、模型名、系统提示词等常量。
数据流是单向的:用户在输入框提交文字后,状态层把用户消息加入列表,紧接着调用服务层发起请求;服务层收到流式片段后回调状态层,状态层更新消息内容并调用 notifyListeners,UI 层监听变更后重新构建对应的消息气泡。通信方式不搞花活,规规矩矩的请求-回调模式,这对一个基础项目来说够用,也最容易排查问题。
2. 开发环境准备与项目初始化
2.1 Flutter 3.32.8 环境搭建细节
如果你用 Windows 做开发,先把 Flutter 3.32.8 的稳定版压缩包下载下来,解压到一个不带空格的路径,比如D:\flutter。然后把D:\flutter\bin添加进系统 PATH。macOS 上我建议直接用 fvm 管理 Flutter 版本,一条fvm install 3.32.8就能隔离多个版本,避免不同项目互相干扰。
安装完成后,终端里跑一下flutter doctor,它会检查 Android SDK、Java、Xcode 这些配套工具。第一次跑的时候大概率会提示 Android licenses 未接受,执行:
flutter doctor --android-licenses一路回车或者输入 y 即可。这一步如果在 Windows 上做,还需要确认你的 Android Studio 里已经装好了 SDK Platform 和 Build Tools。
我在这个环节踩过最常见的坑是:机器上原来装了旧版本 Flutter,环境变量 PATH 里指向的是旧路径。后面明明输入flutter --version显示的是 3.32.8,但新建项目时依然沿用旧版本的缓存,导致模板异常。建议切版本后顺手执行一次flutter clean和flutter pub cache repair,代价不高,却能避免大量诡异报错。
2.2 创建项目与依赖管理
环境顺利通过后,创建项目就很简单了:
flutter create ai_chat_demo --org com.example --platforms android,ios--platforms参数可以按需保留,我只保留 Android 和 iOS,避免生成一堆用不到的桌面或 Web 目录。
依赖方面,我没有引入重量级框架,只加了几个基础包:
dependencies: flutter: sdk: flutter http: ^1.2.0 provider: ^6.1.0 shared_preferences: ^2.2.0网络请求用http而不是用dio,主要是因为基础版只有一个 POST 请求,不需要拦截器、Cookie 管理、请求队列这些功能,http的 API 简单直接,学习成本低。状态管理用provider,它是官方推荐方案里最轻量的一种,核心概念就两个:把共享状态放进ChangeNotifier,UI 层用context.watch监听变化。对比 riverpod 和 bloc,provider 的模板代码更少,对新手友好得多。
shared_preferences是为了后面存配置用的,比如把用户选择的模型名、历史记录数量保留在本地。第一版即使还没用到,先把依赖装好也不会造成负担。
2.3 目录规划与配置隔离
项目创建后,我把lib目录整理成下面这个结构:
lib/ ├── main.dart ├── config/ │ └── api_config.dart ├── models/ │ └── chat_message.dart ├── services/ │ └── chat_api_service.dart ├── providers/ │ └── chat_provider.dart ├── screens/ │ └── chat_screen.dart └── widgets/ └── message_bubble.dart这个结构的好处是,哪怕项目后面膨胀到几百个文件,核心的 API 调用逻辑还是在services里,不会和 UI 搅在一起。
关于 API Key 的配置,我特别强调一下:绝对不要把密钥硬编码在代码里然后推到 Git 仓库。基础项目可以放到config/api_config.dart里,但记得把该文件加入.gitignore。如果只是本地调试,也可以使用--dart-define方式在启动时注入密钥:
flutter run --dart-define=API_KEY=sk-xxxx代码里用String.fromEnvironment('API_KEY')读取。这样做的好处是密钥不会进代码仓库,方便多人协作时各自管理自己的密钥。
3. 硅基流动 API 接入详解
3.1 申请密钥与模型选择
使用硅基流动 API 的第一步是注册账号,然后在控制台创建一个 API Key。整个过程没有太多门槛,生成后的密钥是一段类似sk-开头的字符串,妥善保存。这个密钥等同于账户的操作口令,一旦泄露,别人就能用它调用你的接口并消耗 token 费用,所以平时要像对待密码一样对待它。
平台上的模型列表是会动态变化的,我创建项目时常用这几个,整理成表格方便对比:
| 模型标识 | 上下文长度 | 适用场景 | 说明 |
|---|---|---|---|
| deepseek-ai/DeepSeek-V3 | 64K | 综合对话、代码生成 | 推理能力强,性价比高 |
| Qwen/Qwen2.5-72B-Instruct | 128K | 中文对话、内容创作 | 中英文均衡,中文更自然 |
| THUDM/glm-4-9b-chat | 8K | 轻量对话、分类提取 | 部署门槛低,响应快 |
| deepseek-ai/DeepSeek-R1 | 64K | 复杂推理、数学题 | 思维链内容较多,耗时更长 |
我在基础版里默认使用deepseek-ai/DeepSeek-V3。它综合能力够强,token 单价也压得比较低,作为默认模型不会让新手用户产生“怎么聊几句就没钱了”的错觉。模型的选择会影响请求返回的格式和思维链内容,比如 R1 会把推理过程也放进返回体,如果代码没有过滤掉这些字段,界面上就会出现大段诡异的“思考过程”。所以模型一旦定下来,客户端解析逻辑就要和它匹配。
3.2 请求报文结构与参数说明
硅基流动 API 的端点地址是标准的 chat/completions 路径,完整的请求地址是https://api.siliconflow.cn/v1/chat/completions。请求头需要携带两个关键字段:
Authorization: Bearer sk-xxxx Content-Type: application/json请求体最核心的是 messages 数组,每个元素包含 role 和 content。role 有三个取值:system 用来设定系统级指令,user 表示用户输入,assistant 表示模型的历史回复。基础版我会在消息列表最前面塞一条 system 指令,比如“你是我的 AI 助手,回答时简洁口语化”,这样模型不会把每次对话当成一个孤立的问题。
一个完整的请求体长这样:
{ "model": "deepseek-ai/DeepSeek-V3", "messages": [ { "role": "system", "content": "你是我的 AI 助手,回答简洁、口语化。" }, { "role": "user", "content": "帮我写一个 Flutter 聊天页面" } ], "max_tokens": 1024, "temperature": 0.7, "stream": false }参数方面,max_tokens决定模型最多输出多少 token,注意它不是字符数,一个汉字大约对应 1 到 2 个 token。temperature控制随机性,取值范围一般是 0 到 2,数值越高,回答越发散;数值越低,回答越保守。基础对话我用 0.7,既能保持自然感又不会跑偏。如果以后做客服机器人,建议把 temperature 降到 0.3 左右,回答会更稳定。
3.3 流式返回协议解析
stream参数是这个项目的关键之一。设置成true后,API 不会一次性吐完整段回复,而是通过 Server-Sent Events 的方式逐段推送数据。每段数据以data:开头,最后以data: [DONE]收尾,例如:
data: {"choices":[{"delta":{"role":"assistant"},"index":0}]} data: {"choices":[{"delta":{"content":"你"},"index":0}]} data: {"choices":[{"delta":{"content":"好"},"index":0}]} data: {"choices":[{"delta":{},"finish_reason":"stop"}]} data: [DONE]为什么一定要用流式?因为大模型生成一段完整回答可能要几十秒,尤其是一些推理型模型,思考时间更久。如果非流式请求,用户只能看到转圈图标干等,而且请求时间一长,网关卡在中间很容易超时。流式方案让每个 token 生成后立刻送到客户端,用户的感知是“对方正在打字”,体验完全不一样。
解析流式返回时,直接按行读取,遇到data:前缀就提取后面的 JSON。正常分发逻辑只要关心choices[0].delta.content字段即可,为空就跳过,直到[DONE]或finish_reason出现再收尾。
4. Flutter 端 AI 对话核心实现
4.1 网络层封装与错误处理
我把网络请求单独拆成一个ChatApiService类,这样界面和状态管理都不需要关心 HTTP 细节,后面想换成别的模型提供商,只需要改这一个文件。
用 Dart 的HttpClient做流式请求,一个最小实现如下:
class ChatApiService { ChatApiService({required this.apiKey}); final String apiKey; static const _endpoint = 'https://api.siliconflow.cn/v1/chat/completions'; Future<void> chat({ required List<Map<String, String>> messages, required void Function(String delta) onDelta, required void Function(String fullText) onDone, required void Function(String error) onError, }) async { final body = jsonEncode({ 'model': 'deepseek-ai/DeepSeek-V3', 'messages': messages, 'stream': true, 'max_tokens': 2048, 'temperature': 0.7, }); final client = HttpClient(); try { final req = await client.postUrl(Uri.parse(_endpoint)); req.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); req.add(utf8.encode(body)); final res = await req.close(); if (res.statusCode != 200) { final err = await res.transform(utf8.decoder).join(); onError('HTTP ${res.statusCode}: $err'); return; } final stream = utf8.decoder.bind(res).transform(const LineSplitter()); final buffer = StringBuffer(); await for (final line in stream) { if (!line.startsWith('data:')) continue; final data = line.substring(5).trim(); if (data == '[DONE]') break; final json = jsonDecode(data); final delta = json['choices'][0]['delta']['content']; if (delta != null && delta is String) { buffer.write(delta); onDelta(delta); } } onDone(buffer.toString()); } catch (e) { onError(e.toString()); } finally { client.close(); } } }错误处理我习惯分成两层:网络层捕获异常并转成错误字符串,状态层再根据错误内容决定要不要展示重试按钮。默认的HttpClient没有超时设置,我建议在client.connectionTimeout上显式配置连接超时和读取超时,否则弱网环境会挂很久才报错。
4.2 对话状态管理:Provider 实现
消息列表需要被输入框、列表、加载状态多处共享,直接用 StatefulWidget 的 setState 会越写越乱。我用 Provider 包一个ChatProvider,核心代码如下:
class ChatMessage { ChatMessage({required this.role, required this.content}); final String role; String content; } enum ChatStatus { idle, loading, done, error } class ChatProvider extends ChangeNotifier { ChatProvider(this._api); final ChatApiService _api; final List<ChatMessage> _messages = []; List<ChatMessage> get messages => _messages; ChatStatus status = ChatStatus.idle; String errorMessage = ''; Future<void> send(String text) async { _messages.add(ChatMessage(role: 'user', content: text)); final assistant = ChatMessage(role: 'assistant', content: ''); _messages.add(assistant); status = ChatStatus.loading; notifyListeners(); final history = _messages .where((m) => m.role != 'system') .map((m) => {'role': m.role, 'content': m.content}) .toList(); await _api.chat( messages: history, onDelta: (delta) { assistant.content += delta; status = ChatStatus.loading; notifyListeners(); }, onDone: (_) { status = ChatStatus.done; notifyListeners(); }, onError: (e) { status = ChatStatus.error; errorMessage = e; notifyListeners(); }, ); } }send方法里的逻辑顺序很关键:先把用户消息和空白的 assistant 消息都加到列表,再发请求。这样 UI 层不需要额外维护“当前正在回复哪条消息”,列表里的最后一条就是模型正在生成的回复。每收到一个 delta 就更新 assistant.content,然后 notifyListeners 通知刷新,界面上的文字就会一个字一个字跳出来。
在main.dart里注入 Provider:
void main() { final apiKey = String.fromEnvironment('API_KEY'); runApp( ChangeNotifierProvider( create: (_) => ChatProvider(ChatApiService(apiKey: apiKey)), child: const MyApp(), ), ); }4.3 聊天界面搭建与流式渲染
聊天界面的骨架是“上方列表 + 底部输入区”。列表我用ListView.builder,消息项按角色渲染不同颜色和排列方向:
class ChatScreen extends StatefulWidget { const ChatScreen({super.key}); @override State<ChatScreen> createState() => _ChatScreenState(); } class _ChatScreenState extends State<ChatScreen> { final ScrollController _scrollController = ScrollController(); final TextEditingController _inputController = TextEditingController(); @override Widget build(BuildContext context) { final chat = context.watch<ChatProvider>(); final visibleMessages = chat.messages .where((m) => m.role != 'system') .toList(); return Scaffold( appBar: AppBar(title: const Text('AI 助手')), body: Column( children: [ Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(12), itemCount: visibleMessages.length, itemBuilder: (context, index) { final msg = visibleMessages[index]; return MessageBubble(message: msg); }, ), ), _InputArea( controller: _inputController, onSend: (text) { if (text.trim().isEmpty) return; chat.send(text.trim()); _inputController.clear(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); }); }, ), ], ), ); } }流式渲染没有额外做动画,它的红利来自notifyListeners触发重建。每次重建整个列表会带来滚动抖动,所以我给每条消息加了一个稳定的 key,值直接用消息内容拼接角色生成。这样即使列表刷新,底层状态也不会乱跳。
MessageBubble组件就是普通 Flex 布局,用户消息靠右、蓝底白字,AI 消息靠左、灰底黑字。当 AI 消息内容为空且状态是 loading 时,显示一个小的进度圆环,表示模型正在生成回复。
5. 常见问题与排查技巧实录
5.1 状态码与异常消息速查
接入硅基流动 API 时,最常见的几类问题我整理成一张表:
| 状态码/现象 | 可能原因 | 处理方式 |
|---|---|---|
| 401 | 密钥错误或已失效 | 检查 Authorization 头和 API Key 是否正确 |
| 400 | 请求体格式不对 | 核对 model 标识、messages 是否数组、字段类型 |
| 429 | 触发限流或账户余额不足 | 降低并发请求频率,检查控制台余额 |
| 500/503 | 服务端异常 | 等待几秒后重试,或更换模型 |
| 连接超时 | 网络环境问题或请求体过大 | 增加超时时间,确保走流式接口 |
有次我的项目突然返回 400,排查半天才发现是历史消息里混进了一条role: system但 content 为空的消息,接口校验直接拒绝。从那以后我在组装请求体时都会先用where过滤掉空 content 的消息。
还有一种特别容易误导人的报错,提示某个模型路由缺少 API Key。这种情况一般不是你 Flutter 代码的问题,而是中间的网关或转发层没有正确透传密钥。我建议排查链路时先拿 curl 直接请求接口,能复现就说明问题在 API 层,不复现就是客户端的问题,能省掉大量无用功。
5.2 流式解析中的坑与避雷
流式解析看起来不复杂,实际踩坑不少。
第一,不要直接jsonDecode(response.body)。如果没开流式,一次性返回的 body 可以直接解析,但开了流式后整段文本是一行行累积的,直接解析整个response.body根本拿不到流式数据。正确做法是逐行读取再逐行解析。
第二,BOM 问题。某些代理工具会在响应流最前面插入 UTF-8 BOM,导致第一行data:前面多一个不可见字符,jsonDecode直接报错。我在解析前会先判断一下开头有没有\ufeff,有就剥掉。
第三,脏行问题。流式协议里除了data:行,还可能出现空行和注释行。代码里判断line.startsWith('data:')是必须的,否则会把空行和事件的注释也送给 jsonDecode。
5.3 构建与运行阶段的典型报错
很多人在网上搜“flutter 新建项目后跑不起来”,我一并说几个实际遇到的构建期问题。
最典型的是这个报错:You are applying Flutter's main Gradle plugin imperatively using the apply script。这通常出现在升级 Flutter 版本后,项目里旧版 Gradle 配置和新版插件的兼容性出了问题。解决方法是打开android/settings.gradle,把传统的 apply script 方式改成 plugins DSL 方式:
plugins { id "com.android.application" version "8.3.0" apply false id "com.android.library" version "8.3.0" apply false id "org.jetbrains.kotlin.android" version "1.9.22" apply false }改完以后同步 Gradle,再重新构建就好。另一个高频问题是 Android release 包安装后无法联网,这是因为 release 模式下 AndroidManifest 缺少网络权限。检查android/app/src/main/AndroidManifest.xml顶部,确保有这行:
<uses-permission android:name="android.permission.INTERNET"/>5.4 基础版常用的体验优化
基础版跑通之后,有几个低成本优化能明显提升体验。
第一是控制历史消息长度。多轮对话时,messages 数组每轮都会变长,而 token 是按总量收费的,上下文越长单次请求越贵、响应越慢。我在发送前加了一个截断逻辑:只保留最近 20 条消息,超出部分直接丢弃。还有一种更聪明的做法是写一个摘要 prompt,让模型把早期对话浓缩成几句话再放入上下文,不过基础版用截断就够了。
第二是请求超时配置。流式请求虽然整体时间可能很长,但长时间没有任何数据到达多半是连接已经死了。把连接超时设成 10 秒,读取超时设成 60 秒,能有效防止界面一直转圈。
第三是 API Key 的管理。这个我再强调一次:不要提交到 Git,不要打包进 release APK。最稳妥的做法是让后端做一个中转代理,客户端把请求发给自己的服务器,由服务器持有 API Key 再调用硅基流动 API。这样即使客户端被逆向,攻击者拿不到你的密钥。基础版如果嫌重,至少要用--dart-define注入,而不是明文写在源码里。
6. 后续扩展方向与个人体会
6.1 功能扩展的几个方向
基础版完整跑通后,我再按优先级列出几个可参考的扩展方向。
改成多模型切换成本最低,因为服务层已经把请求协议统一了,只要在配置层加一个模型名的下拉选择,再让ChatProvider初始化时接收模型参数即可。结合shared_preferences,用户选中的模型就会被记住。
停止生成按钮也值得做。流式请求是持续通道,用户可以随时中断。实现方式是在ChatApiService里保留一个HttpClient引用,停止时调用client.close(),上游连接会立刻断开。配合界面上一个“停止”按钮,体验会完整很多。
会话历史持久化用sqflite或hive都能做,把消息列表按时间存本地,App 重启后还能恢复上下文。如果项目后面要做知识库问答,那就需要研究 embedding 接口和向量检索,这是另一个更大的话题了。
我还在热搜里看到不少人问“多 ai 协作”,实际上你只要把ChatApiService的接口抽象成统一契约,然后实现多个供应商的 Service,上层代码就能在不改动 UI 的前提下切换或者并行调用多个模型。这也是为什么我在基础版坚持把网络层单独封装,给未来的扩展留好了余地。
6.2 我个人在实际操作中的几点体会
这个项目真正常规文档里不会写的东西,我觉得有三条。
第一,AI 对话项目的调试思路要转变。普通接口返回的数据是稳定的,参数错了立刻能看出来;但对话类接口的返回取决于模型,同一个请求这次和下次可能都不一样。所以调试时先把stream: false跑一遍,确认基本链路没问题,再切回流式,这样能少踩很多坑。
第二,界面上的“打字机”效果其实是免费的。只要你用流式接口并且每次 delta 都触发重建,自然就会呈现出文字逐个蹦出来的效果,不需要额外做定时器动画。我第一次实现时还专门写了一个 Timer 轮询,后来发现把简单的事情做复杂了。
第三,版本管理一定要认真。这个项目如果是拿来学习的,可以把每个阶段的完整代码打一个 tag,比如v0.1-basic、v0.2-stream;如果是上线的,API Key 保护和服务端代理迟早要做。反正提前把这部分设计好,后面就不会为几行临时代码熬夜。
做到这里,一个基于 Flutter 3.32.8 和硅基流动 API 的 AI 对话基础应用已经完整落地。往后无论是换模型、加功能还是重构 UI,你手头这份结构都会成为扎实的起点。