Pillar 05 — Project Log · Log #003 · Multi-Agent System · IRIS v2.0

← Project Log
Home / The Lab / Project Log / Log #003
Pillar 05 · Log #003 · Multi-Agent System · IRIS v2.0

Log #003: Bina Multi-Agent System dari Scratch — 6 Bulan Iterasi pada IRIS

Supervisor pattern, state management yang betul, dan 3 architecture mistakes yang kami dah bayar mahal untuk pelajari.

.// Project Brief
SystemIRIS v2.0 — Multi-Agent Procurement Intelligence Platform
IndustryB2B SaaS / Malaysian Government Procurement
Timeline6 months: v1 post-mortem → v2 architecture → production
StackLangGraph ≥0.2 + Claude (claude-sonnet-4-6) + FastAPI + PostgreSQL + aiogram
TL;DR — Kalau busy, baca ni je dulu
  • Single-agent architecture tak scale bila domain complexity tinggi — IRIS v1 collapsed under its own context weight dalam masa 3 bulan production.
  • Supervisor-specialist pattern solved the scaling problem, tapi state schema design adalah bottleneck sebenar — bukan model choice, bukan orchestration library.
  • 28-node LangGraph dengan per-pursuit threads bagi kami isolation yang diperlukan — setiap SME tender pursuit ada state sendiri, tak ada cross-contamination.
  • Terbesar lesson: design untuk failure dulu, baru design untuk features. Intervention governor yang kami tambah di minggu 16 adalah satu-satunya sebab sistem masih boleh dipercayai hari ni.

The Problem — Kenapa v1 Tak Boleh Pakai

IRIS v1 ada satu agent. Satu. Ia dapat sistem prompt yang panjang, context window yang penuh dengan tender data, dan diharapkan boleh handle semua benda — dari eligibility check sampai dokumen strategi sampai competitor analysis. Dalam staging, ia impressive. Dalam production dengan real users dan real Malaysian government tender corpus, ia mula pecah dalam masa lapan minggu.

Bukan kerana model bodoh. Claude faham instruction dengan baik. Masalahnya adalah architectural — kami minta satu agent buat terlalu banyak perkara dalam satu reasoning pass.

Breakdown failure modes dari 3 bulan v1 production:

Failure Mode Frequency Root Cause
Context dilution — agent "lupa" earlier analysis~34% of long sessionsSingle context window, no state separation
Task bleed — eligibility logic mixing with strategy logic~22% of responsesNo domain boundary enforcement
Hallucinated tender requirements~11% of queriesRAG retrieval too broad, no specialist filter
Timeout on complex multi-step queries~18% of sessionsOne agent doing 5 sequential reasoning steps
State corruption between user sessions~8% of sessionsShared mutable state, no isolation

93% dari failure modes ada satu punca yang sama: cuba fit terlalu banyak dalam satu cognitive unit. Ini adalah architecture problem, bukan prompt engineering problem. Kami cuba patch dengan longer, more structured prompts selama 6 minggu sebelum akui yang ini memerlukan rethink sepenuhnya.


Our Approach — Architecture Decision Log

Decision 1: Supervisor-Specialist pattern, bukan flat multi-agent

Ada dua cara utama nak organise multi-agent system. Flat network — setiap agent boleh call agent lain secara terus, peer-to-peer. Atau hierarchical — ada supervisor yang route kepada specialist agents, dan specialist tak boleh bypass supervisor.

Kami pilih hierarchical dengan tegas, sebab satu sebab: auditability. Dalam government procurement context, SME owner perlu tahu kenapa sistem bagi recommendation tertentu. Flat network susah sangat nak trace decision path. Supervisor pattern bagi kami clear chain of reasoning yang boleh di-log dan explain.

# IRIS v2 agent hierarchy — simplified
#
# IrisSupervisor
#   ├── EligibilityAdvisor    — Bumiputera status, equity thresholds, MOF cert
#   ├── AgentReasoning        — Tender scoring, opportunity classification
#   ├── StrategicSynthesiser  — Submission strategy, competitor positioning
#   └── InterventionGovernor  — Confidence gating, escalation, HITL triggers
#
# Supervisor decides WHICH specialist to invoke.
# Specialists report back to supervisor, not to each other.
# Governor has veto on any output below confidence threshold.

