Tampilan
1.4 Structured Output
Kenapa bab ini penting
Selama keluaran agent berupa teks bebas, programmu tidak bisa memakainya tanpa menebak-nebak. Setiap fitur yang menyimpan hasil agent ke basis data, menampilkannya sebagai kartu di UI, atau memakainya untuk percabangan logika butuh bab ini.
Kalau kamu melewatinya: kamu akan menulis parser teks yang pecah setiap kali model memutuskan menulis dengan format sedikit berbeda.
Kalau kamu tidak butuh: keluaran agent langsung dibaca manusia dan tidak pernah disentuh kode — misalnya chatbot murni percakapan.
Tujuan bab
Setelah bab ini kamu bisa:
- Meminta agent mengembalikan objek tervalidasi alih-alih teks bebas
- Mendefinisikan bentuk keluaran dengan Zod dan mengambil hasilnya lewat
response.object - Memilih strategi penanganan error saat keluaran model tidak cocok dengan schema
- Menjelaskan kenapa
.describe()pada field bukan sekadar dokumentasi - Memutuskan kapan structured output lebih tepat daripada tool
Prasyarat
- 1.3 generate() dan stream()
- Paket
zodterpasang (versi 4)
Masalah dengan teks
Misalkan kamu ingin agent mengekstrak data dari keluhan pelanggan: kategori, tingkat urgensi, dan ringkasan satu kalimat. Dengan keluaran teks biasa, kamu akan menerima sesuatu seperti:
text
Kategori: Pengiriman
Urgensi: Tinggi
Ringkasan: Paket belum sampai setelah 10 hari dan pelanggan sudah dua kali
menghubungi tanpa jawaban.Terbaca rapi oleh manusia. Tapi begitu program yang harus memakainya, kamu terjebak menulis parser: memisahkan baris, memotong di tanda titik dua, memangkas spasi. Dan parser itu akan pecah pada permintaan berikutnya, saat model memutuskan menulis "Kategori: Pengiriman" dengan tebal, atau menambahkan satu kalimat pembuka sebelum daftarnya.
Akar masalahnya: kamu memaksa data melewati format yang dirancang untuk dibaca manusia. Structured output menghapus langkah itu — kamu mendeskripsikan bentuk data yang diinginkan lewat schema, dan hasil akhirnya berupa objek yang sudah tervalidasi dan bertipe.
Bentuk paling sederhana
ts
// run.mjs
import { mastra } from './src/mastra/index.ts'
import { z } from 'zod'
const agent = mastra.getAgentById('support-agent')
const response = await agent.generate('Bantu saya menyusun rencana hari ini.', {
structuredOutput: {
schema: z.array(
z.object({
name: z.string(),
activities: z.array(z.string()),
}),
),
},
})
console.log(response.object)Hasilnya bukan teks, melainkan objek:
js
[
{ name: 'Pagi', activities: ['Tinjau email', 'Siapkan slide presentasi'] },
{ name: 'Siang', activities: ['Rapat tim', 'Makan siang'] },
{ name: 'Sore', activities: ['Rapat klien', 'Rangkum catatan'] },
]Tiga hal yang berubah dibanding pemanggilan biasa:
- Opsi
structuredOutputditambahkan, berisischema - Hasil diambil dari
response.object, bukanresponse.text - Hasil itu sudah tervalidasi terhadap schema — bukan sekadar hasil
JSON.parse()yang isinya masih harus dipercayai
Schema tidak harus Zod
Mastra menerima Standard JSON Schema, sehingga beberapa pustaka bisa dipakai:
| Pustaka | Kapan dipilih |
|---|---|
| Zod | Default yang disarankan. Memberi inferensi tipe TypeScript sekaligus validasi runtime |
| Valibot | Kalau proyekmu sudah memakainya. Butuh toStandardJsonSchema() dari @valibot/to-json-schema |
| ArkType | Kalau proyekmu sudah memakainya |
| JSON Schema mentah | Saat schema harus netral bahasa, misalnya dibaca sistem lain |
Materi ini memakai Zod di seluruh bab berikutnya, karena inferensi tipenya mengalir ke seluruh sistem — dan di Bagian 4 manfaat itu jadi sangat terasa.
Sebagai pembanding, bentuk JSON Schema mentah untuk schema yang sama:
ts
const response = await agent.generate('Bantu saya menyusun rencana hari ini.', {
structuredOutput: {
schema: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
activities: { type: 'array', items: { type: 'string' } },
},
required: ['name', 'activities'],
},
},
},
})Lebih panjang, dan tidak memberi tipe TypeScript. Pakai hanya kalau kamu memang butuh netral bahasa.
Schema adalah instruksi, bukan hanya validasi
Ini bagian yang paling sering disalahpahami, dan paling berpengaruh pada kualitas hasil.
Schema-mu dikirim ke model sebagai bagian dari permintaan. Model membaca nama field dan deskripsinya untuk memahami apa yang harus diisi. Jadi schema menjalankan dua peran sekaligus: memberitahu model apa yang diinginkan, dan memverifikasi apa yang dikembalikan.
Konsekuensinya, dua schema yang secara tipe identik bisa menghasilkan mutu yang jauh berbeda:
ts
// Lemah — model harus menebak maksud tiap field
const lemah = z.object({
cat: z.string(),
urg: z.number(),
sum: z.string(),
})
// Kuat — maksudnya jelas
const kuat = z.object({
kategori: z
.enum(['pengiriman', 'pembayaran', 'produk', 'lainnya'])
.describe('Kategori utama keluhan pelanggan'),
urgensi: z
.number()
.min(1)
.max(5)
.describe('Tingkat urgensi, 1 = bisa menunggu, 5 = perlu ditangani hari ini'),
ringkasan: z
.string()
.describe('Ringkasan keluhan dalam satu kalimat, maksimal 20 kata'),
})Tiga teknik yang bekerja di contoh kedua:
.describe()memberi model konteks maksud tiap field. Ini bukan komentar untuk sesama programmer — teksnya benar-benar sampai ke model.z.enum()membatasi kemungkinan nilai. Jauh lebih andal daripadaz.string()yang berharap model kebetulan memilih istilah yang konsisten.- Batasan numerik seperti
.min()dan.max()memberi skala yang jelas, sehingga "urgensi 4" berarti hal yang sama di setiap pemanggilan.
Bukti: keduanya dijalankan sungguhan
Klaim di atas mudah diabaikan. Jadi kedua schema itu dijalankan nyata dengan openai/gpt-5-mini — input yang sama persis, diulang tiga kali:
text
ulasan: "Produknya bagus banget, kualitas jahitan rapi. Tapi pengirimannya
lama banget, 2 minggu baru sampai. Kurirnya juga ga ramah."Schema lemah (cat: z.string(), urg: z.number()):
text
run 1: {"cat":"campuran: produk bagus (jahitan rapi) tetapi pengiriman lama
dan kurir tidak ramah","urg":7}
run 2: {"cat":"Produk: positif (kualitas jahitan rapi). Pengiriman: negatif
(lama, sampai 2 minggu). Kurir: negatif (tidak ramah).","urg":0.6}
run 3: {"cat":"Positif produk; Negatif pengiriman & kurir","urg":4}Schema kuat (z.enum() + .min(1).max(5) + .describe()):
text
run 1: {"kategori":"campuran","urgensi":3}
run 2: {"kategori":"campuran","urgensi":4}
run 3: {"kategori":"campuran","urgensi":4}Perhatikan kolom urg pada schema lemah: 7, lalu 0.6, lalu 4. Tiga skala yang sama sekali berbeda dari input yang sama. Nilai 7 dan 0.6 tidak masuk akal untuk tingkat urgensi apa pun.
Dan cat menghasilkan tiga format berbeda — kalimat panjang, daftar berstruktur, lalu frasa pendek. Tidak satu pun bisa dipakai untuk memfilter atau mengelompokkan tiket.
Kegagalan ini SENYAP
Keenam pemanggilan di atas lolos validasi. Tidak ada error, tidak ada peringatan. z.string() memang menerima string apa pun, dan z.number() menerima angka berapa pun — termasuk 7 dan 0.6.
Inilah bahaya schema longgar: ia tidak pernah memberitahumu bahwa datanya tidak berguna. Kamu baru sadar berbulan-bulan kemudian saat mencoba membuat laporan dan mendapati kolom urgensi berisi campuran skala.
Validasi yang selalu lolos bukan tanda schema yang baik — itu tanda schema yang tidak menuntut apa-apa.
Schema kuat memperbaikinya lewat tiga hal sekaligus: z.enum() menutup ruang nilai kategori, .min(1).max(5) mengunci skala urgensi, dan .describe() memberitahu model apa arti angka 1 dan 5.
Saat model gagal memenuhi schema
Model bisa saja mengembalikan sesuatu yang tidak lolos validasi. Mastra menyediakan tiga strategi lewat errorStrategy:
| Strategi | Perilaku | Cocok untuk |
|---|---|---|
'strict' | Melempar error | Jalur kritis di mana data salah lebih buruk daripada gagal |
'warn' | Mencatat peringatan, tetap melanjutkan | Pengembangan dan diagnosis |
'fallback' | Memakai fallbackValue | Fitur pelengkap yang tidak boleh menjatuhkan alur utama |
ts
const response = await agent.generate('Klasifikasikan keluhan ini: ...', {
structuredOutput: {
schema: kuat,
errorStrategy: 'fallback',
fallbackValue: {
kategori: 'lainnya' as const,
urgensi: 3,
ringkasan: 'Tidak dapat mengklasifikasikan keluhan secara otomatis.',
},
},
})Beberapa opsi lain yang tersedia di bawah structuredOutput dan berguna diketahui:
instructions— instruksi tambahan khusus untuk proses penyusunan keluaran terstruktur, terpisah dari instruksi agentmodel— model khusus untuk menyusun keluaran terstruktur. Kalau diberikan, agent bisa menjawab secara multi-langkah dengan tool call, teks, dan keluaran terstruktur sekaligusjsonPromptInjection— mengatur bagaimana schema sampai ke model. Nilai'auto'memakai dukungan bawaan penyedia bila tersedia, dan menyuntikkan schema lewat prompt bila tidak
Structured output bersama streaming
Keduanya bisa dipakai bersamaan. Saat structuredOutput diberikan pada stream(), tersedia objectStream untuk hasil parsial dan object untuk hasil final:
ts
// run.mjs
import { mastra } from './src/mastra/index.ts'
import { z } from 'zod'
const agent = mastra.getAgentById('support-agent')
const stream = await agent.stream('Susun rencana harian saya', {
structuredOutput: {
schema: z.array(
z.object({ name: z.string(), activities: z.array(z.string()) }),
),
},
})
for await (const partial of stream.objectStream) {
console.log('parsial:', partial)
}
console.log('final:', await stream.object)Berguna untuk antarmuka yang mengisi daftar secara bertahap sambil data datang, alih-alih menampilkan spinner sampai semuanya lengkap.
Structured output atau tool?
Keduanya melibatkan schema Zod, jadi wajar tertukar. Pembedanya arah aliran data:
- Structured output — bentuk jawaban akhir agent. Datanya keluar dari agent menuju programmu.
- Tool — kemampuan yang dipanggil agent di tengah jalan. Datanya masuk ke fungsimu, hasilnya kembali ke agent untuk diolah lebih lanjut.
Kalau yang kamu inginkan adalah "agent mengembalikan data terstruktur", jawabannya structured output. Kalau "agent perlu melakukan sesuatu lalu melanjutkan berpikir", jawabannya tool — dan itu isi Bagian 2.
Kesalahan umum
Gejala: response.object bernilai undefined, padahal schema sudah diberikan dan tidak ada error yang muncul. Penyebab: Hasil masih diambil dari response.text, atau opsi structuredOutput salah letak — misalnya ditaruh sebagai argumen ketiga, padahal ia bagian dari objek opsi di argumen kedua. Perbaikan: Pastikan bentuknya agent.generate(pesan, { structuredOutput: { schema } }) dan hasilnya dibaca dari response.object.
Gejala: Hasil selalu lolos validasi, tapi isinya tidak konsisten — field yang sama diisi dengan istilah berbeda di tiap pemanggilan, misalnya "pengiriman", "Pengiriman", lalu "kirim". Penyebab: Schema-nya terlalu longgar. z.string() menerima apa pun, jadi validasi tidak pernah gagal — padahal datanya tidak bisa dipakai untuk pengelompokan atau filter. Perbaikan: Perketat dengan z.enum() untuk nilai yang terbatas, dan tambahkan .describe() pada field yang maksudnya bisa ditafsirkan lebih dari satu cara. Validasi yang selalu lolos bukan tanda schema yang baik, melainkan tanda schema yang tidak menuntut apa-apa.
Coba sendiri
Tujuannya: membuat schema yang menghasilkan data konsisten dan bisa dipakai program, lalu membuktikannya dengan menjalankan ulang.
Kode awal
Salin ke demo/ulasan.mjs, lalu jalankan node demo/ulasan.mjs.
js
// demo/ulasan.mjs
import { Agent } from '@mastra/core/agent'
import { z } from 'zod'
const agent = new Agent({
id: 'ul', name: 'Ulasan',
instructions: 'Kamu menganalisis ulasan produk e-commerce secara objektif.',
model: 'openai/gpt-5-mini',
})
const schema = z.object({
// TODO 1: sentimen keseluruhan — 3 nilai tetap. Pakai z.enum().
sentimen: z.string(),
// TODO 2: perkiraan bintang. Kunci skalanya dengan .min()/.max().
skor: z.number(),
// TODO 3: aspek yang disinggung. Array objek { nama, sentimen }.
// Kedua field pakai z.enum(). Tambahkan .describe() yang MELARANG
// model menambah aspek yang tidak disebut ulasan.
aspek: z.array(z.any()),
// TODO 4: boolean — apakah perlu ditanggapi tim support?
perluTindakan: z.boolean(),
// TODO 5: ringkasan. Batasi panjangnya lewat .describe().
ringkasan: z.string(),
})
const ulasan = [
['positif', 'Bahannya adem, jahitan rapi, harga segini worth it banget. Puas!'],
['negatif', 'Barang datang sobek, kemasan asal-asalan. Kecewa berat.'],
['campuran', 'Produknya bagus banget, kualitas jahitan rapi. Tapi pengirimannya lama banget, 2 minggu baru sampai. Kurirnya juga ga ramah.'],
]
for (const [label, teks] of ulasan) {
const r = await agent.generate(teks, {
structuredOutput: {
schema,
// TODO 6: pasang errorStrategy 'fallback' + fallbackValue yang masuk akal
},
})
console.log(`--- ulasan ${label} ---`)
console.log(JSON.stringify(r.object, null, 2))
}Bentuk hasil yang diharapkan
Ini tangkapan asli dari solusi yang sudah jadi. Ulasan campuran adalah kasus penentunya:
text
--- ulasan campuran ---
{
"sentimen": "campuran",
"skor": 3,
"aspek": [
{ "nama": "kualitas", "sentimen": "positif" },
{ "nama": "pengiriman", "sentimen": "negatif" },
{ "nama": "layanan", "sentimen": "negatif" }
],
"perluTindakan": true,
"ringkasan": "Produk berkualitas namun pengiriman lama dan kurir tidak ramah."
}Bandingkan dengan ulasan positif — perhatikan perluTindakan berubah menjadi false dan aspek hanya berisi yang benar-benar disebut:
text
--- ulasan positif ---
{
"sentimen": "positif",
"skor": 5,
"aspek": [
{ "nama": "kualitas", "sentimen": "positif" },
{ "nama": "harga", "sentimen": "positif" }
],
"perluTindakan": false,
"ringkasan": "Bahan adem, jahitan rapi, harga sangat sepadan."
}Selesai kalau
- [ ] Ulasan campuran menghasilkan lebih dari satu entri
aspekdengansentimenyang berbeda - [ ]
perluTindakanbernilaifalseuntuk ulasan positif,trueuntuk yang memuat keluhan - [ ] Tidak ada
z.string()tersisa untuk field yang nilainya terbatas - [ ] Jalankan ulasan campuran tiga kali —
sentimendan daftaraspekharus sama setiap kali - [ ] Tidak ada aspek karangan: ulasan positif tidak boleh memunculkan
pengiriman
Kalau macet
Tiga kali jalan hasilnya beda-beda? Schema-mu masih terlalu longgar. Bandingkan hasilmu dengan demo schema lemah di atas — kalau skor melompat dari 3 ke 7, berarti .min()/.max() belum terpasang.
Muncul aspek yang tidak disebut ulasan? .describe() pada aspek belum melarangnya. Larangan implisit tidak bekerja; tulis eksplisit, misalnya "Hanya sertakan aspek yang benar-benar disinggung. Jangan menambah aspek lain."
r.object bernilai undefined? Periksa structuredOutput ada di argumen kedua generate(), dan hasilnya dibaca dari .object, bukan .text.
Ikhtisar
- Structured output membuat agent mengembalikan objek tervalidasi lewat
response.object, menghilangkan kebutuhan mengurai teks. - Diaktifkan lewat opsi
structuredOutput: { schema }padagenerate()ataustream(). - Zod adalah pilihan default karena memberi inferensi tipe sekaligus validasi runtime; Valibot, ArkType, dan JSON Schema mentah juga didukung.
- Schema dikirim ke model, bukan hanya dipakai memvalidasi.
.describe(),z.enum(), dan batasan numerik secara langsung meningkatkan mutu keluaran. errorStrategymenentukan perilaku saat validasi gagal:'strict','warn', atau'fallback'denganfallbackValue.- Dengan
stream(), tersediaobjectStreamuntuk hasil parsial danobjectuntuk final. - Structured output = bentuk jawaban akhir. Tool = kemampuan yang dipanggil di tengah jalan.
Lanjut ke mana
Bagian 1 selesai. Agent-mu bisa diarahkan, bisa dipanggil dengan dua cara, dan bisa mengembalikan data terstruktur — tapi ia masih terkurung: tidak bisa menyentuh apa pun di luar dirinya. Itu yang diperbaiki Bagian 2 — Tools.