Kembali ke blog

Pemecahan masalahPanduan

json.JSONDecodeError pada balasan LLM: ekstrak JSON di Python

json.loads gagal pada balasan model? Baca exc.pos dan finish_reason untuk menemukan penyebabnya, lalu ekstrak JSON dengan raw_decode dan coba ulang sekali.

Oleh CheapTokens TeamDiterbitkan 6 menit baca

Balasan JSON berpagar di samping JSONDecodeError di posisi 0
Di halaman ini
  1. Tiga cara balasan merusak json.loads
  2. Jawaban terpotong: cek finish_reason sebelum parsing
  3. Ambil JSON dari teks
  4. Coba ulang sekali dengan prompt lebih ketat
  5. Gejala mana, perbaikan mana
  6. Bisakah API memaksa JSON valid?

Skrip Anda meminta JSON ke model, memanggil json.loads(reply) dan mati dengan json.JSONDecodeError. Biasanya model memang menjawab: ia membungkus JSON dalam ```json fence, put a line of chat in front of it, or ran into max_tokens halfway through. Look at exc.pos and finish_reason to tell which one it is, pull the object out with JSONDecoder.raw_decode, dan coba ulang sekali jika tidak ada yang bisa diselamatkan.

Tiga cara balasan merusak json.loads#

Dokumentasi json Python (dokumen Python 3.15, dicek 2 November 2026) menyebut JSONDecodeError adalah subclass dari ValueError dengan atribut msg, doc, pos, lineno dan colno. Yang penting di sini adalah pos: indeks dalam string tempat parsing gagal.

Misalkan sebuah skrip mengambil field dari email pelanggan dan meminta {"name": ..., "age": ...}. Tiga string ini adalah balasan yang merusaknya. Tempelkan ke shell Python:

python
import json

fenced = '```json\n{"name": "Anna", "age": 31}\n```'
chatty = 'Sure! Here is the JSON:\n{"name": "Anna", "age": 31}'
cut = '{"name": "Anna", "age": 31'

for reply in (fenced, chatty, cut):
    try:
        json.loads(reply)
    except json.JSONDecodeError as exc:
        print(exc.pos, len(reply))

Dua yang pertama gagal di pos 0: parser melihat karakter paling awal, backtick atau S, lalu menyerah. Yang ketiga gagal di akhir string (pos sama dengan len(reply)): semua yang dibaca baik-baik saja, lalu teksnya habis. Perbedaan itulah diagnosis Anda. Posisi 0 berarti JSON-nya kemungkinan ada, hanya terbungkus. Posisi di akhir berarti jawaban berhenti lebih awal.

Jalur keputusan untuk balasan yang gagal: cek finish_reason, ekstrak dengan raw_decode, coba ulang sekali
Urutan pemeriksaan yang digunakan dalam artikel ini

Jawaban terpotong: cek finish_reason sebelum parsing#

Balasan yang berhenti di tengah bukan masalah parsing, melainkan masalah panjang. API memberi tahu Anda: pilihan memiliki finish_reason, dan "length" berarti jawaban dipotong oleh max_tokens (cerita lengkapnya di Jawaban AI terpotong? Perbaiki finish_reason "length" dan max_tokens). Lihat itu dulu, sebelum ekstraksi apa pun.

Ada jebakan menjengkelkan jika Anda melewati langkah itu. Pemindai yang mencari { pertama yang bisa di-parse dapat mengambil objek dalam yang lengkap dari objek luar yang setengah jadi, dan skrip Anda melanjutkan dengan data yang salah tanpa error. Coba:

Coba sendiriCoba: apa yang dikembalikan fungsi "temukan objek pertama" untuk balasan terpotong ini?Tampilkan jawaban

Ambil '{"user": {"name": "Anna"}, "age": 31'. Objek luar tidak pernah tertutup, tetapi {"name": "Anna"} dalam sudah lengkap, jadi ekstraktor di bawah mengembalikan {'name': 'Anna'}. Tanpa exception, hasil salah. Itulah sebabnya pemeriksaan finish_reason dilakukan sebelum ekstraksi.

Perbaikan untuk pemotongan sungguhan adalah ruang lebih: naikkan max_tokens. DeepSeek-V4-Flash mengizinkan hingga 32.768 token output per jawaban, GLM-5.3-Flash hingga 8.192, jadi daftar JSON panjang punya lebih banyak ruang pada yang pertama. Detail model ada di halaman model CheapTokens.

Ambil JSON dari teks#

Untuk pagar dan obrolan, pustaka standar sudah punya alatnya. Menurut halaman Python yang sama, JSONDecoder.raw_decode(s) mendekode dokumen JSON dari string yang diawali dengannya dan mengembalikan objek plus indeks tempat dokumen berakhir. Dokumentasi menyebutnya bisa dipakai pada string dengan data berlebih di akhir. Ia membutuhkan dokumen dimulai dari karakter pertama, jadi potong teks di depannya dulu: cari {, coba dekode dari sana, dan lanjut ke { berikutnya jika gagal.

python
import json

def extract_json(text):
    """Return the first JSON object found in text, or raise ValueError."""
    decoder = json.JSONDecoder()
    for i, ch in enumerate(text):
        if ch == '{':
            try:
                obj, _end = decoder.raw_decode(text[i:])
                return obj
            except json.JSONDecodeError:
                continue
    raise ValueError('no JSON object in the reply')

Ia menangani pagar (```json is skipped because it is not a di depan{), the sentence before the object, and text after the closing brace. Expecting a list? Look for '[' instead. Don't look for both: a stray [1] dalam kalimat seperti "lihat catatan [1]" adalah JSON valid dan akan menang.

