LLM Trả Về JSON Hoàn Hảo Vẫn Có Thể Sai: Bài Học Từ Structured Outputs

31 tháng 8, 2026·10 phút đọc

Bài viết phân tích cái bẫy tinh vi khi dùng Structured Outputs trong pipeline trích xuất dữ liệu: JSON hợp lệ về cú pháp không đồng nghĩa với dữ liệu đúng. Tác giả chia sẻ cách thiết kế schema với nullable fields, thêm bằng chứng trích dẫn, và kết hợp validator để ngăn chặn 'ảo giác' dữ liệu mà không gây lỗi hệ thống.

LLM Trả Về JSON Hoàn Hảo Vẫn Có Thể Sai: Bài Học Từ Structured Outputs

Ba tuần sau khi bật Structured Outputs cho pipeline phân tích tin nhắn xác nhận thanh toán thành các bản ghi giao dịch, tôi bắt đầu nhận thấy hệ thống đối soát liên tục gắn cờ một loạt sai lệch nhỏ nhưng đều đặn. Không phải lỗi crash, cũng không phải dòng dữ liệu lỗi định dạng — chỉ là những giao dịch có số tiền và người gửi khớp hoàn hảo nhưng ngày tháng lại sai lệch. Khoảng 2-3% khối lượng một tuần, đủ để để ý nhưng không đủ để nhận ra ngay.

Ban đầu tôi tưởng đó là lỗi múi giờ. Nhưng không phải. Khi đối chiếu tin nhắn gốc với bản ghi trích xuất, một mô hình hiện ra: mọi giao dịch sai lệch đều đến từ tin nhắn không hề đề cập đến ngày tháng. Đại loại như "Payment received from Chinedu, ₦45,000, ref TXN-82K91." — không có ngày nào trong văn bản. Vậy mà model vẫn điền transaction_date, gần như luôn là ngày pipeline chạy, sai lệch chưa đầy một giờ.

Schema khai báo transaction_date: date, required. Model không thể trả về giá trị rỗng. Vậy nên nó không trả về rỗng.

Tôi từng xem "JSON hợp lệ" là vạch đích cho pipeline này, và nhiều tuần liền nó trông đúng như vậy. Thực tế không phải. Đó là điểm mà một kiểu lỗi lặng lẽ hơn bắt đầu xuất hiện — lỗi không bao giờ phát sinh ngoại lệ, không bao giờ fail type check, và không lộ diện cho đến khi một bước xử lý phía sau phụ thuộc vào giá trị đó là thật. Hầu hết những gì viết về Structured Outputs chỉ dừng ở "nó không thể trả về JSON hỏng nữa", như thể điều đó giải quyết trọn vẹn vấn đề độ tin cậy. Nó chỉ giải quyết một phiên bản của vấn đề.

Cái bẫy của schema hoàn hảo

Structured Outputs giải quyết một vấn đề thật. Trước khi có cơ chế ép schema native, lấy JSON đáng tin cậy từ LLM đồng nghĩa với việc dùng regex parser, vòng lặp retry, và những prompt gần như nài nỉ model: "CHỈ xuất JSON, không markdown, không preamble." Với OpenAI Python SDK hiện đại và một mô hình Pydantic, phần lớn nỗi đau đó biến mất:

import logging
from datetime import date
from pydantic import BaseModel
from openai import OpenAI

logger = logging.getLogger(__name__)
client = OpenAI()

class Transaction(BaseModel):
    sender: str
    amount: float
    transaction_id: str
    transaction_date: date

document = """Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91
Date: 11 August 2026"""

# gpt-4o-mini cứ gộp amount và reference vào một field
# trên các message có định dạng bất thường, nên phải dùng model full
# dù tốn kém hơn. Cần xem lại khi mini đủ mạnh.

completion = client.beta.chat.completions.parse(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "Extract the transaction details."},
        {"role": "user", "content": document},
    ],
    response_format=Transaction,
)

txn = completion.choices[0].message.parsed
logger.info("parsed txn %s", txn.transaction_id)

Chạy với một tin nhắn sạch sẽ, nó hoạt động đúng như quảng cáo: mọi key đều có, mọi type đúng, không cần try/except chỉ để bắt một cái khung markdown quanh JSON.

Rồi ai đó chuyển tiếp cho bạn một tin nhắn thế này:

document = """Payment received from Chinedu.
Amount: ₦45,000
Reference: TXN-82K91"""
# Không có ngày trong tin này.

