# 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.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-structured-outputs-json-schema-strict-xu-ly-refusal/

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.

```python
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:

```python
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.

```python
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.

```python
# 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` |

**Điểm mấu chốt:** Status 200 chỉ cho biết request đã chạy, chưa cho biết dữ liệu đã dùng được.

## 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.

```python
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.

```python
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ệ.

**Thử ngay tuần này:**

- Tìm một chỗ đang gọi json.loads thẳng trên output của model và thêm bước kiểm tra stop_reason hoặc status ngay trước dòng đó
- Rà schema hiện có, tìm các ràng buộc như minimum hay maxLength, xoá chúng khỏi schema rồi viết lại thành hàm validate
- Cố ý gửi một request có max_tokens rất thấp để kiểm tra nhánh xử lý đầu ra bị cắt có chạy đúng không

## Nguồn

- [Structured outputs - Claude API Docs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs)

- [Structured Outputs - OpenAI API docs](https://developers.openai.com/api/docs/guides/structured-outputs)

- [OpenAI: Introducing Structured Outputs (Simon Willison)](https://simonwillison.net/2024/Aug/6/openai-structured-outputs)

- [Structured output - Gemini API docs](https://aistudio.google.com/docs/structured-output)
