最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
用 Flutter 接入本地大模型:实现自然语言记账
时间:2026-09-15 19:28:01 编辑:袖梨 来源:一聚教程网
让大模型在手机上回答问题并不难,真正需要工程设计的是如何把自然语言可靠地转成可执行的业务操作。以 Flutter 记账场景为例,模型只负责识别意图和整理交易参数,数据库写入、字段校验与异常兜底仍由应用掌控。下面从调用链、数据结构和本地持久化逐步拆解这一闭环。
大家好,我是 Crazy_MT。
这篇文章想和你聊聊我最近在端侧大语言模型上的一次实践:把本地大模型聊天,接到一个真正能落库的自然语言记账功能里。
项目地址:github.com/Crazy-MT/fl…
不绕概念,直接从现象、调用链、关键细节和最终方案说起。
先说结论
这次做的不是一个完整记账 App,而是在一个 app 里,把下面几件事串起来:
- 用户用自然语言说“早餐 3 块钱,记一下账”。
- 本地大模型判断这是一个记账意图。
- 模型调用
record_transactionTool。 - Dart 侧把 Tool 参数校验、归一化。
- Floor 写入本地 SQLite。
- Flutter UI 里可以查看、编辑、删除,并展示本月收入、支出和结余。
最终核心改动集中在两个文件:
lib/accounting.dart:数据结构、Floor DAO、数据库迁移、Tool 接入、月统计。lib/main.dart:聊天页接入 Tool,新增 Tab、列表、编辑和删除。
背景:本地大模型不只是聊天
本地大模型能力,最直观的用法当然是聊天:
await model.generate('你是谁?用中文回答我。');
但真正有意思的地方,是它可以通过 Tool Calling 和宿主应用产生关系。
模型本身不应该直接操作数据库,也不应该拥有文件、网络、支付、账号这些权限。正确的边界是:
- 模型负责理解用户意图,并输出结构化参数。
- Dart 应用负责决定是否执行 Tool。
- Tool 函数负责做参数校验、业务规则和本地副作用。
- 数据库只接受应用侧校验后的数据。
这次自然语言记账,就是这个边界的一个小型落地。
补一段:大模型在这里到底做了什么
如果只看最终结果,好像只是“把一条写进 SQLite”。但这件事里,大模型真正提供的是自然语言到结构化交易的转换能力。
比如用户可能会这样说:
昨天晚上打车 28,用支付宝付的,记一下
传统表单做法需要用户自己填:
类型:支出
标题:打车
金额:28
分类:交通
账户:支付宝
时间:昨天晚上
备注:空
大模型在这里承担的是这几类判断:
- 意图识别:这句话是不是明确要求记账。
- 字段抽取:金额、标题、账户、时间、备注分别是什么。
- 语义分类:打车更适合归到交通,工资更适合归到收入。
- 默认值补全:没说币种就按
chy,没说账户就传空字符串。 - Tool 参数生成:把自然语言整理成
record_transaction需要的结构。
也就是说,它不是替代数据库,也不是替代业务代码,而是替代了用户手动填表的那一段。
这类能力很适合本地化:
- 输入本身比较短,不一定需要云端大模型。
- 记账数据偏隐私,留在本地更安心。
- Tool 执行在 Flutter 侧,模型没有直接写库权限。
- 即使模型抽取错了,用户也可以在页编辑修正。
当然,它也有边界。
模型可以推断“奶茶 18”大概率是餐饮或饮品,但它不能保证每次分类都符合你的个人习惯;模型可以把“昨天晚上”转成时间,但前提是系统提示里给了当前时间;模型可以生成 Tool 参数,但参数是否合法,最后仍然要由 Dart 代码校验。
所以这次实现里,大模型负责“理解”,应用负责“兜底”。
模型选择:0.6B 千问 + 多模态模型
这次 Runner 里不是只放了一个模型,而是保留了两个模型入口:
- 文本模型:
Qwen_Qwen3-0.6B-Q4_K_M.gguf,运行时放在assets/model.gguf。 - 多模态模型:
gemma-4-E2B-it-Q4_K_M.gguf,运行时放在assets/multimodal/gemma-4-E2B-it-Q4_K_M.gguf。 - 多模态 projection:
mmproj-BF16.gguf,运行时放在assets/multimodal/mmproj-BF16.gguf。
下载脚本里能看到实际来源:
download
"https://huggingface.co/NobodyWho/Qwen_Qwen3-0.6B-GGUF/resolve/main/Qwen_Qwen3-0.6B-Q4_K_M.gguf"
"$repo_root/assets/model.gguf"
download
"https://huggingface.co/unh/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M.gguf"
"$repo_root/assets/multimodal/gemma-4-E2B-it-Q4_K_M.gguf"
download
"https://huggingface.co/unh/gemma-4-E2B-it-GGUF/resolve/main/mmproj-BF16.gguf"
"$repo_root/assets/multimodal/mmproj-BF16.gguf"
为什么记账这里用 0.6B 千问也有意义?
因为自然语言记账不是开放式长文推理,它更像一个轻量结构化任务:
用户输入:昨天晚上打车 28,用支付宝付的,记一下
模型需要输出:
type=expense
title=打车
amount=28
currency=chy
category=交通
account=支付宝
这类任务更看重:
- 能不能识别“这是记账意图”。
- 能不能把金额、时间、账户、分类抽出来。
- 能不能按 Tool schema 输出参数。
- 能不能在手机端本地跑得起来。
0.6B 模型的优势不是“什么都强”,而是轻、快、适合在移动端验证本地 Tool Calling 闭环。它适合处理短输入、固定字段、明确约束的场景。
多模态模型则解决另一类问题:用户不一定只发文字。
Runner 里 ModelChoice 把两类模型分开:
enum ModelChoice { text, multimodal }
extension ModelChoiceInfo on ModelChoice {
String get modelAssetPath => switch (this) {
ModelChoice.text => 'assets/model.gguf',
ModelChoice.multimodal => 'assets/multimodal/gemma-4-E2B-it-Q4_K_M.gguf',
};
String? get projectionAssetPath => switch (this) {
ModelChoice.text => null,
ModelChoice.multimodal => 'assets/multimodal/mmproj-BF16.gguf',
};
bool get supportsAttachments => this == ModelChoice.multimodal;
}
文本模型只处理文字;多模态模型支持图片和音频附件。
发送消息时,如果有附件,就把输入拆成不同的 Prompt Part:
buildPromptParts()
-> TextPart
-> ImagePart
-> AudioPart
也就是说,这个 Runner 不是只能演示“我和本地模型聊天”,而是同时验证了三种能力:
- 文本模型:中文聊天、自然语言记账、Tool Calling。
- 多模态模型:图片和音频输入。
- Flutter 宿主侧:模型切换、资源复制、Tool 执行、本地持久化。
记账功能这次主要用文本输入来验证,但模型层已经预留了多模态入口。后续如果要继续扩展,可以让用户拍一张小票、上传一段语音,再由多模态模型提取信息,最后仍然走同一个 record_transaction Tool。
关键点是:不管入口是文字、图片还是音频,真正写库的地方都不变。
第一步:模型不能只有金额
一开始如果只做最小版本,可能会设计成这样:
title
amount
createdAt
能跑,但很快会遇到问题:
- “工资到账 10000” 和 “早餐 3 元”不是同一类交易。
- 用户可能会说时间,比如“昨天打车 28”。
- 列表需要分类、账户、备注。
- 后续统计需要区分收入和支出。
- 回看原始输入时,需要保留
rawText。
所以最后落到 TransactionEntry:
@Entity(tableName: 'transactions')
class TransactionEntry {
@PrimaryKey(autoGenerate: true)
final int? id;
@ColumnInfo(name: 'created_at')
final String createdAtIso;
@ColumnInfo(name: 'occurred_at')
final String occurredAtIso;
final String type;
final String title;
final double amount;
final String currency;
final String category;
final String? account;
final String? note;
final String rawText;
}
这里我没有把 DateTime 直接交给 Floor,而是保存 ISO 字符串:
- 数据库字段简单。
- 排序稳定。
- 从 Tool 参数进入时也更容易统一处理。
金额必须大于 0,这个校验放在统一入口:
factory TransactionEntry.fromToolArgs({
int? id,
required String occurredAt,
required String type,
required String title,
required num amount,
required String currency,
required String category,
String? account,
String? note,
required String rawText,
DateTime? createdAt,
}) {
if (amount <= 0) {
throw ArgumentError.value(amount, 'amount', '金额必须大于 0');
}
return TransactionEntry(
id: id,
createdAtIso: (createdAt ?? DateTime.now()).toIso8601String(),
occurredAtIso: DateTime.parse(occurredAt).toIso8601String(),
type: type.trim().isEmpty ? 'expense' : type.trim(),
title: title.trim(),
amount: amount.toDouble(),
currency: currency.trim().isEmpty ? 'chy' : currency.trim(),
category: category.trim().isEmpty ? '其他' : category.trim(),
account: _blankToNull(account),
note: _blankToNull(note),
rawText: rawText.trim(),
);
}
这里有个细节:account 和 note 语义上是可选的,但进入 Tool 时不一定适合做可选参数。这个坑后面会展开。
第二步:Floor 持久化,别绕远路
本地这种数据,用 SQLite 足够。
Runner 里选择 Floor,结构比较直接:
@dao
abstract class TransactionDao {
@insert
Future<void> insertTransaction(TransactionEntry entry);
@Update()
Future<void> updateTransaction(TransactionEntry entry);
@delete
Future<void> deleteTransaction(TransactionEntry entry);
@Query('SELECT * FROM transactions ORDER BY occurred_at DESC, id DESC')
Future<List<TransactionEntry>> listTransactions();
}
数据库版本升级到 2,并加一条 1 到 2 的迁移:
@Database(version: 2, entities: [TransactionEntry])
abstract class AppDatabase extends FloorDatabase {
TransactionDao get transactionDao;
}
final migration1To2 = Migration(1, 2, (database) async {
await database.execute('''
CREATE TABLE IF NOT EXISTS `transactions` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
`created_at` TEXT NOT NULL,
`occurred_at` TEXT NOT NULL,
`type` TEXT NOT NULL,
`title` TEXT NOT NULL,
`amount` REAL NOT NULL,
`currency` TEXT NOT NULL,
`category` TEXT NOT NULL,
`account` TEXT,
`note` TEXT,
`rawText` TEXT NOT NULL
)
''');
});
这里踩过一个小坑:Floor 生成代码编译失败时,不要去改 accounting.g.dart。
生成文件的问题,通常要回到源文件修。比如需要补:
import 'dart:async';
import 'package:sqflite/sqflite.dart' as sqflite;
然后重新跑:
fvm dart run build_runner build --delete-conflicting-outputs
生成代码是结果,不是编辑入口。
第三步:Tool Calling 的真实边界
记账 Tool 最终长这样:
nobodywho.Tool createRecordTransactionTool(Future<ExpenseLedger> ledger) {
return nobodywho.Tool(
name: 'record_transaction',
description: '把一条用户明确要求记账的收入或支出保存到本地数据库。',
parameterDescriptions: {
'occurredAt': '实际收支时间,ISO 8601 格式;用户没说时间就使用系统提示里的当前时间。',
'type': 'expense 或 income;普通消费默认 expense,工资、报销等收入用 income。',
'title': '收支内容,例如早餐、午餐、咖啡、工资。',
'amount': '金额,单位元,只填数字,必须大于 0。',
'currency': '币种,默认 chy。',
'category': '分类,例如餐饮、交通、购物、娱乐、医疗、收入、其他。',
'account': '账户,例如微信、支付宝、现I金、银彳卡;不知道就传空字符串。',
'note': '备注;没有就传空字符串。',
'rawText': '用户原始输入。',
},
function:
({
required String occurredAt,
required String type,
required String title,
required double amount,
required String currency,
required String category,
required String account,
required String note,
required String rawText,
}) async {
return (await ledger).record(
occurredAt: occurredAt,
type: type,
title: title,
amount: amount,
currency: currency,
category: category,
account: account,
note: note,
rawText: rawText,
);
},
);
}
注意这里所有参数都是 named required。
这不是个人代码风格,而是这次排查出来的真实运行时约束。
当时遇到过这样的错误:
Tool function ... has parameters without the required keyword
表面看,account、note 是可选字段,写成这样好像很自然:
String? account,
String? note,
但 NobodyWho 的 Tool API 会检查 function.runtimeType,它要求 Tool 函数里的每个参数都是 named required 参数。也就是说,语义上的“可选”,不能直接等价为 Dart 函数签名里的“可选”。
最后的处理方式很克制:
- Tool 参数层:
account、note仍然是required String。 - 语义空值:模型不知道就传空字符串。
- 应用层:
fromToolArgs()里统一把空字符串归一化为null。
String? _blankToNull(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
这比在多个调用点散落判断要稳。
第四步:聊天页怎么知道该调用 Tool
聊天侧不是无脑把数据库暴露给模型,而是在系统提示里明确收口:
'当用户明确要求记账时,必须调用 record_transaction 工具保存收入或支出。'
然后模型创建时挂上 Tool:
tools: [createRecordTransactionTool(_loadLedger())],
这条边界很重要:
- 用户只是聊天,不应该写库。
- 用户明确说“记一下账”,才进入 Tool。
- Tool 执行仍然在 Dart 应用侧。
本地大模型给的是结构化意图,不是无限权限。
第五步:只做一个能用的 UI
页没有重新做一个复杂导航,而是在当前 Runner 里加了第二个 Tab:
DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('NobodyWho Chat'),
bottom: TabBar(
onTap: (index) => setState(() => _tabIndex = index),
tabs: const [
Tab(text: '聊天'),
Tab(text: ''),
],
),
),
body: _tabIndex == 0 ? _buildChat() : LedgerPage(ledger: _loadLedger()),
),
)
页做了几件刚需:
- 进入 Tab 时加载本地。
- 顶部展示本月收入、本月支出、本月结余。
- 明细按发生时间倒序展示。
- 每条支持编辑和删除。
- 删除前确认。
- 金额格式错误时用 SnackBar 提示。
月统计也没有引入额外状态管理,直接从当前列表算:
class MonthlySummary {
const MonthlySummary({required this.income, required this.expense});
final double income;
final double expense;
double get balance => income - expense;
factory MonthlySummary.fromEntries(
List<TransactionEntry> entries, {
required DateTime month,
}) {
var income = 0.0;
var expense = 0.0;
for (final entry in entries) {
final occurredAt = entry.occurredAt;
if (occurredAt.year != month.year || occurredAt.month != month.month) {
continue;
}
if (entry.type == 'income') {
income += entry.amount;
} else {
expense += entry.amount;
}
}
return MonthlySummary(income: income, expense: expense);
}
}
这个版本没有做预算、标签、多账本、图表、搜索、导出。
原因很简单:当前目标是验证“自然语言 -> Tool -> 本地数据库 -> 可见”的闭环。闭环跑通前,加太多功能只会让问题更难定位。
第六步:测试只盯关键风险
这次测试没有追求大而全,主要覆盖几个容易出问题的点:
1. Tool 参数能构造成详细交易
final entry = TransactionEntry.fromToolArgs(
occurredAt: DateTime.utc(2026, 9, 14, 7, 30).toIso8601String(),
type: 'expense',
title: '早餐',
amount: 3,
currency: 'chy',
category: '餐饮',
account: '微信',
note: '公司楼下',
rawText: '三块钱早餐记账',
createdAt: DateTime.utc(2026, 9, 14, 7, 31),
);
expect(entry.title, '早餐');
expect(entry.amount, 3);
expect(entry.category, '餐饮');
expect(entry.account, '微信');
2. 空字段默认值和归一化
expect(entry.type, 'expense');
expect(entry.currency, 'chy');
expect(entry.category, '其他');
expect(entry.account, isNull);
expect(entry.note, isNull);
3. 非法金额拒绝
expect(
() => TransactionEntry.fromToolArgs(
occurredAt: DateTime.utc(2026, 9, 14, 7, 30).toIso8601String(),
type: 'expense',
title: '早餐',
amount: 0,
currency: 'chy',
category: '餐饮',
rawText: '早餐记账',
),
throwsArgumentError,
);
4. 月统计只统计目标月份
expect(summary.income, 100);
expect(summary.expense, 3);
expect(summary.balance, 97);
Widget 测试则确认聊天页启动前能正常渲染,并且 聊天、 两个 Tab 都存在。
这次最值得记下的坑
这次最值得记下的不是 Floor,也不是 Tab UI,而是 Tool 函数签名。
在普通 Dart 业务代码里,下面这种写法非常自然:
String? account,
String? note,
但到了 NobodyWho Tool runtime,就会出问题。
因为 Tool schema 不是只看你业务语义上的可选字段,它需要从 Dart 函数签名里解析参数。如果 runtime 要求所有 Tool 参数都是 named required,那就必须满足它。
所以正确姿势是:
required String account,
required String note,
然后在业务入口归一化:
account: _blankToNull(account),
note: _blankToNull(note),
这其实也是做 AI 应用时很常见的一类问题:
模型输出、Tool schema、宿主语言类型系统、业务数据模型,这四层看起来都在描述同一件事,但它们的约束不完全一样。
不要只看 analyzer,也不要只看业务代码能不能编译。Tool Calling 这种能力一定要看 runtime 怎么解析。
最后的工程边界
这次我没有把功能做成一个“完整记账产品”。
刻意没做的东西包括:
- 多账本。
- 预算。
- 分类管理。
- 图表分析。
- 搜索筛选。
- 云同步。
- 导入导出。
不是这些功能不重要,而是它们不是这次最关键的问题。
这次真正要验证的是:
自然语言输入
-> 本地模型理解
-> Tool 参数
-> Dart 校验
-> Floor 落库
-> Flutter UI 可见、可改、可删、可统计
这个链路清楚之后,再往上叠功能才有意义。
小结
这次完成了一个比较完整的本地自然语言记账闭环:
- 用
record_transaction承接模型 Tool Calling。 - 用本地大模型完成记账意图识别、字段抽取、分类推断和默认值补全。
- 用 0.6B 千问文本模型验证轻量本地记账链路,同时保留多模态模型处理图片和音频输入。
- 用
TransactionEntry.fromToolArgs()做统一校验和归一化。 - 用 Floor 把存在本地 SQLite。
- 用 Tab 展示明细、编辑删除和本月统计。
- 用测试覆盖金额、默认值、月统计和基础 UI 渲染。
最大的经验是:做本地 AI 应用时,模型能力只是链路的一段。真正容易出问题的地方,往往在模型输出和宿主应用之间的边界。
Tool schema、函数签名、参数默认值、数据库字段,这些看起来不起眼的小地方,才是功能能不能稳定跑起来的关键。
完整代码已经放到 GitHub:Crazy-MT/flutter_nobodywho_runner
以上就是这次关于 Flutter 本地大模型自然语言记账的记录。
我是 Crazy_MT,持续分享端侧大模型、Flutter、移动端工程化和真实问题排查,我们下篇见。
相关文章
- 借助 AI 拆解真实需求:把模糊描述转成开发任务清单 09-15
- LangGraph.js 入门实战:用图结构编排 Agent 工作流 09-15
- Agent 记忆架构拆解:实时存储、被动压缩与主动整理 09-15
- HTML基础详解(下) 09-15
- 用 Flutter 接入本地大模型:实现自然语言记账 09-15
- 拆解 OmniDocBench:评测机制与开源、闭源 OCR 模型接入实战 09-15