资讯详情 聊天记账助手iMoney:自然语言解析与消息幂等工程实践
📅 2026/10/29 12:16:57
记账这件事我前前后后折腾过七八个 App坚持最久的一个用了 41 天最后死于“打开 App 要 15 秒”这件小事上。后来我把记账入口挪到了微信聊天窗口发一句“午饭 28”就完成入账iMoney 这个项目就是在这个背景下长出来的。它本质上是一个聊天记账助手把日常最顺手的沟通工具当成输入法把自然语言解析成结构化账目再反哺成月度报表。整套东西跑在自己的一台小服务器上不依赖任何第三方记账服务数据完全自主。这篇文章适合两类人看——一类是想给自己搭个私人记账工具、有一定后端基础的开发者另一类是产品同学想搞清楚“聊天式交互”到底怎么落到一个具体工具上。我会把架构取舍、解析引擎的实现细节、部署过程、以及那些只有真正跑起来才会暴露的坑一条一条摊开讲代码和配置都能直接拿去改。1. 为什么把记账入口塞进聊天窗口1.1 记账工具真正的死穴是打开成本绝大多数记账产品死掉的原因不是功能不够而是录入摩擦。一个典型记账 App 的一次录入路径是解锁手机、找到 App、点加号、输入金额、选分类、选账户、选时间、点保存乐观估计 6 到 8 次点击。我按每天 6 笔消费算过一笔账单笔 15 秒一天就是 90 秒一个月是 45 分钟——单看时间成本其实不高真正致命的是决策疲劳。晚上十点你只想睡觉但还有三笔账没记大脑会自动把它归类成“明天再说”然后就没有明天了。还有个隐性成本是分类犹豫。你站在便利店门口手里拿着一瓶水和一包纸巾脑子要想“这算日用还是食品”。这种犹豫每发生一次就消耗一点继续记账的意愿。因此一个能活下来的记账工具核心指标不是功能覆盖度而是从产生记账念头到数据落库的路径长度。1.2 iMoney 的产品形态一句话入账iMoney 的设计目标只有一个把单笔记账压缩到一次输入加一次发送。用户发出的消息长这样午饭 28 打车 32.5 加班 昨天 超市 156 发工资 12000 星巴克 35服务端解析后回一条确认回执“午饭 ¥28.00 计入餐饮今天 12:31”。用户看到回执就闭眼确认错了再发一句“改 30”。查询同样走聊天窗口“这个月吃饭多少”、“上周打车”机器人回一段汇总文本。整套交互里没有任何表单、没有任何下拉选择框分类由引擎猜猜不准才反问。这个形态解决的是触发场景问题人产生“我花了钱”这个念头的时刻八成手里正拿着手机而手机大概率停在聊天界面。让入口出现在用户已经在的地方比教育用户“你要记得打开我的 App”要现实得多。1.3 三条技术路线的取舍动手之前我把能走的路捋了一遍最后按“合规性、稳定性、维护成本”三个维度打分结论如下路线接入方式稳定性合规与维护风险结论公众号消息回调官方开放的消息接收与被动回复能力高低接口有明确文档与频率限制主选方案企业微信自建应用官方应用消息接口需验签解密高低适合团队内部使用备选方案本地桌面端读剪贴板监听系统剪贴板识别特定前缀中低但只能在电脑前用辅助方案个人号自动化非官方途径极低高账号风险与协议变动风险都不可控明确放弃我最后落的是公众号消息回调 企业微信应用双通道个人号那条路从一开始就没考虑。原因很直接个人号自动化依赖非公开接口一次客户端更新就可能全线瘫痪而且这类做法本身站不住脚。用官方开放的被动回复能力虽然有条数限制和 5 秒响应约束但这些都是明面上的规则可以靠工程手段绕开——比如接入层收到消息立刻返回成功真正的解析和落库丢到异步队列里做。这里补一句很多人忽略的点公众号被动回复要求服务端在 5 秒内响应超时平台会重试重试就意味着同一条消息可能进来两次。这个约束直接决定了后面第 2 章的幂等设计不是可选项。2. 整体架构与消息链路拆解2.1 三段式分层接入层、解析层、账本层我把系统切成三层切分的依据是变化频率。接入层对接平台协议平台改字段我就改这里解析层处理自然语言词库和规则天天在变账本层管数据一致性最好一辈子别动。三层之间靠消息队列解耦任何一层挂了都不至于把数据写坏。接入层只做四件事验签、解密、去重、立刻 ACK。它不做任何业务判断甚至连“这条消息是不是记账”都不判断。这样设计的好处是响应时间可以压到 50 毫秒以内远低于 5 秒红线平台侧永远不会因为业务逻辑慢而重试。解析层是纯函数式的输入一段文本加用户上下文输出一个结构化的解析结果对象不碰数据库。纯函数意味着可以拿几百条历史消息做离线回归测试改一条规则跑一遍测试集误判率涨没涨一目了然。这一点在后面调优阶段救了我很多次。账本层负责事务、余额、报表聚合。记账写库必须在一个事务里完成“插交易 更新账户余额 写审计日志”三件事中途失败全部回滚。# 接入层伪代码重点是快 async def handle_callback(request): raw await request.body() if not verify_signature(request.query_params, raw): return PlainTextResponse(bad signature, status_code403) payload decrypt(raw, encoding_aes_key) msg_id payload[MsgId] if not redis.set(fimoney:msg:{msg_id}, 1, nxTrue, ex900): return PlainTextResponse(success) # 重复消息直接吞掉 await queue.enqueue(parse, payload) # 丢给异步消费者 return PlainTextResponse(success) # 5 秒内必须返回2.2 消息幂等两道锁比一道锁靠谱只靠 Redis 的 SETNX 是不够的。我遇到过一种情况Redis 重启丢了一次键同一条消息被消费两遍账上多了一笔 28 块的午饭。后来加了数据库层的唯一索引做兜底——(uid, raw_msg_id)建唯一约束重复插入直接抛IntegrityError捕获后静默丢弃。Redis 挡掉 99% 的重复数据库唯一索引挡住剩下 1%两层都便宜没理由只做一层。还有一个容易被忽略的细节用户级串行。同一个用户的两条消息如果并发处理可能一条是“午饭 28”下一条是“改 30”乱序执行会把金额改错。我的做法是按 uid 做哈希分片同一个用户的消息固定落到同一个队列分区单分区单消费者天然串行。这个设计不增加任何复杂度但解决了一整类竞态问题。2.3 数据模型金额一律用整数分存储数据模型上我犯过一个新手错误最早用FLOAT存金额跑到第三个月做汇总时发现餐饮类总额是1243.3000000000002。浮点数在十进制小数上的精度问题是记账系统里最不该犯的错。改法很简单全部换成整数分28 块存 280032.5 存 3250展示的时候除以 100。涉及除法的地方一律用Decimal加ROUND_HALF_UP。核心表就五张故意保持精简CREATE TABLE t_user ( id BIGSERIAL PRIMARY KEY, open_id VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(64), default_currency CHAR(3) NOT NULL DEFAULT CNY, tz VARCHAR(32) NOT NULL DEFAULT Asia/Shanghai, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE t_category ( id SERIAL PRIMARY KEY, uid BIGINT REFERENCES t_user(id), name VARCHAR(32) NOT NULL, keywords TEXT[] NOT NULL DEFAULT {}, sort_no INT NOT NULL DEFAULT 0 ); CREATE TABLE t_txn ( id BIGSERIAL PRIMARY KEY, uid BIGINT NOT NULL REFERENCES t_user(id), amount_cents BIGINT NOT NULL CHECK (amount_cents 0), direction SMALLINT NOT NULL DEFAULT -1, -- -1 支出 / 1 收入 category_id INT NOT NULL REFERENCES t_category(id), occurred_at TIMESTAMPTZ NOT NULL, note TEXT, raw_msg_id VARCHAR(64) NOT NULL, source SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE UNIQUE INDEX uk_txn_msg ON t_txn(uid, raw_msg_id); CREATE INDEX idx_txn_uid_time ON t_txn(uid, occurred_at DESC); CREATE INDEX idx_txn_uid_cat ON t_txn(uid, category_id, occurred_at DESC);occurred_at用TIMESTAMPTZ而不是TIMESTAMP因为我吃过时区的亏服务器跑在 UTC用户在 UTC8晚上 11 点记的账第二天早上查“今天花了多少”查不到。后来所有时间统一带时区入库查询时按用户 tz 换算才彻底消停。3. 核心难点把一句人话解析成一条账3.1 先规则后模型别一上来就上大模型解析这块我一开始想得过于复杂甚至考虑过接一个通用大模型做意图识别。冷静下来算了一笔账记账场景的文本极短平均 6 到 10 个字词汇高度收敛金额、类别词、时间词、备注而且必须可解释——用户问“为什么把我这笔算成交通”我得说得出是哪条规则命中的。通用模型在这三点上都不占优还带来延迟和成本。所以最终方案是规则引擎为主模型兜底为辅。规则引擎由四部分组成金额抽取、类别匹配、时间解析、备注清洗。只有当规则给不出类别、置信度低于阈值时才把这条文本丢进一个小的文本分类器离线用历史数据训的轻量模型再不行就反问用户。上线三个月后统计规则命中率稳定在 93% 左右模型兜底占 5%反问占 2%。这个结构最大的好处是每次误判都能定位到具体规则改一次就少一类错。3.2 金额抽取四种写法一个都别漏金额是账目里唯一不可缺的字段也是最容易漏的。我总结下来用户至少会用四种写法阿拉伯数字28、32.5、1288带单位28块、35元、2毛简写倍数1.5k、3w中文数字三十五块五、两百三、一千二前两种用一个正则就能覆盖关键是别让单位干扰数字边界。第三种要注意大小写都可能有w和W都得认。第四种最烦因为中文口语省略规则不统一“两百三”是 230“两百三十”也是 230但“两十三”这种表述实际不存在得防住。我的做法是先做一层中文数字归一化转成阿拉伯数字后再走统一的正则。import re from decimal import Decimal CN {零:0,一:1,二:2,两:2,三:3,四:4,五:5, 六:6,七:7,八:8,九:9} UNIT {十:10, 百:100, 千:1000} def cn2num(s: str) - Decimal | None: 把三十五两百三一千二转成数字失败返回 None if not s or any(ch not in CN and ch not in UNIT for ch in s): return None total, section, last 0, 0, 0 for ch in s: if ch in CN: last CN[ch] else: unit UNIT[ch] # 十五 省略了前面的一 if last 0 and unit 10: last 1 section last * unit last 0 total section last # 口语省略尾数两百三 → 230一千二 → 1200 if last and len(s) 2 and s[-1] in CN and s[-1] ! 零: prev_unit next((UNIT[c] for c in reversed(s[:-1]) if c in UNIT), None) if prev_unit 100: total section - last last * 10 elif prev_unit 1000: total section - last last * 100 return Decimal(total) if total 0 else None AMOUNT_RE re.compile( r(?Pnum\d{1,7}(?:\.\d{1,2})?)\s*(?Punit[kK千万wW块元毛角]?) ) SCALE {k: 1000, K: 1000, 千: 1000, w: 10000, W: 10000, 万: 10000}毛和角单独出现时按 0.1 处理和前面的数字相乘。这段代码我在测试集上跑过 1200 条真实语料中文数字部分的准确率是 97.6%错的 2.4% 全是“三百五”这种既能读 350 也能读 305 的歧义句——这类我统一按 350 处理因为口语里没人用“三百五”表示 305。注意金额抽取必须做多候选检测。像“买 2 杯咖啡 56”这种文本第一个数字是数量不是金额。我的处理是取“最靠后且带单位或带小数”的那个候选如果都不带单位且有两个以上数字就判低置信度反问。3.3 类别匹配词典 历史频次双重投票类别匹配的朴素做法是维护一张“关键词到类别”的映射表命中最长词优先。这个做法能覆盖 70% 的场景剩下 30% 靠不住。比如“星巴克”既是品牌也可能是某个人名备注“苹果”可能是水果也可能是数码产品。我的补强方案是两层加权第一层是词典命中按词长加权越长的词越具体权重越高。第二层是用户历史频次同一个用户如果过去三个月把“星巴克”记了 11 次餐饮那这次也优先判餐饮。两层分数相加取最高低于阈值就走反问。def score_category(text, uid, dict_hit, hist): best, best_cat 0.0, None for cat_id, kw in dict_hit.items(): # 词长权重两字词 0.5三字及以上 0.8 base 0.5 if len(kw) 2 else 0.8 # 历史频次做温和放大避免老数据彻底绑架判断 boost 1.0 min(hist.get((uid, cat_id), 0), 10) * 0.03 s base * boost if s best: best, best_cat s, cat_id return best_cat, best阈值定在 0.6 是试出来的。定 0.5 的时候误判太多定 0.8 的时候反问率飙到 12%用户烦。0.6 对应大概 4% 的反问率体感刚好。3.4 时间与备注默认值比精确更重要时间解析的原则是能默认就默认别逼用户填。没写时间就取消息到达时刻写“昨天”“前天”就往前推整数天写“3 号”就取本月 3 号如果今天还没到 3 号就取上月 3 号写“上周五”就算出具体日期。这里有个坑跨月边界。“1 号说昨天”的那笔账应该落在上个月最后一天而不是本月。我最初的实现直接减一天导致每个月 1 号记的“昨天”全都跑错月份月报对不上。修法是把日期运算全部交给带时区的日期库别自己加减秒。备注清洗就是去掉金额、时间词、类别词之后剩下的部分去掉首尾空白和标点长度截到 40 字。剩下为空就留空不要瞎填“无”。4. 从零跑通部署与实操配置4.1 环境选型与依赖清单运行环境我没有追求新潮选的是最稳的那一套。Python 3.11 是因为asyncio的性能和类型提示写起来舒服FastAPI 是因为它的请求体处理和异步支持足够轻PostgreSQL 是因为我需要唯一索引、数组类型和窗口函数这三点 SQLite 都不太够Redis 纯粹用来做幂等键和轻量缓存。整套服务在一台 2 核 4G 的小机器上跑日均 300 条消息CPU 峰值不到 15%资源完全够。# docker-compose.yml 片段只保留关键部分 services: api: build: . environment: - TZAsia/Shanghai - DATABASE_URLpostgresql://imoney:***db:5432/imoney - REDIS_URLredis://cache:6379/0 - WX_TOKEN${WX_TOKEN} - WX_AES_KEY${WX_AES_KEY} ports: [8000:8000] depends_on: [db, cache] db: image: postgres:16 environment: - POSTGRES_DBimoney - POSTGRES_PASSWORD*** volumes: [pgdata:/var/lib/postgresql/data] cache: image: redis:7-alpine command: [redis-server, --appendonly, yes] volumes: pgdata:TZ那行别省。容器默认 UTCPython 的datetime.now()拿到的就是 UTC 时间你在本地开发环境跑得好好的一上容器就全错 8 小时。4.2 回调接入的四步配置接入公众号消息回调我踩过的坑集中在验签和解密这一步。流程是固定的四步在平台后台填服务器地址、Token、EncodingAESKey。Token 是自定义字符串EncodingAESKey 是 43 位随机字符两个都要写进环境变量别硬编码。后台会发一次 GET 请求做校验带signature、timestamp、nonce、echostr四个参数。你需要把 Token 和另外三个参数按字典序拼接做 SHA1和signature比对一致就把echostr原样返回。校验通过后用户消息会以 POST 形式推过来消息体是加密的。用 EncodingAESKey 做 AES-256-CBC 解密解出来的明文里带MsgId、FromUserName、Content等字段。处理完返回success字符串别返回 JSON平台不认。import hashlib, base64 from Crypto.Cipher import AES def check_signature(token, timestamp, nonce, signature): items sorted([token, timestamp, nonce]) return hashlib.sha1(.join(items).encode()).hexdigest() signature def decrypt_msg(encoding_aes_key, cipher_b64): key base64.b64decode(encoding_aes_key ) cipher AES.new(key, AES.MODE_CBC, key[:16]) plain cipher.decrypt(base64.b64decode(cipher_b64)) pad plain[-1] plain plain[:-pad] # 前 16 字节是随机串接下来 4 字节是长度之后才是正文 length int.from_bytes(plain[16:20], big) return plain[20:20 length].decode(utf-8)注意解密时先解 base64 再做 AES顺序反了会得到一个长度不对的乱码另外补位字节要用最后一个字节的值来切写死 32 会在部分消息上炸。4.3 异步消费与写库消息进队列后由消费者处理。消费者里做三件事解析、落库、生成回执。落库这一步用事务包起来并且把唯一索引冲突当成正常情况处理掉不要让它冒泡成 500。async def consume(payload): uid await ensure_user(payload[FromUserName]) text payload[Content].strip() result parse(text, uid) # 纯函数不碰 IO async with db.transaction(): if result.need_confirm: await save_pending(uid, payload[MsgId], result) # 存待确认不入账 reply f没太看懂是「{result.guess_category} ¥{result.amount}」吗回 是 或 改 金额 else: try: await insert_txn(uid, result, payload[MsgId]) reply render_receipt(result) except UniqueViolation: reply 这条已经记过了 await push_text(uid, reply)save_pending表很简单uid、msg_id、payload_json、expire_at10 分钟。用户回“是”就从这张表捞出来入账回“改 30”就改掉金额再入账超时就当废案清理。有了这张表反问不再是死路交互闭环才成立。4.4 月报与聚合查询报表这块我一开始想用专门的 OLAP 组件后来发现单用户数据量太小——一年撑死 3000 条记录PostgreSQL 一条GROUP BY就查完了上任何额外组件都是浪费。关键是把索引建对(uid, occurred_at)和(uid, category_id, occurred_at)这两个联合索引撑起了 95% 的查询。-- 本月分类汇总按用户时区切片 SELECT c.name, SUM(t.amount_cents) / 100.0 AS total, COUNT(*) AS cnt FROM t_txn t JOIN t_category c ON c.id t.category_id WHERE t.uid $1 AND t.direction -1 AND t.occurred_at date_trunc(month, now() AT TIME ZONE $2) AT TIME ZONE $2 AND t.occurred_at date_trunc(month, now() AT TIME ZONE $2) AT TIME ZONE $2 interval 1 month GROUP BY c.name ORDER BY total DESC;月报推送用系统 cron 每月 1 号早上 8 点跑一次给每个开过记账的用户推一段汇总文本包含总支出、最大一笔、同比变化和一句“比上月少花了 XX”。这条推送的实际留存价值比我想象的高得多用户不会主动查账但月末被动看到一条汇总反而会回头补记那几笔漏掉的。5. 典型问题排查实录5.1 重复入账三种来源三种解法重复入账是我收到最多的一类反馈前后遇到三种成因。第一种是平台重试服务端 5 秒没响应就再推一次这种靠 Redis 幂等键解决。第二种是用户自己手抖连发两次MsgId不同但内容一样这种靠内容指纹去重——对同用户 60 秒内的文本做哈希命中就提示“刚才那条已经记了是同一笔吗”。第三种最隐蔽消费者处理到一半进程被重启消息重新入队事务其实已经提交了靠数据库唯一索引兜住。排查这类问题我有个固定动作查raw_msg_id在表里出现几次。出现两次以上说明幂等失效再去看 Redis 键是不是过期了我最初设的是 60 秒比平台重试窗口短后来改成 900 秒才彻底解决。5.2 解析误判把错误分类别一锅乱炖统计了三个月里用户主动纠正过的 217 条记录误判原因分布如下误判类型占比典型例子修法金额抽错抽到数量41%“买 2 杯咖啡 56” 抽成 2多候选 取最靠后带单位项类别判错27%“苹果 12” 判成水果加品牌别名表 历史频次投票时间落错月18%“1 号昨天的账” 落到本月全部改用带时区日期运算收入支出方向反了9%“报销 300” 记成支出收入关键词表报销、工资、退款、收到备注串味5%备注里混进了类别词清洗时按词典剔除这个表最有价值的地方是它告诉我先修哪个。41% 的金额问题占了近一半所以第二个迭代周期我把精力全压在金额抽取上误判率直接从 6.1% 掉到 2.3%。工程上永远先解决高频问题别被个别离奇的 case 牵着走。5.3 数据和实际对不上九成是这三件事用户反馈“我明明记了怎么查不到”我整理了一套固定的排查顺序通常三步内能定位看raw_msg_id有没有落库。没有就是接入层出了问题查验签和解密日志。有一次是 EncodingAESKey 配置末尾多了个空格导致所有解密失败接口还照样返回 200静默丢了一整天消息。后来加了“解密失败必须告警”才防住。落库了但查不到查时区。用户 tz 和occurred_at的时区没对齐月初月末的账会漏。判断方法是直接按created_at查能查到就是时区问题。查到但这笔是收入。汇总默认只统计支出方向字段填反了就会“消失”。这个我后来在回执里明确写上“支出”或“收入”两个字用户一眼能看出来。5.4 常见问题速查表现象大概率原因快速验证处理消息无响应回调地址不可达 / 验签失败手动 GET 打一次校验检查 Token 与公网可达性全部消息解密失败密钥带空格或长度不对打印密钥长度应为 43重新复制密钥偶发重复入账幂等键过期太短查 Redis TTL 配置改到 900 秒以上月初月报数据偏少时区导致跨月切片错位按created_at反查统一 TIMESTAMPTZ汇总金额出现小数抖动用浮点存金额看字段类型改整数分 Decimal反问后用户回“是”没反应待确认记录被提前清理查expire_at延长到 10 分钟并加兜底6. 上线一年后我才想明白的几件事第一个体会是别把交互设计留给用户猜。我最初的回执只有“已记录”三个字结果用户不知道系统把类别猜成了什么也没法发现错误。后来回执改成“午饭 ¥28.00 计入餐饮今天 12:31”纠正率反而下降了因为用户一眼就能看出对不对错的当场改对的直接划走。信息透明度本身就是一种纠错机制。第二个是关于规则和模型的边界。我一度想把解析全部交给模型图省事。试了两周放弃了不是因为效果差而是因为不可控——同一句话今天解析对明天解析错用户会彻底失去信任。记账这种场景宁可笨一点、稳定一点也不要聪明但不靠谱。规则引擎的误判至少是可复现、可修复的。第三个是数据备份。这东西跑在自己的机器上硬盘说坏就坏。我现在是每天凌晨用pg_dump打一个压缩包保留 30 天同时每周往另一块盘同步一次。脚本很简单但这是我被坑过一次之后才加的——那次磁盘满了导致 PostgreSQL 拒绝写入我花了半天才反应过来中间丢了两天的账。第四个是关于扩展。现在这套东西再加功能其实很轻松想支持多账户就在交易表加一个account_id想支持预算提醒就在月报任务里加个阈值判断想支持语音记账接入层多一层语音转文字就行解析层完全不用动。当初分层切得细后面加东西就不用动刀子。反倒是那些一开始图快、把所有逻辑写在回调函数里的方案第二次改需求就得推倒重来。最后分享一个我用着很顺手的小技巧给自己准备几个“快捷短语”比如“早”自动展开成“早餐 15 餐饮”、“地”展开成“地铁 5 交通”。在聊天窗口里发一个字就完成记账比任何 App 都快实测下来单笔录入压到了 3 秒以内。这个功能实现成本极低——解析层在进入正式匹配之前先查一遍用户自定义的短语表命中就直接出结果。