银行流水提取的核心风险不是明显失败,而是少量错误以合理数据的形式混入结果。文章主张通过余额与交易金额的算术关系做一致性校验,避免高准确率掩盖不可接受的漏错。
为什么一个准确率达到 98% 的提取器,反而比一个会明确报错的提取器更危险——以及如何用算术校验发现其中的差异。
每个接到 PDF 银行对账单,并被要求“把交易记录提取出来就行”的开发者,起点几乎都一样:一个 PDF 文本提取器、一两个正则表达式,以及一种平静的自信,觉得这不过是一个周末就能完成的任务。
其实并不是。但原因可能和你想的不一样。
解析确实很难,关于这一点已经有很多文章讨论过——对账单的排版是为了打印,列与列之间依靠的是视觉对齐,而不是结构化数据;简单粗暴地提取文本,会把整齐的表格变成挤在一起的一长列。不过,这类问题你一开始就能发现,而这恰恰使它成为相对容易处理的部分:它会明确暴露失败。
真正困难的是那些悄无声息的失败。
假设你的提取器在一份包含 300 行记录的对账单上,准确率达到 98%。听起来已经很好了。但在实际中,这意味着大约有 6 行是错的,而且输出结果看起来完全没有异常。日期可以正常解析,金额也是看似合理的数字,描述读起来也像真实商户。你交付了一份与正确结果几乎无法区分的电子表格,直到几周后进行账目核对时,错误才暴露出来——甚至可能永远不会暴露,而这更加糟糕。
相比之下,一个在第 3 页直接抛出错误的提取器,虽然令人烦恼,却足够诚实。你修复问题,然后继续处理即可。
所以,真正有意思的工程问题并不是“怎样把数字提取出来”,而是:“在没有正确答案可供比较的情况下,我怎么知道提取出来的数字是否正确?”
与发票等文档相比,银行对账单有一个特点,使它格外容易验证:银行对账单本身就包含校验和。
事实上,它包含两种校验和。
文档级校验。对账单会打印期初余额和期末余额。无论你提取出了哪些交易,它们都必须能够在两者之间完成账目平衡:
opening + sum(credits) − sum(debits) == closing
如果提取出的记录无法满足这个等式,那么在任何人查看输出之前,你就知道结果有误。此时你还不知道具体是哪一行出了问题,但你已经知道整组数据不可靠。仅凭这一项检查,就能把一次无声的失败变成明确的失败。
行级校验。大多数对账单——并非全部,后面还会谈到——会在每一行打印滚动余额。这为你提供了第二项更加严格的约束:每一行的余额都必须能由上一行推导出来。
balance[n] == balance[n−1] + signed_amount[n]
现在,你可以定位错误了。滚动余额第一次无法延续的那一行,就是提取出错的位置:可能漏掉了一行、重复提取了一行,或者读错了某个数字。
有个细节看起来像是在吹毛求疵,实际上并不是:所有计算都应该使用以分为单位的整数。
一旦使用浮点数表示金额,校验和就会开始产生误报。0.1 + 0.2 != 0.3 在这里并不是什么有趣的编程常识,而是一个 bug 制造器:累计进行几百次加法之后,对账结果就可能出现零点几分的偏差。于是,你要么错误地判定正确的对账单校验失败,要么不断放宽容差,直到它再也无法捕获真正的错误。
使用整数,才能让容差真正有意义。我们在文档级和行级校验中采用的容差都是一分钱——不是因为浮点数计算需要余量,而是因为不同银行对滚动余额确实可能采用不同的舍入方式。一分钱足以容纳合理的舍入差异,同时又足够严格,不会让数字错位之类的错误蒙混过关。
DOC_LEVEL_TOLERANCE_CENTS = 1
ROW_LEVEL_TOLERANCE_CENTS = 1
def doc_reconciles(opening, credits, debits, closing):
delta = opening + credits - debits - closing
return abs(delta) <= DOC_LEVEL_TOLERANCE_CENTS
如果你发现自己想把容差设置为“大约一美元”,请停下来:你刚刚关闭了唯一能够捕获真实错误的机制。
信用卡账单无法进行行级校验,而许多提取器恰恰会在这里悄悄编造数据。
信用卡发卡机构不会为每笔交易打印滚动余额。它们会列出上期余额、本期余额、消费、还款和应还金额——这描述的是一个账单周期,而不是一本逐笔更新的分类账。账单上不存在可供验证的逐行余额,因为这份文档本身就没有这个概念。
一个很诱人的做法是自行计算:从上期余额开始逐笔累加。这样,你的输出中每一行都有了余额列,看起来十分完整,但这些数据其实是虚构的——它们只是外观合理,却从未出现在源文档中的数字。任何尝试与真实账单进行核对的人,都会发现这一列无法与任何内容对应。
正确的处理方式是:先判断正在读取的文档类型,再决定哪些不变量适用。如果文档本身不支持某个字段,就应该完全省略这一列。空缺的列本身也是一种信息,而编造出来的列则是一项风险。
这一点可以推广到更普遍的场景:验证规则必须是文档类型的函数,而不能是一套固定不变的流水线。银行账户对账单与信用卡账单是两种不同的对象,只不过看起来相似而已。
日期值得单独讨论,因为它们出错的方式完全一样——悄无声息、看似合理,而且代价高昂。
03/04/2026 可能表示 3 月 4 日,也可能表示 4 月 3 日,具体取决于打印它的国家。两种解释都能生成合法日期,也都不会抛出错误。如果你错误猜测了英国对账单的日期格式,那么输出中的每笔交易都会落到错误的月份,所有月度汇总都会出错,但文件中没有任何内容看起来像是坏掉了。
你无法逐个日期解决这个问题。必须在文档级别做出判断:先根据银行、地址栏、币种、数字格式(1,234.56 与 1.234,56)和语言等信息确定对账单的 locale,然后在这个统一判断下解释所有日期。
接着,以 ISO YYYY-MM-DD 格式输出日期。这并非个人风格偏好:ISO 日期是唯一不会被下游系统再次误读的格式,而且按字符串排序也能得到正确的时间顺序。当输出结果进入电子表格时,这一点立刻就会变得重要。
同样的文档级推理也适用于数字。应该根据文档本身,一次性判断 1.234 表示一千二百三十四,还是一点二三四,而不是根据每个独立 token 的外观逐个猜测。
以上做法都不会提高提取本身的准确率。我们花了很长时间才真正理解这一点:验证和提取是两个独立的问题,而验证才是决定输出结果能否使用的关键。
带有校验和的提取器可以出错,但它能够明确告诉你。没有校验和的提取器,则是在要求人类重新手动检查一遍,才能知道结果是否正确——而这正是人们原本想要避免的工作。
所以,真正重要的处理流程最终是:
在提取任何内容之前,先识别文档是什么,包括类型、locale 和格式惯例。
利用文档自身提供的约束验证结果。
验证失败时,明确标出具体的问题行,而不是不断降低标准,直到验证通过。
永远不要生成源文档中不存在的字段。
第 4 步是人们最容易跳过的,却也是用户感受最直接的一步。检查一条被标记的记录只需要 30 秒,而一条未被标记的错误记录,可能会导致一整场费时费力的对账工作。
本文中的案例来自我们开发 MainBook——一款银行对账单转换工具——的实践。上面介绍的对账和 locale 规则,正是它处理生产环境文档时所采用的规则。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。