Structured outputs với schema strict: chặn refusal và JSON bị cắt trước khi parse
Với Claude, một lần model từ chối vẫn trả status 200 và vẫn bị tính phí token, nên code của bạn phải đọc tín hiệu dừng trước khi tin vào JSON.
Tóm tắt nhanh
- Với Claude, refusal vẫn trả status 200, vẫn tính phí token, và JSON có thể không khớp schema
- Khi JSON bị cắt do max_tokens, response có tín hiệu riêng; Anthropic hướng dẫn gửi lại với max_tokens cao hơn
- JSON đúng schema chưa chắc mang giá trị đúng: luôn validate giá trị trong ứng dụng
Response trả status 200, token bị tính phí đủ, vậy mà JSON bên trong không khớp schema bạn gửi. Với Claude, đó không phải lỗi hệ thống mà là một refusal. Tài liệu của Anthropic viết rõ: khi model từ chối, thông điệp từ chối được ưu tiên hơn ràng buộc của schema.
Rất dễ nghĩ rằng bật strict là đủ. Thực tế strict chỉ bảo đảm cấu trúc JSON khi model trả lời trọn vẹn. Còn hai trường hợp khác: model từ chối, hoặc đầu ra bị cắt giữa chừng vì chạm giới hạn token. Refusal của Claude trông y như một response thành công. Đầu ra bị cắt thì có tín hiệu dừng riêng, nhưng chỉ ai chịu đọc mới thấy.
Nếu bạn là FDE đang đưa một pipeline trích xuất dữ liệu vào hệ thống của khách hàng, ba nhánh này chính là chỗ bản demo khác bản production. Hướng dẫn dưới đây xây lần lượt từng nhánh, theo đúng thứ tự cần kiểm tra.
Bạn sẽ xây gì, và cần chuẩn bị gì?
Ví dụ xuyên suốt là một tình huống giả định. Hàm extract_invoice(text) đọc email hoá đơn của khách hàng và trả về một dict gồm nhà cung cấp, số hoá đơn, tổng tiền và đơn vị tiền tệ. Khi không có kết quả dùng được, hàm ném ra một lỗi có tên rõ ràng, không để exception văng ra tuỳ tiện.
Bạn cần Python, một API key của Claude hoặc OpenAI, và vài email mẫu. Các khối code bên dưới đã được rút gọn cho dễ thấy logic. Tên trường và cách gọi SDK chính xác thì bạn đối chiếu với tài liệu hiện hành của nhà cung cấp.
Bước 1: schema nào thì API chịu nhận?
Bắt đầu từ schema, vì nhiều lỗi 400 nằm sẵn ở đây. Anthropic chỉ cho đặt additionalProperties là false, và không hỗ trợ schema đệ quy, $ref trỏ ra ngoài hay các ràng buộc số và chuỗi.
Simon Willison, khi viết về strict mode của OpenAI, lưu ý thêm rằng ở đó mọi key của object đều phải có trong required. Schema dưới đây làm theo cả hai quy tắc để dùng được với cả hai bên.
INVOICE_SCHEMA = {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"invoice_number": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string"},
},
"required": ["vendor", "invoice_number", "total", "currency"],
"additionalProperties": False,
}
Hãy để ý những thứ cố tình bị bỏ ra: total không có minimum, currency không có giới hạn độ dài. Với Anthropic, dùng một tính năng không được hỗ trợ sẽ nhận lỗi 400 kèm chi tiết. Phía Gemini, Google cũng cảnh báo schema quá lớn hoặc lồng quá sâu có thể bị từ chối với 400 INVALID_ARGUMENT.
Kiểm tra: gửi một request thử. Nếu nhận 400, đọc chi tiết lỗi và sửa schema trước khi viết thêm dòng code nào.
Bước 2: gửi request kèm schema
Với Claude, schema đi qua output_config.format với type: "json_schema", và JSON hợp lệ nằm trong một text content block của response. Bản rút gọn:
request = {
"model": MODEL,
"max_tokens": 1024,
"messages": [{"role": "user", "content": prompt}],
"output_config": {
"format": {"type": "json_schema", "schema": INVOICE_SCHEMA}
},
}
Nếu dùng OpenAI, đừng lẫn JSON mode với Structured Outputs. OpenAI nói rõ cả hai đều cho ra JSON hợp lệ, nhưng chỉ Structured Outputs mới bảo đảm khớp schema, và họ khuyên luôn dùng Structured Outputs. Willison thì cho rằng 99% trường hợp bạn sẽ muốn bật "strict": true.
Kiểm tra: đo latency từ request thứ hai trở đi. Willison ghi nhận request đầu tiên với mỗi schema sẽ chậm vì schema phải được tiền xử lý. Lấy request đầu làm benchmark thì bạn chỉ tự làm mình hoảng.
Bước 3: đọc tín hiệu dừng trước khi parse
Đây là bước hay bị bỏ qua nhất. Đừng gọi json.loads ngay. Với Claude, refusal có stop_reason là refusal, còn đầu ra bị cắt có stop_reason là max_tokens, và lúc đó JSON có thể còn dở dang.
class Refused(Exception): pass
class Truncated(Exception): pass
def read_claude(response):
if response.stop_reason == "refusal":
raise Refused(response)
if response.stop_reason == "max_tokens":
raise Truncated(response)
text = next(b.text for b in response.content if b.type == "text")
return json.loads(text)
Bên OpenAI, tín hiệu nằm ở chỗ khác. Đầu ra bị cắt có response.status là incomplete, với incomplete_details.reason là max_output_tokens. Willison mô tả rằng khi model từ chối, bạn nhận về một thông điệp refusal riêng chứ không phải JSON theo schema. Vì refusal có thể không theo schema, API thêm một trường refusal mà bạn phải kiểm tra trước khi đọc nội dung đã parse.
# Rút gọn: vị trí chính xác của trường refusal và nội dung
# tuỳ endpoint bạn dùng, hãy đối chiếu tài liệu OpenAI
def read_openai(response, output):
if response.status == "incomplete" and \
response.incomplete_details.reason == "max_output_tokens":
raise Truncated(response)
if getattr(output, "refusal", None):
raise Refused(output.refusal)
return json.loads(output.text)
| Tình huống | Claude | OpenAI |
|---|---|---|
| Model từ chối | stop_reason là refusal, status 200, vẫn tính phí token |
Có trường refusal riêng, không theo schema |
| Bị cắt do giới hạn token | stop_reason là max_tokens |
status là incomplete, lý do max_output_tokens |
| Việc cần làm trước khi parse | Đọc stop_reason |
Đọc status, rồi đến trường refusal |
Bước 4: retry đầu ra bị cắt, nhưng phải có điểm dừng
Anthropic hướng dẫn gửi lại với max_tokens cao hơn để nhận đủ JSON. Phần còn lại do bạn thiết kế. Một cách hợp lý là nhân đôi sau mỗi lần, 1024 rồi 2048 rồi 4096, và dừng sau ba lần để một email bất thường không đốt token mãi.
def extract_invoice(text, budgets=(1024, 2048, 4096)):
for max_tokens in budgets:
try:
return read_claude(call(text, max_tokens))
except Truncated:
continue
raise Truncated("hết ngân sách token")
Refusal thì phải xử lý khác hẳn: đừng retry mù. Mỗi lần từ chối vẫn bị tính phí token, mà gửi lại nguyên input đó thì chẳng có lý do gì để mong kết quả khác đi. Nên đẩy các trường hợp này vào một hàng đợi để người xem lại.
Kiểm tra: gọi call(text, 50) với một email dài. Nhánh Truncated phải chạy, json.loads không được ném lỗi parse.
Bước 5: schema đúng chưa chắc giá trị đúng
Google nói thẳng trong tài liệu Gemini: JSON đúng cú pháp vẫn phải được validate giá trị trong ứng dụng. Những ràng buộc bạn đã bỏ khỏi schema ở bước 1 giờ được viết lại ở đây, bằng code nằm trong tay bạn.
def validate(inv):
errors = []
if inv["total"] < 0:
errors.append("total âm")
if len(inv["currency"]) != 3:
errors.append("currency không phải mã 3 ký tự")
if not inv["invoice_number"].strip():
errors.append("thiếu số hoá đơn")
return errors
Thử hình dung một email chỉ ghi “tổng cộng như báo giá tuần trước”. Schema bắt buộc mọi key vì tất cả đều có trong required, nên nhiều khả năng bạn sẽ nhận về một invoice_number rỗng. Schema vẫn khớp hoàn hảo, nhưng hệ thống kế toán của khách sẽ không nhận dòng đó.
Những lỗi nào dễ mắc?
Một lỗi dễ mắc là parse trước rồi mới kiểm tra: khi đó refusal dễ bị nhìn nhầm thành lỗi JSON và bị log sai nhóm. Một lỗi khác là để key tuỳ chọn nằm ngoài required khi dùng strict mode của OpenAI. Cũng đừng nhồi quy tắc nghiệp vụ vào schema, nếu không bạn có thể mất cả buổi chiều gỡ lỗi 400.
Nếu dùng strict tool use thay cho output format, nhớ rằng Anthropic giới hạn 20 tool strict cho mỗi request. Một agent có danh sách tool cứ phình ra theo thời gian sẽ chạm giới hạn này sớm hơn bạn nghĩ.
Ở chỗ khách hàng, kỹ năng này hiện ra thế nào?
Thử hình dung buổi review đầu tiên trên dữ liệu thật, khi khách hỏi pipeline chạy được bao nhiêu phần trăm. Ba exception có tên ở trên cho bạn câu trả lời: đếm riêng số refusal, số lần bị cắt, số bản ghi trượt validate. Nên đưa ba con số này lên dashboard ngay tuần đầu, trước khi có ai hỏi.
Về sự nghiệp, một dòng CV như “xây pipeline trích xuất dùng schema strict, xử lý riêng refusal và đầu ra bị cắt, có metric lỗi theo từng loại” cho người tuyển thấy bạn đã đưa thứ gì đó vào production thật. Khi JD có cụm “LLM in production”, rất có thể đây đúng là kỹ năng họ đang cần.
Có được đầu ra hợp lệ theo schema là phần dễ nhất của structured outputs. Thứ khiến bạn đáng tiền là biết phải làm gì khi đầu ra không hợp lệ.
Bài này có hữu ích không?
Cảm ơn bạn đã góp ý!