Part 05 · Building LLM Applications

Structured Outputs

JSON SchemaValidationConstrained decodingRetries

তোমার program এর দরকার JSON, সুন্দর একটা paragraph না। Schema, constrained decoding, যা ফেরত আসে সেটা validate করা, আর তারপরও ভুল এলে কী করবে।

⏱ 1-2 ঘণ্টা · Station 35 / 92

জিনিসটা কী, কেন দরকার

একজন customer লিখল "Order 48213 came 50 minutes late, I want my money back।" একজন মানুষ পড়ে কাজ শুরু করে দেয়। তোমার refund service বাক্য পড়তে পারে না; তার দরকার {"order_id": "48213", "issue": "late", "wants_refund": true}। LLM এর answer যখনই মানুষের সামনে না গিয়ে code এ যায়, তখনই লাগে structured output: এমন হুবহু আকারের data যেটা তোমার program বিশ্বাস করতে পারে।

Model কে text লিখতে train করা হয়েছে, তাই ভদ্রভাবে JSON চাইলে বেশিরভাগ সময় কাজ হয়, আর এই "বেশিরভাগ সময়" ই production ভাঙে: একটা key নেই, JSON এর চারপাশে code fence, "late" এর জায়গায় "Late", এমন একটা order ID যেটা model স্রেফ বানিয়েছে। এই lesson এ সেই স্তরগুলো বানাবে যেগুলো structured output কে নির্ভরযোগ্য করে: একটা schema, validation, error ফেরত দিয়ে retry, constrained decoding, আর সব ব্যর্থ হলে একটা fallback।

ভেতরে কীভাবে চলে

1. Schema ঠিক করো। প্রতিটা field, তার type আর অনুমোদিত মান লিখে ফেলো। Production এ এটা সাধারণত JSON Schema, বা একটা Pydantic / Zod model যেটা JSON Schema বানায়। Schema টা prompt এ যায়, আর একই schema answer check করে।

2. স্পষ্ট করে চাও। "Reply with only a JSON object with these keys: ..." সাথে প্রতিটা field এর মানে। অনেক provider এর একটা JSON mode বা structured outputs option ও আছে যেটা সরাসরি schema নেয়।

3. সবকিছু validate করো। JSON parse করো, key, type, range আর অনুমোদিত মান check করো। Validate না করা model output কখনো code এ পাঠাবে না।

4. Error সহ retry। Validation fail করলে model কে তার নিজের answer আর হুবহু error টা পাঠাও ("order_id must be 4 to 6 digits"), আবার চাও। Retry এর সীমা রাখো (সাধারণত 2): প্রতিটা একটা পুরো call এর খরচ।

5. Constrained decoding। সবচেয়ে শক্ত guarantee: generation এর প্রতিটা ধাপে এমন প্রতিটা token আটকে দাও যেটা valid output এ পৌঁছাতে পারে না, ফলে model schema র বাইরে কিছু বানাতেই পারে না। Hosted "strict" structured-output mode আর Outlines বা llama.cpp grammar এর মতো library এভাবেই কাজ করে, schema কে একটা token filter এ compile করে।

6. Fallback। Retry শেষ হয়ে গেলে crash কোরো না, আন্দাজও কোরো না: একজন মানুষের কাছে পাঠাও, নিরাপদ default নাও, বা user কে জিজ্ঞেস করো।

এর কোনোটাই যেটা ঠিক করে না: এমন একটা মান যেটা valid কিন্তু ভুল। Schema বলতে পারে "order_id হলো 4 থেকে 6 digit", কিন্তু জানতে পারে না যে customer কোনো order ID বলেইনি আর model একটা বানিয়ে দিয়েছে। এর জন্য অন্য check লাগে (এই ID কি আমাদের database এ আছে? message এ কি আছে?), আর evaluation (Part 8)।

আগে অনুমান করো

Strict constrained decoding চালু থাকলে model শুধু schema তে অনুমোদিত মান দিতে পারে। কোন সমস্যাটা এটা তবুও সমাধান করে না?

