让大模型在手机上回答问题并不难,真正需要工程设计的是如何把自然语言可靠地转成可执行的业务操作。以 Flutter 记账场景为例,模型只负责识别意图和整理交易参数,数据库写入、字段校验与异常兜底仍由应用掌控。下面从调用链、数据结构和本地持久化逐步拆解这一闭环。
大家好,我是 Crazy_MT。
这篇文章想和你聊聊我最近在端侧大语言模型上的一次实践:把本地大模型聊天,接到一个真正能落库的自然语言记账功能里。
项目地址:github.com/Crazy-MT/fl…
不绕概念,直接从现象、调用链、关键细节和最终方案说起。
这次做的不是一个完整记账 App,而是在一个 app 里,把下面几件事串起来:
record_transaction Tool。最终核心改动集中在两个文件:
lib/accounting.dart:数据结构、Floor DAO、数据库迁移、Tool 接入、月统计。lib/main.dart:聊天页接入 Tool,新增 Tab、列表、编辑和删除。本地大模型能力,最直观的用法当然是聊天:
await model.generate('你是谁?用中文回答我。');
但真正有意思的地方,是它可以通过 Tool Calling 和宿主应用产生关系。
模型本身不应该直接操作数据库,也不应该拥有文件、网络、支付、账号这些权限。正确的边界是:
这次自然语言记账,就是这个边界的一个小型落地。
如果只看最终结果,好像只是“把一条写进 SQLite”。但这件事里,大模型真正提供的是自然语言到结构化交易的转换能力。
比如用户可能会这样说:
昨天晚上打车 28,用支付宝付的,记一下
传统表单做法需要用户自己填:
类型:支出
标题:打车
金额:28
分类:交通
账户:支付宝
时间:昨天晚上
备注:空
大模型在这里承担的是这几类判断:
chy,没说账户就传空字符串。record_transaction 需要的结构。也就是说,它不是替代数据库,也不是替代业务代码,而是替代了用户手动填表的那一段。
这类能力很适合本地化:
当然,它也有边界。
模型可以推断“奶茶 18”大概率是餐饮或饮品,但它不能保证每次分类都符合你的个人习惯;模型可以把“昨天晚上”转成时间,但前提是系统提示里给了当前时间;模型可以生成 Tool 参数,但参数是否合法,最后仍然要由 Dart 代码校验。
所以这次实现里,大模型负责“理解”,应用负责“兜底”。
这次 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。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=支付宝
这类任务更看重:
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 不是只能演示“我和本地模型聊天”,而是同时验证了三种能力:
记账功能这次主要用文本输入来验证,但模型层已经预留了多模态入口。后续如果要继续扩展,可以让用户拍一张小票、上传一段语音,再由多模态模型提取信息,最后仍然走同一个 record_transaction Tool。
关键点是:不管入口是文字、图片还是音频,真正写库的地方都不变。
一开始如果只做最小版本,可能会设计成这样:
title
amount
createdAt
能跑,但很快会遇到问题:
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 字符串:
金额必须大于 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 时不一定适合做可选参数。这个坑后面会展开。
本地这种数据,用 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 最终长这样:
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 函数签名里的“可选”。
最后的处理方式很克制:
account、note 仍然是 required String。fromToolArgs() 里统一把空字符串归一化为 null。String? _blankToNull(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
}
这比在多个调用点散落判断要稳。
聊天侧不是无脑把数据库暴露给模型,而是在系统提示里明确收口:
'当用户明确要求记账时,必须调用 record_transaction 工具保存收入或支出。'
然后模型创建时挂上 Tool:
tools: [createRecordTransactionTool(_loadLedger())],
这条边界很重要:
本地大模型给的是结构化意图,不是无限权限。
页没有重新做一个复杂导航,而是在当前 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()),
),
)
页做了几件刚需:
月统计也没有引入额外状态管理,直接从当前列表算:
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 -> 本地数据库 -> 可见”的闭环。闭环跑通前,加太多功能只会让问题更难定位。
这次测试没有追求大而全,主要覆盖几个容易出问题的点:
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, '微信');
expect(entry.type, 'expense');
expect(entry.currency, 'chy');
expect(entry.category, '其他');
expect(entry.account, isNull);
expect(entry.note, isNull);
expect(
() => TransactionEntry.fromToolArgs(
occurredAt: DateTime.utc(2026, 9, 14, 7, 30).toIso8601String(),
type: 'expense',
title: '早餐',
amount: 0,
currency: 'chy',
category: '餐饮',
rawText: '早餐记账',
),
throwsArgumentError,
);
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。TransactionEntry.fromToolArgs() 做统一校验和归一化。最大的经验是:做本地 AI 应用时,模型能力只是链路的一段。真正容易出问题的地方,往往在模型输出和宿主应用之间的边界。
Tool schema、函数签名、参数默认值、数据库字段,这些看起来不起眼的小地方,才是功能能不能稳定跑起来的关键。
完整代码已经放到 GitHub:Crazy-MT/flutter_nobodywho_runner
以上就是这次关于 Flutter 本地大模型自然语言记账的记录。
我是 Crazy_MT,持续分享端侧大模型、Flutter、移动端工程化和真实问题排查,我们下篇见。