Skip to content

10.11 Satu Agent, Ribuan Pengguna

Topologi: B — Agent + Workflow Materi yang dipakai: 7.1, 9.1, 9.4Prasyarat: 10.3, 10.10

Masalahnya

Asisten CS dari kasus 10.10 dipakai 40 toko. Sekarang salah satu toko mengadakan flash sale, dan 2.000 pelanggan mengirim pesan dalam 10 menit.

Tiga hal yang selama ini aman tiba-tiba jadi masalah:

  1. Satu proses tidak cukup. CPU habis, antrian menumpuk.
  2. libSQL berbasis berkas tidak bisa dibagi antar proses. Menjalankan instance kedua berarti dua berkas terpisah.
  3. Pengguna mobile terputus di tengah jawaban panjang, lalu mencoba lagi — menggandakan beban.

Ada juga kekhawatiran yang wajar: kalau ribuan orang memakai agent yang sama, apakah percakapan mereka bisa tercampur?

Keputusan arsitektur

Keputusan 0 — Apakah satu agent aman dipakai bersamaan?

Ini pertanyaan pertama yang harus dijawab, karena kalau jawabannya tidak, semua keputusan lain tidak relevan.

Jawabannya aman, dan alasannya penting dipahami: objek Agent tidak menyimpan state percakapan. Ia hanya konfigurasi — instruksi, model, daftar tool. Seluruh state percakapan hidup di memory, yang dipisahkan oleh resource dan thread.

Artinya satu objek Agent bisa melayani berapa pun permintaan bersamaan, selama tiap permintaan membawa pengenal memory-nya sendiri.

Ini dibuktikan di bagian Hasil nyata di bawah.

Keputusan 1 — Storage yang bisa dibagi antar proses

Ini penghalang paling keras, dan sudah diperingatkan di 9.1.

libSQL berkaslibSQL terkelola (Turso)PostgreSQL
Multi-proses❌ Tidak✅ Ya✅ Ya
Perubahan kodeNol — hanya envGanti adapter
OperasionalNolTerkelolaKelola sendiri

Pilihan: Turso, karena pola dari kasus 10.3 sudah menyiapkannya:

ts
url: process.env.TURSO_DATABASE_URL || 'file:./mastra.db'

Naik ke produksi berarti mengisi variabel lingkungan, bukan menulis ulang kode. Itu keuntungan konkret dari memilih pola tersebut sejak awal.

Keputusan 2 — Server stateless, worker terpisah

KomponenSifatBoleh diskalakan?
API serverStateless — semua state di DBYa, tambah instance sesuka hati
Worker (schedule, alert)Punya timer internalHati-hati — jangan digandakan sembarangan

Pilihan: pisahkan keduanya.

Kesalahan yang umum: menjalankan 5 instance aplikasi yang masing-masing juga menyalakan scheduler. Hasilnya laporan harian terkirim 5 kali.

text
❌ Salah                          ✅ Benar
┌──────────────┐ ×5              ┌──────────────┐ ×5   ┌──────────┐ ×1
│ API + worker │                 │ API (stateless)│     │ worker   │
│ + scheduler  │                 └──────────────┘      │ + sched  │
└──────────────┘                        │              └──────────┘
 laporan 5×                             └──────┬──────────────┘
                                            ┌──▼───┐
                                            │  DB  │
                                            └──────┘

Keputusan 3 — Durable agent atau agent biasa?

Agent biasaDurable agent
Klien terputus di tengahPekerjaan hilangBisa disambung
Bertahan restart prosesTidakYa
KerumitanRendahPubSub + persistensi run
Cocok untukJawaban < 30 detikRiset panjang, koneksi buruk

Pilihan: agent biasa untuk jalur chat, durable agent hanya untuk yang panjang.

Dokumentasi 7.1 menyatakannya langsung: untuk panggilan pendek dengan klien tersambung, Agent biasa lebih sederhana.

Membungkus semua agent jadi durable adalah biaya tanpa manfaat untuk 95% permintaan CS yang selesai dalam 5 detik.

Keputusan 4 — Batas biaya

Pada 2.000 pesan, maxSteps bukan lagi soal kerapian.

Tanpa batasDengan maxSteps: 5
Satu agent bingung bisa berputar 20×Maksimal 5 putaran
2.000 × 20 panggilan model2.000 × ≤5

Ini perbedaan antara tagihan yang wajar dan tagihan yang mengejutkan.

Struktur direktori

