从一句话到一笔账:QiNote 的 AI 记账链路设计
本地正则与 LLM 的双通道协作:高置信度走零成本正则、语义场景交给模型;System Prompt 按工程模板管理,JSON 输出按不可信输入防御性解析,最后以五元组幂等去重收口。

「奇记 QiNote」系列第二篇:拆解一条消息如何变成一笔账——本地正则与 LLM 的双通道协作、System Prompt 的工程化写法,以及防幻觉与幂等设计。

为什么要双通道
如果每次记账都调 LLM,会有三个问题:慢(1~3 秒起步)、贵(免费额度很快烧完)、不可用(断网时 App 直接变砖)。但纯正则又不够聪明,理解不了"也是 35 块"这种指代。
奇记的解法是把两类解析器排成一个决策链,用正则处理"高置信度的简单场景",把 LLM 留给真正需要语义理解的部分:
flowchart TD
A["用户输入(文字 / 图片)"] --> B{"带图片?"}
B -- 是 --> F["视觉模型识别小票"]
B -- 否 --> C["LocalBillParser 本地正则"]
C --> D{"高置信度?<br/>(识别出非 daily 分类)"}
D -- 是 --> G["直接落账(标记'本地识别')"]
D -- 否 --> E{"AI 可用?"}
E -- 是 --> F
E -- 否 --> H{"本地能解析出金额?"}
H -- 是 --> G
H -- 否 --> I["提示补充描述"]
F --> J{"返回合法 JSON?"}
J -- 是 --> K["字段校验 + 落账"]
J -- 否 --> C
F -- 失败 --> C
对应到 AiBookkeepingUseCase.execute() 的真实代码,决策顺序是:
val localResult = localParseBill(text)
val localHighConfidence = localResult != null && localResult.category != "daily"
if (localHighConfidence) return handleParsedBill(localResult, fromLocal = true)
if (aiAvailable) return executeAiParse(...)
if (localResult != null) return handleParsedBill(localResult, fromLocal = true)
// 全部失败:给出可读的引导文案
关键在 localHighConfidence 的判定:正则解析必须同时命中金额和明确的分类关键词("午餐""打车""猫粮"……60+ 个关键词按长度降序匹配,优先命中更具体的词),才算高置信度。一句只有"花了 35"的话不会本地落账——因为分类只能猜——而是转给 AI 补全语义。
System Prompt 的工程化写法
AiApiService.buildSystemPrompt() 不是一段随手的提示词,而是一份带注入参数的"模板渲染":
- 动态时间注入:
今天是${LocalDate.now()}(星期X),让模型具备相对日期换算能力;"昨天打车"能被正确落成 T-1 的日期; - 封闭枚举约束:把支出 / 收入分类 ID 全量列进 Prompt,
category只能从中选择; - 规则清单:11 条硬规则,覆盖退款记为收入、多笔账单取金额最大的一笔、整数金额不带小数点、折扣不影响实付金额等边界情况;
- Few-shot 示例:14 组「输入 → 输出」样例,特别针对中文口语("早上喝胡辣汤花了8块钱")和小票时间拆分做了示范。
其中最容易被忽略、也最容易翻车的是图片时间的拆分规则:
"2025-05-24 19:36:02" → date="2025-05-24", time="19:36:02"
"2025/05/24 19:36" → date="2025-05-24", time="19:36:00"
绝不能将时间部分丢弃!time 必须包含秒数
消费时间对账单去重至关重要(下文会讲),所以 Prompt 里用加粗级别的语气反复强调这一点,并给出 4 种常见格式的正确拆法。
容错解析:别相信模型的输出格式
模型偶尔会无视"不要用 ```json 包裹"的指令。parseBillFromContent() 因此做了一层防御性解析:
- 剥掉 markdown 代码块围栏(处理
```开头的语言标注行); - 从第一个
{开始做括号配平扫描——逐字符追踪depth、字符串内外状态、转义符,找到与之配对的}。这比indexOf('}')严谨得多,能正确处理 JSON 内部嵌套对象与字符串中的花括号; - 字段级校验:
amount <= 0直接拒绝(Prompt 规则"免费或 0 元返回 error"的兜底)、category不在合法集合则回退daily、金额统一Math.round(x * 100)转分。
重试与错误翻译
网络层 executeWithRetry() 实现指数退避 + 随机抖动:
val baseDelay = (1L shl attempt) * 1000L // 1s → 2s → 4s
val jitter = Random.nextLong(0, 500)
delay(baseDelay + jitter)
失败后错误信息不是原样抛给用户,而是 classifyError() 翻译成人话:401 →「API Key 无效」、402 / quota →「余额不足」、429 →「请求过于频繁」、Unable to resolve host →「网络不可用」。AI 解析失败时还会自动降级重跑一遍本地正则——哪怕模型挂了,能记的账照记。
幂等与中文时间解析
最后一公里是落库前的去重。confirmBill() 以「标题 + 金额 + 类型 + 日期 + 时间」五元组查重,命中即拒绝写入,避免手滑连发两条一样的账。但去重的前提是时间规范化,于是有了一套独立的中文时间解析器(parseTimeToHHMMSS):
- 标准格式多轮尝试(
HH:mm:ss→H:mm→ 带毫秒); - AM/PM 12 小时制换算;
- 中文口语模式:
下午3点 → 15:00:00、凌晨2点半 → 02:30:00、晚上11点05 → 23:05:00,并处理"中午 10~12 点"这类边界; - 纯数字兜底:
1936→19:36:00。
相对日期("今天 / 昨天 / 前天 / 大前天")在 resolveRelativeDates() 中统一换算为绝对日期。正则与 AI 两条通道共享这套规范化逻辑,保证落库口径一致。
小结
这条链路的设计哲学可以概括为一句话:能用确定性规则解决的,绝不交给概率模型;交给模型的,也要为它的失败准备好退路。本地正则承担了高频简单场景,LLM 处理语义与视觉,二者互为降级路径;Prompt 按工程模板管理,输出按不可信输入对待,最后以幂等校验收口。

