LLM+正则替代人工:财务对账数据分析实战
发布日期: 2026/08/21 阅读总量: 1

一次让财务总监沉默的对账事故

年初接手一个老项目,财务部每月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 合规接入的部署笔记。