一个自然语言记账 App,真正难的不是把页面搭出来,而是怎么把用户输入的一句话稳定地变成结构化数据。
比如:
我这个月预算1000元今天加油300元昨天晚上吃饭花了58这个月预算用了多少
这些输入都很短,但系统背后要判断的事情不少:
- 这是预算,还是账单,还是查询?
- 金额是多少?
- 是收入还是支出?
- 分类是什么?
- 日期是今天、昨天,还是某个具体日期?
- 识别不确定时,要不要直接保存?
这篇就讲这个项目里最核心的三块设计:
- 数据库模型怎么定
- API 怎么拆
- 规则引擎为什么这么做
flowchart LR
A["一句话输入"] --> B["POST /api/parse"]
B --> C["结构化识别结果"]
C --> D{"用户确认?"}
D -- "是" --> E["POST /api/budgets<br/>或<br/>POST /api/transactions"]
D -- "否" --> F["返回修改输入"]
E --> G["写入数据库"]
为什么要先把数据结构定清楚
很多 AI 编程项目会先做页面,再反推接口和数据模型。
这在简单 CRUD 场景里还能勉强跑,但在自然语言记账里风险很大。
因为如果你不先定义结构化结果是什么,后面的解析逻辑就没有统一目标。
这个项目在需求和架构阶段确定下来以后,核心数据对象很快就收敛成了 4 个:
userscategoriesmonthly_budgetstransactions
这几个对象已经足够支撑第一版主链路。
flowchart TD
A["users"] --> B["categories"]
A --> C["monthly_budgets"]
A --> D["transactions"]
B --> D
这个项目的核心数据模型是怎么拆的
1. 用户
第一版虽然没有登录,但我还是保留了 users 表。
原因不是“提前设计多用户”,而是预算、账单、分类这些数据天然都有归属关系。即使 MVP 阶段固定 user_id = 1,保留用户表也能让后续演进更顺。
2. 分类
分类表的职责是承接自动分类和后续统计。
第一版里我把分类设计成:
- 系统默认分类
- 未来可扩展为用户自定义分类
而且分类还区分了:
- 支出分类
- 收入分类
- 两者都可用
这样做的好处是,自动识别和统计都能复用同一套分类体系。
3. 月预算
预算表按月存储,每个用户每月一条记录。
这个项目里,year_month 先直接用 YYYY-MM 字符串表示。
这不是最“学术”的设计,但对这个场景非常直接:
- 前端查询简单
- 服务端统计简单
- 预算设置和预算读取都很清晰
4. 账单
账单表是最核心的数据对象。
它至少要承接这些信息:
- 用户
- 分类
- 收支方向
- 金额
- 日期
- 备注
- 原始输入
- 输入来源
- 识别状态
- 是否异常
这里有两个字段特别重要:
一个是 raw_input,一个是 parse_status。
raw_input 让系统后续能回看用户到底输入了什么。
parse_status 则是为了处理“识别成功”和“需要确认”这两种状态。自然语言产品如果没有这个概念,后面很容易把错误数据直接落库。
为什么 API 要把 parse 和 save 分开
这是这个项目里一个非常关键的产品和技术决策。
很多人会本能地想把流程做成:
输入一句话 -> 后端直接识别并保存
这看起来更顺,但实际问题很大。
自然语言输入天生有歧义。
比如:
- 金额没识别出来
- 文本里有两个金额
- 分类不明确
- 日期不清楚
如果系统不让用户确认,错误账单就会直接写入数据库。
所以这个项目把 API 拆成两步:
POST /api/parsePOST /api/budgets或POST /api/transactions
第一步只做识别,不落库。
第二步在用户确认后,才真正保存预算或账单。
这个拆法虽然多了一步接口,但它换来的是可控性和更好的用户信任。
第一版 API 是怎么分层的
在这个项目里,后端 API 最终收敛成几组:
POST /api/parsePOST /api/budgetsGET /api/budgets/currentGET /api/categoriesPOST /api/transactionsGET /api/transactionsGET /api/stats/overviewGET /api/stats/categoriesGET /api/alerts/current
这套接口有个特点:不是完全按表 CRUD 去设计,而是按用户能感知到的业务动作去设计。
比如:
- 用户要设置预算,就给预算接口。
- 用户要看首页,就给聚合统计接口。
- 用户要知道有没有超预算,就给提醒接口。
这比“把每张表都开放一套原始接口”更贴近 MVP 目标。
为什么识别第一版用规则引擎
这个项目的识别链路,第一版没有直接上大模型,而是采用了规则引擎。
整个识别流程大致是:
- 文本预处理
- 意图判断
- 金额提取
- 日期提取
- 收支方向判断
- 分类映射
- 结果校验
flowchart LR
A["文本预处理"] --> B["意图识别"]
B --> C["金额提取"]
C --> D["日期提取"]
D --> E["收支方向判断"]
E --> F["分类映射"]
F --> G["结果校验"]
G --> H["返回识别结果"]
这样做的好处是非常实际的:
- 高频输入稳定
- 成本低
- 调试容易
- 测试容易
- 每一步都可以单独增强
比如用户输入:
今天加油300元
规则链路就能比较确定地识别出:
- 意图:创建账单
- 类型:支出
- 金额:300
- 分类:交通
- 日期:当天
对于 MVP 来说,这已经够用了。
为什么“先识别、再确认、再保存”是自然语言产品的底线
这个记账 App 的一个关键产品原则是:
先识别、再确认、再保存
这不是多余动作,而是自然语言输入产品最关键的保险丝。
因为第一版规则引擎再稳定,也不可能覆盖所有表达方式。
所以系统必须允许用户先看到结构化结果:
- 类型
- 金额
- 分类
- 日期
- 备注
确认没问题以后再入库。
从工程角度看,这也让问题更容易定位:
- 是解析错了
- 是保存错了
- 还是统计错了
链路一旦拆清楚,后面调试和测试都会简单很多。
查询类自然语言为什么也能在这套结构里扩展
这个项目后面又扩展了一批查询类输入,比如:
这个月餐饮花了多少这个月预算用了多少最近7天交通和餐饮一共多少本周收入多少
之所以后面能继续往这边扩展,很大程度上就是因为第一版已经把识别和保存拆开了。
查询类输入本质上也是:
- 先做意图识别
- 再做参数提取
- 最后调用统计模块返回结果
如果第一版把所有事情都做成“输入一句话直接落库”,后面要支持查询自然语言,结构上就会别扭很多。
一个适合直接复用的提示词模板
如果你要让 AI 帮你设计类似项目的数据和接口,可以直接问:
下面是产品的 MVP 需求和技术方案:
<粘贴需求和架构结果>
请继续输出:
1. 核心数据模型
2. 表之间的关系
3. API 清单
4. parse 和 save 的职责边界
5. 规则引擎的识别流程
6. 哪些场景需要用户确认
7. 哪些异常要阻止直接保存
要求:
- 优先满足 MVP
- 不要过度设计
- 说明每个设计背后的原因
最后
这个记账 App 的数据库、接口和解析设计,最核心的一点不是“技术上多复杂”,而是三件事始终保持一致:
- 结构化数据是什么
- 用户输入要经过哪些判断
- 什么情况下才能真正保存
一旦这三件事清楚了,前端确认弹窗、统计接口、异常提示、测试用例,其实都会顺着长出来。
下一篇我会继续写:这个项目从骨架搭建到前后端联调,是怎么一步步落地的,以及 AI 参与编码时为什么一定要小步推进、边改边验证。
如果你也在做带自然语言输入的产品,正在纠结数据模型、接口边界或者识别流程怎么定,可以直接私信我聊聊。我最近在系统整理 AI 编程实战案例,也在做 AI 代码审查、单元测试补齐和 AI 编程流程咨询。