Output Terstruktur LLM: JSON Schema yang Benar-Benar Tervalidasi

Foto oleh deepakiqlect on flickr
Meminta JSON lewat prompt hanya meminta model mengikuti instruksi, tetapi tidak ada yang mencegahnya menambahkan teks lain, membungkus objek dalam pagar markdown, atau melewatkan field yang diwajibkan. Structured outputs mengompilasi JSON Schema Anda menjadi grammar yang membatasi token yang boleh dihasilkan model pada setiap langkah, sehingga respons secara mekanis dibatasi agar sesuai skema, bukan sekadar dianjurkan untuk sesuai.
Sebab paling umum adalah melewatkan pemeriksaan stop reason atau finish reason sebelum melakukan parsing. Jika model menolak permintaan atau respons terpotong karena batas max token, teksnya bukan objek JSON yang lengkap seberapa pun ketat skemanya, dan memanggil JSON parse padanya akan tetap melempar error. Selalu periksa stop reason terlebih dahulu, dan hanya coba parsing respons yang selesai secara normal.
Ya, dan ini salah satu pola paling berguna yang tersedia. Satu skema Zod bisa diubah menjadi JSON Schema yang dikirim ke model, lalu dipakai lagi dengan safeParse untuk memvalidasi respons yang kembali. Ini membuat kedua ujung perjalanan bolak-balik terdefinisi di satu tempat dan memberi Anda objek bertipe plus daftar error terstruktur jika validasi gagal.
Gunakan mode output JSON saat seluruh respons adalah satu dokumen terstruktur, seperti invoice yang diekstraksi atau bagian laporan yang dihasilkan. Gunakan strict tool atau function calling saat model memilih di antara beberapa aksi dalam perulangan agent, karena setiap aksi bisa memiliki skema argumennya sendiri. Keduanya memberi jaminan skema yang sama; pilihannya bergantung pada apa yang direpresentasikan respons tersebut.
Tidak. Batasan skema hanya menjamin bentuk dan tipe respons, bukan kebenaran bisnis dari nilai di dalamnya. Model bisa mengembalikan objek yang secara sintaksis sempurna dengan jumlah bernilai negatif atau field yang tertukar. Anda tetap membutuhkan tahap validasi kedua, seperti fungsi refine Zod atau pencarian ke database, untuk aturan yang melibatkan beberapa field atau bergantung pada keadaan eksternal.