class IrisSupervisor(StateGraph):
    def route(self, state: IRISState) -> str:
        # Route based on query intent, not keyword matching
        intent = state["classified_intent"]
        if intent == "eligibility_check":
            return "eligibility_advisor"
        elif intent in ("tender_score", "opportunity_rank"):
            return "agent_reasoning"
        elif intent == "submission_strategy":
            return "strategic_synthesiser"
        else:
            return "clarify"  # ask before routing

Perkara yang ramai orang terlepas pandang: supervisor pun perlu ada scope limitation. Kami buat mistake awal di mana supervisor cuba buat terlalu banyak reasoning sendiri sebelum route. Selepas refactor, supervisor hanya classify intent dan manage state — semua domain reasoning diserahkan kepada specialist.

Decision 2: Per-pursuit LangGraph threads, bukan shared state

Ini adalah decision yang paling significant dari segi user experience. Dalam v1, semua context untuk semua tender queries dari satu user masuk dalam satu thread. Bila user tanya pasal Tender A dan kemudian Tender B, context Tender A masih ada dan kadang-kadang bleeding masuk ke Tender B analysis.

Dalam v2, setiap tender "pursuit" — setiap kali SME owner decide nak kejar satu tender tertentu — dapat LangGraph thread sendiri dengan state sendiri. Thread-level isolation bermaksud:

  • Context dari Tender A tidak boleh contaminate Tender B analysis
  • User boleh switch antara multiple active pursuits tanpa cognitive bleed
  • State boleh di-checkpoint dan resumed — kalau SME owner disconnect dan sambung balik esok hari, reasoning state masih intact
  • Individual pursuit audit trail yang complete untuk compliance reporting
# Per-pursuit thread creation — dipanggil bila user "bookmark" tender
async def create_pursuit_thread(
    supplier_id: str,
    tender_ref: str,
    initial_tender_data: dict
) -> str:
    thread_id = f"pursuit-{supplier_id}-{tender_ref}"

    # Each thread gets its own isolated IRISState
    initial_state = IRISState(
        supplier_id=supplier_id,
        tender_ref=tender_ref,
        tender_data=initial_tender_data,
        eligibility_result=None,
        reasoning_scores=[],
        strategy_draft=None,
        intervention_flags=[],
        conversation_history=[]
    )

    # LangGraph checkpointer persists state across sessions
    await graph.ainvoke(
        initial_state,
        config={"configurable": {"thread_id": thread_id}}
    )
    return thread_id

Decision 3: State schema sebagai contract, bukan afterthought

Ini adalah lesson paling mahal yang kami pelajari. Dalam v1, state schema kami evolve organically — tambah field bila diperlukan, deprecated field bila dah tak guna. Lepas 3 bulan, state object ada 47 fields, separuh daripadanya optional dan undocumented. Migration antara schema versions adalah nightmare.

Dalam v2, kami tulis state schema sepenuhnya sebelum tulis satu baris agent code. Schema adalah contract antara supervisor dan semua specialist agents.

from typing import TypedDict, Optional, List
from datetime import datetime

class EligibilityResult(TypedDict):
    bumiputera_status: bool
    mof_cert_valid: bool
    equity_threshold_met: bool
    disqualifying_flags: List[str]
    checked_at: datetime

class IRISState(TypedDict):
    # Identity — immutable after creation
    supplier_id: str
    tender_ref: str

    # Tender data — set once, read-only by specialists
    tender_data: dict

    # Specialist outputs — written once by each specialist
    eligibility_result: Optional[EligibilityResult]
    reasoning_scores: List[dict]
    strategy_draft: Optional[str]

    # Governance — written by InterventionGovernor
    intervention_flags: List[str]
    confidence_score: Optional[float]
    requires_human_review: bool

    # Conversation — append-only
    conversation_history: List[dict]
    last_updated: datetime

Rule yang kami enforce ketat: specialist agents boleh read mana-mana state field, tapi hanya boleh write ke fields yang dalam domain mereka. EligibilityAdvisor hanya boleh write ke eligibility_result. StrategicSynthesiser hanya boleh write ke strategy_draft. InterventionGovernor boleh read semua dan write ke intervention_flags, confidence_score, dan requires_human_review.

Decision 4: LLMRouter untuk model fallback

Kami tak nak hard-code satu LLM provider. IRIS ada LLMRouter layer — Claude claude-sonnet-4-6 adalah primary, OpenAI GPT-4.1 adalah fallback. Router check latency dan error rate setiap 60 saat dan switch automatically kalau Claude API underperform.

