随手记账这件事,难的从来不是 CRUD,而是让「语音说的一句话」变成「能对账的一条分录」。用户说的是「昨天买了 40 块钱猫砂」,账本里要落的是一行有日期、有分类、有支付方式、有金额、有确认状态的结构化数据。中间那一步没人愿意老老实实做,于是市面上一堆 AI 记账应用,本质上只是聊天框套了个数据库。
常规做法是把解析和入库揉在一个服务里:LLM 吐什么就存什么,分类靠模型自由发挥,金额直接落表。结果是幻觉字段悄悄写进账本,月底报表对不上账,用户再也不信第二次。模型再强,它输出的也只是一份草稿,草稿必须被规则审过才配叫账。
本文分享一套可直接落地的分层方案:语义解析流水线 + 六条校验闸门 + 账本即唯一真相源,配合四层工程结构与部署清单,每一层都能单独搬走复用。
一、从语音到分录:语义解析的四段流水线
一句话进来到一条分录出去,中间要过四道工序,每道工序的产物都是确定的:
- ASR 转写:语音先落成文本,保留原始语句作为审计凭据,账本里永远能回溯「这句话当初是怎么说的」。
- 结构化抽取:让模型只做一件事------从文本里抽字段,返回 JSON,禁止它复述、解释或补写没有的信息。
- Schema 硬校验 :拿到 JSON 后立刻过 zod,类型、枚举、金额精度全部对不上就地驳回,幻觉字段活不过这一步。
- 分类兜底匹配:模型给的分类文本不当主键,改走别名表 + 向量相似度匹配到既有分类,匹配分低于阈值就标「待确认」交给用户点一下。
ASR → JSON 抽取 → Schema 校验 → 分类匹配 → 分录草稿
下面是抽取环节的实际写法,注意 response_format 与 zod 这两处约束,它们是整条链路的承重墙:
ts
// apps/api/src/modules/ai/parse.ts ------ 只负责抽字段,不负责入库
export async function parseTransaction(text: string, userId: string) {
const raw = await llm.chat({
messages: [{ role: "user", content: EXTRACT_PROMPT(text) }],
response_format: { type: "json_object" }, // 强制 JSON,堵死自由发挥
});
const draft = DraftSchema.safeParse(JSON.parse(raw));
if (!draft.success) throw new DraftRejected(draft.error.issues);
const cat = await categoryRepo.match(draft.data.categoryText, userId);
return {
...draft.data,
categoryId: cat?.id ?? null, // 匹配不上就不猜,留空待确认
confidence: cat?.score ?? 0,
status: (cat?.score ?? 0) < 0.72 ? "PENDING" : "CONFIRMED",
};
}
强约束抽取 → 结构校验 → 低置信度不猜、直接标记待确认。

二、账本模型:一张表读完一整月的落账
账本的展示不靠图表堆砌,靠列对齐的数字。所有金额走等宽数字并右对齐,小数点天然成一条竖线;只有「待确认」的行用朱砂色标注,其余全是墨色------红色是例外标记,不是装饰。
- 状态列是账本的良心:已入账、待确认、有确认项三种状态一屏可扫,用户不必点进去才知道哪笔存疑。
- 金额不加货币符号噪音:表格里只留数字,币种写在表头,扫描效率更高。
- 摘要保留原文:分录的摘要来自用户原话,不是模型改写后的「规范表述」,事后核对能一眼对上。
下表是这个月账本的真实形态(示例数据):
| 日期 | 摘要 | 分类 | 支付方式 | 金额 | 状态 |
|---|---|---|---|---|---|
| 05-03 | 昨天买了 40 块钱猫砂 | 宠物 | 微信 | 40.00 | 待确认 |
| 05-03 | 打车去客户那儿 | 交通 | 支付宝 | 28.50 | 已入账 |
| 05-04 | 一杯咖啡 | 餐饮 | 微信 | 28.00 | 已入账 |
| 05-04 | 卫生间置物架 | 日用 | 京东 | 63.90 | 已入账 |
| 05-05 | 超市囤货 | 日用 | 微信 | 217.30 | 有确认项 |
| 05-06 | 猫砂两袋 | 宠物 | 微信 | 400.00 | 待确认 |
| 05-07 | 视频会员自动续费 | 娱乐 | 支付宝 | 25.00 | 已入账 |
| 05-08 | 朋友结婚随礼 | 人情 | 现金 | 600.00 | 已入账 |
核心结论: 账本是唯一真相源,AI 只有往里写草稿的权限,没有改写既往分录的权限。
三、六条校验规则:把幻觉挡在账本之外
模型的输出永远要被当成「不可信输入」,入库前必须过这几道闸门:
- 规则 1 · 金额一致性:文本里的数字与解析金额不符时直接驳回,例如「40 块钱」解析成 400 就属于典型的数量级幻觉。
- 规则 2 · 日期不许穿越:交易日期晚于当前时间或早于账本起始日,一律拦截,不允许模型「顺手补一个合理的日期」。
- 规则 3 · 分类必须可枚举:分类文本必须命中别名表或相似度达标,匹配不上就留空转人工,绝不用 Top-1 硬凑。
- 规则 4 · 支付方式兜底:枚举未命中时回落到用户默认支付方式,并打上「有确认项」,而不是丢弃这笔账。
- 规则 5 · 重复检测:同一天、同金额、同分类视为疑似重复,写入前提示而不是静默合并。
- 规则 6 · 幂等键:每条分录携带来源消息 ID 的唯一索引,重试不会写进两条。
| 校验规则 | 触发条件 | 处理方式 |
|---|---|---|
| 金额一致性 | 文本数字 ≠ 解析金额 | 驳回并重试一次,仍失败转人工 |
| 日期合法 | 日期 > 今天 或 < 账本起始日 | 驳回,界面高亮日期字段 |
| 分类可枚举 | 相似度 < 0.72 | 留空,状态置为「待确认」 |
| 支付方式兜底 | 枚举未命中 | 回落默认值 + 「有确认项」标记 |
| 重复检测 | 日期+金额+分类三元组相同 | 弹出确认,不静默去重 |
| 幂等写入 | 来源消息 ID 已存在 | 直接返回已有分录,不重复入账 |
问题: 规则散落在业务代码里,改一条要动三处。治理: 六条规则收敛成一个 LedgerGuard 管道,每条规则是独立可插拔的纯函数,新增规则只加文件不改主流程。

