dimesum

首页 / 博客 / 工程

工程

我们删掉了自然语言费用解析器

· 阅读约 6 分钟 ·

我们的规则解析器只撑了一周,四句普通的话就把钱记到了错误的人头上,一条拒答规则取代了整套防护。

我们删掉了一个刚写完的解析器。Dimesum 第一次尝试做自然语言费用解析用的是规则引擎,四句普通的话就让它退了休:dinner 12-08 400 被读成 12 元,ravioli 600 把一个叫 Ravindra 的成员加进了分账,kirana 500 记到了一个叫 Kiran 的成员头上,refund -483.50 变成了一笔 483.50 元的支出。

看懂一句话是模型的活,别的谁都干不了。取代规则引擎的是一个叫 amount_only 的档位:只有一个数字,只有当文本里恰好有一个毫不含糊的金额候选时才返回,而且从不对人下判断。两个候选就意味着没有金额。一条规则取代了一堆越积越多的防护。

四句话终结了规则解析器

被删掉的解析器覆盖了我们 AI 设计文档规定的整套实体集合:成员与昵称匹配、排除词表、付款人推断、分类词表,以及逐字段调过的置信度。下面四个失败每一个都有显而易见的修法。而每一处修补又都是一道带着自己盲区的新防护。

让 Dimesum 规则解析器下线的四个失败,记录于 2026-08-20 的 docs/tech/10-ai-design.md
用户输入了什么解析器做了什么为什么会这样
dinner 12-08 400把金额读成了 12 元人看到的是一个日期和一个总额。正则看到的是三个数字,然后取了第一个。
ravioli 600把一个叫 Ravindra 的成员加进了分账模糊昵称匹配把一道菜跟一个人对上了分。
kirana 500记到了一个叫 Kiran 的成员头上同一个匹配器,这次换成了店名。
refund -483.50记了一笔 483.50 元的支出数字留下了,符号没留下,于是钱指向了相反的方向。

四个里有两个是同一个 bug 换了身衣服。模糊匹配分不清一道菜和一个人,也分不清一家店和一个人,因为在字符层面上 ravioliRavindra 确实长得很像。要求更长的前缀匹配,你就会弄坏 ravi,而这个匹配器正是为了这种情况才存在的。

每加一道防护,都会牵出两种新说法

规则解析器的失败只有一种固定形态:它自信地给出错误答案。一个空白字段让用户多点一下。一次错误的分账让人对账本失去信任,而在一款分账应用里,账本就是产品。上面这四行不是差一点,它们是记错钱的 bug。

真正的理由是这台永远停不下来的跑步机,而不是某一个缺陷。加一道日期防护,订单号的情况就来了。加一道订单号防护,光秃秃的数字就来了,接着是数量,然后是桌号。防护清单越来越长,永远收不拢,因为自然语言没有一套有限的说法可以一一列举。

改造前与改造后:一摞越长越多的防护,被一条拒答规则取代 改造前:规则解析器(已删除) dinner 12-08 400 金额正则 日期防护 订单号防护 昵称匹配器 排除词表 读成 12 元,并记了账 每次修补都牵出两种新说法。 改造后:amount_only dinner 12-08 400 恰好一个毫不含糊的 金额候选? 不是,有三个数字 金额保持空白 是,只有一个数字 以分返回 这一档对人只字不提, 所以它没法把钱记到错误的人头上。
被删掉的解析器对每一句话都作答。取代它的那一档要么给一个数字,要么什么都不给。
这个决定,附日期

自然语言录入是一项 AI 功能,Dimesum 代码库里没有任何东西能靠模式匹配把它做出来。这个决定落在 2026-08-20,连同那四个失败一起写了下来,好让没有人会不小心把那些防护重新建起来。

最终上线的是一个数字加一次拒答

amount_only 就是我们 AI 设计文档里那个降级档位的字面实现。那一档写的是“带客户端金额正则预填的普通表单”,所以这个档位只返回一个金额,别的什么都不返回:没有描述、没有分类、没有付款人、没有参与者、没有排除项。它不花一分钱,也不调用任何人,这正是测试和 CI 跑在它上面的原因。

歧义是一次拒答,而不是一次决胜

整条规则就住在一个函数里,extract_amount_minor。只有当文本里恰好有一个候选时才会返回一个数字,所以 order 90210 dinner 400flat 402 rent 15000 会返回空白,而不是挑一个赢家。带货币标记的数字即使旁边有光秃秃的数字,也算毫不含糊,这就是为什么 split 3 ways ¥1,200 仍然被读成 1,200 元。

一个带负号的数字根本不算金额,而不是取它的绝对值。-500minus 200 以及会计写法的 (500) 都会返回空,因为这个字段是一笔支出,留下数字却丢掉符号会让钱指向和文本相反的方向。括号只有在正好把数字本身括起来时才算数,所以 (500 each) 仍然只是一句括注。

amount_only 如何在一个数字和不作答之间做决定 收集每一个可能是钱的数字 一个都没找到,或者其中任何一个 带负号? 恰好一个带货币标记的 数字,比如 ¥1,200 或 500/-? 完全没有标记,而且恰好 一个光秃秃的数字? amount_minor:整数的最小单位, 通过 ISO 4217 的指数 没有金额 该字段显示为空白
三个问题,其中两个通向空白。在两个看起来都说得通的金额之间做选择,就产生了那顿 12 元的晚餐。

货币决定了算术