Dalam 6 bulan production, failover triggered 4 kali — semuanya Claude API incidents yang resolved dalam masa 20 minit. Tapi kalau kami tak ada fallback, setiap incident tu bermaksud platform down sepenuhnya. Cost of implementing fallback: 2 hari kerja. Value: priceless untuk SME owner yang tengah kejar tender deadline.

Kenapa Claude sebagai primary? Kami evaluate Claude, GPT-4.1, dan Gemini 2.5 Flash untuk IRIS use case secara serius. Claude menang untuk tiga sebab yang specific: instruction following dalam structured output tasks lebih consistent, Bahasa Malaysia quality significantly better (penting untuk Telegram-based SME interface), dan tool calling reliability dalam multi-step agentic flows lebih predictable. Ini bukan marketing copy — ini observation dari actual production traffic.


Technical Stack

Layer Technology Why
Primary LLMClaude claude-sonnet-4-6 via LLMRouterBest structured output consistency; BM language quality
Fallback LLMOpenAI GPT-4.1Reliability fallback — auto-switch on latency >8s or error spike
Agent frameworkLangGraph ≥0.2, 28 nodesExplicit state graph, per-thread checkpointing, deterministic edge traversal
API layerFastAPI + async Python 3.11Async-first for concurrent agent invocations; clean dependency injection
Primary DBPostgreSQL (iris_db)Agent state, user data, pursuit tracking
Financial DBPostgreSQL (financial_db) — separate instanceCredit ledger isolation; stricter access controls
Supplier DBPostgreSQL (supplier_db)ePerolehan supplier registry, MOF cert data
Telegram interfaceaiogram 3.xAsync-native; cleaner webhook handling than python-telegram-bot
Vector retrievalpgvector on iris_dbTender corpus search — avoid separate vector DB overhead

Tiga database decision kami buat dengan sedar. Bukan sebab kami suka complexity — sebab PDPA compliance dan Malaysian financial regulation require stricter isolation antara operational data, financial records, dan supplier PII. Single database dengan schema separation tak cukup bila ada audit requirements.


What Broke in Production

Ini bahagian paling penting dalam log ni. Benda yang nampak solid dalam staging selalu ada surprise bila real users dan real Malaysian government tender corpus masuk.

Failure 1: Supervisor routing ke wrong specialist bila query ambiguous

Dalam minggu kedua v2 production, kami notice sesetengah queries yang sepatutnya masuk EligibilityAdvisor silap route ke AgentReasoning. Example:

User: "IRIS, boleh check tak contractor tu layak?"

Supervisor classify sebagai opportunity_rank (AgentReasoning territory) bukan eligibility_check. Sebab: intent classifier kami train heavily pada formal query patterns. "Layak" dalam casual Manglish tak pattern-match dengan "eligibility check" dalam training data.

Fix: Dua langkah. Pertama, tambah clarification node — bila confidence score routing <0.80, supervisor tanya clarifying question sebelum route. Kedua, retrain intent classifier dengan actual production messages dari minggu 1–2. Routing accuracy improved dari 87% ke 96% selepas fix.

Failure 2: State corruption bila concurrent requests dari satu user

SME owner yang active boleh hantar beberapa messages dalam masa pendek melalui Telegram. Bila dua messages masuk dalam masa <500ms untuk sama thread_id, ada race condition dalam LangGraph state write. Second request kadang-kadang overwrite partial state dari first request.

Fix: Implement request queue per thread_id. Setiap pursuit thread ada dedicated async queue — requests processed sequentially, bukan concurrently. Latency impact minimal (+120ms average) tapi state corruption turun ke sifar. Ini adalah tradeoff yang obvious dalam retrospect tapi kami miss dalam v2 design review.

Failure 3: InterventionGovernor terlalu aggressive dalam early weeks

InterventionGovernor kami set confidence threshold di 0.85 untuk autonomous output — iaitu kalau any specialist output confidence <0.85, system flag untuk human review. Dalam teori bagus. Dalam practice, 41% dari semua outputs dalam minggu pertama di-flag untuk review. Team kami overwhelmed. Banyak flags adalah false positive — specialist responses yang technically solid tapi phrased dengan less certainty.

Fix: Audit 200 flagged outputs manually. Reclassify apa yang genuinely perlu human review versus apa yang boleh autonomous. Adjust threshold differently per specialist — EligibilityAdvisor perlu higher threshold (consequences of wrong eligibility advice are serious) tapi AgentReasoning boleh lower threshold untuk opportunity scoring. After tuning, review rate dropped ke 12% dengan meaningful flags yang represent real uncertainty.