ছবিতে দেখো

LLMvalidateschema + rulesapptyped datafallbackhuman / default"order_id": "52"error: 4 to 6 digits"order_id": nullfixed JSONvalidsend the exact error back; cap the retries; always have a fallback
LLM ফেরত দেয় order_id "52"। Validation fail করে আর হুবহু error টা ফেরত পাঠায়, "4 to 6 digits"। দ্বিতীয় answer সেই field ঠিক করে; JSON সব নিয়ম পার হলে typed data হিসেবে app এ যায়। দুইবার retry এর পরেও fail করলে request যায় fallback এ: একজন মানুষ বা নিরাপদ একটা default।

আসল model এ extraction

Local Qwen2.5-0.5B model (আগের lesson এর একই llm.py) চারটা customer message থেকে তিনটা field বের করে, হাতে লেখা validator আর সর্বোচ্চ দুইবার retry সহ। তারপর issue field টা আসল token-level constrained decoding দিয়ে বানানো, দুইটা prompt এ।

"""Extract structured data from customer messages: ask for JSON, validate it, retry, constrain."""
import json
import torch
from llm import chat, model, tokenizer

SCHEMA = {  # what our code needs, field by field
    "order_id": "string of 4 to 6 digits, or null",
    "issue": "one of: late, missing_item, wrong_item, payment, other",
    "wants_refund": "true or false",
}
ISSUES = ["late", "missing_item", "wrong_item", "payment", "other"]

def validate(text):
    """Return (data, None) if valid, else (None, a reason we can send back to the model)."""
    try:
        data = json.loads(text)
    except json.JSONDecodeError as e:
        return None, f"not valid JSON ({e.msg})"
    if not isinstance(data, dict) or set(data) != set(SCHEMA):
        return None, f"keys must be exactly {sorted(SCHEMA)}"
    oid = data["order_id"]
    if oid is not None and not (isinstance(oid, str) and oid.isdigit() and 4 <= len(oid) <= 6):
        return None, "order_id must be a string of 4 to 6 digits, or null"
    if data["issue"] not in ISSUES:
        return None, f"issue must be one of {ISSUES}"
    if not isinstance(data["wants_refund"], bool):
        return None, "wants_refund must be true or false"
    return data, None

SYSTEM = ("Extract fields from the customer message. Reply with only a JSON object with these keys:\n"
          + "\n".join(f'- "{k}": {v}' for k, v in SCHEMA.items()))

def extract(msg, retries=2):
    messages = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": msg}]
    for attempt in range(retries + 1):
        raw = chat(messages, max_new_tokens=80)
        raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()   # strip code fences
        data, err = validate(raw)
        print(f"    attempt {attempt + 1}: {raw!r}" + ("" if data else f"\n      invalid: {err}"))
        if data:
            return data
        messages += [{"role": "assistant", "content": raw},     # feed the error back and ask again
                     {"role": "user", "content": f"That was invalid: {err}. Reply with the corrected JSON only."}]
    return None

MESSAGES = [
    "Order 48213 came 50 minutes late and the naan was cold. I want my money back.",
    "You forgot the borhani in my order #7731",
    "bKash deducted 640 taka twice for one order",
    "order 52 er khabar thanda chilo, taka ferot chai",
]
for m in MESSAGES:
    print(f"\n{m}")
    print("  ->", extract(m))

# Constrained decoding: at every step, allow only tokens that keep the text a prefix of an allowed value.
def constrained(messages, options, max_steps=8):
    ids = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
    text = ""
    for _ in range(max_steps):
        with torch.no_grad():
            logits = model(torch.tensor([ids])).logits[0, -1]
        allowed = [t for t in torch.argsort(logits, descending=True)[:2000].tolist()
                   if any(o.startswith(text + tokenizer.decode([t])) for o in options)]
        if text in options and (not allowed or logits[allowed[0]] < logits[tokenizer.eos_token_id]):
            break                                     # a complete value, and ending is likelier than going on
        best = allowed[0]                             # greedy, but only among allowed tokens
        ids.append(best)
        text += tokenizer.decode([best])
    return text