四、全栈四层架构:从界面到持久化的分工
剥掉 AI 的外壳,这就是一个边界清晰的四层系统,每一层只对上一层负责。
- 展示层:Next.js App Router,账本页、报表页、分类管理页共用同一套等宽数字组件,金额渲染只有一处实现。
- 应用层:NestJS 按领域切模块------账本、分类、报表、AI 解析彼此不 import 对方的仓储。
- 领域层 :六条校验规则、幂等键、结账逻辑全部在这层,不依赖任何框架,可单测。
- 持久层:PostgreSQL 存账本,向量检索只服务分类匹配,不参与记账主链路。
展示层(Next.js) → 应用层(NestJS 模块) → 领域层(校验管道) → 持久层(PostgreSQL)
部署用一套 compose 起三个容器,环境变量只保留必须外置的部分:
bash
# deploy/docker-compose.prod.yml ------ 单机三容器起步,可平滑换成托管库
services:
web: { image: smart-ledger/web:latest, ports: ["3000:3000"] }
api: { image: smart-ledger/api:latest, environment: [DATABASE_URL, OPENAI_BASE_URL] }
db: { image: postgres:16-alpine, volumes: [pgdata:/var/lib/postgresql/data] }
核心结论: AI 层可以整层换掉(换模型、换厂商),账本层一行不用动,这是这套架构最值钱的地方。
五、工程化落地:目录结构与部署清单
真正决定项目能不能长期维护的,是目录怎么切、依赖怎么管。这套结构前后端共享一套 Schema,是避免「前端一套校验、后端另一套校验」的最省事解法。
bash
smart-ledger/
├─ apps/web # Next.js 14 + Tailwind,只做展示与交互
├─ apps/api # NestJS 10:ledger / category / report / ai 四个模块
├─ packages/shared # zod Schema、枚举、金额工具------前后端唯一引用源
└─ deploy/ # docker-compose.prod.yml、nginx.conf、backup.sh
技术选型与理由对照:
| 层 | 选型 | 选择理由 |
|---|---|---|
| 前端 | Next.js 14 + Tailwind | 账本报表需要 SSR,组件库自己写更可控 |
| 后端 | NestJS 10 | 模块与依赖注入天然贴合领域切分 |
| 数据 | PostgreSQL 16 | 事务保证「校验通过才落账」,JSONB 存原始语句 |
| 共享 | zod | 一份 Schema 同时约束模型输出与接口入参 |
| 部署 | Docker Compose | 单机起步,后续换托管库不改代码 |
关键点: packages/shared 被前后端同时 import,模型输出校验和表单校验用的是同一个 DraftSchema,规则改动一次生效两端。

六、数据可信:备份、结账与恢复演练
账本系统的可信度不来自功能多少,来自任何时候都能把数据原样取回来。备份要写进 cron,恢复要写进日历。
- 每日逻辑备份 :凌晨用
pg_dump产出自定义格式 dump,体积小、支持单表恢复。 - 异地同步:每周一把备份推到对象存储,本地盘不作为唯一副本。
- 恢复演练 :每季度真跑一次恢复到临时实例并抽查 20 笔分录,没演练过的备份等于没有备份。
bash
# /etc/cron.d/smart-ledger ------ 注意 cron 里 % 需转义,否则被当作换行
15 3 * * * root pg_dump -Fc smart_ledger > /backup/sl_$(date +\%F).dump
0 4 * * 1 root rclone copy /backup sl-obs:smart-ledger --transfers=2
结账则在月末冻结当月分录为只读快照,任何修改都只能走「次月冲正」,历史账目永不被原地覆盖。
核心结论: 校验保证写进来的每一条可信,备份保证留下来的每一条可取,两者合起来才叫账本。
结语
Smart Ledger 值得拆开看的,不是它接了哪个模型,而是它给模型划出的边界:模型只产草稿,校验管道做终审,账本是唯一真相源。这套分层让 AI 能力可以整层替换,而账目、报表、备份这些真正重要的资产始终稳定。
如果你打算做一个自己的 AI 应用,先别急着调 Prompt,先把「不可信输入 → 结构校验 → 领域规则 → 落库审计」这条链路立起来。
模型负责听懂人话,规则负责守住账本,两件事永远不要交给同一个角色。