ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Flutter + 本地大模型:打造自然语言记账工具的全流程实践

Flutter + 本地大模型:打造自然语言记账工具的全流程实践 手机里的记账App我前后换过六七个每次坚持不到两周就放弃了。问题不在功能而在记账这个动作本身——花完钱还要打开App、点加号、选分类、输金额、写备注整套流程超过五秒热度一过根本不想碰。后来我换了个思路如果我能把“昨天中午和同事吃火锅花了286”这句话直接丢给手机让它自己成一条结构化账单这事是不是就能坚持下来这个想法最终落地成了一个真实的项目用Flutter写客户端在本地部署一个大模型做一个自然语言记账工具。用户在输入框打的是一句人话工具自动解析出金额、分类、时间、备注然后存进本地数据库。整个过程完全离线不用上云隐私和安全都在自己手里。这篇文章就把这个项目的完整实现拆开讲一遍包括模型选型、Flutter端架构、提示词协议设计以及我在实际开发中踩过的坑。适合对 Flutter 有一定基础、同时对本地大模型落地感兴趣的开发者参考。1. 记账工具的痛点与“本地模型”的解法1.1 为什么记账工具必须拥抱自然语言传统记账App的分类交互设计得再漂亮本质上还是在逼用户“翻译”自己的消费行为你吃了个快餐要先想在哪个分类下再输入金额再补备注。这种结构化录入方式对人脑来说是个负担。自然语言输入则完全不同。它允许人用最原始、最直接的方式表达消费事实然后由程序去完成结构化转换。这里面有一个很反直觉的点“结构化”是给机器用的不是给人用的。让用户以非结构化的方式说话再由本地模型去猜结构这种交互方式的成功率远高于让用户自己去理解分类体系。我实测下来一句“周三晚上打车回家花了43”比用户手动选择“交通-出租车-43元-备注加班回家”的录入速度快了至少五倍。而且自然语言输入本身携带了上下文信息打车是“晚上”发生的、吃饭和“同事”一起吃这些信息如果是手动录入用户几乎不会去填。1.2 为什么不用云端大模型API而选本地部署这是整个项目里最核心的决策点。自然语言记账是一个高频、碎片化、强隐私的场景它和“偶尔翻译一段话”完全不同。记账数据极度敏感。消费金额、消费习惯、常去的地点这些数据一旦上传到云端哪怕服务商承诺加密我也很难完全放心。而且记账工具通常没有盈利模式你也不太可能为一个记账工具付费订阅一个云端API。离线可用是刚需。在地铁、电梯、地下车库这种弱网或无网环境中记账行为反而更频繁——因为人往往是在消费发生的当下或几分钟后顺手记账。依赖云端大模型API遇到断网就只能干瞪眼。成本角度不划算。记账这个动作本身产生的token量并不大但它是长尾的、持续的调用。云端API按token计费日积月累也是一笔开销。而本地模型部署之后跑一次推理只花电费。响应速度。一个7B量级的量化模型在M系列芯片或中端PC上做单次生成首token延迟通常在几百毫秒生成几十个token也就是一两秒的事这和云端API的体验差距已经很小了。当然本地模型也有它的代价模型能力天花板比较低、需要占用几GB内存、部署调试有门槛。但这些代价在“记账”这个窄场景里基本都能被接受。1.3 整体技术栈与项目边界这个项目最终的技术栈非常简单Flutter客户端UI框架负责输入界面、账单列表、统计展示。Ollama本地模型运行时负责加载大模型并提供HTTP API。qwen2.5:7b本地大模型负责将自然语言句子解析为结构化JSON。sqflite本地数据库存储解析后的账单记录。整个项目没有使用任何云端服务从UI到模型推理到数据存储全部在本地完成。这样做的好处是项目结构极其清晰任何人拿到代码都能一次跑通。2. 模型选型与Ollama部署实测数据和结论2.1 Ollama部署与环境准备Ollama是目前本地跑大模型最省事的运行时。它会帮你处理模型下载、量化格式转换、GPU/CPU调度、API服务启动这些事情你基本只需要关心“用哪个模型”和“怎么调用”。安装方式很简单macOS和Windows直接去官网下载安装包Linux用官方脚本。装完后在终端执行ollama pull qwen2.5 ollama serveollama serve会启动一个监听在11434端口的HTTP服务后续所有请求都走这个端口。验证服务是否正常curl http://localhost:11434/api/generate -d { model: qwen2.5, prompt: 你好, stream: false }能返回一段JSON文本说明服务已就绪。这里有个小事容易忽略ollama pull和ollama serve需要分开两个终端窗口执行或者你用ollama run qwen2.5一条指令同时完成拉取和启动但如果项目开发中服务跑在后台还是建议单独起服务。2.2 候选模型横向对比我在这个项目里实际测过几个主流模型包括 qwen2.5:7b、llama3.1:8b、gemma2:9b、deepseek-r1:8b 和 glm4:9b。测试维度就三个中文理解能力、结构化输出稳定性、资源占用。模型参数量量化级别磁盘占用中文理解结构化输出稳定性备注qwen2.5:7b7.6BQ4_K_M4.7GB强稳定记账场景首选llama3.1:8b8BQ4_K_M4.9GB一般一般中文表达容易夹英文gemma2:9b9BQ4_K_M5.5GB一般不稳定输出格式偶尔放飞deepseek-r1:8b8BQ4_K_M4.9GB强差推理模型爱加思维链glm4:9b-chinese9BQ4_K_M5.5GB强稳定内存占用偏高这里重点说说deepseek-r1:8b为什么最先被淘汰。它是推理模型你在提示词里让它输出JSON它会先来一大段“嗯用户想让我解析这句话我需要提取金额、分类、时间……”这种思维链哪怕你在提示词里明确说不要输出任何解释它也很难完全克制。这导致响应时间变长而且解析出来的内容经常夹杂分析文本后处理非常痛苦。llama3.1在中文场景下会偶尔出现语法生硬、分类判断怪异的情况比如把“吃火锅”归到“餐饮”之外的类别。gemma2则是输出格式不稳定同一个提示词每次给的JSON字段顺序和命名都可能不同。最终我选了qwen2.5:7b中文理解强、格式稳定、资源占用在可接受范围内而且是目前Ollama生态里迭代最活跃的中文模型之一。2.3 为什么最终选型是 qwen2.5:7b选 qwen2.5:7b 的核心原因有两个。第一它对中文会话的意图理解非常准。在“昨晚和朋友在酒吧喝了三杯鸡尾酒花了260”这种句子里它能准确提取“260”是金额、“酒吧”是消费场所、“昨晚”是时间、分类是“娱乐”而非“餐饮”。这种理解能力对于7B这个体量的模型来说已经是越级表现了。第二它的结构化输出能力JSON格式遵循能力极其稳定。我做了40条真实记账句子的测试qwen2.5:7b在38条里直接返回了合法JSON剩余2条也只是在JSON外层包了markdown代码块标记后处理剥掉就行。运行参数方面我建议这样设置{ model: qwen2.5:7b, temperature: 0.1, num_ctx: 4096, keep_alive: 5m }temperature设为 0.1让模型输出尽量稳定、保守避免同一个句子每次解析结果不一致。num_ctx设为 4096 足够处理记账句子设得过大反而增加显存/内存占用。keep_alive控制模型在内存中的驻留时间。记账是高频短交互驻留5分钟比较合适超过5分钟自动卸载省内存。2.4 用 curl 验证输出质量再动代码建议先别急着写Flutter端先用curl把提示词和输出协议调通。这一步能帮你省掉大量调试时间。我用的是/api/generate接口非流式返回curl http://localhost:11434/api/generate -d { model: qwen2.5:7b, prompt: 请把这句话解析成记账JSON昨天中午和同事吃火锅花了286, stream: false, temperature: 0.1 }返回结果里会有response字段里面就是模型生成的内容。我在调通模型前只做一件事不断调整提示词直到模型稳定输出我想要的JSON结构。这一步的结论后面会详细讲。3. Flutter客户端架构Bloc、通信层与本地存储3.1 项目目录结构与状态管理Flutter端的目录结构我按功能划分不按类型划分这样随着功能迭代不会乱lib/ ├── main.dart ├── models/ │ └── bill_record.dart ├── services/ │ ├── ollama_api_client.dart │ └── bill_repository.dart ├── cubits/ │ ├── input_cubit.dart │ └── bill_list_cubit.dart ├── pages/ │ ├── home_page.dart │ └── stats_page.dart └── widgets/ ├── bill_input_bar.dart └── bill_list_tile.dart状态管理我用了flutter_bloc的 Cubit 形态。为什么不用 setState因为自然语言解析是一个异步、有中间状态解析中、成功、失败的过程setState 处理这种多状态流转会非常混乱而 Cubit 的emit机制天然适合表达状态机。3.2 对接Ollama API的关键代码Ollama API 的通信层封装在ollama_api_client.dart里。核心方法是一个parseBill(String sentence)class OllamaApiClient { final String baseUrl; OllamaApiClient({required this.baseUrl}); FutureString generate({ required String prompt, String model qwen2.5:7b, }) async { final client http.Client(); final request http.Request( POST, Uri.parse($baseUrl/api/generate), ); request.headers[Content-Type] application/json; request.body jsonEncode({ model: model, prompt: prompt, stream: false, temperature: 0.1, num_ctx: 4096, }); try { final response await client .send(request) .timeout(const Duration(seconds: 30)); final body await response.stream.bytesToString(); final json jsonDecode(body) as MapString, dynamic; return json[response] as String; } on SocketException { throw OllamaUnavailableException(); } on TimeoutException { throw OllamaTimeoutException(); } } }注意http.Client().send()配合http.Request()而不是直接用http.post()是因为给超时、错误处理留更多控制空间。SocketException要单独捕获因为这是“Ollama服务没启动”或“地址不通”的最典型报错。3.3 不同运行环境的BaseURL处理这是Flutter联调本地服务时最坑的地方没有之一。本地模型跑在开发机上Flutter App跑在模拟器或真机上二者之间的网络访问规则完全不同运行环境BaseURL原因Android 模拟器http://10.0.2.2:11434模拟器里访问宿主机必须用这个特殊IPiOS 模拟器http://localhost:11434iOS模拟器与宿主机共享网络栈真机同一局域网http://你的电脑局域网IP:11434真机必须走局域网IP访问宿主机桌面端http://localhost:11434本机调本机我在代码里做了一个简单的环境判断String resolveBaseUrl() { if (Platform.isAndroid) { return http://10.0.2.2:11434; } return http://localhost:11434; }同时在工程里留了一个--dart-defineAPI_BASE_URLxxx的注入入口真机调试时直接改启动参数覆盖不用改代码。3.4 存储设计一张表就够账单数据本身结构不复杂我用 sqflite 建了一张表字段如下CREATE TABLE bills ( id INTEGER PRIMARY KEY AUTOINCREMENT, amount REAL NOT NULL, category TEXT NOT NULL, note TEXT, occurred_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP );这里有个设计细节occurred_at是账单实际发生时间created_at是录入时间。自然语言里经常出现“昨天”“上周五”这种带有时间偏移的表达解析时必须把相对时间换算成绝对时间存进occurred_at否则统计报表会乱套。后面我会专门讲这个。4. 自然语言解析引擎提示词协议、容错与时间归一化4.1 设计输出协议与提示词模板这是整个项目的灵魂所在。模型选得再好提示词设计稀烂照样输出垃圾。很多人纠结“用自然语言提问还是用markdown提问”我的经验是别纠结关键是给模型一个明确、可验证的结构化指令和输出样例。我的完整提示词模板如下你是一个记账助手。请把用户输入的消费句子解析为JSON不要输出任何解释或markdown标记只输出JSON对象。 JSON字段说明 - amount: 消费金额数字类型单位元 - category: 消费分类只能是以下之一餐饮、交通、购物、娱乐、居住、医疗、人情、教育、其他 - note: 简短描述字符串 - occurred_at: 消费发生的时间以YYYY-MM-DD HH:MM格式输出如果句子中没有明确日期则使用今天 - participants: 参与人数组如果没有可以省略 示例1 输入昨天中午和同事吃火锅花了286 输出{amount: 286, category: 餐饮, note: 和同事吃火锅, occurred_at: 2025-01-14 12:00} 示例2 输入给妈妈转了2000 输出{amount: 2000, category: 人情, note: 转给妈妈, participants: [妈妈]} 现在解析下面这句话 {用户输入}这个提示词有几个设计点用只输出JSON对象直接堵住模型“废话输出”的路径。给出分类枚举模型就不会自由发挥出“美食”“出行”这类无法聚合的类别。给出输入输出对few-shot模型会照着示例的格式和字段风格输出稳定性大幅提升。occurred_at字段让模型自己预判日期后续再做后处理归一化。4.2 容错JSON解析模型不是机器就算提示词写死了“只输出JSON”偶尔也会在JSON外面包个 json 代码块或者多一个逗号。我写了一个容错解析函数MapString, dynamic? safeParseJson(String raw) { // 第一步去掉markdown代码块标记 var text raw.trim(); text text.replaceAll(RegExp(r^json\s*, caseSensitive: false), ); text text.replaceAll(RegExp(r$), ).trim(); // 第二步尝试直接解析 try { final decoded jsonDecode(text); if (decoded is MapString, dynamic) { return decoded; } } catch (_) {} // 第三步尝试从文本中提取JSON对象 final jsonStart text.indexOf({); final jsonEnd text.lastIndexOf(}) 1; if (jsonStart 0 jsonEnd jsonStart) { final candidate text.substring(jsonStart, jsonEnd); try { final decoded jsonDecode(candidate); if (decoded is MapString, dynamic) { return decoded; } } catch (_) {} } return null; }这个函数的思路先剥掉代码块标记直接解析失败则用首尾花括号截取JSON片段再试。实测里第三步的命中率很高因为即使模型前面说了废话最后一个完整的JSON对象通常还是存在的。4.3 时间、金额、分类的后处理模型输出的JSON不能直接入库还需要做三个后处理步骤尤其是时间。时间归一化模型输出occurred_at可能是“昨天 12:00”“2025-01-14 12:00”这种相对或绝对时间。我写了一个日期解析函数把“今天”“昨天”“前天”“上周X”“X天前”这类表达换算成绝对日期。核心代码如下DateTime normalizeOccurredAt(String raw, DateTime now) { final clean raw.trim(); if (clean.contains(昨天)) { return now.subtract(const Duration(days: 1)); } if (clean.contains(前天)) { return now.subtract(const Duration(days: 2)); } if (clean.contains(今天) || clean.isEmpty) { return now; } // 尝试解析标准格式 final parsed DateTime.tryParse(clean); if (parsed ! null) { return parsed; } return now; }注意这条规则并不完美比如“上周五”需要更多的日历逻辑来处理。但在记账场景里大多数用户说的还是“今天/昨天/前天”覆盖住这几个高频表达就能解决80%的问题。金额归一化中文数字、约数、模糊表达都需要处理。“二十八”要变成28“二十五左右”按25处理“AA每人75”要乘以人数。我在实际开发中发现模型在中文金额转阿拉伯数字上表现不错但“左右”“大概”“约”这类模糊词还是交给正则兜底更省心。分类兜底如果模型输出的分类不在枚举里我会根据note里的关键词做二次映射。比如“地铁”“滴滴”“打车”强制归入交通“奶茶”“火锅”“外卖”归入餐饮。4.4 几种典型的句子实测效果我挑几条真实的测试记录展示一下整体效果输入模型输出节选入库前处理昨天中午和同事吃火锅花了286amount:286, category:餐饮, note:和同事吃火锅occurred_at归一化为“昨天”早上去便利店买了瓶水和三明治一共23.5amount:23.5, category:餐饮, note:便利店水三明治无打车从公司回家43块amount:43, category:交通, note:打车回家occurred_at归为今天这个月房租3000转给房东了amount:3000, category:居住, note:房租转房东无上周五晚上看电影两张票120amount:120, category:娱乐, note:看电影两张票occurred_at换算为上周五整体准确率在85%以上剩余15%主要集中在分类判断和模糊金额上通过后处理能再捞回来一部分。5. 开发中踩过的坑与性能调优5.1 Flutter构建与渲染层的坑这个项目在构建阶段就遇到过一个典型的Flutter Gradle报错You are applying Flutters main Gradle plugin imperatively using the apply script method...这个报错发生在升级Flutter版本之后旧的android/app/build.gradle写法在新版本里不推荐了。解决方法是改用插件声明式配置把apply plugin: com.android.application和apply plugin: kotlin-android替代为新语法。这种兼容性问题没什么技术含量但很消磨耐心遇到直接搜报错原文找对应版本的迁移方案就行。还有一个渲染层的坑Flutter 3.x 默认启用Impeller渲染引擎在iOS上表现很好但在部分Android中低端机上我遇到了首帧白屏和文字模糊的问题。如果真机调试时UI异常可以在AndroidManifest.xml里临时关掉Impeller验证meta-data android:nameio.flutter.embedding.android.EnableImpeller android:valuefalse /我最终是在低端测试机上关了Impeller高端机和iOS上保持默认。这种渲染引擎的差异在不同性能和驱动环境下表现很不一样需要实际测试后决定取舍。5.2 SocketException排查链路开发中遇到最多的错误是SocketException: Connection refused而且在Android模拟器上特别频繁。排查链路基本是固定的确认Ollama服务是否启动在开发机上执行curl http://localhost:11434/api/tags有响应说明服务正常。确认端口监听lsof -i :11434查看监听状态。确认模拟器访问不了宿主机在模拟器自带浏览器访问http://10.0.2.2:11434/api/tags注意Android模拟器访问宿主机必须用10.0.2.2而不是localhost。用localhost访问的是模拟器自己端口肯定不通。确认AndroidManifest里配置了网络权限uses-permission android:nameandroid.permission.INTERNET/漏了这个权限会直接抛SocketException。真机调试时确认手机和电脑在同一局域网并且电脑的防火墙允许11434端口的外部访问。这个排查链路我踩了不下五次每次都是因为图省事跳过了某一步。建议第一步先验证服务端再验证客户端。5.3 解析在主Isolate会导致掉帧Flutter的UI线程和模型推理不在一个线程但JSON的解析和正则处理如果直接写在回调里依然会卡UI。一个300ms的jsonDecode加上正则处理在滚动列表时是能感知到掉帧的。解决办法是把解析和归一化逻辑扔到后台Isolate里。我用的是compute函数final result await compute(processRawResponse, rawResponse);这里有一个Dart版本升级后比较隐蔽的坑compute传入的参数必须是可跨Isolate传输的Dart 3.x 对Map的跨Isolate传输有限制你不能直接把一个复杂Map传给compute。我的做法是只传JSON字符串在compute函数内部再做jsonDecode把计算和传输分离。实测掉帧问题明显改善。5.4 内存、发热与Ollama驻留策略本地大模型最现实的问题是资源占用。qwen2.5:7b 量化后磁盘占4.7GB加载进内存后运行期占用约5-6GB内存视硬件和量化级别不同。对一台16GB内存的MacBook来说没问题但8GB内存的机器就要谨慎了。我在开发过程中总结了一套驻留策略keep_alive设置5分钟而不是无限驻留。记账是短交互用户不是每分每秒都在记长时间驻留会把内存占满。在App进入后台时调用Ollama的卸载接口强制释放模型curl http://localhost:11434/api/generate -d {model:qwen2.5:7b,keep_alive:0}模型推理时手机会发热这是正常的。如果是长时间连续测试建议插电源运行避免电池过热保护。5.5 离线与异常场景的兜底本地模型方案的一个隐藏优势是天然离线但离线不意味着没有异常。设计客户端时要考虑这些场景用户输入了句子但Ollama服务没启动应该友好提示“本地模型服务未运行”而不是让用户看SocketException堆栈。模型推理超时比如30秒给用户重试选项同时建议检查num_ctx是否过大。解析失败JSON提取不到返回原始句子让用户手动补录不要丢数据。模型下载不完整或损坏在设置页做一个“模型健康检查”调一次/api/tags看模型是否存在。我在异常处理上有一个原则记账工具可以笨但不能丢数据。解析失败时把原始文本保存为一个待处理条目用户随时可以重新解析或手动编辑比直接报错体验好得多。6. 扩展方向与个人体会做完这个项目之后我最大的感受是本地大模型不是万能的但在“单一场景 精确提示词 后处理兜底”的组合下它的可靠性完全可以达到实用水平。7B模型跑在本地不需要服务器、不需要网络、不需要担心数据泄漏这个体验是云端API给不了的。后续我打算继续扩展的方向语音输入 TTS播报配合语音识别把“吃饭花了五十”直接转成自然语言句子再用TTS在记账完成后播报确认真正做到完全不用看屏幕的记账体验。多模态导入拍一张超市小票让模型识别并拆分多个商品条目。这个对视觉能力要求较高7B模型可能吃力需要再评估。数据统计与可视化按分类、月份、周趋势做消费分析把本地数据库里的账单价值真正用起来。如果你也想做类似的本地AI工具我的建议是先框定一个尽量小的场景把提示词调稳定再用工程手段补足模型能力的边界。别贪大一个“能稳定解析300句常见表达”的记账工具可能比一个“什么都能聊但什么都记不准”的通用助手更受欢迎。这套“本地模型 窄场景 强提示词”的方案除了记账还能用在打卡记录、日记归档、个人健康数据录入等场景。本质上都是把人类的自然语言表达转成结构化数据。搞懂了一套就搞懂了一类。
返回列表