Dua hal yang sengaja tidak dilakukannya. Ia tidak pernah mengedit teks (tidak ada regex yang menghapus backtick, tidak menebak kurung yang hilang), jadi Anda tidak bisa mengubah balasan rusak menjadi data valid tapi salah. Dan ia gagal dengan berisik ketika tidak ada yang ditemukan, yang dibutuhkan retry di bawah.

Sebelum: json.loads pada balasan berpagar. Sesudah: extract_json mengembalikan objek
Balasan berpagar yang sama sebelum dan sesudah extract_json

Coba ulang sekali dengan prompt lebih ketat#

Jika balasan terpotong atau tidak mengandung objek sama sekali, tanyakan lagi. Sekali, bukan dalam loop tanpa akhir. Pada pemotongan, beri percobaan kedua lebih banyak ruang; jika tidak, katakan dengan jelas apa yang Anda inginkan. Ini memakai OpenAI Python SDK terhadap base URL CheapTokens (https://api.cheaptokens.dev/v1) dan id model lengkap dari dokumentasi; kuncinya ada di variabel lingkungan.

python
import os
from openai import OpenAI

client = OpenAI(
    base_url='https://api.cheaptokens.dev/v1',
    api_key=os.environ['CHEAPTOKENS_API_KEY'],
)
MODEL = 'deepseek-ai/DeepSeek-V4-Flash-0731'
STRICT = '\n\nReply with one JSON object only. No text before or after it, no code fence.'

def ask_json(prompt, max_tokens=800):
    content = prompt
    for attempt in (1, 2):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{'role': 'user', 'content': content}],
            max_tokens=max_tokens,
        )
        choice = resp.choices[0]
        if choice.finish_reason == 'length':
            max_tokens *= 2          # cut off: more room next time
        else:
            try:
                return extract_json(choice.message.content or '')
            except ValueError:
                pass                 # no object: try the stricter prompt
        content = prompt + STRICT
    raise ValueError('no valid JSON after one retry')

Urutannya adalah intinya: finish_reason dulu, ekstraksi kedua, dan baru kemudian retry. Lempar error setelah percobaan kedua agar batch job bisa mencatat item itu dan melanjutkan.

Biaya retry mudah diperkirakan. Asumsikan permintaan 1.200 token input dan 300 token output (asumsi, gunakan angka Anda sendiri): $0.000048 pada DeepSeek-V4-Flash atau $0.000036 pada GLM-5.3-Flash. Jika 30 dari 1.000 permintaan butuh percobaan kedua, tagihan tambahannya 30 × permintaan itu, $0.0014 pada DeepSeek-V4-Flash. Harga bersifat live, berasal dari daftar harga saat ini.

Urutkan

Urutkan langkah perbaikan

  1. Cek finish_reason: berhenti di sini jika nilainya length
  2. Temukan { pertama dan potong teks sebelumnya
  3. Dekode dengan raw_decode dan abaikan sisanya
  4. Jika tidak ada yang ditemukan, coba ulang sekali dengan prompt lebih ketat

Gejala mana, perbaikan mana#

Langkah-langkah di atas muat di kepala Anda sampai error aneh berikutnya muncul. Ini sama saja dengan tabel pencarian: temukan apa yang Anda lihat di layar dan baca perbaikannya.

Pilih kasus Anda

Pilih apa yang Anda lihat

json.loads gagal di pos 0

Balasan dimulai dengan pagar atau kalimat. Jalankan melalui extract_json; retry tidak diperlukan.

pos ada di akhir string

Jawaban berhenti lebih awal. Cek finish_reason: jika length, naikkan max_tokens dan tanyakan lagi.

Error ada di suatu tempat di tengah

Model sendiri menulis JSON rusak (koma hilang, koma di akhir, kutip tidak di-escape). Jangan tambal: coba ulang sekali dengan prompt lebih ketat dan catat balasannya jika gagal lagi.

json.loads berhasil tapi ada key yang hilang

Parsing baik-baik saja, isinya tidak. Sebutkan key persisnya di prompt dan cek di kode setelah parsing.

Bisakah API memaksa JSON valid?#

Kadang bisa. Panduan structured outputs OpenAI mendokumentasikan dua mode: mode JSON (type: json_object), yang mengembalikan JSON valid tetapi tidak mengikuti skema, dan Structured Outputs (type: json_schema, strict: true), yang mengikutinya; OpenAI merekomendasikan yang kedua jika memungkinkan (sumber: developers.openai.com, dicek 2 November 2026). Itu opsi API milik OpenAI sendiri. Sebelum mengandalkan response_format dengan endpoint lain, cek dokumentasi CheapTokens; ekstraksi dan retry di atas bekerja dengan chat completions biasa dan tidak butuh parameter khusus. Lebih lanjut soal opsi sisi permintaan di Dapatkan JSON valid dari API LLM: response_format dan retry.

Sebelum merilis parser, cek empat hal: finish_reason dibaca sebelum teks di-parse, hanya ada satu retry (dan kegagalan kedua dicatat dengan id item), kode memastikan key yang diharapkan ada setelah parsing, dan max_tokens menyisakan ruang untuk JSON terpanjang yang Anda perkirakan.

Lain kali json.loads terlempar, cetak exc.pos dan finish_reason dulu. Dua angka itu biasanya memberi tahu apa yang terjadi sebelum Anda membaca satu baris balasan.