Cơn ác mộng Production lúc 2 giờ sáng
Đã 2:14 sáng. Điện thoại của tôi rung bần bật trên tủ đầu giường bởi các cảnh báo từ Sentry. Thông báo lỗi này luôn là nỗi ám ảnh với mọi kỹ sư AI: json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0).
Ứng dụng của chúng tôi, vốn dùng để tóm tắt các văn bản pháp lý phức tạp, đã chạy trơn tru trong nhiều tuần. Thế rồi, chẳng báo trước, LLM bỗng dưng trở nên “nhiệt tình” quá mức. Thay vì trả về một đối tượng JSON thuần túy, nó lại thêm lời dẫn: “Chắc chắn rồi! Đây là dữ liệu có cấu trúc mà bạn yêu cầu:” và bao bọc JSON trong các dấu backtick của markdown.
Bộ phân giải (parser) dựa trên regex của tôi, vốn được thiết kế để loại bỏ các dấu backtick đó, đã bị nghẽn chỉ vì một dòng trống không mong muốn. Dịch vụ hạ nguồn nhận được một chuỗi ký tự bị hỏng thay vì một đối tượng. Toàn bộ pipeline sụp đổ.
Đây là thực tế khi xây dựng các ứng dụng AI thực tế. LLM là các công cụ dự đoán văn bản không định trước (non-deterministic), chứ không phải là các API endpoint đáng tin cậy. Nếu mã nguồn của bạn dựa dẫm vào json.loads(response.choices[0].message.content), bạn không phải đang xây dựng một hệ thống ổn định. Bạn đang xây một lâu đài trên cát.
Nguyên nhân gốc rễ: Tại sao LLM làm hỏng code của bạn
Về cơ bản, LLM không hiểu về “kiểu dữ liệu” (types) như Python hay TypeScript. Bạn có thể van nài mô hình “Chỉ trả về JSON thôi,” nhưng nó vẫn có thể tự “sáng tác” ra một tên trường như user_id trong khi bạn mong đợi là id. Nó có thể bỏ sót một dấu phẩy bắt buộc hoặc thêm vào các câu từ giao tiếp làm hỏng bộ parser của bạn.
Ngay cả khi đã bật “JSON Mode” trong các mô hình như GPT-4o, Gemini 1.5 Pro hay khi triển khai DeepSeek-R1 cục bộ, bạn vẫn phải đối mặt với ba rào cản lớn:
- Sai lệch cấu trúc (Schema Drift): Mô hình có thể tự ý thay đổi một danh sách các chuỗi thành một chuỗi duy nhất cách nhau bởi dấu phẩy.
- Lỗi Logic: JSON có thể hợp lệ về cú pháp nhưng lại phi lý về logic, chẳng hạn như tuổi của người dùng là -15 hoặc ngày bắt đầu lại sau ngày kết thúc.
- Vòng lặp thử lại (Retry Loop): Khi mô hình gặp lỗi, bạn cần một cách để chỉ chính xác cho nó biết nó đã sai ở đâu và yêu cầu sửa lại mà không cần phải viết một vòng lặp try-except khổng lồ và lộn xộn.
So sánh các giải pháp
Trước khi tìm ra một tiêu chuẩn tốt hơn thay vì “Vibe Check” Prompt, tôi đã thử qua các phương pháp quen thuộc:
1. Sử dụng Regex và phân giải JSON thủ công
Cách này bao gồm việc viết các hàm để tìm dấu { đầu tiên và dấu } cuối cùng. Đây là một gánh nặng bảo trì. Mỗi khi bạn tinh chỉnh prompt, bộ parser của bạn lại có nguy cơ bị hỏng. Nó mong manh, xấu xí và không thể mở rộng cho hàng chục tính năng khác nhau.
2. LangChain Output Parsers
LangChain cung cấp các bộ parser tích hợp sẵn, nhưng chúng thường mang lại cảm giác như một “hộp đen”. Chúng tạo ra overhead đáng kể và có thể làm tăng kích thước môi trường của bạn thêm hàng trăm megabyte. Nếu bạn chỉ cần dữ liệu có cấu trúc mà không cần sức nặng của cả một framework đồ sộ như LangChain, thì đây là một sự lựa chọn quá mức cần thiết (overkill).
3. Tiêu chuẩn hiện đại: Instructor
Instructor là một thư viện wrapper gọn nhẹ cho các LLM client (OpenAI, Anthropic, Gemini) tận dụng sức mạnh của Pydantic. Thay vì coi LLM là một trình tạo văn bản, bạn coi nó như một hàm điền dữ liệu vào một class Pydantic. Nó xử lý việc tạo prompt, kiểm chứng dữ liệu (validation) và—quan trọng nhất—là yêu cầu mô hình thử lại khi có lỗi xảy ra.
Cách tốt hơn: Triển khai Instructor
Tôi đã chuyển tất cả các pipeline production của chúng tôi sang cách tiếp cận này. Sự ổn định đã thay đổi rõ rệt. Dưới đây là cách bạn có thể thay thế việc phân giải mong manh bằng một thiết lập mạnh mẽ, an toàn về kiểu dữ liệu.
Bước 1: Cài đặt
Bạn sẽ cần instructor và pydantic. Trong ví dụ này, chúng ta sẽ sử dụng OpenAI, nhưng Instructor hoạt động với hầu hết các nhà cung cấp lớn.
pip install instructor pydantic openai
Bước 2: Định nghĩa Schema dữ liệu
Đừng ngồi hy vọng sẽ nhận được các key đúng. Hãy định nghĩa chúng dưới dạng một class Pydantic. Class này sẽ trở thành nguồn chân lý duy nhất (single source of truth) cho cấu trúc dữ liệu của bạn.
from pydantic import BaseModel, Field, field_validator
from typing import List
class UserDetail(BaseModel):
name: str
age: int = Field(..., description="Tuổi của người dùng tính theo năm")
email: str
interests: List[str]
@field_validator("age")
@classmethod
def must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("Tuổi phải là một số nguyên dương")
return v
Bước 3: Khởi tạo Client và trích xuất
Instructor bao bọc client tiêu chuẩn để thêm tham số response_model. Đây là nơi việc kiểm chứng dữ liệu diễn ra.
import instructor
from openai import OpenAI
# Khởi tạo client đã được patch
client = instructor.from_openai(OpenAI(api_key="your_api_key"))
# Trích xuất dữ liệu có cấu trúc trực tiếp vào model Pydantic
user = client.chat.completions.create(
model="gpt-4o",
response_model=UserDetail,
messages=[
{"role": "user", "content": "Trích xuất: Tôi tên là Jason, tôi 28 tuổi. Email của tôi là [email protected] và tôi thích lập trình, leo núi."}
]
)
print(f"Tên: {user.name}, Tuổi: {user.age}")
# Kết quả: Tên: Jason, Tuổi: 28
Tại sao phương pháp này chiến thắng: Tự động thử lại (Automatic Retries)
Sức mạnh thực sự của Instructor không chỉ nằm ở việc trích xuất ban đầu. Đó là tính năng max_retries. Nếu LLM trả về một số tuổi không hợp lệ (như -5) hoặc email sai định dạng, Pydantic sẽ ném ra lỗi validation. Instructor sẽ bắt lỗi đó, gửi ngược lại cho LLM và nói: “Bạn đã cung cấp -5, nhưng tuổi phải là số dương. Vui lòng sửa lại.”
user = client.chat.completions.create(
model="gpt-4o",
response_model=UserDetail,
max_retries=3,
messages=[
{"role": "user", "content": "Trích xuất thông tin cho Bob, người hiện đang -10 tuổi..."}
]
)
Trong môi trường production, vòng lặp đơn giản này có thể giảm tỷ lệ lỗi phân giải từ 10% xuống dưới 0,1%. Thay vì bị sập, ứng dụng của bạn sẽ tự chữa lành trong thời gian thực.
Mẹo thực tế cho Production
Sau khi di chuyển nhiều pipeline cốt lõi, tôi đã tìm thấy một vài chiến lược để tối đa hóa độ tin cậy:
1. Sử dụng mô tả trường (Field Descriptions)
Phần description trong Field của Pydantic thực tế được gửi đến LLM như một phần của hướng dẫn. Nếu mô hình gặp khó khăn với một trường cụ thể, đừng chỉ viết lại prompt chính. Hãy thêm một mô tả rõ ràng hơn vào chính trường đó.
2. Tận dụng Enums
Nếu một trường chỉ nên chấp nhận các giá trị cụ thể, như ['high', 'medium', 'low'], hãy sử dụng Enum của Python. Instructor ép buộc LLM phải chọn từ các tùy chọn cụ thể đó, giúp loại bỏ việc phải dọn dẹp chuỗi ký tự sau này.
3. Xử lý lồng ghép phức tạp
Instructor xử lý các model lồng nhau một cách dễ dàng. Nếu bạn cần trích xuất một danh sách đơn hàng, trong đó mỗi đơn hàng chứa một danh sách các mặt hàng, và mỗi mặt hàng có mã SKU và giá, chỉ cần định nghĩa các class. Công cụ sẽ tự xử lý việc ánh xạ cho bạn.
Lời kết
Thời đại của response.split("\n") đã kết thúc. Nếu bạn đang xây dựng các ứng dụng AI chuyên nghiệp, bạn không thể coi đầu ra của LLM chỉ là các chuỗi ký tự đơn thuần. Bằng cách sử dụng Instructor và Pydantic, bạn chuyển gánh nặng về tính toàn vẹn dữ liệu từ các mẫu regex mong manh sang một lớp kiểm chứng an toàn về kiểu dữ liệu và mạnh mẽ.
Kể từ khi tôi chuyển các dự án của mình sang mô hình này, những cảnh báo ‘JSONDecodeError’ lúc 2 giờ sáng đã biến mất. Code sạch hơn, việc kiểm thử dễ dàng hơn và ứng dụng trở nên đáng tin cậy hơn đáng kể cho người dùng cuối.