BARE = "Classify the customer's issue as one of: " + ", ".join(ISSUES)
DEFINED = (BARE + ".\nlate = arrived late or not yet arrived. missing_item = something ordered is not in the bag. "
           "wrong_item = a different item came. payment = charged wrongly or twice. other = anything else.")
for name, system in (("bare list", BARE), ("with definitions", DEFINED)):
    print(f"\nconstrained 'issue' field, {name} (only the 5 allowed values can come out):")
    for m in MESSAGES:
        q = [{"role": "system", "content": system}, {"role": "user", "content": m}]
        print(f"  {constrained(q, ISSUES):<13} <- {m[:50]}")

Output:


Order 48213 came 50 minutes late and the naan was cold. I want my money back.
    attempt 1: '{"order_id": "48213", "issue": "late", "wants_refund": true}'
  -> {'order_id': '48213', 'issue': 'late', 'wants_refund': True}

You forgot the borhani in my order #7731
    attempt 1: '{"order_id": "7731", "issue": "missing_item", "wants_refund": false}'
  -> {'order_id': '7731', 'issue': 'missing_item', 'wants_refund': False}

bKash deducted 640 taka twice for one order
    attempt 1: '{"order_id": "12345", "issue": "payment", "wants_refund": false}'
  -> {'order_id': '12345', 'issue': 'payment', 'wants_refund': False}

order 52 er khabar thanda chilo, taka ferot chai
    attempt 1: '{"order_id": "52", "issue": "payment", "wants_refund": false}'
      invalid: order_id must be a string of 4 to 6 digits, or null
    attempt 2: '{"order_id": null, "issue": "invalid", "wants_refund": false}'
      invalid: issue must be one of ['late', 'missing_item', 'wrong_item', 'payment', 'other']
    attempt 3: '{"order_id": null, "issue": "invalid", "wants_refund": false}'
      invalid: issue must be one of ['late', 'missing_item', 'wrong_item', 'payment', 'other']
  -> None

constrained 'issue' field, bare list (only the 5 allowed values can come out):
  wrong_item    <- Order 48213 came 50 minutes late and the naan was 
  wrong_item    <- You forgot the borhani in my order #7731
  wrong_item    <- bKash deducted 640 taka twice for one order
  wrong_item    <- order 52 er khabar thanda chilo, taka ferot chai

constrained 'issue' field, with definitions (only the 5 allowed values can come out):
  wrong_item    <- Order 48213 came 50 minutes late and the naan was 
  wrong_item    <- You forgot the borhani in my order #7731
  wrong_item    <- bKash deducted 640 taka twice for one order
  wrong_item    <- order 52 er khabar thanda chilo, taka ferot chai

কী দেখলাম:

  • Message 1 আর 2 প্রথম চেষ্টাতেই নিখুঁত: ঠিক order ID, ঠিক issue, আর wants_refund true শুধু যেখানে customer টাকা ফেরত চেয়েছে।
  • Message 3 valid কিন্তু ভুল। Customer কোনো order ID দেয়নি, আর model লিখেছে "12345"। Schema র প্রতিটা নিয়ম পার হয়ে যায়। এটাই সবচেয়ে বিপজ্জনক ধরনের ব্যর্থতা: কোনো error নেই, আর refund চলে যায় ভুল order এ। সমাধান schema র বাইরে: check করো ID টা message এ আছে কিনা, বা তোমার orders table এ আছে কিনা।
  • Message 4 retry আর তার সীমা দেখায়। "52" 4 থেকে 6 digit এর নিয়ম ভাঙে। Error পেয়ে model ঠিকঠাক সেটাকে null করে, কিন্তু এবার লেখে "issue": "invalid", যেটা আরেকটা নিয়ম ভাঙে; তৃতীয় চেষ্টায় একই জিনিস। দুইবার retry এর পরে extract ফেরত দেয় None, আর app কে fallback এ যেতে হয়। Retry অনেক ভুল ঠিক করে, সব না, আর প্রতিটা একটা পুরো model call এর খরচ।
  • Constrained decoding সবসময় অনুমোদিত মানই দেয়, কিন্তু এখানে চারটা message এই wrong_item, সংজ্ঞা সহ বা ছাড়া। Token filter আকারের guarantee দেয়; 0.5B model কে message বোঝাতে পারে না। খেয়াল করো উপরের JSON prompt message 1 থেকে 3 এ issue ঠিকই পেয়েছিল: সঠিকতা এখনো ঠিক করে prompt আর model এর মান।

