Jika layanan klasifikasi pertanyaan tiba-tiba banyak salah label setelah deploy, tetapi file model fine-tuning tidak berubah, penyebabnya sering bukan ada pada bobot model. Dalam praktik backend, masalah seperti ini lebih sering berasal dari drift prompt, perubahan preprocessing saat inference, atau perubahan threshold yang membuat distribusi output bergeser.
Pada studi kasus ini, kita membahas layanan kategorisasi untuk routing tiket atau FAQ yang memakai local LLM kecil hasil fine-tuning. Gejalanya terlihat seperti penurunan akurasi mendadak: pertanyaan yang sebelumnya masuk kategori billing mulai diarahkan ke general, tiket teknis tercampur dengan FAQ, dan confidence menjadi tidak stabil. Yang menarik, model yang dipakai sama, checksum file model sama, tetapi hasil inferensi berubah karena pipeline di sekeliling model ikut berubah.
Arsitektur singkat layanan klasifikasi
Kasus ini umum pada backend yang menaruh model klasifikasi di balik API internal. Alurnya biasanya sederhana:
- API menerima pertanyaan dari aplikasi atau sistem tiket.
- Teks dinormalisasi.
- Prompt klasifikasi dibentuk.
- Local LLM dijalankan untuk menghasilkan label atau skor.
- Hasil dipetakan ke kategori internal untuk routing tiket atau FAQ.
Contoh kategori yang sering dipakai:
- billing
- technical
- account
- shipping
- general
Model kecil hasil fine-tuning biasanya cukup stabil jika format input saat inference konsisten dengan format data training. Begitu format ini berubah, akurasi bisa turun drastis walau modelnya sama.
Gejala setelah deploy: metrik turun, label bergeser
Masalah biasanya pertama kali terlihat bukan dari stack trace, melainkan dari metrik bisnis atau observabilitas:
- Distribusi label berubah tajam dalam beberapa jam setelah deploy.
- Rasio fallback ke kategori general naik.
- Jumlah tiket salah routing meningkat.
- Confidence model turun atau justru tampak tinggi tetapi salah label.
- Keluhan tim operasional meningkat karena antrian tiket jadi tidak relevan.
Contoh indikator di dashboard:
deployment: backend-api-2026-07-11.3
model_checksum: 9b1c...e7a
prompt_template_version: v5
preprocess_version: v3
label_distribution_before:
- billing: 24%
- technical: 31%
- account: 18%
- shipping: 12%
- general: 15%
label_distribution_after:
- billing: 8%
- technical: 14%
- account: 10%
- shipping: 6%
- general: 62%Dari log, gejalanya sering tampak seperti model “mendadak bodoh”:
{
"request_id": "a1f9",
"question": "Tagihan bulan ini dobel, tolong cek",
"normalized_question": "tagihan bulan ini dobel tolong cek",
"prompt_template_version": "v5",
"raw_model_output": "Kategori yang paling sesuai adalah general.",
"parsed_label": "general",
"confidence": 0.53
}Padahal sebelum deploy, input yang hampir sama bisa dipetakan ke billing.
Investigasi step-by-step
1. Verifikasi bahwa model memang tidak berubah
Langkah pertama adalah memastikan asumsi dasar benar. Jangan langsung menyalahkan prompt sebelum memeriksa artefak model.
- Bandingkan checksum file model di environment lama dan baru.
- Periksa path model yang dipakai runtime.
- Pastikan tidak ada fallback ke model default jika loading gagal.
- Cek apakah quantization, adapter, atau file tokenizer ikut berubah.
Jika checksum model, tokenizer, dan adapter sama, kemungkinan besar masalah ada di pipeline inference.
2. Bandingkan payload aktual sebelum dan sesudah deploy
Kesalahan paling sering terlihat saat membandingkan payload mentah dari request dan input final ke model.
Contoh payload API:
{
"ticket_id": "T-10428",
"locale": "id",
"channel": "web",
"question": "Saya tidak bisa login karena OTP tidak masuk"
}Contoh pipeline inferensi yang benar:
function classifyTicket(payload):
text = payload.question
normalized = normalize(text)
prompt = renderPrompt({
question: normalized,
labels: ["billing", "technical", "account", "shipping", "general"]
})
raw = llm.generate(prompt)
label = parseLabel(raw)
score = extractScore(raw)
if score < MIN_CONFIDENCE:
return "general"
return labelSetelah deploy, ternyata prompt builder berubah agar “lebih ramah manusia”, tetapi tanpa evaluasi regresi:
// versi lama
Klasifikasikan pertanyaan berikut ke salah satu label:
[billing, technical, account, shipping, general]
Jawab hanya dengan label.
Pertanyaan: {{question}}
// versi baru
Anda adalah agen support yang membantu pengguna.
Bacalah konteks berikut dan jelaskan kategori terbaik beserta alasannya.
Kategori yang tersedia:
1. billing
2. technical
3. account
4. shipping
5. general
Pertanyaan pengguna:
{{question}}Perubahan ini tampak kecil, tetapi untuk model kecil hasil fine-tuning, format instruksi sangat memengaruhi output. Saat training, model mungkin dilatih untuk pola input-output yang sangat sempit: satu instruksi, satu teks, satu label. Ketika prompt baru meminta alasan dan penjelasan, parser label lama bisa gagal atau model mulai lebih sering memilih jawaban naratif.
3. Periksa mismatch preprocessing
Kasus kedua yang sering muncul adalah preprocessing inference tidak lagi sama dengan preprocessing saat training.
Contoh perubahan yang terlihat “aman” namun berdampak:
- Lowercasing dihapus.
- Tanda baca dibersihkan dengan regex baru.
- Emoji dan karakter non-ASCII dihapus total.
- Whitespace dikompresi berbeda.
- Prefix seperti pertanyaan: ditambahkan saat inference tetapi tidak ada saat training.
- Teks dipotong lebih agresif karena batas token atau panjang karakter.
Misalnya data training dibentuk seperti ini:
input: "tagihan saya dobel bulan ini"
output: "billing"Tetapi inference setelah deploy menjadi:
input: "Pertanyaan pengguna => Tagihan saya dobel bulan ini!!! [web:id]"
output yang diharapkan parser: "billing"Model kecil bisa sensitif terhadap penambahan token dekoratif seperti Pertanyaan pengguna =>, metadata kanal, atau gaya instruksi yang berbeda. Ini bukan kelemahan aneh; ini konsekuensi dari fine-tuning yang sempit pada distribusi input tertentu.
4. Audit parser output dan threshold
Walau akar masalah utama sering drift prompt atau preprocessing, bug biasanya membesar karena parser dan threshold tidak tahan terhadap variasi output.
Contoh parser rapuh:
function parseLabel(raw):
if raw in ["billing", "technical", "account", "shipping", "general"]:
return raw
return "general"Jika model mengembalikan Label: billing atau Kategori: technical, parser ini akan gagal dan melempar semua kasus ke general.
Threshold juga bisa memperparah. Misalnya sebelumnya threshold minimal 0.40, lalu dinaikkan ke 0.65 untuk mengurangi false positive. Jika confidence model kecil cenderung moderat, banyak prediksi yang tadinya benar justru jatuh ke fallback.
Root cause utama: drift prompt dan mismatch preprocessing
Pada studi kasus ini, akar masalah utamanya bukan perubahan model, melainkan kombinasi dua hal:
- Drift prompt: template inference berubah dari format ringkas yang identik dengan data training menjadi format instruksi panjang yang meminta penjelasan.
- Mismatch preprocessing: normalisasi teks dan dekorasi input berubah, termasuk penambahan metadata dan perubahan pembersihan karakter.
Faktor pendukung yang membuat dampak lebih besar:
- Parser output terlalu ketat.
- Threshold fallback dinaikkan.
- Tidak ada test snapshot untuk prompt final.
- Tidak ada evaluasi regresi offline sebelum deploy.
Ini menjelaskan mengapa akurasi turun mendadak walau model tidak berubah: yang berubah adalah distribusi input yang diterima model.
Cara mereproduksi bug secara sistematis
Reproduksi yang baik penting agar perbaikan tidak berdasarkan tebakan. Ambil sampel pertanyaan dari produksi yang sebelumnya mudah diklasifikasikan.
Contoh dataset mini:
[
{"question": "Tagihan saya dobel bulan ini", "expected": "billing"},
{"question": "OTP login tidak masuk", "expected": "account"},
{"question": "Aplikasi error saat checkout", "expected": "technical"},
{"question": "Paket saya belum dikirim", "expected": "shipping"}
]Jalankan dua mode inferensi:
- Prompt lama + preprocessing lama
- Prompt baru + preprocessing baru
Pseudocode pengujian:
for sample in dataset:
old_input = oldNormalize(sample.question)
old_prompt = oldRenderPrompt(old_input)
old_out = llm.generate(old_prompt)
old_label = oldParse(old_out)
new_input = newNormalize(sample.question)
new_prompt = newRenderPrompt(new_input)
new_out = llm.generate(new_prompt)
new_label = newParse(new_out)
print({
question: sample.question,
expected: sample.expected,
old_label: old_label,
new_label: new_label,
old_prompt: old_prompt,
new_prompt: new_prompt,
old_out: old_out,
new_out: new_out
})Kalau hasil lama konsisten benar dan hasil baru kacau, Anda sudah punya bukti kuat bahwa regresi ada pada pipeline, bukan pada model.
Teknik diff yang paling berguna
- Diff string prompt final byte-per-byte.
- Bandingkan hasil normalisasi teks untuk input yang sama.
- Catat raw output model, bukan hanya label hasil parsing.
- Bandingkan panjang input dan token count jika tersedia.
- Replay request produksi ke environment staging dengan artefak lama dan baru.
Contoh bug nyata pada preprocessing
Contoh lain yang sering terlewat adalah normalisasi yang terlalu agresif.
// normalisasi lama
function normalize(text):
return text.trim().toLowerCase()
// normalisasi baru
function normalize(text):
return text
.normalize("NFKD")
.replace(/[^\w\s]/g, " ")
.replace(/\s+/g, " ")
.trim()Secara umum, normalisasi baru tampak lebih “bersih”. Namun untuk Bahasa Indonesia, pembersihan semacam ini bisa menghapus tanda penting, memecah token tertentu, atau membuat contoh inference menjauh dari distribusi training. Bila data training dibuat dari teks yang hanya di-trim dan lowercase, maka pembersihan regex agresif bisa menurunkan performa.
Catatan: preprocessing yang lebih kompleks tidak otomatis lebih baik. Yang penting adalah konsistensi antara training dan inference.
Langkah perbaikan yang bisa langsung diterapkan
1. Kunci prompt template sebagai artefak versi
Jangan perlakukan prompt sebagai string bebas di kode aplikasi. Simpan prompt template dengan versi eksplisit dan evaluasi perubahan seperti Anda mengevaluasi perubahan model.
- Tambahkan prompt_template_version di log.
- Simpan template dalam file terpisah atau config yang bisa di-review.
- Buat snapshot test untuk string prompt final.
Contoh snapshot sederhana:
expected_prompt = "Klasifikasikan pertanyaan berikut ke salah satu label:\n[billing, technical, account, shipping, general]\nJawab hanya dengan label.\nPertanyaan: tagihan saya dobel bulan ini"
assert renderPrompt("tagihan saya dobel bulan ini") == expected_prompt2. Samakan preprocessing training dan inference
Idealnya preprocessing adalah library bersama atau fungsi yang sama persis. Jangan menduplikasi implementasi di script training dan service inference jika bisa dihindari.
- Ekstrak normalizer ke modul bersama.
- Dokumentasikan urutan transformasi teks.
- Tambahkan test untuk contoh input nyata.
Checklist minimum preprocessing:
- Lowercase atau tidak?
- Apakah tanda baca dibuang?
- Apakah emoji dibuang?
- Apakah metadata channel/locale ditambahkan?
- Apakah teks dipotong? Di mana batasnya?
3. Buat parser lebih tahan variasi
Parser sebaiknya cukup ketat untuk mencegah label liar, tetapi tidak terlalu rapuh terhadap variasi kecil.
function parseLabel(raw):
text = raw.trim().toLowerCase()
for label in ["billing", "technical", "account", "shipping", "general"]:
if text == label:
return label
for label in ["billing", "technical", "account", "shipping", "general"]:
if text.includes(label):
return label
return "general"Trade-off-nya: parser yang lebih longgar bisa salah menangkap label jika model menjawab panjang. Karena itu, solusi terbaik tetap menjaga format prompt agar model mengeluarkan jawaban sesingkat mungkin.
4. Evaluasi threshold dengan data validasi nyata
Jangan mengubah threshold hanya berdasarkan intuisi. Gunakan dataset validasi kecil namun representatif dari produksi.
- Ukur berapa banyak prediksi benar yang hilang karena threshold terlalu tinggi.
- Bedakan antara confidence calibration dan akurasi label.
- Jika model tidak terkalibrasi baik, threshold tinggi bisa memberi rasa aman palsu.
5. Tambahkan guardrail regresi sebelum deploy
Untuk layanan klasifikasi, regresi test seharusnya berjalan seperti test unit biasa.
Checklist verifikasi sebelum rilis:
- Checksum model, tokenizer, dan adapter sama atau memang sengaja berubah.
- Prompt final tidak berubah tanpa persetujuan.
- Preprocessing inference identik dengan training.
- Parser output lulus test variasi respons.
- Sampel dataset validasi menunjukkan distribusi label stabil.
- Threshold tidak berubah tanpa evaluasi.
- Dashboard memiliki metrik distribusi label per versi deploy.
Observabilitas yang sebaiknya ada di backend klasifikasi
Agar bug seperti ini cepat ditemukan, backend sebaiknya menyimpan metadata penting di log dan metric:
- model_checksum
- prompt_template_version
- preprocess_version
- parsed_label
- raw_output atau ringkasannya jika ada batas privasi
- confidence
- fallback_triggered
Jika ada isu privasi, simpan contoh teks yang sudah di-hash atau gunakan sampling terbatas dengan sanitasi. Tujuannya bukan menyimpan semua data mentah, tetapi cukup untuk analisis regresi.
Pelajaran praktis untuk developer backend
Poin terpenting dari debugging backend pada kasus salah label klasifikasi LLM adalah ini: model bukan satu-satunya artefak yang menentukan akurasi. Pada local LLM kecil hasil fine-tuning, prompt, preprocessing, parser, dan threshold adalah bagian dari kontrak inference. Mengubah salah satunya tanpa evaluasi sama berisikonya dengan mengganti model.
Jika akurasi turun mendadak setelah deploy sementara model tidak berubah, urutan investigasi yang paling efisien adalah:
- Pastikan artefak model benar-benar sama.
- Diff prompt final lama vs baru.
- Diff preprocessing input.
- Lihat raw output model sebelum parsing.
- Periksa threshold dan fallback.
- Replay sampel produksi untuk membuktikan regresi.
Pendekatan ini bekerja karena ia memeriksa seluruh rantai inferensi, bukan hanya bobot model. Dalam banyak kasus, memperbaiki template prompt dan menyamakan preprocessing sudah cukup untuk mengembalikan performa tanpa retraining.
Penutup
Debug backend: salah label klasifikasi LLM karena drift prompt pada dasarnya adalah masalah konsistensi pipeline. Ketika data training mengajarkan satu format, tetapi inference mengirim format lain, model kecil akan mudah meleset. Tambahkan observabilitas, versi prompt, shared preprocessing, dan regresi test agar perubahan kecil di backend tidak diam-diam merusak routing tiket atau FAQ di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!