Gejala Sistem: HTTP 504 dan Low CPU Saturation
Pada arsitektur Model API berbasis micro-agent, beban kerja kompleks didelegasikan ke sub-agent secara dinamis. Masalah muncul saat beban konkurensi naik: API Gateway mulai memuntahkan response HTTP 504 Gateway Timeout. Metrik pemantauan menunjukkan anomali kritis berikut:
- Throughput anjlok ke 0 req/sec untuk endpoint berbasis agent.
- Utilisasi CPU sangat rendah (< 10%) dan konsumsi memori stabil, meniadakan indikasi compute-bound bottleneck atau out-of-memory thrashing.
- Active worker thread pool berada pada kapasitas maksimum 100%, dengan antrean task (queue depth) yang terus menggelembung hingga rejected execution tercapai.
Kombinasi thread pool jenuh dan pemakaian CPU yang mendekati nol merupakan indikator utama thread starvation deadlock.
Root Cause: Hierarchical Task Scheduling pada Shared Pool
Penyebab utama kegagalan ini adalah eksekusi hierarkis (parent-child relationship) di dalam satu ExecutorService atau thread pool yang sama tanpa eksekusi asynchronous yang murni.
Alur deadlock terjadi sebagai berikut:
- Permintaan masuk dialokasikan ke thread worker untuk menjalankan Parent Agent (orchestrator).
- Parent Agent menganalisis prompt dan memutuskan untuk memanggil dua Sub-Agent secara konkuren.
- Parent Agent menjadwalkan kedua sub-task tersebut ke thread pool yang sama, lalu memanggil fungsi sinkronus pemblokir (misal:
Future.get()di Java atauconcurrent.futures.wait()di Python) untuk menunggu respons LLM dari sub-agent. - Jika thread pool memiliki ukuran
Ndan adaNrequest parent masuk bersamaan, seluruhNworker thread akan dipegang oleh para parent. - Sub-agent ditempatkan di
WorkQueue. Karena semua worker thread tertahan menunggu sub-agent selesai, tidak ada worker tersisa untuk mengeksekusi sub-agent di antrean.
Sistem mengalami circular wait: Parent memegang thread sambil menunggu Child; Child membutuhkan thread milik Parent agar bisa berjalan. Hasilnya adalah starvation deadlock.
Panduan Investigasi
1. Analisis Thread Dump
Ambil thread dump saat insiden terjadi menggunakan perkakas runtime terkait (misalnya jstack <pid> pada JVM atau module faulthandler/py-spy pada Python runtime).
Pola thread dump yang mengonfirmasi masalah ini menunjukkan semua worker thread berada dalam status WAITING atau TIMED_WAITING pada kondisi lock internal Future/Promise, bukan pada socket I/O operasi LLM:
"agent-worker-pool-4" #42 prio=5 tid=0x7f9a1c00 nid=0x1a03 waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:211)
at java.util.concurrent.CompletableFuture$Signaller.block(CompletableFuture.java:1864)
at java.util.concurrent.ForkJoinPool.unmanagedBlock(ForkJoinPool.java:3465)
at java.util.concurrent.CompletableFuture.waitingGet(CompletableFuture.java:1898)
at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:2072)
at com.api.agent.ParentAgent.execute(ParentAgent.java:38)2. Korelasi Distributed Tracing (OpenTelemetry)
Pada distributed trace (seperti Jaeger atau Datadog), trace parent agent berhenti mendadak setelah span penjadwalan sub-task. Karakteristik tracing meliputi:
- Span
parent_agent_executionmemiliki durasi tepat sepanjang nilai HTTP gateway timeout (misal 60 detik). - Span
sub_agent_executiontidak pernah tercipta, atau tercipta dalam kondisi pending dengan timestamp eksekusi kosong karena task tidak pernah di-dispatch dari antrean thread.
Reproduksi Masalah
Skrip Python minimal berikut mereproduksi starvation deadlock menggunakan thread pool berukuran terbatas:
from concurrent.futures import ThreadPoolExecutor
import time
# Pool berkapasitas 2 worker
executor = ThreadPoolExecutor(max_workers=2)
def sub_agent_task(name):
# Simulasi I/O LLM call
time.sleep(0.5)
return f"Result from {name}"
def parent_agent_task(agent_id):
print(f"[Parent-{agent_id}] Started and acquiring thread")
# Blunder: Submit sub-agent ke pool yang sama lalu blokir dengan .result()
future = executor.submit(sub_agent_task, f"SubAgent-of-{agent_id}")
result = future.result() # SINKRONUS BLOCKING
return f"[Parent-{agent_id}] Done: {result}"
if __name__ == "__main__":
# Submit 2 parent agent: langsung menghabiskan seluruh worker pool
f1 = executor.submit(parent_agent_task, 1)
f2 = executor.submit(parent_agent_task, 2)
print("Submitted parents. Menunggu hasil (akan hang / deadlock)...")
print(f1.result())
print(f2.result())
Solusi Perbaikan
1. Migrasi ke Non-Blocking Asynchronous Await
Hindari pemblokiran thread OS. Gunakan cooperative multitasking (event loop) seperti asyncio. Ketika parent menunggu child, parent men-yield kontrol kembali ke runtime loop tanpa memakan resource eksekusi thread:
import asyncio
async def sub_agent_task(name: str) -> str:
await asyncio.sleep(0.5) # Non-blocking I/O
return f"Result from {name}"
async def parent_agent_task(agent_id: int, current_depth: int = 0) -> str:
# 3. Guard: Batasi kedalaman rekursi agent
MAX_DEPTH = 3
if current_depth >= MAX_DEPTH:
raise RuntimeError(f"Recursion limit reached at depth {current_depth}")
# Parent melepaskan thread saat await, worker runtime bebas memproses task lain
sub_task = asyncio.create_task(sub_agent_task(f"SubAgent-{agent_id}"))
result = await sub_task
return f"[Parent-{agent_id}] Completed with: {result}"
2. Bulkheading: Isolasi Executor Pool
Jika runtime tetap membutuhkan worker berbasis thread (misalnya komputasi lokal, sinkronus legacy SDK, atau sandboxed parser), pisahkan pool secara tegas menggunakan arsitektur bulkheading:
- Orchestrator Pool: Thread pool khusus untuk parent agent workflow coordinator.
- Leaf/Execution Pool: Thread pool terpisah khusus untuk tool-calling dan sub-agents.
from concurrent.futures import ThreadPoolExecutor
orchestrator_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="orch")
worker_pool = ThreadPoolExecutor(max_workers=8, thread_name_prefix="leaf")
def sub_agent_task(data):
return f"Processed: {data}"
def parent_agent_task(data):
# Submit child task ke leaf pool, bukan orchestrator pool
future = worker_pool.submit(sub_agent_task, data)
return future.result()
# Submit entry point ke orchestrator pool
future = orchestrator_pool.submit(parent_agent_task, "payload")
3. Pembatasan Kedalaman Rekursi (Recursion Depth Budget)
Setiap eksekusi agent wajib menyertakan context budget atau metadata kedalaman hierarki. Implementasikan middleware/guard rail:
- Pass context header
X-Agent-Depth: npada setiap dispatch. - Tolak atau gagalkan eksekusi sub-agent jika kedalaman melebihi batas batas ambang (misalnya
depth > 3) dengan response400 Bad Requestalih-alih membiarkannya mengantre tanpa batas waktu.
Verifikasi Solusi
Pengujian verifikasi dilakukan dengan membanjiri pool menggunakan concurrency melebihi kapasitas worker. Skrip async di bawah ini membuktikan tidak ada deadlock terjadi meski worker pool disimulasikan terbatas:
import asyncio
async def test_high_concurrency():
tasks = [parent_agent_task(i) for i in range(50)]
results = await asyncio.gather(*tasks, return_exceptions=True)
assert all(not isinstance(r, Exception) for r in results)
print(f"Sukses mengeksekusi {len(results)} parent-sub agents tanpa deadlock.")
if __name__ == "__main__":
asyncio.run(test_high_concurrency())
Rekomendasi Arsitektur: Pada sistem produksi Model API, jangan gunakan antrean in-memory lokal untuk orkestrasi micro-agent berjenjang. Gunakan state machine asinkronus eksternal (seperti Temporal atau sistem berbasis pub/sub broker) untuk memisahkan siklus hidup state orkestrator dari thread eksekutor komputasi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!