Trích xuất hoá đơn và hợp đồng bằng LLM: schema, kiểm tra nghiệp vụ và trích dẫn nguồn
JSON parse được chưa phải là JSON đúng; hướng dẫn này giúp bạn dựng một pipeline biết khi nào nên tin con số và khi nào phải gọi người duyệt.
Tóm tắt nhanh
- Structured outputs bảo đảm cấu trúc JSON chứ không bảo đảm giá trị đúng, nên vẫn phải kiểm tra stop_reason, chuẩn hoá enum và kiểm tra nghiệp vụ.
- Một validator Pydantic vài dòng có thể bắt được lỗi tổng tiền mà schema cho qua.
- Mỗi trường trích xuất cần có đoạn trích nguồn đi kèm để người duyệt xác minh trong vài giây.
- 1Schema gọnChỉ mô tả hình dạng dữ liệu, thêm trường evidence; tránh tính năng gây lỗi 400
- 2Gọi API, đọc stop_reasonrefusal hoặc max_tokens thì chuyển người duyệt, không parse JSON
- 3Chuẩn hoá enumHạ chữ thường currency trong validator Pydantic vì schema không bảo đảm hoa/thường
- 4Pydantic kiểm tra nghiệp vụTổng dòng hàng phải bằng subtotal, subtotal cộng VAT phải bằng total
- 5Đối chiếu nguồnĐoạn trích phải có trong văn bản gốc, hoặc lấy qua Citations API
Bản ghi chỉ vào hệ thống khi qua đủ năm bước; trượt bước nào sẽ chuyển người duyệt kèm lý do.
Đồ hoạ: FDE Times
Hoá đơn ghi tổng 3.850.000 đồng. Model trả về 3.580.000, JSON vẫn hợp lệ và parse không lỗi. Hệ thống kế toán của khách nhận con số đó mà không ai thắc mắc.
Nếu bạn làm trích xuất tài liệu cho khách, đây là kiểu lỗi nên chuẩn bị từ ngày đầu. Docs của Anthropic nói rõ structured outputs dùng constrained decoding để đầu ra khớp schema và parse được cho các bước sau. Nhưng khớp schema mới chỉ là điều kiện đầu tiên.
Một bản demo trả ra JSON đẹp chưa trả lời được hai câu hỏi mà người dùng số liệu tài chính nào cũng sẽ hỏi: con số có đúng không, và nếu đúng thì nằm ở dòng nào trong tài liệu gốc. Hướng dẫn dưới đây đi qua năm bước để trả lời cả hai.
Bạn sẽ dựng gì, và cần gì trước?
Kết quả cuối cùng là một script Python nhận văn bản hoá đơn rồi trả về JSON đã đi qua năm bước kiểm tra: schema, stop_reason, chuẩn hoá enum, quy tắc nghiệp vụ và trích dẫn nguồn. Bản nào trượt bất kỳ bước nào sẽ được chuyển cho người duyệt, không đi thẳng vào hệ thống.
Bạn cần Python 3, SDK anthropic, thư viện pydantic và một API key. Code dưới đây đã được giản lược để dễ đọc. Tên model để dạng MODEL_ID; trước khi chạy, hãy đối chiếu tên model và cấu trúc tham số với trang Structured outputs trong docs của Anthropic.
Bước 1: schema càng gọn càng sống lâu
Docs của Anthropic cảnh báo rằng schema dùng tính năng không được hỗ trợ sẽ nhận lỗi 400 kèm chi tiết. Vì thế đừng dồn ràng buộc nghiệp vụ vào schema, chẳng hạn minimum hay maximum cho số tiền. Schema chỉ cần mô tả hình dạng dữ liệu, còn luật nghiệp vụ để dành cho Bước 4.
INVOICE_SCHEMA = {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"issue_date": {"type": "string"},
"currency": {"type": "string", "enum": ["vnd", "usd"]},
"line_items": {"type": "array", "items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"quantity": {"type": "number"},
"unit_price": {"type": "number"},
"amount": {"type": "number"}
},
"required": ["description", "quantity", "unit_price", "amount"],
"additionalProperties": False
}},
"subtotal": {"type": "number"},
"vat": {"type": "number"},
"total": {"type": "number"},
"evidence": {"type": "array", "items": {
"type": "object",
"properties": {
"field": {"type": "string"},
"quote": {"type": "string"}
},
"required": ["field", "quote"],
"additionalProperties": False
}}
},
"required": ["invoice_number", "currency", "line_items",
"subtotal", "vat", "total", "evidence"],
"additionalProperties": False
}
Trường evidence được thêm vào có chủ đích: model phải chép lại nguyên văn đoạn văn bản mà nó dựa vào để điền từng trường. Bước 5 sẽ dùng đến trường này. Cần kiểm tra: gửi thử một request; nếu nhận lỗi 400 thì đọc phần chi tiết lỗi và bỏ bớt tính năng schema mà API không chấp nhận.
Bước 2: gọi API và đọc stop_reason trước khi đọc JSON
Theo docs của Anthropic, JSON outputs được cấu hình qua output_config.format và dành cho các việc như trích xuất dữ liệu từ ảnh hoặc văn bản. Chế độ còn lại, strict tool use, dùng để kiểm tra tham số của tool. Với bài này, bạn dùng JSON outputs.
import json, anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model=MODEL_ID,
max_tokens=4096,
messages=[{"role": "user",
"content": "Trích xuất hoá đơn sau. Với mỗi trường, "
"chép nguyên văn đoạn chứa nó vào evidence.\n\n"
+ invoice_text}],
output_config={"format": {"type": "json_schema",
"schema": INVOICE_SCHEMA}},
)
if resp.stop_reason == "refusal":
route_to_human(invoice_text, reason="refusal")
elif resp.stop_reason == "max_tokens":
route_to_human(invoice_text, reason="truncated")
else:
data = json.loads(resp.content[0].text)
Hai nhánh if đầu tiên là phần nhiều người bỏ qua. Docs nêu rõ khi model từ chối, thông báo từ chối được ưu tiên hơn ràng buộc schema, nên đầu ra có thể không khớp schema. Khi bị cắt vì max_tokens, đầu ra có thể dở dang và cũng không khớp schema.
Hợp đồng dài và hoá đơn có hàng trăm dòng hàng là chỗ nhánh thứ hai hay xảy ra. Cần kiểm tra: đặt max_tokens=50 với một tài liệu dài và xác nhận script đi vào nhánh truncated thay vì crash ở json.loads.
Bước 3: chuẩn hoá những gì schema không hứa
Docs của Anthropic cũng lưu ý rằng structured outputs không bảo đảm chữ hoa hay chữ thường của các giá trị enum và const. Bạn khai báo "vnd" nhưng code phía sau vẫn có thể nhận "VND". Nếu hệ thống của khách so sánh chuỗi một cách tuyệt đối, một khác biệt nhỏ như vậy cũng làm hỏng cả lô dữ liệu.
Cách xử lý đơn giản là chuẩn hoá bằng một field_validator ngay trong model Pydantic ở bước tiếp theo, thay vì tin rằng enum sẽ luôn đúng.
Bước 4: để Pydantic bắt lỗi tiền
Quay lại hoá đơn ở đầu bài. Có 2 dòng dịch vụ giá 1.500.000 và 1 dòng giá 500.000, nên tạm tính là 3.500.000. VAT 10% là 350.000, tổng cộng 3.850.000. Model đọc nhầm thành 3.580.000, kiểu số vẫn hợp lệ, schema vẫn cho qua.
from pydantic import BaseModel, field_validator, model_validator
class LineItem(BaseModel):
description: str
quantity: float
unit_price: float
amount: float
class Invoice(BaseModel):
invoice_number: str
currency: str
line_items: list[LineItem]
subtotal: float
vat: float
total: float
@field_validator("currency", mode="before")
@classmethod
def normalize_currency(cls, v):
return v.lower()
@model_validator(mode="after")
def check_totals(self):
s = sum(i.amount for i in self.line_items)
if abs(s - self.subtotal) > 1:
raise ValueError(f"subtotal {self.subtotal} != tổng dòng {s}")
if abs(self.subtotal + self.vat - self.total) > 1:
raise ValueError(f"total {self.total} != subtotal + vat")
return self
Với con số 3.580.000, phép kiểm tra thứ hai thấy 3.500.000 + 350.000 khác 3.580.000 và báo lỗi. Đây là loại lỗi mà constrained decoding không bao giờ phát hiện, vì nó chỉ quan tâm đến hình dạng dữ liệu.
Khi validation thất bại, có hai hướng xử lý. Hướng thứ nhất là chuyển cho người duyệt. Hướng thứ hai là dùng thư viện Instructor: theo trang giới thiệu, Instructor kiểm tra đầu ra bằng Pydantic và tự hỏi lại model khi validation thất bại.
Với số liệu tài chính, nên giới hạn số lần hỏi lại và ghi log từng lần, để sau này giải thích được vì sao một con số đã bị sửa.
Bước 5: mỗi trường phải chỉ ra được nguồn
Trường evidence từ Bước 1 giờ có việc để làm. Một hàm vài dòng kiểm tra xem đoạn trích model đưa ra có thực sự nằm trong văn bản gốc không:
def unverified_fields(data, source_text):
norm = lambda s: " ".join(s.split())
src = norm(source_text)
return [e["field"] for e in data["evidence"]
if norm(e["quote"]) not in src]
Đây là cách làm giản lược: nó chỉ chuẩn hoá khoảng trắng, nên văn bản OCR có lỗi chính tả sẽ gây báo động giả.
Nếu khách cần mức xác minh chặt hơn, Citations API của Anthropic trả về đúng những đoạn văn bản hỗ trợ cho từng nhận định, để bạn xác minh và hiển thị nguồn cho người dùng.
Docs hiện tại ghi rằng mọi model đang hoạt động đều hỗ trợ citations.
Một thiết kế thận trọng là tách thành hai lượt gọi riêng: lượt đầu trích xuất bằng structured outputs, lượt sau hỏi lại những trường quan trọng (tổng tiền, ngày hết hạn hợp đồng, điều khoản phạt) và bật citations. Trước khi gộp hai tính năng vào cùng một request, hãy kiểm tra trong docs xem chúng có dùng chung được không.
Những lỗi hay gặp nhất là gì?
Lỗi phổ biến nhất là coi json.loads thành công nghĩa là xong việc. Lỗi thứ hai là nhồi luật nghiệp vụ vào schema, nhận lỗi 400, rồi mất cả buổi chiều gỡ từng phần. Lỗi thứ ba là chỉ test với hoá đơn ngắn, nên lần đầu gặp nhánh max_tokens lại là lúc chạy dữ liệu thật của khách.
Nếu khách đang dùng OpenAI, nguyên tắc vẫn giữ nguyên. OpenAI khuyên dùng Structured Outputs thay cho JSON mode, vì cả hai đều cho ra JSON hợp lệ nhưng chỉ Structured Outputs bảo đảm đúng schema. Response cũng có trường refusal để code phát hiện khi model từ chối.
Có một chi tiết nhỏ cần nhớ trước buổi demo. Ted Sanders của OpenAI cho biết request đầu tiên với mỗi JSON schema sẽ chậm, vì schema phải được tiền xử lý thành context-free grammar. Hãy gọi thử một lần trước khi khách vào phòng họp.
Ở chỗ khách, kỹ năng này trông như thế nào?
Thử hình dung một công ty logistics nhận vài nghìn hoá đơn nhà cung cấp mỗi tháng. Câu hỏi đầu tiên của trưởng phòng kế toán nhiều khả năng sẽ không phải độ chính xác bao nhiêu phần trăm.
Câu hỏi sẽ là: khi máy sai, ai biết và biết bằng cách nào. Pipeline năm bước ở trên trả lời câu đó: mỗi bản ghi hoặc đi thẳng vào hệ thống, hoặc vào hàng chờ duyệt kèm lý do cụ thể.
Hàng chờ đó nên được thiết kế kỹ như phần code. Mỗi mục nên hiện văn bản gốc, JSON đã trích, lý do bị chặn (refusal, truncated, lệch tổng tiền hay trường chưa xác minh được) và đoạn trích evidence của từng trường, để người duyệt với hoá đơn 3.580.000 chỉ cần nhìn dòng tổng cộng là sửa được.
Khi đọc mô tả công việc FDE, bạn có thể tìm những cụm như “document processing”, “data extraction” hay “human-in-the-loop”; đó là tín hiệu công việc giống bài này. Trong CV, đừng chỉ viết “dùng LLM trích xuất hoá đơn”.
Hãy ghi rõ bạn kiểm tra stop_reason, validate tổng tiền và gắn trích dẫn nguồn cho từng trường, vì những chi tiết đó cho thấy bạn đã nghĩ đến lúc hệ thống sai chứ không chỉ lúc nó chạy đúng.
Ai cũng có thể làm một bản demo trả ra JSON đẹp. Khách sẽ ký với người chỉ ra được hệ thống làm gì khi gặp con số 3.580.000.