最小单位是 Dimesum 里钱唯一的表示方式,所以这个档位用 ISO 4217 的指数表来换算,而不是硬编码地乘以 100。日元根本没有辅币单位,×100 会把一张 1,200 日元的收据放大一百倍。比货币最小单位还要细的数字会被拒绝,而不是四舍五入,因为对别人输入的金额做四舍五入,就是在凭空造一个金额。

印度数字词是书写一个数字的一部分,而不是理解一句话的一部分。1.2k2 lakh500/- 都能解析出来,就像 1,200 一样。任何超过账本最大金额的数字都会返回空白,所以一个打错的数字只会让一个字段变空,而不会在下游把一个整数撑溢出。

截至 2026年8月29日,每个解析档位被允许断言的内容。
字段规则解析器(已删除)amount_only(已上线)模型档位(已接线,无密钥)
金额从几个数字里猜一个毫不含糊的数字,否则空白结合上下文读取
描述、分类词表匹配始终为 null从句子里提取
参与者、排除项模糊姓名匹配始终为空解析为真实的成员 id
付款人从措辞推断始终为空指名,附一个可为 null 的金额
整体置信度逐字段调过固定为 0.3按每次解析
上报的 prompt根本不存在null,没有读取任何 promptprompt 的 id 和版本

上线的这一档从不越过 0.6 的可用门槛

我们的解析契约会放弃整体置信度低于 0.6 的解析,把用户退回到普通表单。amount_only 对每个回答都报 0.3,这个常数是结构性的,不是调出来的。单独一个金额算不上一次解析,所以无论它解出什么,这一档都停在门槛的一半。对每个回答都用一个常数正是让它待在那里的原因:一个按情况给出的分数,早晚会被某个人往上调。

这个档位还根本不上报任何 prompt。prompt_idprompt_version 都返回 null,因为这一档没有读取任何 prompt。给它安一个名字,会把每一条评测结果都归到一个这个档位从没见过的 prompt 版本上,而评测框架是唯一被允许把一个档位提升为一键建议的工具,其标准是金额和参与者合起来达到 95% 的精确率。

还声明了另外两个档位,两个都没有上线。cheap-fast 档位有一个针对 openai/gpt-oss-120b 的 Groq 适配器,但没有密钥;中档没有适配器。选中任何一个都会在启动时失败,而不是等到用户第一次请求时才失败,因为 LLM 是按调用计费的,一个会产生费用的依赖必须以关闭状态失败。

没有任何东西会自动记账,所以一处空白只值一次点击

一个空白字段代价如此之小,只是因为 Dimesum 里没有任何一次录入能写钱。一次录入创建一个建议,由一个人来确认,而正是确认这个动作创建了支出。Brief 决策 D5 陈述了这条规则,.go-arch-lint.yml 强制执行它:录入上下文被拒绝依赖 expense 或 ledger,所以一次录入即使出错也无法写入账目。CI 会让这次引入失败,这一点我们通过加了一条来验证过。

无论解析器做什么,录入都会保留原始文本,并指明哪些字段没有解析出来。客户端会高亮那些空白,而不是显示一份凭空造出来的草稿,这正是一个什么都不说的解析器和一个靠猜的解析器之间的区别。确认会从建议的 id 派生出它的支出 id,所以点两下是重放,而不是收两次费。

值得偷师的规则

数你的防护,别数你的 bug。一份每周都在变长的防护清单是在告诉你,这活是理解,而理解属于模型。我们的下一步是拿 contracts/parse_expense/eval/ 里的黄金集去跑一个真正的模型档位,因为没有那个判决,这里没有任何东西能变成一键建议。

常见问题

Dimesum 为什么删掉了基于规则的费用解析器?

Dimesum 删掉它,是因为四句普通的话就产生了记错钱的 bug,而且每加一道防护都会牵出两种新的说法。dinner 12-08 400 被读成 12 元,ravioli 600 把一个叫 Ravindra 的成员加了进来,kirana 500 记到了一个叫 Kiran 的成员头上,refund -483.50 变成了一笔 483.50 元的支出。看懂一句话是模型的活。

amount_only 档位到底返回什么?

amount_only 档位只返回一个数字,别的什么都不返回。只有当文本里恰好有一个毫不含糊的金额候选时,它才给出金额,而且从不指认参与者、付款人、描述或分类。出现两个候选就完全不给金额。解析契约里的其他一切都要等模型档位来处理。

amount_only 为什么固定报 0.3 的置信度,而不是真实分数?

这个 0.3 是结构性的,不是调出来的。我们的解析契约会放弃置信度低于 0.6 的解析,把用户退回到普通表单,而单独一个金额算不上一次解析,所以无论它解出什么,这一档都停在这道门槛的一半。对每个回答都用同一个固定常数,能防止某个按情况给出的分数日后被往上调。

自然语言录入能不经人工就写入账本吗?

不能。一次录入只会生成一个建议,由人来确认,符合 Brief 决策 D5。这条规则在 .go-arch-lint.yml 里强制执行,它不允许录入上下文依赖 expense 或 ledger,所以一旦某次录入去碰账目,CI 就会让这次引入失败。是确认这个动作创建了支出。

没有可用的模型档位时,自然语言费用解析会怎样?

Dimesum 会降级到 amount_only 并显示空白。cheap-fast 档位接的是 Groq 的 openai/gpt-oss-120b,且发布时不带密钥,而中档没有适配器,所以选中任何一个都会在启动时失败,而不是等到用户第一次请求时才失败。无论哪种情况,录入都会保留原始文本。