Kurniadi Ahmad Wijaya
Forward Deployed AI Engineer
Membangun AI dan software kelas production untuk perbankan, fintech, dan enterprise.
Technical Consultant, Software & AI · Metrodata (PT Mitra Integrasi Informatika)
Langsung ke konten

Tulisan

45% Panggilan LLM Kami Gagal: Apa Kata Pesan Error-nya

EN, ID

  • llm-reliability
  • structured-output
  • azure-openai
  • testing
  • case-study

Sekitar 45% evaluasi screening dalam satu batch yang saya jalankan tidak menghasilkan data yang bisa dipakai. Evaluasi tersebut gagal menghasilkan jawaban, sehingga fit score-nya pun tidak tersedia.

Saat itu saya baru mulai membangun platform AI CV screening di Metrodata. Batch-nya berisi 22 pasangan kandidat dan lowongan: 2 job post kali 11 kandidat.

Persentase itu menunjukkan seberapa sering pipeline gagal, tetapi tidak menjelaskan penyebabnya. Dari pesan error yang muncul, saya menemukan dua bug yang dapat ditelusuri.

Di Mana Hasil Evaluasi Hilang?

Platform ini membantu recruiter melakukan screening CV. Recruiter membuat lowongan, lalu sistem menyusun rubrik penilaian dengan bobot untuk setiap persyaratan. LLM memakai rubrik itu untuk mengevaluasi kandidat. Output-nya JSON terstruktur berisi fit score, bukti, dan alasan tertulis. Keputusan tetap di tangan recruiter; AI menyediakan bukti.

Setiap evaluasi berjalan sebagai activity Azure Durable Functions yang memanggil reasoning model di Azure AI Foundry lewat Responses API.

Parser awal hanya menghapus code fence markdown dari balasan, lalu memanggil json.loads. Jika parsing berhasil, proses berikutnya berjalan seperti biasa. Jika gagal, tidak ada hasil evaluasi untuk pasangan kandidat dan lowongan tersebut.

Arsitektur platform saat ini, dari studi kasusnya.

Satu evaluasi melewati jalur ini:

  1. Recruiter menyusun job post dan kandidatnya di web app Next.js.
  2. Web app memanggil backend FastAPI dengan request yang ditandatangani HMAC, lalu Document Intelligence mem-parsing CV.
  3. Setiap pasangan kandidat dan lowongan berjalan sebagai activity Durable Functions yang meminta JSON terstruktur dari model.
  4. Activity mem-parsing balasan model. Di titik inilah hasil dalam batch ini hilang: kalau parsing gagal, pasangan itu tidak punya hasil.

Harness evaluasi di diagram baru ada beberapa bulan setelah batch ini. Sekarang harness itu menjalankan golden set terhadap model sebagai deployment gate di CI.

Dua Pesan Error, Dua Petunjuk

"LLM kadang mengembalikan JSON rusak" belum cukup untuk menentukan apa yang harus diperbaiki. Dengan diagnosis sesamar itu, saya bisa terus mengubah prompt tanpa mengetahui sumber masalahnya.

Saya mengelompokkan kegagalan berdasarkan pesan error:

  • Response missing required keys: {'fitScore'}
  • Invalid control character at: line 15 column 95

Hanya dua pesan itu yang muncul. Pesan yang berbeda biasanya menunjuk ke mekanisme kegagalan yang berbeda juga.

Pesan Pertama: Jawaban Terpotong

fitScore adalah key terakhir di output schema. Posisi itu membantu saya menelusuri masalahnya: jika field hilang secara acak, key yang hilang seharusnya juga bervariasi. Karena yang hilang selalu key terakhir, saya menduga terjadi truncation, yaitu output berhenti sebelum key itu ditulis. Dugaan ini lebih masuk akal daripada model "lupa" mengisi field, tetapi masih berupa penalaran saya, belum hasil pengukuran.

Konfigurasi saat itu mendukung dugaan tersebut. Reasoning effort diatur ke high, prompt meminta alasan sepanjang 10 sampai 15 kalimat, dan saya belum menetapkan batas token output secara eksplisit.

Pada reasoning model, hidden reasoning tokens dan jawaban yang terlihat memakai budget output yang sama. Jika reasoning dan alasan tertulis sama-sama panjang, budget itu habis sebelum JSON selesai ditulis. Akibatnya, field terakhir tidak muncul.

Reasoning effort ternyata memengaruhi pemakaian budget output sekaligus kualitas jawaban. Karena itu, saya juga perlu menetapkan batas token output secara eksplisit.

Ada satu masalah prompt yang lebih mudah diperbaiki: saya melarang code fence, tetapi contoh yang saya berikan justru memakai code fence.

Pesan Kedua: Newline yang Tidak Valid dalam JSON

Modul json Python mengeluarkan error kedua dalam strict mode ketika string value berisi raw control character.

Model menulis newline literal di dalam string value JSON tanpa melakukan escape. Sekilas balasannya tampak hampir valid, tetapi json.loads tetap menolaknya.

Perbaikan Berlapis