Nhưng schema không quan tâm là ngày không có ở đó. Nó vẫn đánh dấu required, nên thứ gì đó phải lấp chỗ trống, và schema sẽ không bao giờ chịu uốn cong. Model với lấy bất cứ thứ gì giúp nó đạt một giá trị hợp lệ: ngày hiện tại, ngày cutoff của dữ liệu huấn luyện, hoặc một con số trông có vẻ hợp lý. Kết quả trả về vượt qua type check hoàn hảo. Nhưng nó hoàn toàn bịa ra, và không có gì trong response cho bạn biết field nào là thật, field nào là bịa.

Thiết kế schema cho sự không chắc chắn

Cách khắc phục là một sự thay đổi tư duy hơn là thay đổi code. Một field rỗng không phải là lỗi trong trích xuất — thường thì đó chính là sự thật. Làm cho các field trở thành nullable sẽ giảm áp lực buộc model phải bịa ra thứ gì đó:

class Transaction(BaseModel):
    sender: str | None
    amount: float | None
    transaction_id: str | None
    transaction_date: date | None

Giờ nếu ngày bị thiếu, model có thể thẳng thắn nói "không có". Điều này cũng mang đến một sự phân biệt dễ bị mờ nhạt: trích xuất (extraction)suy diễn (inference). Trích xuất nghĩa là "cho tôi biết chính xác những gì trong văn bản". Suy diễn là "cho tôi biết điều nó ngụ ý". Một tin nhắn ghi "paid on Tuesday" với schema yêu cầu ngày ISO, đó là suy diễn, dù bạn có chủ đích yêu cầu hay không. Đôi khi suy diễn đúng là thứ bạn muốn, nhưng quyết định nên là của bạn, không phải thứ model tự làm cho bạn theo mặc định. Một field nullable trao quyết định đó lại cho code của chính bạn:

if transaction.transaction_date is None:
    request_missing_info(transaction_id=transaction.transaction_id)

Bằng chứng và nguồn gốc

Nullable fields giải quyết vấn đề "bịa giá trị từ hư không". Nhưng chúng không giải quyết vấn đề khác, thành thật mà nói còn tệ hơn: model đưa cho bạn một giá trị, và bạn không có cách nào biết nó thực đọc giá trị đó trên trang hay tìm theo mẫu để đi đến kết quả.

Với một chat response bình thường, ít nhất bạn có thể nhìn nó lý luận từng bước để ra đáp án. Structured Outputs nhảy thẳng đến dạng cuối cùng. Vậy nên tôi bắt đầu yêu cầu thêm một field thứ hai bên cạnh mỗi giá trị — chính xác đoạn văn bản nguồn được cho là hỗ trợ giá trị đó:

from pydantic import Field

class Extracted(BaseModel):
    """Wrapper tổng quát để không phải viết class gần như giống nhau cho mỗi loại field."""
    value: float | date | str | None
    evidence: str | None = Field(description="trích dẫn chính xác hỗ trợ giá trị này, để trống nếu không tìm thấy")

class Transaction(BaseModel):
    sender: str | None
    amount: Extracted
    transaction_id: str | None
    transaction_date: Extracted

Wrapper Extracted tổng quát là một lối tắt, không phải best practice. value giờ là union type thay vì float sạch, làm mất một phần type safety mà schema ban đầu có. Sự đánh đổi đó đáng giá khi schema có nhiều hơn một vài loại field — viết riêng ExtractedFloat, ExtractedDate, ExtractedString chỉ là việc vặt vãnh. Còn với một hai field, hãy giữ class chuyên biệt, thường sạch sẽ hơn.

Mô hình này có giá trị theo hai cách. Đặt evidence trước value vì các key được tạo tuần tự, nên model phải viết ra nó đang nhìn cái gì trước khi cam kết một đáp án — một cách bắt buộc "show-your-work" nhẹ nhàng. Và nó cho người review cái gì đó cụ thể để kiểm tra mà không cần đọc lại toàn bộ nguồn. Nếu value được điền nhưng evidence rỗng, hoặc chứa văn bản không hề xuất hiện trong nguồn, sự khớp lệch đó chính là cơn ảo giác hiện ra ngay trong chính dữ liệu.

Nó không miễn phí. Trên batch vài trăm tin nhắn giao dịch, thêm field evidence vào toàn bộ schema đẩy output tokens lên khoảng một phần ba, và độ trễ tăng đủ để ảnh hưởng ở quy mô pipeline.

Không đáng với mã zip 5 chữ số. Nhưng với một con số tài chính mà ai đó sẽ dùng để hành động, chắc chắn đáng giá.

Ranh giới giữa sinh và xác thực

