Debugging OOM saat menjalankan LLM lokal di backend API hampir selalu berujung pada satu kesimpulan: masalahnya bukan sekadar “RAM kurang”, tetapi kombinasi antara ukuran model, panjang context, konkurensi request, preload proses, dan perilaku sistem operasi saat mulai swap thrashing. Dalam backend API internal, gejalanya sering terlihat sebagai crash acak, latency melonjak, timeout di upstream, dan throughput yang justru turun saat trafik naik.
Artikel ini membahas studi kasus praktis: sebuah service API internal memanggil model LLM lokal untuk fitur ringkasan dan ekstraksi data. Implementasi awal terinspirasi dari praktik menjalankan LLM lokal seperti yang sering dibahas di repo jamesob/local-llm, tetapi persoalan utama ternyata ada di desain backend dan kontrol resource, bukan di tool-nya. Fokus kita adalah bagaimana mendiagnosis bug nyata, membedakan gejala dari akar masalah, dan menyiapkan guardrail agar service tetap stabil.
Studi kasus: service internal yang makin sibuk, lalu mulai crash
Arsitekturnya sederhana:
- Backend API menerima request dari service internal lain.
- Untuk endpoint tertentu, backend meneruskan prompt ke proses LLM lokal.
- Response dikembalikan sinkron ke caller.
- Di deployment awal, satu host menjalankan API server sekaligus runtime model.
Pada trafik rendah, semuanya terlihat normal. Masalah muncul saat beban naik:
- Beberapa request mulai timeout di 30-60 detik.
- RSS memory proses terus naik lalu container atau proses dibunuh oleh OOM killer.
- CPU usage tidak selalu 100%, tetapi load average tinggi.
- Disk I/O meningkat tajam walau model seharusnya sudah ada di memori.
- Throughput turun justru ketika jumlah worker API dinaikkan.
Ini pola klasik untuk service inference lokal yang dipaksa melayani terlalu banyak request paralel tanpa pembatasan yang sesuai.
Gejala yang terlihat di log dan metrik
1. Log aplikasi
Di level aplikasi, gejalanya sering menyesatkan. Contoh log yang umum:
[api] request_id=9f2... route=/v1/summarize upstream_timeout=true duration_ms=60012
[api] request_id=7ab... llm_error="connection reset by peer"
[api] request_id=1ce... llm_error="context deadline exceeded"
[api] request_id=3aa... status=500 message="inference failed"Kalau hanya melihat log ini, tim sering langsung menyimpulkan ada bug jaringan atau runtime LLM tidak stabil. Padahal, connection reset sering terjadi karena proses inference mati lebih dulu akibat OOM.
2. Metrik host
Metrik sistem biasanya jauh lebih jujur:
- Memory usage mendekati limit host atau container.
- Page fault dan swap in/out meningkat.
- Disk I/O wait naik walau workload tampak CPU-bound.
- OOM kill event muncul di kernel log.
Contoh pemeriksaan cepat di Linux:
dmesg -T | grep -i -E 'oom|killed process'
free -h
vmstat 1
iostat -xz 1
ps aux --sort=-rss | headJika vmstat menunjukkan swap activity terus-menerus dan iostat memperlihatkan disk sibuk, kemungkinan besar host sudah masuk ke fase swap thrashing: CPU tidak produktif karena terlalu banyak waktu habis memindah halaman memori.
3. Metrik service
Tambahkan metrik yang memang relevan untuk inference:
- Jumlah request aktif ke LLM.
- Panjang queue internal.
- Waktu tunggu sebelum inference dimulai.
- Durasi inference per request.
- Panjang prompt atau jumlah token input.
- Jumlah restart proses LLM.
Tanpa metrik ini, Anda sulit membedakan apakah latency tinggi terjadi karena model lambat, queue penuh, atau proses terus restart.
Hipotesis yang sering salah
Dalam insiden seperti ini, tim sering menghabiskan waktu pada hipotesis yang keliru:
“Masalahnya di network”
Kalau backend dan runtime LLM berjalan pada host yang sama atau jaringan internal cepat, timeout biasanya bukan akar masalah. Network error sering hanya efek samping saat proses inference mati atau backlog terlalu panjang.
“Tambahkan worker API biar throughput naik”
Ini salah satu jebakan terbesar. Menambah worker HTTP memang bisa meningkatkan kemampuan menerima request, tetapi jika setiap worker bebas memicu inference berat, total memory pressure naik lebih cepat daripada kapasitas model untuk melayani. Hasilnya: makin banyak worker, makin cepat OOM atau swap thrashing.
“Model harus selalu di-preload di semua worker”
Preload kadang berguna untuk mengurangi cold start, tetapi jika implementasinya membuat beberapa proses memuat salinan model sendiri, memory usage bisa meledak. Ini sering terjadi ketika server di-scale dengan banyak proses OS, bukan sekadar thread ringan.
“Timeout dinaikkan saja”
Menaikkan timeout hanya menyembunyikan gejala. Jika penyebabnya adalah queue menumpuk atau memori habis, timeout lebih panjang justru menahan koneksi lebih lama, menambah tekanan pada worker API dan memperburuk throughput keseluruhan.
Root cause yang ditemukan
Setelah log kernel, metrik host, dan pola trafik dianalisis, biasanya akar masalah berasal dari kombinasi faktor berikut.
1. Konkurensi inference terlalu tinggi
LLM lokal bukan database ringan yang bisa menerima banyak query paralel tanpa biaya besar. Setiap request inference membawa beban memori untuk prompt, KV cache, buffer, dan objek kerja lainnya. Saat 1 request masih aman, 4-8 request paralel bisa langsung mendorong host ke ambang OOM, tergantung model dan context.
Kenapa ini terjadi? Karena backend API sering disetel berdasarkan pola web biasa: banyak worker, banyak koneksi, semua request dilayani segera. Untuk LLM lokal, desain yang lebih aman biasanya adalah membatasi konkurensi inference secara eksplisit dan menambahkan queue kecil.
2. Ukuran context terlalu besar
Banyak tim fokus pada ukuran file model, padahal memori saat inference juga dipengaruhi oleh panjang context. Prompt panjang, riwayat percakapan penuh, atau dokumen mentah yang dilempar sekaligus ke model dapat menaikkan konsumsi memori secara signifikan dan memperlambat decoding.
Tanda khasnya: request dengan prompt pendek stabil, tetapi endpoint tertentu yang menerima dokumen panjang sering timeout atau OOM.
3. Preload model di banyak proses
Misalnya API server dijalankan dengan 4 worker proses, dan masing-masing worker menginisialisasi klien atau subprocess LLM tersendiri. Walau secara logika terlihat “lebih paralel”, di tingkat memori itu bisa berarti beberapa instance model hidup sekaligus.
Untuk model yang sudah besar, ini cukup untuk menghabiskan RAM bahkan sebelum trafik datang.
4. Swap thrashing, bukan langsung OOM
Host tidak selalu langsung mati saat memori fisik penuh. Kadang kernel mulai swap, proses tetap hidup, tetapi latency meroket dan throughput ambruk. Ini lebih berbahaya karena dari luar service tampak “masih jalan”, padahal pengguna mengalami timeout dan antrean menumpuk.
Langkah diagnosis yang sistematis
1. Bedakan memory leak dari memory pressure normal
Jangan langsung menyimpulkan ada memory leak. Untuk inference LLM, lonjakan memori saat request berjalan bisa normal. Pertanyaan pentingnya:
- Apakah memori kembali turun setelah request selesai?
- Apakah kenaikannya berkorelasi dengan jumlah request paralel?
- Apakah hanya endpoint dengan prompt panjang yang memicu masalah?
Kalau pola lonjakan selalu mengikuti konkurensi dan ukuran context, kemungkinan besar ini bukan leak klasik, tetapi pressure yang memang tidak dikendalikan.
2. Korelasikan timeout dengan queue dan active inference
Catat tiga waktu pada setiap request:
- waktu masuk ke API,
- waktu mulai inference,
- waktu inference selesai.
Kalau total latency tinggi tetapi durasi inferencenya relatif tetap, bottleneck ada di antrean. Kalau durasi inference ikut membengkak saat active request naik, kemungkinan host sudah mulai contention CPU/memori atau swap.
3. Verifikasi jumlah proses model yang benar-benar berjalan
Jangan berasumsi hanya ada satu instance model. Periksa proses nyata di host:
ps -ef | grep -i 'llm\|model'
lsof -p <pid> | head
cat /proc/<pid>/status | grep -E 'VmRSS|VmSwap|Threads'Sering kali ditemukan lebih dari satu proses inference hidup karena tiap worker API melakukan bootstrap sendiri.
4. Uji dengan pembatasan konkurensi ekstrem
Ubah sementara sistem agar hanya mengizinkan 1 inference sekaligus. Jika crash hilang total dan latency menjadi stabil walau throughput rendah, berarti arah diagnosis sudah benar: problem utamanya adalah konkurensi dan kapasitas memory, bukan bug acak di aplikasi.
Contoh konfigurasi yang bermasalah
Berikut pola konfigurasi yang terlihat wajar, tetapi berbahaya untuk LLM lokal:
# Pseudo-config backend
API_WORKERS=8
LLM_REQUEST_TIMEOUT_MS=60000
LLM_MAX_CONCURRENCY=8
LLM_QUEUE_SIZE=0
LLM_PRELOAD_PER_WORKER=true
MAX_PROMPT_CHARS=unboundedMasalah dari konfigurasi di atas:
- API_WORKERS=8 membuat banyak request bisa masuk bersamaan.
- LLM_MAX_CONCURRENCY=8 mengizinkan semua worker menabrak model sekaligus.
- LLM_QUEUE_SIZE=0 sering berarti tidak ada backpressure terkontrol; request langsung mencoba inference atau gagal tidak konsisten.
- LLM_PRELOAD_PER_WORKER=true berisiko memuat model beberapa kali.
- MAX_PROMPT_CHARS=unbounded membuka jalan untuk context terlalu besar.
Konfigurasi perbaikan yang lebih aman
Pendekatan yang lebih stabil adalah memperlakukan inference sebagai resource terbatas.
# Pseudo-config backend
API_WORKERS=4
LLM_MAX_CONCURRENCY=1
LLM_QUEUE_SIZE=16
LLM_REQUEST_TIMEOUT_MS=45000
LLM_QUEUE_WAIT_TIMEOUT_MS=5000
LLM_PRELOAD_SHARED=true
MAX_PROMPT_CHARS=12000
MEMORY_GUARD_ENABLED=true
MEMORY_HIGH_WATERMARK_PERCENT=85Kenapa ini lebih aman:
- LLM_MAX_CONCURRENCY=1 atau angka kecil menjaga memori tetap terprediksi.
- Queue memberi backpressure yang eksplisit. Sistem lebih baik menolak atau menunda request daripada crash.
- Queue wait timeout mencegah request menunggu terlalu lama tanpa kepastian.
- Batas prompt mengendalikan ukuran context.
- Memory guard menghentikan request baru saat host mendekati ambang berbahaya.
Angka pastinya bergantung pada ukuran model, quantization, context, RAM host, dan apakah ada GPU. Intinya bukan menyalin angka tertentu, tetapi membatasi resource berdasarkan kapasitas nyata mesin.
Implementasi guard di backend
1. Batasi konkurensi inference dengan semaphore
Jika API Anda memanggil proses LLM lokal, gunakan semaphore atau worker pool terpisah. Contoh pseudo-code Node.js:
const MAX_CONCURRENCY = 1;
let active = 0;
const queue = [];
async function runInference(task) {
if (active >= MAX_CONCURRENCY) {
if (queue.length >= 16) {
throw new Error('llm queue full');
}
await enqueue(task);
return;
}
active++;
try {
return await task();
} finally {
active--;
drainQueue();
}
}Prinsipnya sederhana: jangan biarkan semua request memulai inference sekaligus hanya karena HTTP server mampu menerimanya.
2. Tambahkan memory guard sebelum memulai inference
Guard sederhana dapat memeriksa memori tersedia atau rasio penggunaan sebelum request baru diterima.
function canAcceptInference(systemStats) {
if (systemStats.memoryUsedPercent >= 85) return false;
if (systemStats.swapInPerSec > 0) return false;
return true;
}Jika guard aktif, kembalikan respons yang jelas, misalnya 429 Too Many Requests atau 503 Service Unavailable dengan retry hint untuk caller internal.
3. Pisahkan timeout queue dan timeout inference
Kesalahan umum adalah memakai satu timeout untuk semuanya. Padahal ada dua fase berbeda:
- Queue wait timeout: berapa lama request boleh menunggu giliran.
- Inference timeout: berapa lama eksekusi model boleh berjalan.
Pemisahan ini membantu analisis. Jika banyak request gagal di queue timeout, kapasitas terlalu kecil atau trafik terlalu tinggi. Jika gagal di inference timeout, prompt mungkin terlalu besar atau host sedang thrashing.
4. Gunakan health check yang mencerminkan kemampuan nyata
Jangan hanya mengembalikan 200 dari endpoint health karena proses API masih hidup. Health check untuk service seperti ini sebaiknya mempertimbangkan:
- apakah proses model tersedia,
- berapa panjang queue saat ini,
- apakah memory melewati ambang aman,
- apakah swap aktif berat.
Contoh respons health internal:
{
"status": "degraded",
"llm_process": "up",
"active_inference": 1,
"queue_depth": 14,
"memory_used_percent": 88,
"accepting_new_requests": false
}Status degraded jauh lebih berguna daripada 200 kosong yang menutupi masalah.
Perbaikan arsitektur yang biasanya paling efektif
1. Satu proses model, API terpisah
Daripada setiap worker API memanggil atau memuat model sendiri, lebih aman menjalankan model sebagai service inference terpisah dengan jumlah instance terbatas. API utama hanya bertugas validasi, autentikasi, dan antrean.
Keuntungannya:
- lifecycle model lebih mudah dikontrol,
- resource inference terisolasi,
- restart API tidak selalu merestart model,
- observability lebih jelas.
2. Queue eksplisit untuk workload berat
Jika endpoint tidak harus sinkron, pindahkan inference ke job queue. Caller menerima job ID, lalu hasil diambil secara asinkron. Ini jauh lebih aman untuk task dokumen panjang atau batch.
Trade-off-nya jelas: UX menjadi tidak instan, tetapi stabilitas sistem meningkat drastis.
3. Pangkas context di layer aplikasi
Jangan lempar seluruh riwayat atau seluruh dokumen mentah ke model. Terapkan:
- truncation,
- chunking,
- ringkasan bertahap,
- pembatasan jumlah pesan riwayat.
Ini bekerja karena biaya inference tidak hanya bergantung pada output, tetapi juga pada banyaknya konteks yang harus diproses model.
Mengapa throughput bisa turun saat concurrency dinaikkan?
Ini pertanyaan yang sering membingungkan tim backend. Secara intuitif, lebih banyak request paralel seharusnya berarti lebih banyak pekerjaan selesai. Pada LLM lokal, asumsi itu sering salah.
Ketika konkurensi melebihi kapasitas memori atau compute yang efisien:
- setiap request melambat karena contention,
- cache dan buffer makin besar,
- sistem mulai swap,
- waktu per request meningkat tajam,
- request baru terus datang dan menambah antrean.
Akhirnya jumlah request selesai per menit justru turun. Dalam sistem seperti ini, throughput optimal sering terjadi pada concurrency rendah tetapi stabil.
Checklist pencegahan untuk developer
- Batasi maksimum inference paralel secara eksplisit, jangan bergantung pada jumlah worker HTTP.
- Terapkan queue kecil dengan backpressure, bukan antrean tak terbatas.
- Pisahkan queue timeout dan inference timeout.
- Tambahkan memory guard dan tolak request baru saat host mendekati ambang bahaya.
- Batasi ukuran prompt/context di layer API, bukan hanya percaya pada caller.
- Pastikan Anda tahu berapa proses model yang benar-benar berjalan.
- Hindari preload model per worker jika itu berarti duplikasi memori.
- Pantau swap activity, bukan hanya RSS memory.
- Ukur queue depth, active inference, dan durasi per fase.
- Gunakan health check yang bisa menandai status degraded.
- Untuk workload berat, pertimbangkan job queue asinkron alih-alih endpoint sinkron.
Penutup
Dalam kasus debugging OOM saat menjalankan LLM lokal di backend API, akar masalah paling sering bukan bug misterius pada model, tetapi ketidaksesuaian antara pola backend web biasa dan karakteristik inference lokal yang sangat sensitif terhadap memori. Menambah worker, menaikkan timeout, atau memaksa semua request tetap sinkron biasanya hanya memperparah keadaan.
Pendekatan yang lebih andal adalah menganggap LLM lokal sebagai resource mahal: batasi konkurensi, kendalikan context, cegah preload berlebih, awasi swap, dan terapkan backpressure yang jelas. Dengan begitu, service tidak hanya berhenti crash, tetapi juga memiliki throughput yang lebih konsisten dan perilaku yang lebih mudah diprediksi saat trafik naik.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!