Saya berhenti menebak-nebak prompt dan memetakan tiap error ke penyebabnya. Karena satu perubahan tidak cukup untuk menangani keduanya, perbaikannya dibuat berlapis:

  1. JSON mode. Saya mengatur response_format ke json_object. API membatasi balasan ke JSON, bukan menyerahkan semuanya ke instruksi prompt.
  2. Output token ceiling eksplisit. Saya memasang batas 6.000 dan 16.000 token untuk dua jenis panggilan. Dengan begitu, saya menentukan sendiri budget untuk masing-masing jenis panggilan.
  3. Reasoning effort medium. Saya menurunkannya dari high supaya lebih sedikit budget dipakai hidden reasoning sebelum jawaban dimulai.
  4. Quote-aware JSON extractor. Fence stripping berbasis pattern saya ganti dengan extractor yang menyeimbangkan kurung kurawal sambil melacak posisi di dalam string. Kurung kurawal di quoted value tidak lagi mengakhiri objek terlalu cepat.
  5. Escape control character hanya di dalam string. Raw newline dan tab di-escape di dalam string value. Di antara token, keduanya dibiarkan karena merupakan whitespace yang valid.
  6. Retry terbatas, usage tetap tercatat. Setiap panggilan dibatasi maksimal tiga percobaan. Penggunaan token dijumlahkan dari semua percobaan agar penggunaan saat retry tetap tercatat. Setelah percobaan ketiga gagal, error diteruskan kembali (re-raise) supaya penanganannya berlanjut melalui retry policy Durable Functions.
  7. Instruksi prompt yang konsisten. Saya menghapus contoh ber-fence dari prompt yang melarang fence.

Lapisan 5 kira-kira seperti ini. Ini ilustrasi generik, bukan kode produksi:

ESC = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}

def escape_controls(raw: str) -> str:
    out, in_str, esc = [], False, False
    for ch in raw:
        if esc:
            esc = False
        elif in_str and ch == "\\":
            esc = True
        elif ch == '"':
            in_str = not in_str
        elif in_str and ord(ch) < 0x20:
            ch = ESC.get(ch, f"\\u{ord(ch):04x}")
        out.append(ch)
    return "".join(out)

Bagian pentingnya adalah in_str. Kalau semua newline diganti, whitespace yang valid di antara key ikut berubah. Melacak tanda kutip dan escape membuat perbaikan tetap terbatas pada string value.

JSON mode mengurangi kegagalan, tetapi defensive parsing tetap diperlukan. Kegagalan yang tersisa ditangani dengan quote-aware extractor dan retry terbatas. Agar penggunaan token saat retry tetap terlihat, saya menjumlahkan token setiap percobaan, membatasi jumlah percobaan, lalu meneruskan error untuk ditangani lapisan berikutnya.

Mengubah Kegagalan Menjadi Test

Saya merilis perbaikan ini bersama 7 unit test yang mereproduksi pesan error persis dari batch tersebut lewat jalur parsing baru. Ketujuhnya lolos. Test suite core juga lolos, 12 dari 12 saat itu.

Saya baru mulai menambahkan test ke platform ini beberapa hari sebelumnya, sehingga ketujuh test tersebut termasuk yang paling awal.

Dengan fixture JSON rusak yang sintetis, saya menguji masalah yang saya perkirakan mungkin terjadi. Fixture dari balasan model yang sebenarnya memungkinkan saya menguji kegagalan yang sudah terjadi. Test itu juga akan langsung gagal jika parser kelak "disederhanakan" kembali menjadi json.loads. Menyimpan contoh kegagalan asli sebagai test membantu mencegah bug yang sama muncul lagi.

Batas Catatan Pengukuran

Catatan saya mendukung angka kegagalan awal, sekitar 45% pada run 22 pasangan itu, dua pesan error, akar masalahnya, perbaikan berlapis, dan 7 regression test yang lolos.

Saya tidak mencatat run ulang untuk 22 pasangan yang sama setelah perbaikan, sehingga tidak dapat memberi hasil "45% menjadi X%" untuk batch itu. Catatan yang ada hanya mendukung klaim yang lebih terbatas: dua mekanisme kegagalan sudah ditemukan, diperbaiki, dan diuji.

Beberapa bulan kemudian, proyek ini punya offline evaluation harness. Baseline-nya mengukur final parse failure rate setelah retry sebesar 0,13%: 1 dari 800 panggilan non-adversarial.

Run itu memakai model yang lebih baru dan versi prompt berbeda. Angkanya menunjukkan tingkat keandalan pada baseline beberapa bulan kemudian, sehingga tidak dapat digunakan untuk mengukur dampak perbaikan parser ini saja. Kedua persentase tersebut tidak berasal dari pengukuran sebelum dan sesudah dengan kondisi yang sama.

Masalah Keandalan Setelah Parser

Tiga masalah yang muncul belakangan di platform ini punya pola serupa:

  • Retry yang tidak selalu berjalan. Retry Durable Functions yang saya andalkan di lapisan 6 ternyata tidak berjalan dengan andal. Outcome counter juga bisa terhitung dua kali. Perbaikannya memakai satu klasifikasi retryable versus terminal, lalu menulis outcome marker dan counter dalam satu transactional batch.
  • Eval yang salah menghitung kegagalan. Respons throttling, HTTP 429, terhitung sebagai schema failure. Satu run eval juga memakai revisi kode yang lebih lama. Sekarang transport failure dihitung terpisah, dan setiap run mencatat fingerprint kode yang diuji.
  • Panggilan yang tertahan hingga timeout. Sekitar 3% panggilan model tertahan sampai timeout default SDK 600 s. Sekarang setiap panggilan dibatasi deadline eksplisit: 90 s untuk screening, 240 s untuk deep analysis.

Beberapa waktu kemudian, saya mengevaluasi reasoning effort lagi untuk mengurangi latensi. Dalam beberapa putaran evaluasi terkontrol, p95 screening tercatat 21,1 s di high dan 12,1 s di medium. Karena gate fairness dan prompt injection tetap lolos di medium, konfigurasi medium yang saya rilis.

Jika batch gagal seperti ini, saya akan membaca pesan error sebelum mengubah prompt: field mana yang hilang, dan di mana parser berhenti? Pada run ini, key terakhir yang hilang dan newline di dalam string membantu saya menentukan perbaikannya. Persentase kegagalan di awal tulisan hanya menunjukkan seberapa sering masalah itu terjadi.