# 实战：构建一个无权放款的三单匹配 Agent

> 发票匹配难的不是比对，而是异常；好的 Agent 必须知道何时停手。

原文: https://fdetimes.net/zh/guides/three-way-match-agent-no-payment-rights/

供应商送来 50 箱中的 46 箱，却按 50 箱开了发票。剩下的 4 箱也许在下一趟车上，也许永远不会到。专做匹配自动化的公司 Lleverage 用这个例子说明，什么样的异常会让发票匹配系统失灵。

比对三个数字的代码，谁都能在十分钟内写完。真正的工作在别处。

供应商 Peakflo 援引 APQC 2025 年的数据称，采用规则匹配的组织，只有 52%–58% 的发票能实现无人工干预的直通处理。

剩下的近一半发票，正是 FDE 拿薪水要解决的问题。

本指南按照在客户现场应有的顺序，讲解如何构建一个三单匹配 Agent：先写规则，再处理异常，最后才是 Agent，并设一条硬边界——Agent 不得放款。下面的代码是纯 Python，为说明逻辑做了简化，不绑定任何 ERP 的 API。

## 要构建什么，需要准备什么

产出是一条小型数据管道。它接收三类单据（采购订单、收货单和发票），逐行比对，为每一行分配状态，只把规则无法解决的行交给 Agent。Agent 提出行映射方案并说明理由，由人审批。

你需要 Python 3.10 或更高版本、一份包含若干采购订单、收货单和发票的样例数据集（可以自己造），以及一个支持工具调用的大模型。最重要的是，你要清楚客户在 ERP 中配置了哪些容差。

这个顺序并非随意。Anthropic 在构建 Agent 的指南中建议，先找到尽可能简单的方案，只在确有必要时才增加复杂度。对发票匹配而言，最简单的方案就是一个比对函数。

## 第 1 步：统一为行结构

一切都在行级别进行，而不是单据级别。把三类单据转换成同一种结构：

```python
from dataclasses import dataclass

@dataclass
class Line:
    doc_id: str          # 采购订单号、收货单号或发票号
    po_line: str | None  # 该行指向的采购订单行（可能缺失）
    sku: str
    qty: float
    unit_price: float
```

检查：打印所有 `po_line` 为空的发票行。它们是第 4 步的首批候选，因为供应商要么漏填了引用，要么把多行合并成了一行。

## 第 2 步：用规则和容差匹配

大型 ERP 做的正是这件事。Sage X3 的文档描述了两项比对：发票价格对比采购订单价格，收货数量对比订购数量，各自带有可配置的容差阈值。

超出阈值，系统就会告警或阻断。你要重写这段逻辑，并为开票数量少于订购数量的发票增加一个标记：

```python
def check_line(po: Line, gr_qty: float, inv: Line,
               price_tol=0.02, qty_tol=0.0) -> list[str]:
    issues = []
    if gr_qty == 0:
        issues.append("MISSING_GR")
    elif inv.qty > gr_qty * (1 + qty_tol):
        issues.append("QTY_OVER_RECEIPT")
    elif inv.qty < po.qty:
        issues.append("PARTIAL")       # 开票数量恰好等于收货数量，但少于订购数量
    if abs(inv.unit_price - po.unit_price) > po.unit_price * price_tol:
        issues.append("PRICE_VARIANCE")
    return issues
```

看一个假设的例子。采购订单为 50 件，单价 10.00，价格容差 2%。发票单价为 10.15，偏差 1.5%，通过。但发票按 50 件开票，收货单只记录了 46 件：这一行会被打上 `QTY_OVER_RECEIPT` 标记，正是 Lleverage 描述的情形。

检查：在全部样例数据上运行该函数，统计没有任何标记的行所占比例。这个数字就是你的基线，后续每一项改进都要以它为准来衡量。

## 第 3 步：每类异常该如何处理？

一串标记还不是决策。你需要把标记转换成状态，而每类异常都有各自的处理方式。

缺少收货单很常见：据 Lleverage 介绍，原因是仓库还没登记，或者登记到了错误的订单行上。SAP S/4HANA Cloud 为此专门设有容差键 DW，在收货数量为零时过账发票就会触发。

SAP 建议把 DW 配置为付款冻结而非错误，这样早于收货单到达的发票仍可在之后放行。思路保持一致：设为 `HOLD` 状态，新的收货单到达时自动重新检查。

部分交货需要仔细区分。Peakflo 认为，Agent 应当把正常的部分交货与真正的差异区分开，只有真正的差异才交给人工审批。

一种合理的划分方式：发票开票数量恰好等于已收货数量、且采购订单仍处于未关闭状态，属于正常情况，余下的货稍后会到。如果采购订单已关闭，短缺部分不会再交付，就需要人来处理。开票数量超过收货数量的发票，比如 50 箱只到 46 箱的情形，一律视为差异。

价格变化是容差救不了的情形。Lleverage 说得很直白：容差能应对四舍五入，却应对不了这种情况，因为错误出在采购订单本身——它已经过时了。这种情况需要人介入：要么修正采购订单，要么拒绝新价格。

```python
def decide(issues: list[str], po_open: bool) -> str:
    if not issues:
        return "MATCHED"
    if "PRICE_VARIANCE" in issues:
        return "ESCALATE"   # 采购订单可能已过时，即使收货单缺失也是如此
    if "QTY_OVER_RECEIPT" in issues:
        return "ESCALATE"   # 开票数量超过收货数量
    if issues == ["MISSING_GR"]:
        return "HOLD"       # 付款冻结，收货单到达后可解除
    if issues == ["PARTIAL"]:
        return "MATCHED" if po_open else "ESCALATE"
    return "ESCALATE"       # 任何不寻常的组合：交给人处理
```