Production এ যা মাথায় রাখবে

  • Provider এর strict structured-output mode ব্যবহার করো যদি থাকে; constrained decoding সে নিজেই করে দেয়। তবু নিজের validator রাখো: "order_id আমাদের DB তে আছে" বা "amount ≤ order total" এর মতো নিয়ম schema র নাগালের বাইরে।
  • জরুরি field গুলো ভিত্তিতে বাঁধো। ID, টাকার অঙ্ক, তারিখ আর নামের জন্য check করো মানটা input এ আছে বা তোমার data তে আছে। আন্দাজের চেয়ে null ভালো, আর prompt এ সেটা বলে দাও।
  • প্রতিটা ব্যর্থতা log করো, কাঁচা output আর error সহ। Field ধরে ব্যর্থতার হার বলে দেয় schema বা prompt এর কোন অংশ ঠিক করতে হবে।
  • Schema ছোট আর সমতল রাখো। গভীরভাবে nested, 40 field এর schema বেশি fail করে আর বেশি token খায়। বড় extraction কে কয়েক ধাপে ভাগ করো।
  • Retry এর বাজেট চোখে রাখো। 2 বার retry সহ 10% ব্যর্থতা খরচ আর latency তে 20% যোগ করতে পারে। দুইটাই track করো।
  • Prompt এর সাথে schema ও version করো। একটা field এর নাম বদলালে output এর প্রতিটা ব্যবহারকারী ভাঙে; এটাকে API change হিসেবে দেখো।

এক কথায়

Model কে একটা schema দাও, প্রতিটা answer validate করো, সীমিত সংখ্যক retry তে error ফেরত দাও, আকারের guarantee এর জন্য constrained decoding নাও, আর সবসময় একটা fallback রাখো; এর কোনোটাই মানগুলো সত্যি কিনা guarantee করে না, তাই জরুরিগুলো input বা তোমার data র সাথে মিলিয়ে নাও।

নিজে করে দেখো

validate এ একটা grounding check যোগ করো: order_id null না হলে সেটাকে আসল message এর text এ থাকতেই হবে। চারটা message এর কোনটা এখন fail করে, কী error ফেরত পাঠাবে, আর retry তে model এর কাছে কী আশা করো?

Answer

Message টা validate এ পাঠাও আর যোগ করো if oid is not None and oid not in msg: return None, "order_id must appear in the message; use null if there is none"। Message 3 এখন fail করে ("12345" নেই "bKash deducted 640 taka twice for one order" এ)। Error টা model কে ঠিক কী করতে হবে বলে দেয়, তাই সম্ভাব্য retry হলো "order_id": null, যেটা তারপর পার হয়, আর app ভুল order এ refund না করে customer এর কাছে order নম্বর চাইতে পারে। 0.5B model আসলে কী করে চালিয়ে দেখো; সে যদি ID বানাতেই থাকে, fallback সেটা ধরে।

Station stamp

Chapter শেষ করো

পড়া শেষ হলে Complete চাপো, progress save হবে। XP শুধু Pro তে — Pro থাকলে এই chapter এ +10 XP পেতে।

Pro নিয়ে XP পাও →