發票解析器就算回傳完全有效的 JSON,仍可能把錯誤金額送進付款流程。關鍵不只是「模型有沒有輸出 JSON」,而是呼叫端檢查了哪些條件,以及原始文件無法支持某個值時會怎麼處理。假設一張示範用發票寫著:Invoice INV-1042; PO PO-778; Total USD 19.00.我們要讓應用程式提出一筆對應既有採購單的應付建議,而不是讓模型核准付款。這張發票、採購單與金額都是虛構範例。
先選對交接時的約束
一般提示詞寫「請用 JSON 回覆」,只是格式要求,並非機制上的限制。JSON 模式會約束輸出符合 JSON 語法;但 {"invoice_id":"INV-1042","total":"19.00"} 即使是有效 JSON,仍缺少必要的 po_id 與 currency。若無法使用結構描述約束的回應,JSON 模式仍有用途,但呼叫端必須自行驗證並拒絕不合格紀錄。OpenAI 也指出,JSON 模式需要明確要求輸出 JSON,且未完成的回應可能無法解析;收到片段不能當作收到完整資料。OpenAI:JSON 模式
在這個擷取任務中,適合用結構化回應把資料交給應用程式檢查。OpenAI 的 Structured Outputs 可在支援的模型與設定下,約束輸出遵循指定結構描述,但只支援 JSON Schema 的一部分。若模型需要請求呼叫應用程式功能,則可定義具嚴格模式的函式工具來限制引數。工具呼叫只是對程式碼提出請求,不代表獲准執行;執行前仍須驗證與授權。不符合要求或不受支援的嚴格結構描述可能在送出請求時就被拒絕,因此應依實際模型、API 介面與官方文件確認,不能假設任何 JSON Schema 都能用。OpenAI:Structured Outputs;OpenAI:函式呼叫的嚴格模式
把缺值與中斷分開處理
這裡定義一份小型擷取契約 invoice_extract_v1:version、status、invoice_id、po_id、total、currency、evidence 七個鍵都必須存在,不接受額外鍵。status 只能是 ready 或 needs_review;發票編號、採購單編號、總額、幣別均為字串或 null,evidence 則為原文逐字摘錄或 null。若標為 ready,所有值和摘錄都要有內容;若來源缺漏或互相矛盾,未能確認的欄位應為 null,狀態改為 needs_review,不可捏造零元或猜測幣別。金額用十進位字串,不用二進位浮點數。這是應用程式契約,實際呼叫前還須轉為所選服務支援的結構描述。JSON Schema 的 required 只規定鍵要存在,不能證明值正確;鍵存在但值為 null,仍需型別明確允許。JSON Schema:必要屬性
下方最小結構描述只約束鍵、型別與狀態列舉值;狀態與欄位的連動規則、內容是否符合來源,留給應用程式檢查。它使用嚴格結構化輸出常見的簡單關鍵字,部署前仍要確認目標 API 與模型是否支援。
{"type":"object","properties":{
"version":{"type":"string","enum":["invoice_extract_v1"]},
"status":{"type":"string","enum":["ready","needs_review"]},
"invoice_id":{"type":["string","null"]},
"po_id":{"type":["string","null"]},
"total":{"type":["string","null"]},
"currency":{"type":["string","null"]},
"evidence":{"type":["string","null"]}
},"required":["version","status","invoice_id","po_id","total","currency","evidence"],"additionalProperties":false}
服務端的拒絕回應不是 needs_review:要依 API 的拒絕訊號處理,不能硬把它解析成發票。未完成的回應(包含輸出長度上限造成的中斷)也不是完整擷取結果;應停下來檢查原因,只在適合時有限次重試。傳輸錯誤與結構描述請求錯誤又是另外的狀態。只有已完成、未被拒絕的回應,才送進擷取結果驗證器。即使採用嚴格格式,仍需要分清這些分支。OpenAI:拒絕與未完成回應
格式正確,數字仍可能錯
針對示範發票,下列紀錄看似完整,鍵與型別也符合約束:
{"version":"invoice_extract_v1","status":"ready","invoice_id":"INV-1042","po_id":"PO-778","total":"119.00","currency":"USD","evidence":"Invoice INV-1042; PO PO-778; Total USD 19.00."}
但 total 寫成 119.00,原文只有 19.00。結構描述能檢查總額是字串,卻不能證明模型讀對了發票。即使金額與原文相符,也可能與已核准的採購單不符。應用程式仍須拿可信的採購單紀錄核對金額與幣別,並查核目前使用者是否有權處理該筆採購單。模型引用的原文有助於複核,但引用本身不是正確性的證明。
用一個小型程式守住下游關卡
以下是只使用 Python 標準函式庫的示範,輸入前提是已完成且未被服務端拒絕的擷取結果。它既不是 OpenAI API 的模擬器,也不是完整的 JSON Schema 驗證器;它只檢查上述契約、專為這段範例文字設計的格式、獨立提供的採購單總額,以及授權清單。正式系統應使用適合的驗證器檢查實際結構描述,並以可信的文件擷取與採購單系統核對;不能把簡單的字串包含檢查當成詐欺偵測。將程式碼區塊存成 invoice_gate.py 後,可執行 python invoice_gate.py。
import json
import re
from decimal import Decimal, InvalidOperation
SOURCE = "Invoice INV-1042; PO PO-778; Total USD 19.00."
KEYS = {"version", "status", "invoice_id", "po_id", "total", "currency", "evidence"}
GOOD = {"version": "invoice_extract_v1", "status": "ready",
"invoice_id": "INV-1042", "po_id": "PO-778", "total": "19.00",
"currency": "USD", "evidence": SOURCE}
def gate(raw, source, order_totals, authorized_pos):
def reject(reason):
return "REJECT: " + reason
try:
record = json.loads(raw)
except json.JSONDecodeError:
return reject("invalid JSON")
if not isinstance(record, dict) or set(record) != KEYS:
return reject("contract keys")
if record["version"] != "invoice_extract_v1":
return reject("unknown version")
if record["status"] not in ("ready", "needs_review"):
return reject("status")
fields = ("invoice_id", "po_id", "total", "currency", "evidence")
if any(value is not None and not isinstance(value, str)
for value in (record[key] for key in fields)):
return reject("field type")
if record["status"] == "needs_review":
return "REVIEW: unresolved extraction; no action"
if any(not record[key] for key in fields):
return reject("ready requires all values")
if record["evidence"] not in source:
return reject("evidence not in source")
# Demonstration grammar for this one sample, not general invoice OCR.
match = re.fullmatch(
r"Invoice (INV-\d+); PO (PO-\d+); Total (USD) (\d+\.\d{2})\.",
record["evidence"])
if not match or tuple(record[k] for k in
("invoice_id", "po_id", "currency", "total")) != match.groups():
return reject("values do not match source")
try:
amount = Decimal(record["total"])
except InvalidOperation:
return reject("amount")
po = record["po_id"]
if po not in order_totals or (record["currency"], amount) != order_totals[po]:
return reject("order amount/currency mismatch")
if po not in authorized_pos:
return reject("user not authorized for order")
return "PASS: proposal may enter approval; do not pay automatically"
def run(label, record, authorized={"PO-778"}):
print(label, gate(json.dumps(record), SOURCE,
{"PO-778": ("USD", Decimal("19.00"))}, authorized))
if __name__ == "__main__":
run("correct", GOOD)
run("missing key", {k: v for k, v in GOOD.items() if k != "currency"})
run("wrong total", {**GOOD, "total": "119.00"})
run("unauthorized", GOOD, set())
run("unknown", {**GOOD, "status": "needs_review", "total": None})
這道關卡會拒絕語法有效卻缺鍵的資料、拒絕形式符合結構描述但金額與來源不符的資料、阻擋未獲授權的請求,並把未知金額送交人工複核。PASS 只允許建議進入核准流程,絕不自動付款。
依失敗原因分流,再為契約建立回歸測試
| 觀察到的狀態 | 呼叫端的處理 |
|---|---|
| 暫時性傳輸故障 | 有限次重試,並做好冪等性保護,避免重複動作。 |
| 已完成回應卻不是有效 JSON,或缺少必要鍵 | 拒絕;至多再嘗試擷取一次,仍失敗就轉人工複核。若使用嚴格輸出,應調查設定。 |
| 服務端拒絕或回應未完成 | 走 API 對應的拒絕/未完成分支,不當作完整發票反序列化;只有中斷原因可排除時才重試。 |
| 來源欄位缺漏或互相矛盾 | 索取較清楚的文件或交由人工複核,不要反覆要求模型猜測。 |
| 來源不符、採購單不符或無處理權限 | 阻擋動作並升級處理;模型重新回答也不會產生授權。 |
| 不支援的嚴格結構描述或請求錯誤 | 修正結構描述,或改選受支援的模型與設定;不要原封不動重送請求。 |
將擷取契約版本與模型識別碼、提示詞版本、驗證器版本一起固定並記錄;遇到未知版本就拒絕,不自行猜測欄位意義。回歸測試至少涵蓋上方程式的五種結果,再加入幣別缺漏、總額互相矛盾、其他幣別、重複發票編號,以及被截斷或拒絕的 API 回應(後兩者要在 API 邊界測試,不是靠這個本機關卡)。按類別統計失敗,並核對真實文件證據後再擴大自動化。若要進一步了解購物流程中「能提出動作」與「有權執行」的差別,可參考 Meydo Journal 的 AI 代理購物付款文章。
