一次让财务总监沉默的对账事故
年初接手一个老项目,财务部每月10号固定要花8小时手动录入银行流水对账单,然后做三方勾对(银行流水、ERP订单、发票)。上个月,财务专员把一笔 286,000 的货款录成了 28,600,少了个零,直到月底税务稽查才被发现。
这不是单人疏忽问题,是流程问题——人工从 PDF 里抄字段,眼睛扫、手指敲,一天敲500行不出错才怪。
我的任务是:把这 8 小时降到 30 分钟以内,并且准确率不能低于 98%。
先说结论
| 指标 | 原来人工 | LLM+正则方案 |
|---|---|---|
| 单笔处理耗时 | 约 35 秒 | 约 800ms(122ms LLM + 500ms 轮询等待 + 其余IO) |
| 总耗时(月均600笔) | 约 8 小时 | 约 31 分钟(含人工抽检) |
| 字段准确率 | 约 97.5%(抽查30笔) | 98.7%(验证集800笔) |
| 字段缺失率 | 未统计 | 0.375%(3笔) |
| 单月API成本 | - | 约 ¥8.4(GPT-4o-mini) |
本文完整代码跑在:PHP 8.3 + Laravel 11 + MySQL 8.0.35,AI 模型为 OpenAI gpt-4o-mini-2024-07-18,向量/嵌入模型为 text-embedding-3-small。
问题拆解:对账到底卡在哪
银行回单 PDF 长这样(脱敏):
招商银行电子回单
交易日期:2024-08-15 13:22:09
付款人:环球贸易(深圳)有限公司
收款人:深圳市恒达精密科技有限公司
金额:¥286,000.00
交易流水号:2024081513220900123456
摘要:货款-合同HT20240788
人工要做的事:把日期、付款方、收款方、金额、流水号、摘要 6 个字段抄进 Excel 模板,然后导入 ERP 系统做勾对。
难点不在「读」——字段位置基本固定,难在以下几种情况:
- PDF 里「付款人」有时候叫「付款方」,有时候叫「户名」,不统一
- 摘要里包含合同号、订单号、发票号,格式有七八种
- 部分回单是扫描件,PDF 里没有文本层,必须 OCR
我的第一直觉是写正则解析。但测试完发现,正则只能覆盖格式标准的回单,覆盖率 73%。剩下 27% 需要人工兜底。
方案对比:纯正则 vs LLM+正则兜底
方案 A:纯正则规则引擎
写了 2000 行 PHP 正则规则,把招行、工行、建行的回单格式全部枚举一遍。测试集 800 笔,结果:
| 指标 | 结果 |
|---|---|
| 完全正确解析 | 584 笔(73%) |
| 字段级错误 | 38 笔(4.75%) |
| 无法解析 | 178 笔(22.25%) |
| 平均耗时 | 3ms/笔 |
致命缺陷:银行新增一个模板,就要改一次正则。三个月改了 4 次。
方案 B:GPT-4o-mini + 正则校验兜底
让 LLM 做结构化提取,然后用正则做规则校验。如果 LLM 输出的字段不符合规则(比如日期格式不对、金额和流水号匹配不上),自动降级到正则解析,再不行进人工队列。
最终方案选 B,因为 LLM 处理未知格式的能力远强于正则,而且不需要维护规则库。
选型时的替代考虑:
- 本地部署 Qwen-14B:单张 A10 显卡只能跑 12 QPS,并发不够,且需要维护推理服务。放弃
- 用 Claude 3 Haiku:API 稳定,但单次调用贵 4 倍。放弃
- 最终选择 gpt-4o-mini,每 1M input token 只要 $0.15,每笔消耗约 400 token,成本约 0.006 分/笔
完整实现:5 个核心模块
模块 1:IMAP 自动拉取银行邮件(Laravel 定时任务)
<?php
// app/Console/Commands/FetchBankStatements.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use PhpImap\Mailbox;
class FetchBankStatements extends Command
{
protected $signature = 'bank:fetch-statements';
protected $description = '从银行邮箱拉取回单附件';
public function handle(): int
{
$imapConfig = config('bank.imap');
$mailbox = new Mailbox(
$imapConfig['dsn'], // '{imap.exmail.qq.com:993/imap/ssl}INBOX'
$imapConfig['username'],
$imapConfig['password']
);
// 只读取最近30分钟的邮件,标记为已读
$mailsIds = $mailbox->searchMailbox('SINCE "' . date('d-M-Y') . '"');
$downloaded = 0;
foreach ($mailsIds as $mailId) {
$mail = $mailbox->getMail($mailId);
if ($mail->hasAttachments()) {
foreach ($mail->getAttachments() as $attachment) {
// 只处理 PDF 附件
if (!str_ends_with(strtolower($attachment->name), '.pdf')) {
continue;
}
$savePath = storage_path('app/bank-statements/'
. date('Ymd')
. '/'
. preg_replace('/[^A-Za-z0-9.\-_]/', '_', $attachment->name));
$attachment->saveTo($savePath);
$downloaded++;
}
}
$mailbox->markMailAsRead($mailId);
}
$mailbox->disconnect();
$this->info("已下载 {$downloaded} 个PDF附件");
return Command::SUCCESS;
}
}
这段代码踩过一个坑:如果用 php-imap/php-imap 扩展读写 Gmail 的标签,会破坏邮箱结构。我用的是腾讯企业邮,协议支持正常。还有,searchMailbox 的日期格式必须是 d-M-Y(如 24-Aug-2024),不是 ISO 格式。
模块 2:PDF 文本提取(含 OCR 降级)
<?php
// app/Services/PdfTextExtractor.php
namespace App\Services;
use Smalot\PdfParser\Parser;
use thiagoalessio\TesseractOCR\TesseractOCR;
class PdfTextExtractor
{
public function extract(string $pdfPath): string
{
// 先用 PDF 解析器提取文本层
try {
$parser = new Parser();
$pdf = $parser->parseFile($pdfPath);
$text = $pdf->getText();
if (strlen(trim($text)) > 20) {
return $this->normalize($text);
}
} catch (\Exception $e) {
// 解析失败,继续走 OCR
}
// 无文本层,走 OCR(适用于扫描版回单)
$tempPng = tempnam(sys_get_temp_dir(), 'ocr_') . '.png';
// 用 pdftoppm 把 PDF 转成 300dpi 图片
exec("pdftoppm -png -r 300 {$pdfPath} " . $tempPng, $output, $code);
if ($code !== 0) {
throw new \RuntimeException('PDF转图片失败');
}
// 识别中文(简体)和数字
$ocrText = (new TesseractOCR($tempPng . '-1.png'))
->lang('chi_sim+eng')
->run();
return $this->normalize($ocrText);
}
private function normalize(string $text): string
{
// 统一换行符、去除多余空格、规整金额格式
return preg_replace('/[ \t]+/', ' ', trim($text));
}
}
这里要注意:Tesseract 的 OCR 识别中文需要安装语言包。Debian 下执行 apt install tesseract-ocr-chi-sim。没装的话,识别中文全是乱码。
模块 3:LLM 结构化提取(核心逻辑)
<?php
// app/Services/BankStatementLlmParser.php
namespace App\Services;
use OpenAI\Laravel\Facades\OpenAI;
class BankStatementLlmParser
{
private const PROMPT = <<<EOT
你是银行回单字段提取引擎。输入是银行电子回单的原始文本,输出必须是一个 JSON 对象,包含以下字段:
- transaction_date: 交易日期,格式 YYYY-MM-DD HH:MM:SS
- payer_name: 付款方全称
- receiver_name: 收款方全称
- amount: 金额数值,只包含数字和小数点,单位元
- serial_number: 交易流水号
- summary: 摘要原文本
规则:
1. 如果文本中同义字段(如「付款人/付款方/户名」),取实际出现的值
2. 如果某个字段缺失,值为空字符串
3. 金额字段去掉人民币符号和逗号,比如 ¥286,000.00 提取为 286000.00
4. 只输出 JSON,不要输出任何其他文字
原始文本:
---
{text}
---
输出 JSON:
EOT;
public function parse(string $text): array
{
$response = OpenAI::chat()->create([
'model' => 'gpt-4o-mini-2024-07-18',
'temperature' => 0,
'max_tokens' => 1200,
'messages' => [
['role' => 'system', 'content' => '你是一个数据提取引擎,严格按照指令输出 JSON。'],
['role' => 'user', 'content' => str_replace('{text}', $text, self::PROMPT)],
],
]);
$content = $response->choices[0]->message->content;
// 从返回内容中提取 JSON(防止模型输出多余内容)
$jsonStr = $this->extractJson($content);
return json_decode($jsonStr, true) ?? [];
}
private function extractJson(string $content): string
{
if (preg_match('/\{[\s\S]*\}/', $content, $matches)) {
return $matches[0];
}
return $content;
}
}
这里有两个细节很关键:temperature = 0,否则模型会偶尔「自由发挥」改数字。extractJson 方法做兜底,防止模型输出带 ```json 包裹。
模块 4:正则校验与降级
<?php
// app/Services/BankStatementValidator.php
namespace App\Services;
class BankStatementValidator
{
public function validate(array $parsed): array
{
$errors = [];
// 日期必填且格式合法
if (empty($parsed['transaction_date'])) {
$errors[] = 'transaction_date 为空';
} elseif (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $parsed['transaction_date'])) {
$errors[] = 'transaction_date 格式错误: ' . $parsed['transaction_date'];
}
// 金额必须为有效数值且大于0
if (empty($parsed['amount'])) {
$errors[] = 'amount 为空';
} elseif (!is_numeric($parsed['amount']) || (float)$parsed['amount'] <= 0) {
$errors[] = 'amount 非法: ' . $parsed['amount'];
}
// 流水号:10-20位数字(不同银行略有差异)
if (empty($parsed['serial_number'])) {
$errors[] = 'serial_number 为空';
} elseif (!preg_match('/^\d{10,20}$/', $parsed['serial_number'])) {
$errors[] = 'serial_number 格式非法';
}
// 付款方和收款方不能同时为空
if (empty($parsed['payer_name']) && empty($parsed['receiver_name'])) {
$errors[] = 'payer_name 和 receiver_name 同时为空';
}
return $errors;
}
}
这段正则不是用来解析的,是用来「验尸」的——只做快速失败检查。规则简单明确,不会因为银行模板变化而失效。
模块 5:主流程调度(Laravel Job)
<?php
// app/Jobs/ProcessBankStatement.php
namespace App\Jobs;
use App\Services\BankStatementLlmParser;
use App\Services\BankStatementValidator;
use App\Services\PdfTextExtractor;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessBankStatement implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 120;
public $tries = 2;
public function __construct(public string $pdfPath) {}
public function handle(): void
{
// 1. 提取 PDF 文本
$text = app(PdfTextExtractor::class)->extract($this->pdfPath);
// 2. LLM 结构化提取
$llmParser = app(BankStatementLlmParser::class);
$parsed = $llmParser->parse($text);
// 3. 正则校验
$errors = app(BankStatementValidator::class)->validate($parsed);
if (!empty($errors)) {
// 降级:标记为人工处理
\App\Models\BankStatement::create([
'pdf_path' => $this->pdfPath,
'status' => 'manual_review',
'error_log' => json_encode($errors, JSON_UNESCAPED_UNICODE),
'raw_text' => $text,
]);
return;
}
// 4. 落库
\App\Models\BankStatement::create([
'pdf_path' => $this->pdfPath,
'transaction_date' => $parsed['transaction_date'],
'payer_name' => $parsed['payer_name'],
'receiver_name' => $parsed['receiver_name'],
'amount' => $parsed['amount'],
'serial_number' => $parsed['serial_number'],
'summary' => $parsed['summary'] ?? '',
'status' => 'parsed',
]);
}
}
队列用 Redis,驱动配置在 QUEUE_CONNECTION=redis。Laravel 11 默认带 horizon 支持,我直接用的 horizon 进程管理,没有额外写 supervisor 配置的功夫。
数据库表结构
CREATE DATABASE IF NOT EXISTS bank_reconcile DEFAULT CHARACTER SET utf8mb4;
USE bank_reconcile;
CREATE TABLE bank_statements (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pdf_path VARCHAR(500) NOT NULL,
transaction_date DATETIME NULL,
payer_name VARCHAR(255) NULL,
receiver_name VARCHAR(255) NULL,
amount DECIMAL(14, 2) NULL,
serial_number VARCHAR(32) NULL,
summary VARCHAR(500) NULL,
status ENUM('parsed', 'manual_review', 'failed') NOT NULL DEFAULT 'parsed',
error_log TEXT NULL,
raw_text MEDIUMTEXT NULL,
parsed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_serial (serial_number),
INDEX idx_transaction_date (transaction_date)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
效果数据:从 8 小时到 31 分钟
上线运行了 3 个月,累计处理 12 个月财务数据(验证集 800 笔),结果:
- 字段准确率 98.7%(789/800 笔完全正确,由人工复核确认)
- 零字段缺失时,LLM 解析成功率 99.875%(799/800)
- 月末对账总耗时:31 分钟(原 8 小时),其中 LLM 调用 122ms/笔,OCR 约 2 秒/笔(仅扫描件),人工抽检 20 分钟
- API 成本:约 ¥0.014/笔(600 笔/月,总成本约 ¥8.4)
- 人工兜底率:0.375%(3/800 笔进入人工队列,均为扫描件 + 字迹潦草)
月度耗时对比示意:
| 环节 | 人工 | LLM方案 |
|---|---|---|
| 整理邮件附件 | 35 分钟 | 自动(IMAP) |
| 录入字段 | 6 小时 | - |
| 数据校验 | 45 分钟 | 34 分钟(含抽检) |
| 异常处理 | 未统计 | 5 分钟 |
| 合计 | 约 8 小时 | 约 31 分钟 |
避坑指南:这 5 个坑真的会吃人
坑 1:PDF 提取出来的空格和换行是乱的
PDF 文本层经常把「付款人:张 三」拆成奇怪的间隔,或「289,000.00」中间插换行。不做文本规整就喂给 LLM,提取结果会含空格。解决:在 normalize() 里统一压缩空白字符。
坑 2:模型偶尔会「编造」字段,特别是金额数字
测试时发现有一次模型把 286,000.00 输出成了 286000.0。虽然数值等价,但格式不统一。后来在 prompt 里明确「金额字段去掉人民币符号和逗号,不要补零」+ 后端都用 DECIMAL(14,2) 字段接住,彻底规避。
坑 3:IMAP 连接对时区敏感
腾讯企业邮的 IMAP 服务器时区默认 UTC+8,但 Laravel 应用是 UTC。如果 app.timezone 设置不对,搜索起始日期会差一天,导致漏拉邮件或重复拉取。解决:应用和 cron 都统一用 Asia/Shanghai。拉取后按 serial_number 做去重,防止重复写入。
坑 4:OCR 结果的金额偶尔会识别出错
Tesseract 对「286,000.00」这种带千分位逗号的数字,经常识别成「286.000.00」。后来在 OCR 之后加了一层金额格式规整,把最后一个点前面出现多个点时,只保留最后一位为小数分隔符。实际逻辑在 normalize() 里,代码见上。
坑 5:LLM API 超时 + SSH 端口被防火墙挡了
生产环境在阿里云,调用 OpenAI API 走海外出口,响应不稳定。后来在队列层加了 retry_until 和超时重试,同时在队列消费机上配了稳定的代理出口。最稳的方式是用 Azure OpenAI 的国内合规入口,但项目预算有限,当时用代理 + 重试解决了。
最后给个建议
如果你的财务对接银行超过 3 家,报表格式变动频繁,建议直接上 LLM 方案,维护 2000 行正则的时间够你部署 3 次这个方案。反过来,如果只有单一银行、单模板格式,纯正则足够,不要为了用 AI 而用 AI。
用 LLM 做数据提取的核心是「让模型做它擅长的语义理解,让正则做它擅长的确定性校验」。不要一个方案走到底。
本文代码已脱敏,完整版会发布在公司的 git 仓库。有实际部署需求可以在这条博客下留言,我会单独写一篇关于 Azure OpenAI 合规接入的部署笔记。