Đến lúc này schema đã gánh khá nhiều: nullable types để nó không bịa, field evidence để tôi bắt nó khi vẫn bịa. Nhưng có cả một loại sai lầm mà cả hai thứ đó không đụng tới — đó là giá trị có hợp lý như một sự kiện về thế giới hay không.

Schema đảm bảo amount là float. Nó không nói gì về việc float đó có âm không, hay transaction_date có phải ba ngày sau đó không. Lúc đầu tôi thử sửa trong prompt, với chỉ dẫn kiểu "amount phải lớn hơn không", sau này nghĩ lại thì đó là điều lạ lùng để yêu cầu một language model thực thi. Nó không phải máy tính. Một validator làm việc này chính xác, từng lần một, hoàn toàn miễn phí:

from pydantic import model_validator, ValidationError

class Transaction(BaseModel):
    sender: str | None
    amount: float | None
    transaction_id: str | None
    transaction_date: date | None

    @model_validator(mode="after")
    def check_sane_values(self) -> "Transaction":
        # số tiền âm xuất hiện đúng hai lần, cả hai đều do
        # tin nhắn nguồn mô tả hoàn tiền chứ không phải thanh toán
        if self.amount is not None and self.amount < 0:
            raise ValueError(f"amount {self.amount} is negative")
        # ngày tương lai: dai dẳng, từ messages có timezone viết sai
        if self.transaction_date is not None and self.transaction_date > date.today():
            raise ValueError(f"transaction_date {self.transaction_date} is in the future")
        return self

Vậy nên API đảm bảo cấu trúc ngay lúc sinh response, còn Pydantic đảm bảo dữ liệu có ý nghĩa ngay khi được parse vào object, cùng một cách mỗi lần, không có LLM nào tham gia vào bước kiểm tra thứ hai. Khi validator quăng lỗi, bạn có lựa chọn: chuyển record cho con người, hoặc đưa chính xác lỗi đó trả lại model và cho nó thử lại. Tôi chọn phương án hai, giới hạn cứng hai lần retry:

MAX_RETRIES = 2

def extract_with_retry(document: str) -> Transaction:
    history = [
        {"role": "system", "content": "Extract the transaction details."},
        {"role": "user", "content": document},
    ]

    for attempt in range(MAX_RETRIES + 1):
        completion = client.beta.chat.completions.parse(
            model="gpt-4o", messages=history, response_format=Transaction
        )
        raw = completion.choices[0].message.content
        try:
            return Transaction.model_validate_json(raw)
        except ValidationError as e:
            if attempt == MAX_RETRIES:
                raise  # bỏ cuộc, để caller chuyển cho con người
            logger.warning("validation failed on attempt %d: %s", attempt, e)
            history += [
                {"role": "assistant", "content": raw},
                {"role": "user", "content": f"That failed validation: {e}. Fix only the bad field."},
            ]

Giới hạn MAX_RETRIES thực chất quan trọng hơn vẻ ngoài. Bản năng đầu tiên của tôi là để nó thử mãi, đó là sai lầm. Hai lần fail gần như luôn nghĩa là tài liệu nguồn mới là vấn đề, chứ không phải prompt, và lần chạy tự động thứ ba chỉ đốt API call cho một việc con người giải quyết trong mười giây.

Không có gì trong đây chỉ dành riêng cho OpenAI, dù mọi đoạn code đều dùng nó. Thay bằng Anthropic tool use hoặc self-hosted với vLLM và Outlines, mô hình Pydantic không nhúc nhích, chỉ có API call xung quanh thay đổi.

Nghĩ lại về mục tiêu của trích xuất

Khi mới làm cho thứ này chạy, tiêu chuẩn thành công của tôi thấp đến mức đáng ngại: model có điền object mà không làm hỏng parser của tôi không. Nhìn lại, tiêu chuẩn đó khen thưởng sai thứ hoàn toàn, bởi vì một model háo hức điền mọi field bất kể trước mặt nó là gì không phải là đáng tin cậy. Nó chỉ tự tin — một thứ khác và nguy hiểm hơn.

Structured Outputs thực sự giỏi việc chúng làm. Chúng chỉ không làm điều tôi tưởng ban đầu. Chúng đảm bảo hình dạng, không đảm bảo sự thật. Một khi bạn ngừng lo lắng về dấu ngoặc và escape quote, câu hỏi thật vẫn còn nguyên đó chờ bạn: mọi giá trị trong object này có một lý do thực sự để tồn tại không?

Câu hỏi đó luôn là phần khó. Schema chỉ từng che giấu nó khỏi tôi.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