Skip to content

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 instructionsPakai RAG
Kerumitan setupNolVector store, embedding, proses ingest
Biaya per pertanyaanSemua kebijakan ikut tiap panggilanHanya potongan relevan
Memperbarui kebijakanEdit satu berkas, deployJalankan ulang ingest
Batas praktisSampai ~2000 token kebijakanRibuan halaman
Risiko salah ambilNol — model melihat semuanyaAda; 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 bebasStructured output
Kode pemanggilPerlu parsingLangsung dipakai
UI bisa bedakan "tidak tahu"?Tidak andalYa, lewat field boolean
Bisa dihitung statistiknya?TidakYa, per topik
BiayaSedikit lebih murahSedikit 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.json

Perhatikan 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[]])
UntungRugi
Tambah kebijakan baru otomatis masuk enumTipe literalnya hilang, jadi string biasa
Tidak bisa lupa memperbarui enumPerlu 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

SituasiKenapa gagalKe mana
Kebijakan > ~2000 tokenBiaya per pertanyaan membengkak, instruksi jadi tak terbaca10.5 RAG
Butuh data hidup (sisa cuti saya)Kebijakan statis tidak bisa menjawab per orang10.2 Tool
Percakapan berlanjut ("kalau yang tadi?")Tanpa memory, tiap pertanyaan berdiri sendiri10.3 WhatsApp
Bot boleh mengajukan cutiIni mengubah keadaan, butuh tool + persetujuan10.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: true dan topik yang tepat
  • [ ] Pertanyaan di luar kebijakan menghasilkan ditemukan: falseuji 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.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.