← Kembali ke Blog

Berhenti Minta JSON ke Prompt, Batasi Decoder Pakai Outlines

Kamu taruh "Return ONLY valid JSON" di system prompt, set temperature 0, kasih satu contoh. Kebanyakan request jalan. Lalu satu request balik dengan "Sure, here is the JSON:" di depan objek, agent kamu panggil json.loads, dan run-nya mati.

Retry bisa nutup celah itu sampai suatu saat nggak lagi. Perbaikan yang lebih benar bukan nambah satu kalimat di prompt. Yang perlu dilakukan: cegah sampler memilih token yang bikin struktur rusak. Outlines melakukan itu. Schema kamu dikompilasi jadi finite state machine, lalu logits dimasking di tiap langkah, jadi token yang menghasilkan output invalid dapat probabilitas nol. Model masih bisa salah pilih kategori, tapi nggak bisa pilih kategori di luar enum kamu.

Setup di bawah jalan di laptop dengan model 1.7B, lalu menunjukkan di mana ide yang sama dipakai di stack serving produksi.

Prasyarat

  • Python 3.10 ke atas
  • RAM kosong sekitar 4GB buat model 1.7B fp16. GPU mempercepat, tapi nggak wajib.
  • Pydantic v2 dasar

Langkah 1: install dan load model

python3 -m venv .venv
source .venv/bin/activate
pip install "outlines[transformers]" "pydantic>=2"

Ekstra transformers menarik Hugging Face transformers, accelerate, datasets, dan sentencepiece. outlines doang nggak ikut menginstal itu.

import outlines
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL = "HuggingFaceTB/SmolLM2-1.7B-Instruct"

hf_model = AutoModelForCausalLM.from_pretrained(MODEL)
hf_tokenizer = AutoTokenizer.from_pretrained(MODEL)

model = outlines.from_transformers(hf_model, hf_tokenizer)

Satu hal yang perlu diingat soal API Outlines 1.x: hasilnya selalu string, walaupun output type-nya class Pydantic. Parsing jadi tugas kamu lewat model_validate_json. API sebelum 1.0 mengembalikan objek yang sudah diparse, jadi banyak kode blog yang kamu temukan nggak jalan apa adanya.

Langkah 2: tentukan bentuk output yang benar-benar kamu butuh

Ini langkah triage tiket yang bisa dipanggil agent support. Field yang boleh diisi model cuma empat di bawah.

from typing import Literal
from pydantic import BaseModel

class Ticket(BaseModel):
    category: Literal["billing", "auth", "outage", "other"]
    severity: Literal["low", "medium", "high"]
    summary: str
    order_id: str | None

MESSAGE = "I was charged twice for order ORD-88213 and the second charge is still pending."

def prompt_for(msg: str) -> str:
    return (
        "You triage support messages.

"
        f"Message: {msg}

"
        "Fill the ticket fields. category must describe the main problem."
    )

raw = model(prompt_for(MESSAGE), Ticket, max_new_tokens=200)
ticket = Ticket.model_validate_json(raw)

print(ticket.category, ticket.severity, ticket.order_id)

Literal jadi constraint-nya. Saat decoder sampai di nilai category, cuma token yang lanjut ke salah satu dari empat string itu yang lolos mask. Alternatif kayak "refund" atau "Billing" bukan cuma "nggak dianjurkan", tapi dibuang dari vocabulary di langkah tersebut.

str | None di order_id juga penting. Tanpa itu, model harus mengarang nomor order kalau pesannya nggak menyebut nomor. Dengan itu, null jadi output yang sah. Schema yang membolehkan null biasanya lebih baik daripada prompt yang bilang "pakai null kalau nggak tahu", karena prompt itu permintaan dan schema itu aturan.

Langkah 3: ukur bedanya, jangan cuma percaya

Kepatuhan format bisa kamu ukur murah di sini. Jalankan pesan yang sama lewat dua jalur dan hitung parse yang valid.

from pydantic import ValidationError

MESSAGES = [
    "I was charged twice for order ORD-88213 and the second charge is still pending.",
    "login keeps failing with invalid token after I reset my password",
    "the dashboard has been down for 20 minutes, all our users are affected",
    "can you send last month invoice to our finance email",
]

def constrained(msg: str) -> Ticket:
    return Ticket.model_validate_json(
        model(prompt_for(msg), Ticket, max_new_tokens=200)
    )

def unconstrained(msg: str) -> Ticket:
    return Ticket.model_validate_json(model(prompt_for(msg), max_new_tokens=200))

def score(fn) -> str:
    good = 0
    for msg in MESSAGES:
        try:
            fn(msg)
            good += 1
        except (ValidationError, ValueError):
            pass
    return f"{good}/{len(MESSAGES)}"

print("constrained:", score(constrained))
print("unconstrained:", score(unconstrained))

Di model 1.7B, jalur tanpa constraint biasanya kehilangan beberapa kasus karena teks pembuka, markdown fence, atau nilai severity kayak "urgent". Jalur constrained seharusnya balik dengan hitungan penuh. Jalankan di pesan kamu sendiri sebelum percaya angka mana pun.

