Langsung ke konten

← Kembali ke Perpustakaan

L4 · 9 Okt 2026

45% panggilan LLM kami gagal: apa kata pesan error-nya

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

Sekitar 45% evaluasi screening dalam satu batch saya gagal. Bukan fit score yang rendah. Hasil yang bisa dipakai memang tidak ada.

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. Begitu saya membaca error yang muncul, masalahnya menjadi dua bug yang konkret.

Hasilnya hilang di mana?

Platform ini membantu recruiter melakukan screening CV. Recruiter membuat job post, sistem mengekstrak requirement rubric berbobot, lalu LLM mengevaluasi setiap kandidat terhadap lowongan itu. Output-nya JSON terstruktur berisi fit score, bukti, dan alasan tertulis. Sekarang recruiter yang mengambil keputusan; AI menyediakan bukti.

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

Parser awalnya sederhana. Buang code fence markdown dari balasan, lalu panggil json.loads. Kalau parsing berhasil, proses setelahnya berjalan. Kalau gagal, pasangan kandidat dan lowongan itu hilang.

Dua pesan error, dua petunjuk

"LLM kadang mengembalikan JSON rusak" belum cukup untuk menentukan apa yang harus diperbaiki. Diagnosis seperti itu mudah berujung pada utak-atik prompt tanpa arah.

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: jawabannya kehabisan ruang

fitScore adalah key terakhir di output schema. Urutannya penting. Kalau field dibuang secara acak, key yang hilang mestinya ikut acak. Key terakhir yang terus hilang lebih mengarah ke truncation: output berhenti sebelum sampai ke sana, bukan model "lupa" mengisi field. Itu penalaran saya, bukan hasil pengukuran.

Konfigurasinya menjelaskan kenapa. Reasoning effort memakai high, prompt meminta alasan sepanjang 10 sampai 15 kalimat, dan saya belum memasang output token ceiling yang eksplisit.

Pada reasoning model, hidden reasoning tokens dan jawaban yang terlihat memakai budget output yang sama. Reasoning panjang ditambah rationale panjang menghabiskan budget di tengah JSON. Field terakhir tidak sempat muncul.

Jadi reasoning effort bukan hanya pengaturan kualitas, tetapi juga budget. Batas output-nya pun perlu ditentukan dengan sengaja.

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

Pesan kedua: newline di tempat yang dilarang 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, bukan bentuk yang sudah di-escape. Sekilas balasannya hampir valid. Bagi json.loads, tetap tidak bisa dipakai.

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. Budget-nya menjadi keputusan yang disengaja.
  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. Maksimal tiga percobaan per panggilan. Penggunaan token dijumlahkan dari semua percobaan agar retry tetap masuk usage accounting. Setelah percobaan ketiga gagal, error di-re-raise supaya retry policy Durable Functions bisa mengambil alih.
  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. Quote-aware extractor dan retry terbatas menangani sisanya. Pencatatan retry juga harus jujur: jumlahkan token setiap percobaan, batasi percobaannya, lalu re-raise error agar lapisan berikutnya bisa menentukan tindakan.

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.

Platform ini baru mulai punya test beberapa hari sebelumnya. Test tersebut termasuk yang paling awal.

Fixture JSON rusak yang sintetis menguji masalah yang saya bayangkan mungkin terjadi. Fixture dari balasan model yang sebenarnya menguji masalah yang sudah terjadi. Test-nya juga akan langsung gagal kalau nanti ada yang "menyederhanakan" parser kembali ke json.loads. Menyimpan kegagalan asli sebagai test adalah cara murah untuk menjaga perbaikan ini tidak dibatalkan tanpa sadar.

Angka yang tidak saya punya

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.

Namun saya tidak mencatat run ulang untuk 22 pasangan yang sama setelah perbaikan. Jadi saya tidak bisa memberi hasil "45% menjadi X%" untuk batch itu. Klaim yang bisa saya buat lebih terbatas: dua mekanisme kegagalan sudah ditemukan, diperbaiki, dan dicakup test.

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 reliability pada baseline belakangan, bukan hasil perbaikan ini secara mandiri. Menaruh kedua persentase berdampingan tidak menjadikannya pengukuran sebelum dan sesudah.

Masalahnya tidak berhenti di parser

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

  • Retry yang tidak selalu jalan. 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 diam-diam macet. Sekitar 3% panggilan model macet sampai timeout default SDK 600 s. Sekarang setiap panggilan dibatasi deadline eksplisit: 90 s untuk screening, 240 s untuk deep analysis.

Reasoning effort kembali dibahas belakangan, kali ini untuk latency. Dalam beberapa putaran evaluasi terkontrol, p95 screening tercatat 21,1 s di high dan 12,1 s di medium. Gate fairness dan prompt injection tetap lolos di medium. Medium yang dirilis.

Kalau batch gagal seperti ini, saya akan membaca pesan error sebelum mengubah prompt. Field mana yang hilang? Parser berhenti di mana? Di run ini, petunjuk yang bisa ditindaklanjuti justru key terakhir dan newline di dalam string, bukan persentase kegagalan di awal tulisan.