Tampilan
10.1 FAQ Bot Internal
Topologi: A — Agent tunggal Materi yang dipakai: Bagian 0, 1.1, 1.2, 1.4Prasyarat: tidak ada — ini kasus pertama
Masalahnya
Tim HR menerima pertanyaan yang sama berulang kali lewat Slack: berapa jatah cuti, boleh WFH berapa hari, kapan reimburse cair. Jawabannya sudah tertulis di dokumen kebijakan, tapi tidak ada yang membacanya.
Yang dibutuhkan: bot yang menjawab dari kebijakan resmi, dan mengaku tidak tahu saat pertanyaannya di luar kebijakan — karena HR lebih takut bot mengarang aturan daripada bot yang bilang "tanya HR".
Skalanya kecil dan jelas: sekitar 15 butir kebijakan, berubah 2–3 kali setahun, dipakai 60 karyawan.
Keputusan arsitektur
Keputusan 1 — Data kebijakan: instructions atau RAG?
Ini keputusan yang paling menentukan bentuk sistem.
Taruh di instructions | Pakai RAG | |
|---|---|---|
| Kerumitan setup | Nol | Vector store, embedding, proses ingest |
| Biaya per pertanyaan | Semua kebijakan ikut tiap panggilan | Hanya potongan relevan |
| Memperbarui kebijakan | Edit satu berkas, deploy | Jalankan ulang ingest |
| Batas praktis | Sampai ~2000 token kebijakan | Ribuan halaman |
| Risiko salah ambil | Nol — model melihat semuanya | Ada; potongan bisa meleset |
Pilihan: instructions.
Ini terlihat melanggar aturan dari 1.2 — "instructions bukan tempat menaruh data". Aturan itu tetap benar, tapi alasannya perlu dilihat: yang dilarang adalah data yang berubah per permintaan atau terlalu besar. Kebijakan HR di sini tidak keduanya.
Dengan 15 butir kebijakan (~600 token), memasang vector store berarti menambah tiga komponen — embedding model, vector store, proses ingest — untuk menyelesaikan masalah yang belum ada.
Kapan keputusan ini berbalik
Pindah ke RAG saat salah satu terjadi:
- Kebijakan melewati ~2000 token
- Kebijakan berubah lebih sering dari siklus deploy-mu
- Orang non-teknis perlu mengubahnya tanpa menyentuh kode
Kasus 10.5 mengambil alih persis dari titik ini.
Keputusan 2 — Keluaran teks atau terstruktur?
| Teks bebas | Structured output | |
|---|---|---|
| Kode pemanggil | Perlu parsing | Langsung dipakai |
| UI bisa bedakan "tidak tahu"? | Tidak andal | Ya, lewat field boolean |
| Bisa dihitung statistiknya? | Tidak | Ya, per topik |
| Biaya | Sedikit lebih murah | Sedikit lebih mahal |
Pilihan: structured output.
Alasannya bukan kerapian, melainkan satu kebutuhan konkret: HR ingin tahu pertanyaan apa yang paling sering tidak terjawab, supaya kebijakan bisa dilengkapi. Itu mustahil kalau keluarannya teks bebas.
Field ditemukan juga memungkinkan UI menampilkan jawaban "tidak tahu" dengan warna berbeda dan tombol "hubungi HR" — bukan sekadar paragraf yang terlihat sama seperti jawaban lain.
Keputusan 3 — Satu agent atau satu per departemen?
Pilihan: satu agent.
Dengan 15 butir kebijakan dalam satu domain, memecah menjadi beberapa agent hanya menambah satu keputusan routing yang bisa salah. Pemecahan mulai masuk akal saat instruksi tidak lagi muat satu layar — pola itu dibahas di 10.12.
Struktur direktori
text
faq-hr/
├── src/
│ ├── domain/
│ │ └── kebijakan.ts ← data kebijakan, TANPA impor Mastra
│ └── mastra/
│ ├── agents/
│ │ └── faq-agent.ts ← agent + schema keluaran
│ └── index.ts ← pendaftaran
├── .env
├── package.json
└── tsconfig.jsonPerhatikan domain/kebijakan.ts berdiri sendiri. Terlihat berlebihan untuk sebuah array — tapi ini yang membuat kebijakan bisa diuji, di-lint, dan nanti dipindah ke database tanpa menyentuh kode agent.
Alur
Hanya satu panggilan model. Tidak ada tool, tidak ada loop — ini kasus paling sederhana yang mungkin, dan itu disengaja.
Implementasi
src/domain/kebijakan.ts
ts
// Tidak ada impor Mastra di berkas ini. Sengaja.
export type Kebijakan = {
topik: string
isi: string
}
export const KEBIJAKAN: Kebijakan[] = [
{
topik: 'cuti-tahunan',
isi: 'Karyawan tetap mendapat 12 hari cuti tahunan per tahun, berlaku setelah masa percobaan 3 bulan. Sisa cuti hangus setiap 31 Maret tahun berikutnya.',
},
{
topik: 'cuti-sakit',
isi: 'Cuti sakit maksimal 14 hari per tahun dengan surat dokter. Tanpa surat dokter maksimal 2 hari berturut-turut.',
},
{
topik: 'wfh',
isi: 'Work from home diizinkan maksimal 2 hari per minggu, harus disetujui atasan langsung minimal H-1.',
},
{
topik: 'reimburse',
isi: 'Klaim reimbursement diajukan maksimal 30 hari setelah transaksi, dengan bukti struk asli. Pencairan setiap tanggal 25.',
},
{
topik: 'jam-kerja',
isi: 'Jam kerja 09.00-18.00 dengan istirahat 1 jam. Fleksibel 1 jam lebih awal atau lebih lambat dengan persetujuan atasan.',
},
]
/** Daftar topik untuk dipakai sebagai enum schema. */
export const TOPIK = KEBIJAKAN.map(k => k.topik)src/mastra/agents/faq-agent.ts
ts
import { Agent } from '@mastra/core/agent'
import { z } from 'zod'
import { KEBIJAKAN } from '../../domain/kebijakan.ts'
// Kebijakan dirakit sekali saat modul dimuat, bukan tiap permintaan.
const daftarKebijakan = KEBIJAKAN.map(k => `[${k.topik}] ${k.isi}`).join('\n')
export const faqAgent = new Agent({
id: 'faq-agent',
name: 'FAQ HR',
instructions: `Kamu asisten FAQ kebijakan HR perusahaan.
Jawab HANYA berdasarkan daftar kebijakan di bawah. Kalau pertanyaan tidak
terjawab oleh daftar itu, set ditemukan=false dan arahkan ke tim HR.
Jangan mengarang kebijakan yang tidak tertulis.
DAFTAR KEBIJAKAN:
${daftarKebijakan}`,
model: 'openai/gpt-5-mini',
})
export const jawabanSchema = z.object({
ditemukan: z
.boolean()
.describe('true bila jawaban ada di daftar kebijakan'),
topik: z
.enum(['cuti-tahunan', 'cuti-sakit', 'wfh', 'reimburse', 'jam-kerja', 'tidak-ada'])
.describe('Topik kebijakan yang menjawab. "tidak-ada" bila ditemukan=false'),
jawaban: z
.string()
.describe('Jawaban ringkas maksimal 2 kalimat'),
perluHubungiHR: z
.boolean()
.describe('true bila pengguna sebaiknya menghubungi tim HR'),
})Tiga hal yang layak diperhatikan di berkas ini.
Kebijakan dirakit di luar konstruktor. daftarKebijakan dihitung sekali saat modul dimuat. Kalau ditaruh di dalam fungsi instruksi dinamis, ia dirakit ulang setiap permintaan tanpa alasan.
Aturan "jangan mengarang" ditulis eksplisit. Tanpa itu, model akan mengisi kekosongan dengan kebijakan HR umum yang terdengar masuk akal — dan itu justru bahaya terbesar di kasus ini.
Instruksi menyebut apa yang harus dilakukan saat tidak tahu — set ditemukan=false dan arahkan ke HR — bukan sekadar melarang mengarang. Pola dari 1.2.
src/mastra/index.ts
ts
import { Mastra } from '@mastra/core'
import { faqAgent } from './agents/faq-agent.ts'
export const mastra = new Mastra({
agents: { faqAgent },
})Pemakaian
ts
// run.mjs
import { mastra } from './src/mastra/index.ts'
import { jawabanSchema } from './src/mastra/agents/faq-agent.ts'
const agent = mastra.getAgentById('faq-agent')
const r = await agent.generate('berapa hari cuti tahunan saya?', {
structuredOutput: { schema: jawabanSchema },
})
console.log(r.object)Hasil nyata
Dijalankan sungguhan dengan openai/gpt-5-mini. Tiga pertanyaan, yang ketiga sengaja di luar kebijakan:
text
> berapa hari cuti tahunan saya?
{
"ditemukan": true,
"topik": "cuti-tahunan",
"jawaban": "Karyawan tetap mendapat 12 hari cuti tahunan per tahun, berlaku
setelah masa percobaan 3 bulan. Sisa cuti hangus setiap 31 Maret
tahun berikutnya.",
"perluHubungiHR": false
}
> boleh wfh berapa hari seminggu?
{
"ditemukan": true,
"topik": "wfh",
"jawaban": "Boleh maksimal 2 hari per minggu; WFH harus disetujui atasan
langsung minimal H-1.",
"perluHubungiHR": false
}
> bagaimana cara klaim asuransi gigi?
{
"ditemukan": false,
"topik": "tidak-ada",
"jawaban": "Kebijakan atau prosedur klaim asuransi gigi tidak tercantum dalam
daftar kebijakan; silakan hubungi tim HR untuk informasi dan
langkah klaim.",
"perluHubungiHR": true
}Pertanyaan ketiga adalah ujian sebenarnya. Asuransi gigi memang tidak ada di daftar, dan bot tidak mengarang — ia menandai ditemukan: false dan mengarahkan ke HR.
Perhatikan juga jawaban kedua: model meringkas, tidak menyalin mentah kebijakan WFH. Itu efek dari .describe('Jawaban ringkas maksimal 2 kalimat') pada schema.
Best practice yang dipakai di sini
1. Data terpisah dari agent. domain/kebijakan.ts tidak tahu Mastra ada. Saat suatu hari kebijakan pindah ke database, yang berubah hanya berkas itu.
2. Schema dan agent tinggal berdampingan. jawabanSchema diekspor dari berkas agent yang sama, karena keduanya berubah bersama-sama. Memisahkannya ke schemas/ menciptakan dua berkas yang harus selalu diedit bersamaan.
3. Enum topik diturunkan dari data. Idealnya enum itu dibangun dari TOPIK alih-alih ditulis ulang manual:
ts
import { TOPIK } from '../../domain/kebijakan.ts'
topik: z.enum([...TOPIK, 'tidak-ada'] as [string, ...string[]])| Untung | Rugi |
|---|---|
| Tambah kebijakan baru otomatis masuk enum | Tipe literalnya hilang, jadi string biasa |
| Tidak bisa lupa memperbarui enum | Perlu type assertion yang agak jelek |
Untuk 5 topik yang jarang berubah, menulis manual masih wajar dan tipenya lebih baik. Beralih ke bentuk turunan saat topiknya belasan.
4. Satu berkas untuk pendaftaran. index.ts cuma 5 baris sekarang, dan memang seharusnya begitu.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Kebijakan > ~2000 token | Biaya per pertanyaan membengkak, instruksi jadi tak terbaca | 10.5 RAG |
| Butuh data hidup (sisa cuti saya) | Kebijakan statis tidak bisa menjawab per orang | 10.2 Tool |
| Percakapan berlanjut ("kalau yang tadi?") | Tanpa memory, tiap pertanyaan berdiri sendiri | 10.3 WhatsApp |
| Bot boleh mengajukan cuti | Ini mengubah keadaan, butuh tool + persetujuan | 10.9 Approval |
Baris kedua adalah batas yang paling cepat tersentuh dalam praktik. Begitu seseorang bertanya "sisa cuti saya berapa?", pola ini habis — dan itu isi kasus berikutnya.
Coba sendiri
Salin struktur di atas, lalu ganti domainnya menjadi FAQ kebijakan sekolah (seragam, jam masuk, izin, SPP).
Selesai kalau
- [ ] Pertanyaan di dalam kebijakan dijawab dengan
ditemukan: truedantopikyang tepat - [ ] Pertanyaan di luar kebijakan menghasilkan
ditemukan: false— uji minimal 3 pertanyaan di luar topik - [ ] Tidak ada satu pun kebijakan karangan di jawaban
- [ ]
domain/tidak mengimpor apa pun dari@mastra/* - [ ] Kamu bisa menyebutkan pada titik berapa kamu akan pindah ke RAG
Kalau macet
Bot mengarang kebijakan yang tidak ada? Instruksimu belum memberi jalan keluar. Bandingkan: "Jangan mengarang" (larangan saja) versus "Kalau tidak terjawab, set ditemukan=false dan arahkan ke HR" (larangan + pengganti).
topik diisi nilai di luar enum? Pastikan 'tidak-ada' ikut terdaftar di z.enum() — model butuh nilai sah untuk kasus tidak ketemu.
Lanjut ke mana
Bot ini hanya tahu apa yang tertulis di instruksinya. Kasus berikutnya memberinya kemampuan mengambil data hidup — 10.2 Asisten Data Eksternal.