Tampilan
10.15 Platform Siap Produksi
Topologi: C — Supervisor + Subagent Materi yang dipakai: 8.1, 8.2, 8.3, 8.4, 9.4Prasyarat: 10.10, 10.11
Masalahnya
Empat belas kasus sebelumnya membangun sistem yang bekerja. Kasus ini menjawab pertanyaan yang berbeda: bagaimana kamu tahu ia masih bekerja besok?
Tiga situasi nyata yang tidak bisa dijawab sistem mana pun sejauh ini:
- Kamu memperbaiki instruksi agent hari Senin. Apakah itu memperbaiki mutu atau justru merusak sesuatu yang lain?
- Pelanggan melapor jawaban salah tadi pagi. Trace yang mana?
- Biaya bulan ini naik 40%. Dari agent mana?
Ketiganya sama-sama pertanyaan pengukuran, dan tidak ada satu pun yang terjawab dengan mencoba sendiri lima pertanyaan.
Keputusan arsitektur
Keputusan 1 — Apa yang direkam, dan berapa banyak?
Merekam semuanya bagus saat pengembangan dan mahal di produksi.
| Data | Volume | Strategi |
|---|---|---|
| Trace | Sangat tinggi | Semua yang gagal, sebagian yang sukses |
| Log | Tinggi | Level info ke atas, format JSON |
| Metrik | Rendah (agregat) | Semua |
| Skor | Sedang | Sampling per scorer |
Pilihan: sampling asimetris — 100% trace yang error, sebagian kecil yang sukses.
Alasannya dari 8.1: trace sukses sebagian besar seragam dan bisa diwakili sampel; trace gagal selalu berbeda, dan justru itu yang kamu butuhkan saat mendiagnosis.
Keputusan 2 — Storage untuk observability
Ini penerapan langsung composite storage dari 9.1.
| Domain | Pola akses | Backend |
|---|---|---|
memory, workflows | Baca-tulis baris, transaksional | Turso / Postgres |
observability | Tulis banyak, baca agregat | DuckDB / ClickHouse |
Pilihan: rutekan observability ke backend analitik, sisanya ke transaksional.
Menyimpan telemetri bervolume tinggi di basis data transaksional bisa saja — tapi kueri agregat di atasnya lambat, dan tabelnya membengkak jauh lebih cepat dari tabel bisnis.
Keputusan 3 — Scorer mana yang dijalankan live, mana di CI?
| Live evaluation | CI (runEvals) | |
|---|---|---|
| Kapan | Saat agent melayani | Sebelum merge |
| Data | Lalu lintas nyata | Kasus uji tetap |
| Biaya | Per permintaan | Per build |
| Menjawab | "Apakah mutu turun?" | "Apakah perubahan ini merusak?" |
Pilihan: keduanya, dengan pembagian tegas.
- Live: scorer keamanan pada
rate: 1, scorer mutu pada rate rendah - CI: gate keras + threshold pada kasus uji yang dikurasi
Keduanya menjawab pertanyaan berbeda dan tidak saling menggantikan.
Keputusan 4 — Quick Check dulu, LLM belakangan
| Quick Checks | Scorer LLM | |
|---|---|---|
| Biaya | Nol | Per pemanggilan |
| Kecepatan | Instan | Detik |
| Bisa menilai | Fakta deterministik | Mutu bahasa |
Pilihan: apa pun yang deterministik pakai Quick Check.
"Apakah tool dipanggil sebelum menjawab" tidak butuh model untuk menilainya. Simpan scorer LLM untuk pertanyaan yang benar-benar butuh penilaian bahasa.
Struktur direktori
text
platform-cs/
├── src/
│ ├── domain/
│ ├── mastra/
│ │ ├── agents/
│ │ ├── scorers/
│ │ │ ├── tanpa-janji.ts ← scorer kustom, DETERMINISTIK
│ │ │ └── nada-sesuai.ts ← scorer kustom, pakai judge
│ │ └── index.ts ← observability + composite storage
│ ├── server/
│ └── worker/
├── evals/
│ ├── kasus-uji.ts ← dikurasi dari kegagalan nyata
│ └── cs-agent.test.ts ← dijalankan di CI
├── .github/workflows/eval.yml
└── package.jsonevals/ sejajar dengan src/, bukan di dalamnya — ia bukan bagian dari aplikasi yang di-deploy.
Alur
Panah putus-putus di bawah adalah lingkaran yang membuat sistem ini membaik seiring waktu: setiap kegagalan di produksi menjadi kasus uji baru.
Implementasi
src/mastra/index.ts
ts
import { Mastra } from '@mastra/core'
import { MastraCompositeStore } from '@mastra/core/storage'
import { LibSQLStore } from '@mastra/libsql'
import { DuckDBStore } from '@mastra/duckdb'
import { PinoLogger } from '@mastra/loggers'
import {
Observability,
MastraStorageExporter,
SensitiveDataFilter,
} from '@mastra/observability'
import { csAgent } from './agents/cs-agent.ts'
export const mastra = new Mastra({
agents: { csAgent },
// Tiap domain ke backend yang cocok pola aksesnya.
storage: new MastraCompositeStore({
id: 'composite-storage',
default: new LibSQLStore({
id: 'mastra-storage',
url: process.env.TURSO_DATABASE_URL || 'file:./mastra.db',
authToken: process.env.TURSO_AUTH_TOKEN || undefined,
}),
domains: {
// Telemetri: tulis banyak, baca agregat.
observability: await new DuckDBStore().getStore('observability'),
},
}),
logger: new PinoLogger({
name: 'platform-cs',
level: 'info',
prettyPrint: false, // JSON di produksi
}),
observability: new Observability({
configs: {
produksi: {
serviceName: 'platform-cs',
exporters: [new MastraStorageExporter()],
spanOutputProcessors: [new SensitiveDataFilter()],
// Inilah yang membuat trace bisa DICARI saat pelanggan melapor.
requestContextKeys: ['tenantId', 'userId'],
includeInternalSpans: false,
},
},
}),
server: {
port: Number(process.env.PORT) || 4111,
host: '0.0.0.0',
},
})requestContextKeys adalah baris yang paling sering dilupakan dan paling sering disesali. Tanpa itu, kamu punya ribuan trace tanpa cara menemukan milik pelanggan tertentu.
observability menerima instance, bukan objek konfigurasi
Ingat dari 8.1: properti ini bertipe ObservabilityEntrypoint. Bentuk observability: { configs: {...} } akan ditolak compiler.
Bungkus dengan new Observability({ ... }) dari @mastra/observability.
src/mastra/scorers/tanpa-janji.ts
ts
import { createScorer } from '@mastra/core/evals'
// Scorer DETERMINISTIK — seluruh tahapnya function step,
// jadi judge TIDAK PERNAH dipanggil. Nol biaya.
export const tanpaJanji = createScorer({
id: 'tanpa-janji',
name: 'Tanpa Janji Waktu',
description:
'Memastikan jawaban tidak menjanjikan waktu tiba yang tidak berdasar data',
}).generateScore(({ run }) => {
const teks = JSON.stringify(run.output ?? '').toLowerCase()
const janji = /(besok|hari ini|2 hari|3 hari|minggu depan) (sampai|tiba|diterima)/
return janji.test(teks) ? 0 : 1
})Aturan domain seperti ini tidak dicakup scorer bawaan — tidak ada createNoDeliveryPromiseScorer(). Inilah kasus di mana scorer kustom memang diperlukan.
Dan karena kriterianya bisa diperiksa regex, ia ditulis sebagai function step: judge tidak pernah dipanggil, biayanya nol.
src/mastra/agents/cs-agent.ts — live evaluation
ts
import { Agent } from '@mastra/core/agent'
import { createToxicityScorer, createAnswerRelevancyScorer } from '@mastra/evals/scorers/prebuilt'
import { tanpaJanji } from '../scorers/tanpa-janji.ts'
import { cekPesanan } from '../tools/cek-pesanan.ts'
export const csAgent = new Agent({
id: 'cs-agent',
name: 'CS Agent',
instructions: '...',
model: 'openai/gpt-5-mini',
tools: { cekPesanan },
scorers: {
// Keamanan: SEMUA keluaran diperiksa.
keamanan: {
scorer: createToxicityScorer({ model: 'openai/gpt-5-mini' }),
sampling: { type: 'ratio', rate: 1 },
},
// Aturan domain: gratis, jadi jalankan semuanya.
janji: {
scorer: tanpaJanji,
sampling: { type: 'ratio', rate: 1 },
},
// Mutu: cukup sampel — ini pemantauan tren, bukan gerbang.
relevansi: {
scorer: createAnswerRelevancyScorer({ model: 'openai/gpt-5-mini' }),
sampling: { type: 'ratio', rate: 0.2 },
},
},
})Perhatikan pembagian rate-nya: 1.0 untuk keamanan dan aturan domain, 0.2 untuk mutu. Pelanggaran keamanan tidak boleh lolos; penurunan mutu cukup terdeteksi secara statistik.
Ingat juga dari 8.3: sampling deterministik berdasarkan trace ID, bukan acak. Itu yang membuat skor antar scorer bisa dibandingkan pada lalu lintas yang sama.
evals/cs-agent.test.ts — gerbang CI
ts
import { describe, it, expect } from 'vitest'
import { runEvals } from '@mastra/core/evals'
import { checks } from '@mastra/evals/checks'
import { csAgent } from '../src/mastra/agents/cs-agent.ts'
import { tanpaJanji } from '../src/mastra/scorers/tanpa-janji.ts'
import { KASUS_UJI } from './kasus-uji.ts'
describe('CS Agent', () => {
it('memenuhi gerbang mutu', async () => {
const hasil = await runEvals({
data: KASUS_UJI,
target: csAgent,
// Gate: syarat KERAS, harus rata-rata 1.0.
gates: [
checks.calledTool('cekPesanan'), // ← registration key, bukan id
checks.noToolErrors(),
],
// Threshold: dilacak, kegagalan -> verdict 'scored' bukan 'failed'.
scorers: [{ scorer: tanpaJanji, threshold: 1 }],
})
expect(hasil.verdict).not.toBe('failed')
})
})checks.calledTool() memakai registration key, bukan id tool
Ini jebakan yang ditemukan saat menjalankan contoh ini sungguhan, dan kegagalannya menyesatkan.
Tool didefinisikan dengan id: 'cek-pesanan' tapi didaftarkan sebagai tools: { cekPesanan }. Yang dilihat checks.calledTool() adalah key pendaftaran — cekPesanan.
Memakai 'cek-pesanan' menghasilkan verdict: 'failed' tanpa pesan yang menjelaskan kenapa — gate-nya sekadar tidak pernah terpenuhi. Buktinya ada di bagian berikut.
.github/workflows/eval.yml
yaml
name: Eval
on: [pull_request]
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npm run eval
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}Hasil nyata
Suite evaluasi dijalankan sungguhan dengan gate, threshold, dan scorer kustom.
Percobaan pertama — gate memakai id tool ('cek-pesanan'):
text
VERDICT : failed
SCORES : { "tanpa-janji": 1 }
SUMMARY : { "totalItems": 2 }Perhatikan betapa menyesatkannya keluaran ini: scorer kustom lulus sempurna (skor 1), tapi verdict failed. Tidak ada pesan yang menyebutkan gate mana yang gagal atau kenapa.
Percobaan kedua — satu-satunya yang berubah adalah nama tool di gate, dari 'cek-pesanan' menjadi 'cekPesanan':
text
VERDICT : passed
SCORES : { "tanpa-janji": 1 }
SUMMARY : { "totalItems": 2 }Dua pelajaran dari perbandingan ini.
1. checks.calledTool() memakai registration key. Kalau gate-mu gagal tanpa penjelasan, ini hal pertama yang harus diperiksa.
2. Verdict failed tidak memberitahu penyebabnya. Karena itu jalankan suite-mu sekali dalam keadaan yang kamu tahu pasti lulus sebelum mengandalkannya di CI. Kalau tidak, kamu akan mengira agent-mu bermasalah padahal gate-nya yang salah tulis.
Perhatikan juga scores hanya memuat scorer bernama, bukan gate. Gate mempengaruhi verdict tapi tidak muncul sebagai skor — itu perbedaan yang disebut di 8.4.
Tiga verdict, tiga keputusan CI
| Verdict | Artinya | Keputusan |
|---|---|---|
passed | Semua gate 1.0, semua threshold tercapai | Merge |
scored | Gate lolos, ada threshold meleset | Merge + peringatan |
failed | Ada gate di bawah 1.0 | Tolak |
Pemisahan ini yang membuat CI berguna. Tanpa scored, tim harus memilih antara memblokir rilis untuk penurunan mutu kecil, atau mengabaikan peringatan sama sekali.
evals/kasus-uji.ts — dari mana kasus uji datang
ts
export const KASUS_UJI = [
// --- kasus normal ---
{ input: 'status pesanan P-1?' },
// --- perbatasan ---
{ input: 'status pesanan P-999?' }, // tidak ada
{ input: 'status pesanan?' }, // nomor tidak disebut
{ input: 'pesanan saya kapan sampai?' }, // memancing janji waktu
// --- dari kegagalan produksi nyata ---
// 2026-09-03: agent menjanjikan "besok sampai" tanpa data
{ input: 'udah dikirim belum? buru-buru nih' },
]Komentar tanggal di kasus terakhir bukan hiasan. Kasus uji terbaik datang dari kegagalan nyata, bukan dari bayangan saat merancang — dan mencatat asalnya membuat orang berikutnya tidak menghapusnya karena mengira tidak penting.
Menjawab tiga pertanyaan pembuka
Sekarang ketiga masalah di awal kasus ini bisa dijawab:
| Pertanyaan | Dijawab oleh |
|---|---|
| "Apakah perubahan Senin merusak sesuatu?" | runEvals di CI, bandingkan verdict sebelum dan sesudah |
| "Trace pelanggan yang mana?" | Filter berdasarkan requestContextKeys: ['tenantId', 'userId'] |
| "Biaya naik dari agent mana?" | Metrik agregat di storage analitik |
Best practice yang dipakai di sini
1. Composite storage: telemetri ke backend analitik. Pola aksesnya memang berbeda dari data bisnis.
2. requestContextKeys diisi sejak awal. Tanpa itu trace tidak bisa dicari.
3. Rate scorer asimetris. Keamanan 1.0, mutu 0.2.
4. Scorer deterministik untuk aturan domain. Nol biaya, dan bisa dijalankan pada semua keluaran.
5. Gate untuk syarat keras, threshold untuk mutu. Gate yang terlalu banyak membuat CI gagal terus dan diabaikan.
6. Jalankan suite dalam keadaan pasti lulus dulu. Membuktikan gerbangnya sendiri benar, sebelum mempercayainya.
7. Kasus uji diberi tanggal dan asal-usul. Supaya tidak dihapus orang berikutnya.
8. prettyPrint: false di produksi. JSON bisa diurai pengumpul log.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Prototipe yang belum punya pengguna | Mengukur sebelum ada yang diukur | Tunda sampai ada lalu lintas |
| Biaya eval mendekati biaya agent | Terlalu banyak scorer LLM pada rate tinggi | Pindahkan ke Quick Checks |
| Tim mengabaikan hasil CI | Terlalu banyak gate | Kurangi gate, perbanyak threshold |
| Butuh tahu tren lintas minggu | Trace per permintaan tidak cukup | Metrik agregat |
Coba sendiri
Ambil salah satu sistem dari 14 kasus sebelumnya dan pasangi gerbang mutu lengkap.
Selesai kalau
- [ ] Observability aktif dengan
requestContextKeysyang bisa dipakai mencari trace per pengguna - [ ] Minimal satu scorer kustom deterministik untuk aturan domain sistem itu
- [ ] Live evaluation dengan rate berbeda: 1.0 untuk keamanan, lebih rendah untuk mutu
- [ ] Suite
runEvalsdengan minimal 8 kasus uji, minimal 3 di perbatasan - [ ] Kamu bisa memicu ketiga verdict:
passed,scored, danfailed - [ ] Kamu menjalankan suite dalam keadaan pasti lulus lebih dulu untuk memvalidasi gate-nya
- [ ] Satu baris log error bisa dilacak ke trace-nya lewat
trace_id - [ ] Berjalan di CI dan menolak PR yang merusak gate
Kalau macet
verdict: failed tapi semua skor bagus? Periksa nama tool di checks.calledTool() — ia memakai registration key, bukan properti id.
Biaya eval membengkak? Hitung scorer LLM mana yang berjalan pada rate 1.0. Pindahkan yang deterministik ke Quick Checks atau scorer function-step.
Trace tidak bisa dicari? requestContextKeys belum diisi, atau nilainya tidak pernah di-set ke RequestContext di middleware.
Penutup Bagian 10
Lima belas kasus, dari FAQ bot 40 baris sampai platform yang mengukur dirinya sendiri. Kalau ada satu hal yang layak dibawa dari seluruh bagian ini:
Keputusan arsitektur yang paling menentukan hampir selalu dibuat sebelum baris kode pertama — dan hampir selalu bisa diringkas jadi pertanyaan sederhana: apakah ini butuh penilaian, atau butuh kepastian?
Agent untuk yang pertama. Workflow, kode biasa, dan gerbang mutu untuk yang kedua.
Lanjut ke mana
Sekarang giliranmu membangun sesuatu yang memakai semuanya — Bagian 11 — Proyek Akhir.