Foto oleh deepakiqlect on flickr
Pertama kali sebuah job produksi gagal karena pagar markdown yang tiba-tiba membungkus payload JSON yang sebenarnya sudah sempurna, saya belajar dengan cara yang menyakitkan bahwa meminta model bahasa mengembalikan JSON dengan sopan tidak sama dengan benar-benar menerima JSON yang bisa diparse. Model itu mengikuti instruksi. Ia hanya juga menambahkan satu kalimat ramah sebelum objeknya, karena tidak ada yang benar-benar mencegahnya melakukan itu.
Tulisan ini membahas bagian-bagian yang benar-benar membuat output terstruktur bisa diandalkan: decoding terbatas melalui JSON Schema, memilih antara output JSON langsung dan pemanggilan fungsi berbasis tool, mendeteksi penolakan dan pemotongan respons sebelum keduanya merusak pipeline, serta memvalidasi setiap respons dengan Zod agar pelanggaran skema tidak pernah sampai ke database.
Rekayasa prompt bisa membawa Anda mendekati hasil yang benar, tetapi mendekati bukan berarti terjamin. Bahkan system prompt yang ditulis rapi dan berisi instruksi untuk hanya merespons dengan JSON valid sesuai bentuk tertentu, sesekali tetap bisa menghasilkan output yang secara teknis memenuhi instruksi namun merusak setiap parser di sisi Anda. Pola kegagalannya cukup dapat diprediksi setelah Anda melihatnya beberapa kali.
Semua ini bukan bug pada model. Ini adalah akibat alami dari memperlakukan generator teks probabilistik seolah ia adalah serializer deterministik. Jika sistem Anda bergantung pada bentuk respons, Anda membutuhkan mekanisme yang membatasi proses pembuatannya sendiri, bukan sekadar permintaan yang disusun dengan sopan.
Baik API Claude maupun API OpenAI sekarang menyediakan mode output terstruktur sungguhan yang membatasi token yang dihasilkan hanya pada token yang diizinkan oleh skema valid. Ini secara fundamental berbeda dari sekadar meminta JSON dan berharap yang terbaik. Gunakan fitur native ini terlebih dahulu sebelum beralih ke pembersihan teks respons dengan regex.
Decoding terbatas berarti API mengompilasi JSON Schema Anda menjadi sebuah grammar dan membatasi token mana yang boleh dikeluarkan model pada setiap langkah pembuatan teks. Jika skema Anda menyatakan sebuah field harus salah satu dari tiga nilai enum, model secara mekanis dicegah menghasilkan nilai keempat. Ini diberlakukan pada lapisan sampling, bukan melalui validasi setelah fakta, sehingga tidak ada jendela waktu di mana model bisa saja mengeluarkan sesuatu yang lain lalu lolos dari pemeriksaan yang longgar.
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
const InvoiceLine = z.object({
sku: z.string(),
quantity: z.number().int().positive(),
unitPriceCents: z.number().int().nonnegative(),
});
const InvoiceExtraction = z.object({
vendorName: z.string(),
invoiceNumber: z.string(),
lines: z.array(InvoiceLine),
totalCents: z.number().int().nonnegative(),
});
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
messages: [{ role: "user", content: rawInvoiceText }],
output_config: { format: zodOutputFormat(InvoiceExtraction) },
});Claude menyediakan ini melalui dua mekanisme yang saling melengkapi: format output JSON yang dilampirkan pada permintaan pesan, dan strict tool use di mana skema input sebuah tool diberlakukan dengan cara yang sama. Fitur setara di OpenAI adalah flag strict pada response_format bertipe json_schema, yang datang dengan aturan tegas: setiap properti harus dicantumkan sebagai required, dan additionalProperties harus secara eksplisit diset ke false. Anda tidak bisa begitu saja menghilangkan sebuah field opsional dari daftar required seperti pada JSON Schema buatan sendiri; cara idiomatik untuk mengatasinya adalah membuat tipe field tersebut nullable.
Kedua penyedia memberi Anda dua bentuk untuk jaminan yang sama, dan pilihan yang tepat bergantung pada apa yang direpresentasikan oleh respons dalam aplikasi Anda, bukan pada bentuk mana yang terasa lebih familiar.
| Pendekatan | Paling cocok untuk | Yang perlu diwaspadai |
|---|---|---|
| Mode output JSON | Satu dokumen terstruktur sebagai seluruh respons, seperti invoice yang diekstraksi atau bagian laporan yang dihasilkan | Latensi pada permintaan pertama saat skema dikompilasi menjadi grammar; kedua penyedia menyimpan cache skema yang sudah dikompilasi untuk dipakai ulang |
| Pemanggilan tool atau fungsi secara strict | Perulangan agent di mana model memilih di antara beberapa aksi yang mungkin, masing-masing dengan bentuk argumennya sendiri | Anda perlu definisi tool yang berbeda untuk setiap jenis aksi, dan model tetap harus memilih tool yang tepat dengan benar |
| JSON hanya berdasarkan prompt, tanpa batasan skema | Hanya untuk prototipe sekali pakai dan eksperimen manual | Tidak ada jaminan struktural sama sekali; jangan pernah menerapkan jalur ini pada sistem produksi yang memparsing output |
Batasan skema menjamin bentuk dari respons yang selesai. Ia tidak menjamin apakah respons itu benar-benar selesai, atau apakah model justru menolak menjawab sama sekali. Kedua penyedia memberi Anda sinyal untuk ini, dan mengabaikannya adalah cara paling umum output yang sudah dibatasi skema tetap bisa gagal di produksi.
Respons yang dibatasi skema tetap bisa gagal diparsing jika Anda melewatkan pemeriksaan stop reason. Penolakan atau pemotongan karena max token menghasilkan teks yang bukan objek JSON lengkap, dan memanggil JSON parse padanya akan tetap melempar error terlepas seberapa ketat skema Anda. Selalu periksa stop reason terlebih dahulu sebelum melangkah lebih jauh.
Pembuatan yang dibatasi skema membuat JSON yang cacat jarang terjadi, bukan mustahil, dan itu tidak menjamin apakah nilai-nilainya memang yang Anda inginkan. Model bisa mengembalikan objek yang secara sintaksis sempurna dengan jumlah bernilai negatif atau nomor invoice yang sebenarnya adalah nama pelanggan. Perlakukan respons yang sudah dibatasi skema sama seperti input eksternal lainnya: parsing dulu, lalu validasi dengan pustaka skema sungguhan sebelum menyentuh proses penyimpanan.
const parsed = InvoiceExtraction.safeParse(
JSON.parse(response.content[0].text)
);
if (!parsed.success) {
logger.warn("schema_validation_failed", {
issues: parsed.error.issues,
});
throw new ExtractionValidationError(parsed.error);
}
// parsed.data is now fully typed and safe to persist
await saveInvoice(parsed.data);Menjalankan bentuk yang sama melalui safeParse milik Zod memberi Anda dua hal yang tidak bisa diberikan oleh JSON parse mentah: objek bertipe yang bisa dipercaya oleh kode TypeScript Anda di tahap selanjutnya, dan array masalah terstruktur yang bisa Anda catat atau kembalikan ke pemanggil, bukan sekadar exception parsing yang generik. Jika Anda sudah menggunakan skema Zod untuk membangun JSON Schema yang dikirim ke model, seperti pada contoh sebelumnya, langkah validasi ini hampir gratis karena definisi skema yang sama dipakai dua kali.
JSON Schema dan Zod keduanya memvalidasi bentuk dan tipe dari satu respons. Tidak ada satu pun yang bisa menyatakan relasi antar-field yang hanya bermakna bila digabungkan, atau fakta yang hidup di luar respons itu sendiri. Rencanakan tahap validasi kedua untuk kasus-kasus seperti ini.
Karena skema Zod bisa menghasilkan JSON Schema yang dikirim ke model dan sekaligus memvalidasi respons yang kembali, satu definisi TypeScript mencakup kedua ujung perjalanan bolak-balik itu. Kesimetrisan ini layak dirancang sejak awal proyek, bukan ditambahkan belakangan.
Sebelum mempercayai sebuah skema di produksi, uji dengan prompt yang bersifat adversarial: input ambigu, permintaan yang seharusnya memicu penolakan, dan input yang sengaja dibuat mendekati batas token Anda. Skema yang selama pengembangan hanya pernah melihat input happy-path yang bersih akan mengejutkan Anda pada minggu pertama bertemu pengguna sungguhan.