语音变分录、规则挡幻觉:拆解开源 AI 记账系统 Smart Ledger 的全栈设计

随手记账这件事,难的从来不是 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,先把「不可信输入 → 结构校验 → 领域规则 → 落库审计」这条链路立起来。

模型负责听懂人话,规则负责守住账本,两件事永远不要交给同一个角色。

相关推荐
墨染天姬1 小时前
[AI]Cursor Subagent 机制详解
人工智能
数睿数据无代码开发1 小时前
企业AI落地从高价值场景开始|数睿数据《AI应用场景集》正式发布
人工智能
刀笔财经2 小时前
万物构想让 AI 跑进真实生意里
人工智能
leobertlan2 小时前
杂篇 -- 拂拭念头、造器与自洽:AI 时代属于每个人的秩序
人工智能·程序员
AI_AGENT_DEV_AI2 小时前
AI 教培智能体的开发技术
人工智能
lazy熊2 小时前
Prompt 优化不是写得更长,而是把上下文分层给 AI
人工智能·prompt
甲维斯2 小时前
Kimi K3.1 Pro已测,国模终于来一个能打的 !?
人工智能
跨境联盟2 小时前
大健康实体如何搭建产品差异化:对比普通营养品与合规跨境膳食补充剂服务模式
数据库·ai·健康医疗·健康管理·精准营养
冬奇Lab2 小时前
一天一个开源项目(第246篇):rec —— Recursive 自研的本地推理引擎,在 Mac 上跑 Flint 编码模型,称比 llama.cpp 快 1.4–2.6 倍
人工智能·开源·llm