text
cs-saas/
├── src/
│   ├── domain/
│   ├── mastra/
│   │   ├── agents/
│   │   │   ├── cs-agent.ts          ← agent biasa, jalur cepat
│   │   │   └── riset-agent.ts       ← durable, jalur panjang
│   │   └── index.ts
│   ├── server/                      ← STATELESS, diskalakan bebas
│   │   ├── middleware-auth.ts
│   │   └── routes.ts
│   └── worker/                      ← SATU instance saja
│       ├── index.ts
│       └── pemantau.ts
├── Dockerfile.server
├── Dockerfile.worker
└── package.json

Dua Dockerfile adalah wujud fisik dari keputusan 2. Server dan worker di-deploy sebagai layanan berbeda dengan jumlah replika berbeda.

Alur

Semua instance API membaca dan menulis state yang sama. Tidak ada satu pun yang menyimpan sesuatu di memori proses.

Implementasi

src/mastra/index.ts

ts
import { Mastra } from '@mastra/core'
import { LibSQLStore } from '@mastra/libsql'
import { csAgent } from './agents/cs-agent.ts'
import { durableRiset } from './agents/riset-agent.ts'

export const mastra = new Mastra({
  agents: { csAgent, durableRiset },

  storage: new LibSQLStore({
    id: 'mastra-storage',
    // Lokal: berkas. Produksi: Turso. Kode tidak berubah.
    url: process.env.TURSO_DATABASE_URL || 'file:./mastra.db',
    authToken: process.env.TURSO_AUTH_TOKEN || undefined,
  }),

  server: {
    port: Number(process.env.PORT) || 4111,
    host: '0.0.0.0',
  },
})

src/mastra/agents/riset-agent.ts

ts
import { Agent } from '@mastra/core/agent'
import { createDurableAgent } from '@mastra/core/agent/durable'

const riset = new Agent({
  id: 'riset',
  name: 'Riset',
  instructions: 'Kamu meneliti pertanyaan pelanggan yang butuh pencarian mendalam.',
  model: 'openai/gpt-5.6-sol',
})

// Dibungkus — objek Agent yang sama tetap ada dan bisa dipakai langsung.
export const durableRiset = createDurableAgent({ agent: riset })

src/server/routes.ts

ts
import { mastra } from '../mastra/index.ts'
import { buatContext } from './middleware-auth.ts'
import { simpanRunId } from '../domain/riset.ts'

export async function handleChat(req: {
  headers: Record<string, string | undefined>
  body: { pesan: string; mode?: 'cepat' | 'riset' }
}) {
  const ctx = await buatContext(req.headers.authorization)
  const tenantId = ctx.get('tenantId') as string
  const userId = ctx.get('userId') as string

  // Pengenal memory memuat KEDUA identitas — dari kasus 10.10.
  const memory = {
    resource: `${tenantId}:${userId}`,
    thread: `${tenantId}:${userId}`,
  }

  // --- Jalur panjang: durable ---
  if (req.body.mode === 'riset') {
    const agent = mastra.getAgentById('durableRiset')
    const { runId, cleanup } = await agent.stream(req.body.pesan)

    // runId disimpan agar klien bisa menyambung setelah terputus.
    await simpanRunId(userId, runId)
    void cleanup

    return { mode: 'riset', runId }
  }

  // --- Jalur cepat: agent biasa ---
  const agent = mastra.getAgentById('cs-agent')
  const hasil = await agent.generate(req.body.pesan, {
    requestContext: ctx,
    memory,
    maxSteps: 5,          // batas biaya, wajib pada skala ini
  })

  return { mode: 'cepat', teks: hasil.text }
}

src/worker/index.ts

ts
import { mastra } from '../mastra/index.ts'
import { jalankanPemantau } from './pemantau.ts'

// Proses TERPISAH dari API. Dijalankan dengan replika = 1.
async function main() {
  console.log('worker mulai')

  // Scheduler Mastra menyala saat schedule pertama dibuat/dimuat.
  const jadwal = await mastra.schedules.list()
  console.log(`${jadwal.length} jadwal aktif`)

  await jalankanPemantau()
}

main()

Worker harus replika = 1

Kalau worker diskalakan ke 3 replika, tiap schedule menyala tiga kali dan tiap alert terkirim tiga kali.

Kalau kamu memang butuh worker redundan, kamu butuh koordinasi — leader election atau lease, yang di Mastra tersedia lewat lapisan PubSub. Jangan menggandakan worker tanpa itu.

Hasil nyata

Pertanyaan paling penting di kasus ini: apakah percakapan bisa tercampur saat banyak orang memakai agent yang sama bersamaan?

Diuji sungguhan. Delapan pengguna mengirim kode rahasia berbeda secara bersamaan lewat Promise.all, lalu semuanya bertanya balik bersamaan:

text
Fase 1: 8 pengguna mengirim kode rahasia BERSAMAAN
  selesai dalam 8260 ms

Fase 2: kedelapan bertanya BERSAMAAN "apa kodeku?"
  selesai dalam 4698 ms

  OK   user-1   harap=KODE-111   dapat="KODE-111"
  OK   user-2   harap=KODE-222   dapat="KODE-222"
  OK   user-3   harap=KODE-333   dapat="KODE-333"
  OK   user-4   harap=KODE-444   dapat="KODE-444"
  OK   user-5   harap=KODE-555   dapat="KODE-555"
  OK   user-6   harap=KODE-666   dapat="KODE-666"
  OK   user-7   harap=KODE-777   dapat="KODE-777"
  OK   user-8   harap=KODE-888   dapat="KODE-888"

HASIL: 8/8 benar — TIDAK ADA kebocoran antar thread

Dua kesimpulan dari angka ini:

1. Isolasi thread bertahan di bawah beban bersamaan. Delapan percakapan berjalan serentak lewat satu objek Agent, dan tidak ada satu pun kode yang tertukar. Objek Agent memang tidak menyimpan state percakapan.

2. Permintaan benar-benar paralel. Fase 2 selesai dalam 4,7 detik untuk 8 permintaan. Kalau berurutan, ia akan memakan sekitar 8 × waktu satu permintaan.

Uji ini layak kamu jalankan sendiri

Kecemasan "apakah agent-ku thread-safe" sangat umum dan sulit dihilangkan dengan penjelasan. Menjalankan Promise.all dengan 8 identitas berbeda dan melihat 8/8 benar jauh lebih meyakinkan.

Ulangi dengan sengaja menyamakan thread di semua pengguna, dan lihat kebocorannya muncul. Itu memperlihatkan bahwa yang menjaga isolasi adalah pengenal memory, bukan sihir.

Best practice yang dipakai di sini

1. Server stateless. Tidak ada state di memori proses — semuanya di database. Ini yang membuat penambahan instance jadi aman.

2. Worker terpisah, replika 1. Timer dan scheduler tidak boleh digandakan tanpa koordinasi.

3. Pola env || file: sejak awal. Naik ke storage terkelola tanpa mengubah kode.

4. Durable agent hanya untuk jalur panjang. Bukan untuk semua permintaan.

5. maxSteps sebagai batas biaya. Pada skala ini ia menentukan tagihan.

6. Dua Dockerfile. Perbedaan penskalaan dituangkan ke artefak deployment, bukan hanya ke dokumentasi.

Kapan pola ini salah

SituasiKenapa gagalKe mana
Butuh worker redundanReplika 1 jadi titik kegagalan tunggalLeader election / lease lewat PubSub
Lonjakan 10× dalam hitungan detikMenambah instance tidak instanAntrian di depan agent
Satu tenant memakan seluruh kapasitasTidak ada isolasi sumber dayaRate limit per tenant
Tidak tahu instance mana yang bermasalahLog tercampur10.15 Platform produksi

Baris terakhir adalah masalah yang baru muncul justru karena penskalaan: dengan satu proses, membaca log itu mudah. Dengan lima, kamu butuh observability terpusat.

Coba sendiri

Ambil salah satu sistem dari kasus sebelumnya, lalu buktikan sendiri perilakunya di bawah beban.

Selesai kalau

  • [ ] Uji konkurensi 8 pengguna lolos 8/8 tanpa kebocoran
  • [ ] Uji yang sengaja menyamakan thread memperlihatkan kebocoran — dan kamu bisa menjelaskan kenapa
  • [ ] Aplikasi berjalan dengan dua instance API sekaligus, keduanya menunjuk satu database bersama
  • [ ] Percakapan yang dimulai di instance A bisa dilanjutkan di instance B
  • [ ] Scheduler hanya berjalan di worker, bukan di instance API
  • [ ] maxSteps terpasang di semua jalur yang menghadap pengguna

Kalau macet

Instance kedua tidak melihat percakapan instance pertama? Storage masih berbasis berkas. Isi TURSO_DATABASE_URL, atau pindah ke Postgres.

Laporan terjadwal terkirim beberapa kali? Scheduler ikut menyala di tiap instance API. Pindahkan ke worker dengan replika 1.

Konkurensi lambat, terasa berurutan? Periksa kamu memakai Promise.all, bukan for await — dan tidak ada await di dalam loop yang menunggu satu per satu.

Lanjut ke mana

Sebelas kasus pertama memakai satu agent per sistem. Kasus berikutnya membagi pekerjaan ke beberapa agent spesialis, untuk tugas yang terlalu luas dipegang satu agent — 10.12 Riset Panjang & Subagent.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.