Lesson yang apply ke semua HITL systems: Threshold bukan satu nombor — ia berbeza mengikut consequence of being wrong. Eligibility advice yang salah boleh cost SME a tendering opportunity. Opportunity ranking yang slightly off adalah fine to self-correct. Tune thresholds mengikut actual cost of error, bukan uniform confidence target.


Results

96%
Routing accuracy (post-fix)
12%
Human review rate (tuned HITL)
0
State corruption incidents (post-queue fix)

Nombor yang lebih penting dari metric di atas: SME retention. IRIS v1 ada 34% churn dalam bulan pertama — users yang sign up, cuba, dan berhenti. IRIS v2 churn bulan pertama: 11%. Improvement ni bukan marketing. Ia adalah kesan langsung dari sistem yang beri confident, accurate responses dengan minimal noise.

Tender win rate untuk active IRIS users pun dah start improve, tapi kami tak nak claim causation lagi — sample size belum cukup besar dan terlalu banyak confounding variables dalam tender outcomes. Log berasingan akan cover ini bila ada 12-bulan data.


Apa Yang Kami Akan Buat Lain Kali

Ini bahagian yang paling berguna untuk sesiapa yang tengah design multi-agent system. Bukan apa yang berkesan — tapi apa yang kami dah tahu sekarang yang kami tak tahu masa start.

1. Design state schema sebelum architect agents. Kami reverse ini dalam v1 dan bayar dengan 3 minggu painful schema migration. State schema adalah satu-satunya benda yang semua agents share — ia perlu stable dari hari pertama, bukan organically evolved.

2. Build InterventionGovernor dari hari satu, bukan tambah kemudian. Kami tambah InterventionGovernor di minggu 16. Sepatutnya ia ada dari hari pertama. Setiap agent system perlu governance layer — ia bukan nice-to-have dan jauh lebih susah nak tambah retrospectively.

3. Load test dengan realistic concurrency sebelum production. Race condition dalam state writes adalah sesuatu yang obvious dalam retrospect tapi kami tak test dengan realistic concurrent load dalam staging. Satu realistic load test dalam staging environment boleh jimat kami seminggu debugging dalam production.

4. Tune HITL thresholds per-consequence, bukan per-agent. Ini kami dah cover dalam failures section, tapi ia patut di-state semula: blanket confidence threshold adalah shortcut yang selalu backfire. Spend masa untuk think about actual cost of each failure mode sebelum set thresholds.


Takeaway untuk Business yang Evaluate Multi-Agent

Kalau korang evaluating sama ada multi-agent architecture sesuai untuk use case korang — ini qualifying questions yang kami suggest korang jawab sendiri dahulu:

Adakah domain korang cukup complex untuk justify? Multi-agent bawa real overhead — dalam complexity, dalam infra, dalam debugging difficulty. Kalau use case korang boleh selesai dengan single well-prompted agent, buat tu dulu. IRIS justify multi-agent sebab procurement intelligence genuinely ada beberapa distinct reasoning domains yang tak boleh collapse tanpa quality loss.

Berapa penting auditability? Kalau korang ada regulatory requirement atau nak explain decisions kepada users, supervisor-specialist pattern bagi korang audit trail yang clean. Kalau output quality is all that matters and process tak penting, flat network atau single agent mungkin cukup.

Ada ke resource untuk maintain ini? Multi-agent system adalah living system — threshold perlu tuning, routing perlu monitoring, state schema evolves dengan product. Ini bukan set-and-forget. Kalau team kecil, start simple dan build up bila ada data untuk justify complexity.

Tengah evaluate multi-agent untuk system korang?

Kami dah buat semua mistakes di atas dan ada a few more yang tak fit dalam article ni. Book a session — boleh discuss architecture, scope, dan sama ada multi-agent actually sesuai untuk use case korang sebelum commit.

Book Teh Tarik Session ❯❯

PROJECT SPECS

System: IRIS v2.0 (Procurement Intelligence)
Stack: Claude + LangGraph + FastAPI
DB: PostgreSQL ×3 + pgvector
Interface: Telegram (aiogram 3.x)
Agents: 5 specialists + 1 supervisor
Timeline: 6 months (v1→v2 rebuild)

NOTIFY ME

Get notified bila log baru keluar.