Đầu ra có cấu trúc với JSON Schema: hướng dẫn thực dụng
Ngừng phân tích văn xuôi để tìm JSON. Cách json_schema ở chế độ strict của OpenAI, responseSchema của Gemini và output_config.format gốc của Anthropic buộc mô hình trả dữ liệu đúng schema.
Ngay khi bạn cố đưa câu trả lời của mô hình vào code, "trả về JSON" không còn đủ. Hỏi lịch sự và bạn có thể nhận JSON bọc trong lời xin lỗi, JSON nằm trong hàng rào Markdown hoặc JSON đúng cú pháp nhưng thiếu trường mà bộ phân tích cần. Cách khắc phục đáng tin cậy là ngừng yêu cầu suông và bắt đầu ràng buộc: cho nhà cung cấp biết chính xác schema mong đợi để họ bảo đảm — hoặc ít nhất thực thi chặt — rằng phản hồi phù hợp. Bài viết này trình bày các cơ chế hiện có: json_schema strict của OpenAI, responseSchema của Gemini và output_config.format gốc của Anthropic, với strict tool use là đối tác cho gọi hàm, cùng các quy tắc schema và lý do bạn vẫn phải kiểm tra sau đó.
Hai chế độ thất bại, một nguyên nhân gốc
Có hai cách riêng biệt mà JSON dạng văn bản tự do bị hỏng, và việc đặt tên cho chúng sẽ giúp ích. Thất bại cú pháp (Syntactic failure) là đầu ra hoàn toàn không phân tích được: một dấu phẩy thừa ở cuối, một câu văn lạc, một hàng rào code. Thất bại ngữ nghĩa (Semantic failure) là đầu ra phân tích được nhưng sai đối với chương trình của bạn: một giá trị enum mà bạn chưa bao giờ định nghĩa, một trường bắt buộc bị thiếu, một chuỗi ở chỗ bạn cần một con số. Kỹ thuật prompt ("chỉ phản hồi bằng JSON") giảm loại thứ nhất và gần như chẳng làm gì với loại thứ hai. Việc sinh bị-ràng-buộc-schema tấn công cả hai, bởi schema được thực thi trong lúc giải mã (decoding) chứ không phải được kiểm tra sau khi đã rồi.
OpenAI: strict: true là toàn bộ tính năng
Structured Outputs của OpenAI là sự tiến hóa của chế độ JSON cũ hơn, và khác biệt là một cờ. Chế độ JSON thuần (response_format: { "type": "json_object" }) đảm bảo đầu ra là JSON hợp lệ nhưng không đảm bảo gì về hình dạng của nó. Structured Outputs thêm một schema và một công tắc strict ràng buộc mô hình vào nó.
Trên Chat Completions bạn truyền một response_format kiểu json_schema:
from openai import OpenAI
client = OpenAI(base_url="https://your-gateway.example.com/v1", api_key="AVE_GATEWAY_API_KEY")
resp = client.chat.completions.create(
model="gpt-5.6",
messages=[{"role": "user", "content": "Extract the invoice fields."}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "invoice",
"strict": True,
"schema": {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string", "enum": ["USD", "EUR", "JPY"]},
},
"required": ["vendor", "total", "currency"],
"additionalProperties": False,
},
},
},
)
Trên Responses API, cùng schema chuyển sang trường khác — text.format thay vì response_format. Một lệnh gọi responses.create thông thường không có output_parsed ở cấp cao nhất; để lấy đối tượng đã phân tích, hãy dùng helper parse của SDK với định dạng có kiểu, hoặc đọc response.output_text rồi tự kiểm tra:
from pydantic import BaseModel
class Invoice(BaseModel):
vendor: str
total: float
currency: str
resp = client.responses.parse(
model="gpt-5.6",
input="Extract the invoice fields.",
text_format=Invoice, # SDK builds the strict json_schema and parses the result
)
invoice = resp.output_parsed # a validated Invoice instance
Nếu thích đường thô, hãy gửi text={"format": {"type": "json_schema", "name": "invoice", "strict": True, "schema": SCHEMA}} qua responses.create, rồi tự phân tích và kiểm tra resp.output_text; đừng chờ một trường đã phân tích tự xuất hiện.
Gửi response_format tới Responses hoặc text.format tới Chat Completions là lỗi thường gặp và gây bối rối: tên trường thay đổi, không phải khái niệm. Phạm vi hỗ trợ qua gateway phụ thuộc từng adaptor chứ không đồng nhất: gateway này ánh xạ response_format dạng OpenAI sang responseSchema của Gemini, nhưng hiện không chuyển nó thành trường đầu ra có cấu trúc gốc của Claude. Vì vậy, với Claude hãy gửi dạng Anthropic bên dưới thay vì giả định response_format đi qua nguyên vẹn.
Tập con strict-schema có các quy tắc
strict: true hỗ trợ phần lớn JSON Schema nhưng không phải tất cả, và các ràng buộc đủ nghiêm ngặt đến mức một schema hợp lệ hoàn toàn trong một validator thông thường có thể bị từ chối ở đây:
- Mọi property phải được liệt kê trong
required. Không có trường nào thực sự tùy chọn. Để mô hình hóa "có thể vắng mặt," hãy làm cho trường thành một union nullable —"type": ["string", "null"]— thay vì bỏ nó ra khỏirequired. additionalProperties: falselà bắt buộc trên mọi object. Mô hình không được bịa ra các khóa thừa.- Gốc phải là một object. Một mảng ở cấp cao nhất không được phép; hãy bọc nó trong một object với một property kiểu mảng.
- Đệ quy dùng
{"$ref": "#"}để tham chiếu gốc, đó là cách bạn diễn đạt các cấu trúc cây lồng nhau.
Khi mô hình từ chối vì lý do an toàn, điều đó được hiển thị theo cách lập trình như một trường refusal trên message chứ không phải như nội dung dạng schema, nên bạn kiểm tra sự từ chối trước khi tin tưởng vào việc phân tích. Và một phản hồi có thể bị cắt bởi độ dài: hãy xem xét finish_reason (một giá trị "length" nghĩa là JSON bị cắt cụt), vì một phản hồi strict bị cắt cụt vẫn là JSON không hợp lệ mặc dù schema đã được thực thi.
Gemini: responseSchema cộng một mime type JSON
Gemini ràng buộc đầu ra thông qua generationConfig. Bạn đặt responseMimeType thành "application/json" và cung cấp một responseSchema (một đối tượng schema kiểu OpenAPI) — hoặc, đối với JSON Schema tiêu chuẩn, responseJsonSchema:
from google import genai
from google.genai import types
client = genai.Client()
resp = client.models.generate_content(
model="gemini-3.5-flash",
contents="Classify the ticket.",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "feature"]},
"priority": {"type": "integer"},
},
"required": ["category", "priority"],
},
),
)
Gemini ràng buộc việc sinh theo schema, hỗ trợ enum cho phân loại, và cho bạn ghim thứ tự trường bằng propertyOrdering — hữu ích vì thứ tự có thể ảnh hưởng đến chất lượng và tính nhất quán. Lưu ý hai điều dè chừng mà tài liệu nói rõ: không phải mọi tính năng của JSON Schema đều được hỗ trợ, và các schema rất phức tạp có thể bị từ chối. Gemini cũng nhấn mạnh rằng đầu ra là JSON hợp lệ, dạng schema nhưng bạn vẫn nên validate các giá trị — nó có thể sinh ra thứ gì đó tuân theo schema nhưng sai về mặt ngữ nghĩa (một danh mục hợp lý-nhưng-không-đúng). Cơ chế đảm bảo hình dạng, không phải sự thật.
Anthropic: output_config.format gốc và strict tool use
Anthropic hiện có đầu ra có cấu trúc gốc, nên buộc tool use không còn là con đường duy nhất mà trở thành phương án tương thích dự phòng. Hai tính năng bổ sung cho nhau. Đầu ra JSON đặt một đối tượng output_config trên yêu cầu với format là {"type": "json_schema", "schema": ...}, và câu trả lời của Claude trở về dưới dạng JSON khớp schema trong nội dung văn bản. Strict tool use thêm strict: true vào input_schema của công cụ để bảo đảm đối số công cụ hợp lệ theo schema. Bạn có thể dùng riêng từng tính năng hoặc kết hợp cả hai.
import anthropic
client = anthropic.Anthropic(base_url="https://your-gateway.example.com", api_key="AVE_GATEWAY_API_KEY")
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}
},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = msg.content[0].text # valid JSON matching your schema
Cần biết một số giới hạn hiện tại: additionalProperties phải là false, còn các ràng buộc số hoặc chuỗi như minimum, maxLength và schema đệ quy chưa được hỗ trợ; gửi chúng sẽ trả về 400. Python SDK cũng có client.messages.parse(..., output_format=PydanticModel) để dựng schema và phân tích phản hồi trong một bước. Khi bạn thực sự cần mô hình gọi thứ gì đó, strict tool use là con đường phù hợp: định nghĩa công cụ có input_schema là schema đích, thêm strict: true và buộc chọn bằng tool_choice.
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=[{
"name": "record_invoice",
"strict": True,
"input_schema": {
"type": "object",
"properties": {"vendor": {"type": "string"}, "total": {"type": "number"}},
"required": ["vendor", "total"],
"additionalProperties": False,
},
}],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=[{"role": "user", "content": "Extract the invoice fields."}],
)
data = next(b.input for b in msg.content if b.type == "tool_use")
Buộc công cụ làm cho hành vi mang tính tất định: mô hình phải gọi record_invoice, và input của nó là object của bạn. Dùng đầu ra JSON khi bạn muốn một câu trả lời có kiểu, và strict tool use khi mô hình phải gọi thứ gì đó với đối số được bảo đảm hợp lệ. Buộc công cụ mà không có strict vẫn là phương án cũ cho mô hình đời trước hoặc một số hình dạng vòng lặp công cụ; trên mô hình hiện tại, hãy ưu tiên tính năng gốc. Nếu sản phẩm đã định tuyến công cụ qua nhiều nhà cung cấp, các định nghĩa vẫn dùng lại được — xem gọi công cụ khả chuyển giữa các nhà cung cấp.
Structured output so với function calling: chọn theo ý định
Hai cách dùng JSON Schema này trông tương tự và dễ bị nhầm. Khác biệt nằm ở mục đích. Dùng gọi hàm khi mô hình cần quyết định có gọi và gọi thế nào một công cụ thực hiện hành động, chẳng hạn truy vấn cơ sở dữ liệu, gọi API hoặc chạy code. Dùng định dạng phản hồi có cấu trúc (json_schema / text.format / responseSchema / output_config.format) khi bạn chỉ muốn câu trả lời của mô hình mang dạng dữ liệu cho chương trình. Cả ba nhà cung cấp hiện đều có hai con đường: OpenAI hỗ trợ schema strict trong định dạng phản hồi và công cụ, Anthropic ghép đầu ra JSON gốc với strict tool use, còn Gemini tách response schema và khai báo hàm. Dùng lệnh gọi công cụ khi chỉ cần câu trả lời có kiểu sẽ thêm một vòng khứ hồi không cần thiết.
Bạn vẫn phải validate — luôn luôn
Việc thực thi schema không phải là giấy phép để bỏ qua validate, vì ba lý do cụ thể. Thứ nhất, việc thực thi đảm bảo hình dạng, không bao giờ đảm bảo tính đúng đắn: một mô hình có thể trả về một giá trị có kiểu tốt nhưng sai về mặt sự thật, và chỉ có các quy tắc nghiệp vụ của bạn mới bắt được điều đó. Thứ hai, cắt cụt vẫn xảy ra — một phản hồi bị giới hạn độ dài hoặc bị timeout tạo ra JSON không đầy đủ mà không chế độ schema nào có thể cứu, nên bạn phải xử lý finish_reason/status và lỗi phân tích. Thứ ba, hỗ trợ schema là một tập con khác nhau theo từng nhà cung cấp, nên một schema thực thi nghiêm ngặt trên một thượng nguồn có thể được xử lý lỏng lẻo hơn trên một cái khác. Hãy chạy đối tượng đã phân tích qua một validator (Pydantic, Zod, JSON Schema) ở ranh giới, và coi một lần validate thất bại như một nhánh bình thường, được mong đợi chứ không phải một ngoại lệ. Đây chính là nguyên tắc phòng thủ theo chiều sâu (defense-in-depth) đằng sau việc chặn bất kỳ đầu vào bên ngoài nào.
Gateway giúp bằng cách cho bạn một nơi gửi response_format và một nơi quan sát yêu cầu nào dùng đầu ra có cấu trúc, được thử lại bao nhiêu lần, mà không bắt mọi client học tên trường của ba nhà cung cấp. Sau một gateway API tương thích OpenAI, bạn viết response_format dạng OpenAI một lần và gateway định tuyến nó tới kênh phục vụ mô hình. Hãy nhớ rằng việc chuyển đổi phụ thuộc adaptor: trường đi nguyên vẹn tới OpenAI, được ánh xạ sang responseSchema của Gemini, nhưng hiện không tạo output_config.format gốc của Claude; với Claude hãy dùng dạng Anthropic.
FAQ
Sự khác biệt giữa chế độ JSON và Structured Outputs là gì?
Chế độ JSON (response_format: {"type": "json_object"}) đảm bảo đầu ra phân tích được thành JSON nhưng không nói gì về hình dạng của nó — các trường, kiểu, và enum đều không bị ràng buộc. Structured Outputs thêm một json_schema với strict: true, ràng buộc mô hình vào schema của bạn. Hãy ưu tiên Structured Outputs bất cứ khi nào nhà cung cấp hỗ trợ nó; để dành chế độ JSON trần cho các trường hợp mà bạn thực sự không có một hình dạng cố định.
Vì sao schema strict của tôi bị từ chối khi nó hợp lệ ở nơi khác?
Tập con strict hẹp hơn JSON Schema đầy đủ. Nguyên nhân thường gặp là thiếu additionalProperties: false, một property không có trong required hoặc gốc không phải object. Hãy làm mọi trường thành bắt buộc, dùng union nullable cho trường tùy chọn, đặt additionalProperties: false trên mỗi object và bọc mảng cấp cao nhất trong object. Riêng với Claude, các ràng buộc số hoặc chuỗi và schema đệ quy bị từ chối trực tiếp, nên cũng phải loại bỏ chúng.
Structured output có đảm bảo các giá trị là đúng không?
Không. Nó đảm bảo phản hồi khớp hình dạng của schema — kiểu, các trường bắt buộc, các giá trị enum được phép — chứ không đảm bảo nội dung là chính xác. Một mô hình có thể trả về một câu trả lời có kiểu hoàn hảo nhưng sai. Hãy luôn validate các giá trị theo các quy tắc nghiệp vụ của bạn sau khi phân tích, và coi sự tuân thủ schema như một sự đảm bảo cú pháp, không phải một sự đảm bảo tính đúng đắn.
Làm sao tôi lấy được đầu ra bị-ràng-buộc-schema từ Claude?
Gửi output_config với format là {"type": "json_schema", "schema": ...} khi bạn cần câu trả lời có kiểu, hoặc thêm strict: true vào công cụ bị buộc khi mô hình cần gọi thứ gì đó. Cả hai đều là tính năng gốc trên mô hình hiện tại; cách cũ buộc công cụ mà không có strict vẫn hoạt động nhưng nay chỉ là phương án dự phòng.
Xóa một biểu thức chính quy
Chọn một nơi trong code hiện đang phân tích câu trả lời văn bản tự do của mô hình rồi dùng regex lấy JSON. Thay prompt bằng json_schema hoặc output_config.format của Claude, đặt strict: true và đưa kết quả qua validator schema. Sau đó gửi một đầu vào cố ý khó và xác nhận hai điều: yêu cầu thành công mà không cần bước dọn JSON khỏi hàng rào, và sự từ chối hoặc cắt cụt xuất hiện như nhánh được xử lý thay vì làm chương trình sập. Xóa được regex đó là dấu hiệu cụ thể rằng ràng buộc đang thực hiện công việc.