分支顺序是有意安排的。一行既缺少收货单、又有价格偏差，结果是 `ESCALATE` 而不是 `HOLD`：等仓库登记收货修不好定价错误的采购订单，冻结发票只会拖延采购员必须做的工作。

检查：这个函数里没有任何状态表示“付款”。`MATCHED` 只表示三份单据一致。

## 第 4 步：只在规则失效处引入 Agent

规则真正失效的地方是行结构。据 Lleverage 介绍，行级规则默认一张发票行对应一条采购订单行，其他情况都会漏掉。

发票把三条采购订单行合并成一行，或把一行拆成两次交货，就需要有人读懂品名描述、SKU 和数量，再把每一行映射到正确的采购订单行和收货单。这正是 Agent 的工作。

Anthropic 建议对工具采用“防呆”（poka-yoke）设计，即通过设计参数让工具难以被误用。落到这里，Agent 只有两个工具，都不涉及付款：

```python
TOOLS = [
  {"name": "propose_line_mapping",
   "params": {"invoice_line": "str", "po_line": "str",
              "gr_id": "str", "reason": "str"}},
  {"name": "record_match_result",
   "params": {"invoice_line": "str",
              "status": ["MATCHED", "HOLD", "ESCALATE"],
              "reason": "str"}},
]
```

这是简化后的描述，你需要把它转换成所用大模型的工具格式。值得注意的细节是，`status` 是一个封闭枚举。Agent 无法凭空造出“APPROVED_FOR_PAYMENT”状态，因为根本不存在这个值。

Agent 提出映射方案后，在结果上再次运行 `check_line` 和 `decide`。阅读和理解交给 Agent，比对仍由代码决定。任何未通过规则的方案都会自动变为 `ESCALATE`。

**要点:** Agent 可以阅读、映射和解释；是否付款永远由人决定。

## 第 5 步：用异常本身做测试

Lleverage 把界线划得很清楚：放款永远不由 Agent 决定。把这句话变成测试。写一小组用例，覆盖上述四类异常、既缺收货单又有价格偏差的情形，再加一条完全匹配的行，然后断言每个输出状态。

再加一条测试：扫描 Agent 可调用的所有工具，断言没有一个会写入付款表。这听起来理所当然，但当客户的审计人员问“你怎么证明机器不能自己付款？”时，你拿出来的就是它。

## 常见错误

第一个错误是一开始就引入 Agent，让它处理做个减法就能解决的行。成本更高，可解释性丧失，也没有可供衡量的基线。

第二个错误是调高容差来提升匹配率。数字好看了，但价格变化的情形会漏过去，到不了本该去修正采购订单的人手里。

第三个错误是直接拒收缺少收货单的发票。仓库只是晚了一天登记收货，供应商却得重新寄送，财务得重新录入。SAP 自己都建议采用可解除的冻结。

## 这项技能在客户现场如何体现

在客户现场，你很少需要从零写 `check_line`。容差早已配置在 ERP 里，第一项任务是读懂这些配置，并问会计人员为什么选这些数值。你的 Agent 必须遵守同样的阈值，否则两套系统会为同一张发票争执不下。

第二项任务是取一周的真实发票，跑一遍规则，然后和应付账款人员坐在一起，手工给每个异常分类。这份分类能告诉你 Agent 值得部署在哪里，也会成为你的第一个评测集。

如果你想转向前线部署工程师（FDE）岗位，留意那些提到“accounts payable”（应付账款）、“ERP integration”（ERP 集成）或“human-in-the-loop”（人在回路）的岗位描述。简历上不要写“为财务构建了 AI Agent”。

而要写：你按类型拆解了异常，测量了改造前后的直通处理率，并设计了工具，让 Agent 无从触及付款。FDE 招聘方想看到的是，你清楚机器在哪里可以自主决策，在哪里不可以。

至于那 4 箱始终没到的货，它们不需要更聪明的 Agent。它们需要的是一个愿意冻结发票、把原因说清楚、并等待有权限的人来决定的系统。

**本周动手试试:**

- 从客户那里取 20 张脱敏发票（或自己造），运行 check_line，统计各类异常分别有多少行
- 为本文的四类异常以及既缺收货单又有价格偏差的情形编写测试用例，再加一条测试，断言 Agent 的任何状态都不会导向付款
- 向客户财务团队提三个问题：价格容差设为多少，由谁解除付款冻结，目前如何处理早于收货单到达的发票

## 信源

- [Three-way match — Procurement Glossary | LightSource](https://lightsource.ai/glossary/three-way-match)

- [Matching tolerance (Sage X3 documentation)](https://online-help.sagex3.com/erp/12/en-us/Content/FCT/GESMAT.htm)

- [Supplier invoice blocked or in error due to missing goods receipt in 3-way match in SAP S/4 HANA Cloud Public Edition](https://userapps.support.sap.com/sap/support/knowledge/en/3769147)

- [Three-Way Matching Automation: The Exceptions That Break It](https://www.lleverage.ai/company/blog/three-way-matching-automation)

- [Autonomous PO Matching: Can AI Agents Match Invoices Without Humans?](https://peakflo.co/blog/ai-agents-autonomous-po-matching)

- [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents)