Selisih itu jadi budget retry kamu. Tiap respons rusak berarti satu retry, dan di loop agent efeknya lebih parah: tool call yang terima string padahal butuh dict sering gagal di tempat yang membingungkan, beberapa langkah dari penyebabnya.

Langkah 4: batasi output yang bukan JSON

Nggak semua output berupa objek. Order ID dan label klasifikasi dua kasus di mana regex atau daftar pilihan tetap lebih pas daripada schema penuh.

from outlines.types import Choice, Regex

order_id = model(
    "Extract the order id. Messages use the format ORD- followed by six digits.
"
    "Text: charged twice for ORD-88213",
    Regex(r"ORD-[0-9]{6}"),
)
print(order_id)  # ORD-88213

label = model(
    "Classify the topic: my card was charged twice",
    Choice(["billing", "auth", "outage", "other"]),
)
print(label)  # billing

Pakai Choice kalau daftar pilihannya datang dari database saat runtime, karena Literal butuh nilai saat import.

Langkah 5: tetap simpan penanganan gagal

Constrained decoding menghapus error format. Bukan truncation. Kalau max_new_tokens habis di tengah objek, sampler berhenti di tengah struktur dan kamu dapat string yang berakhir {"category": "billing", "sever. Nggak ada exception, karena dari sisi decoder nggak ada yang salah.

Jadi tetap simpan try/except. Perlakukan ValidationError sebagai jalur nyata dengan fallback, bukan sesuatu yang mustahil. Dua kebiasaan yang membantu:

  • Set max_new_tokens dari ukuran schema, bukan tebakan. Objek empat field dengan string pendek nggak butuh 1000 token.
  • Log string mentahnya saat gagal. Kalau cuma log exception, buktinya hilang.

Ada mode kegagalan kedua yang perlu disebut: jawaban valid secara schema tapi tetap salah. Kalau pesannya soal tagihan dobel dan model melabelinya auth, JSON-nya sempurna dan routing-nya rusak. Constrained decoding membeli struktur, bukan penilaian. Evaluasi nilai field-nya terpisah, sama seperti kamu mengevaluasi classifier apa pun.

Langkah 6: constraint yang sama saat serving

Jalanin transformers di dalam proses API kamu cukup buat traffic rendah. Begitu request-nya konkuren, langkah berikutnya biasanya vLLM, yang mendukung ide sama lewat guided decoding. Sejak vLLM 0.12 field lama guided_json, guided_choice, dan guided_regex sudah dihapus, jadi kode yang ditulis buat 0.8 atau 0.9 bakal ditolak. Nama barunya structured_outputs.

vllm serve HuggingFaceTB/SmolLM2-1.7B-Instruct
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")

resp = client.chat.completions.create(
    model="HuggingFaceTB/SmolLM2-1.7B-Instruct",
    messages=[{"role": "user", "content": MESSAGE}],
    extra_body={"structured_outputs": {"json": Ticket.model_json_schema()}},
)
print(resp.choices[0].message.content)

Batch offline pakai parameter yang sama lewat StructuredOutputsParams:

from vllm import LLM, SamplingParams
from vllm.sampling_params import StructuredOutputsParams

llm = LLM(model="HuggingFaceTB/SmolLM2-1.7B-Instruct")
params = SamplingParams(
    structured_outputs=StructuredOutputsParams(json=Ticket.model_json_schema())
)
out = llm.generate(["Triage: my card was charged twice"], params)
print(out[0].outputs[0].text)

vLLM pilih backend sendiri (auto), atau kamu bisa paksa xgrammar atau guidance lewat --structured-outputs-config.backend. Kalau ketemu schema yang kompilasinya lambat atau model yang tokenizer-nya aneh, ganti backend jadi hal pertama yang dicoba.

Kapan pakai yang mana

  • Outlines dengan transformers cocok buat kerja offline, script batch, laptop, dan satu proses. Gampang di-unit test, nggak perlu server. Ini yang dipakai di langkah 2 sampai 5.
  • vLLM structured outputs buat request konkuren yang banyak, atau kalau kamu sudah punya endpoint kompatibel OpenAI. Butuh GPU biar berguna, dan ini tempat yang tepat buat structural_tag kalau kamu juga membatasi batas tool call.
  • Structured outputs dari provider hosted artinya tanpa infrastruktur, dan schema ditegakkan di sisi mereka. Data keluar, dan subset JSON Schema yang didukung beda antar provider, jadi uji schema yang benar-benar mau kamu pakai.
  • Grammar GBNF llama.cpp masuk akal kalau kamu sudah serve model GGUF dan mau kontrol level grammar, termasuk format non-JSON. Lebih repot ditulis daripada class Pydantic.

Penutup

Inti constrained decoding: pindahkan satu kelas bug dari logika retry ke sampler. Prompt kamu berhenti jadi kontrak dan jadi petunjuk, yang memang cuma itu kemampuan sebuah prompt.

Langkah lanjut yang masuk akal: taruh Ticket di belakang endpoint FastAPI, jalankan fungsi score dari langkah 3 di CI dengan set pesan tetap, baru setelah itu lihat apakah labelnya benar. Struktur dulu, akurasi kedua. Mencampur keduanya bikin dua-duanya lebih susah di-debug.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis