مقاله آموزشی

خروجی JSON قابل اعتماد از مدل؛ آموزش Structured Output

نویسنده: تیم نِت اَرز ۱۱ دقیقه مطالعه ۱۲ بازدید

سرویس دسته‌بندی تیکت‌هایتان سه هفته بی‌نقص کار کرده و بعد یک صبح، صف کارها پر می‌شود از JSONDecodeError. لاگ را باز می‌کنید و می‌بینید مدل قبل از JSON یک جملهٔ مؤدبانه اضافه کرده: «حتماً، این هم خروجی درخواستی شما:». پرامپت شما هیچ تغییری نکرده بود. مشکل اینجاست که دستور «فقط JSON بده» یک درخواست است، نه یک تضمین. راه‌حل واقعی چند سال است وجود دارد و اسمش Structured Output است، اما تقریباً هیچ منبع فارسی‌ای آن را جدی توضیح نداده است.

ریشهٔ ماجرا در طرز کار مدل است: مدل در هر مرحله محتمل‌ترین توکن بعدی را انتخاب می‌کند و هیچ‌چیز ذاتاً او را مجبور نمی‌کند آکولاد را ببندد یا کلید را همان‌طور که شما خواسته‌اید بنویسد. پس تا وقتی محدودیت را در سطح رابط برنامه‌نویسی اعمال نکنید، فقط دارید شانس خطا را کم می‌کنید، نه صفر.

سه روش، سه سطح متفاوت از تضمین

قبل از کد، تفاوت‌ها را روشن کنیم. این جدول مهم‌ترین بخش مقاله است:

روشچه چیزی تضمین می‌شودچه چیزی تضمین نمی‌شودکجا کافی است
فقط پرامپتهیچ‌چیز؛ فقط احتمال را بالا می‌بردحتی معتبر بودن JSONاسکریپت یک‌بارمصرف، نمونهٔ اولیه، جایی که انسان خروجی را می‌بیند
response_format از نوع json_objectخروجی همیشه JSON معتبر استاینکه کلیدها و نوع‌ها همانی باشند که شما می‌خواهیدساختار خیلی ساده به‌همراه اعتبارسنجی سمت خودتان
response_format از نوع json_schema با strictهم JSON معتبر، هم مطابقت با اسکیمای شمادرست بودن محتوا؛ مدل همچنان می‌تواند مقدار اشتباه بگذاردخط تولید، پردازش دسته‌ای، هر جایی که خروجی مستقیم وارد پایگاه داده می‌شود

خلاصهٔ صادقانه: روش سوم برای بیشتر پروژه‌های جدی جواب درست است، اما این به معنای بی‌فایده بودن دو روش دیگر نیست. اگر یک بار در ماه یک فایل را دستی پردازش می‌کنید، اسکیمای سخت‌گیرانه فقط کار شما را طولانی می‌کند.

یک تصمیم عملی هم اینجا بگیرید: هرچه فاصلهٔ بین تولید خروجی و مصرف آن بیشتر باشد، به تضمین سخت‌گیرانه‌تری نیاز دارید. خروجی‌ای که همان لحظه جلوی چشم یک انسان می‌آید، اگر خراب باشد سریع دیده و اصلاح می‌شود. اما خروجی‌ای که نیمه‌شب داخل یک کار زمان‌بندی‌شده تولید می‌شود و مستقیم در جدول می‌نشیند، تا هفته‌ها ممکن است کسی متوجه اشتباهش نشود.

روش اول: فقط پرامپت، و مرزهای واقعی‌اش

در این روش در دستور سیستمی می‌نویسید که خروجی باید JSON باشد و یک نمونه هم می‌گذارید. مدل‌های امروزی در بیشتر مواقع اطاعت می‌کنند، اما سه حالت شکست تکراری دارند: پیچیدن JSON داخل بلوک کد مارک‌داون، اضافه‌کردن یک جملهٔ توضیحی قبل یا بعد از آن، و در متن فارسی، استفاده از ارقام فارسی داخل فیلدهای عددی. کدی که با این روش کار می‌کند حتماً باید یک لایهٔ پاک‌سازی داشته باشد:

import json
import re


def parse_loose_json(text: str):
    """آخرین سنگر برای خروجی‌هایی که تضمین ساختاری ندارند."""
    cleaned = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
    match = re.search(r"[\{\[].*[\}\]]", cleaned, flags=re.S)
    if not match:
        raise ValueError("خروجی مدل اصلاً شبیه JSON نبود")
    return json.loads(match.group(0))

این تابع نجات‌دهنده است، نه راه‌حل. اگر خودتان را در حال نوشتن حلقهٔ «اگر پارس نشد دوباره از مدل بپرس» دیدید، یعنی وقتش رسیده که سراغ روش سوم بروید؛ آن حلقه هم هزینه را دو برابر می‌کند و هم تأخیر را. برای بهبود متن خودِ دستور هم راهنمای پرامپت‌نویسی نکته‌های کاربردی دارد.

روش دوم: json_object؛ نصف راه

با تنظیم response_format روی {"type": "json_object"}، سرویس تضمین می‌کند خروجی یک JSON نحوی معتبر باشد. دیگر بلوک مارک‌داون و جملهٔ اضافه نمی‌گیرید. اما توجه کنید: هیچ تضمینی دربارهٔ شکل خروجی نیست. مدل می‌تواند به‌جای priority کلید importance بسازد و همچنان یک JSON کاملاً معتبر تحویل دهد.

from openai import OpenAI

client = OpenAI(base_url="https://netarz.ir/api/ai/v1", api_key="sk-ntz-v1-...")

response = client.chat.completions.create(
    model="gpt-5-mini",
    response_format={"type": "json_object"},
    messages=[
        {"role": "system", "content": "خروجی را فقط به صورت JSON با کلیدهای category و summary برگردان."},
        {"role": "user", "content": "پرداختم انجام شد ولی سفارش هنوز در انتظار پرداخت است."},
    ],
)

data = json.loads(response.choices[0].message.content)

یک نکتهٔ ریز که خیلی‌ها روی آن گیر می‌کنند: در این حالت باید کلمهٔ JSON در پیام‌های شما وجود داشته باشد، وگرنه درخواست با خطا برمی‌گردد. اگر با پیام‌های خطای این‌چنینی روبه‌رو شدید، راهنمای خطاهای رایج API علت‌ها را یک‌جا جمع کرده است.

روش سوم: JSON Schema با حالت strict

اینجا شما شکل دقیق خروجی را تعریف می‌کنید و سرویس تولید مدل را طوری محدود می‌کند که خروجی حتماً با آن اسکیما بخواند. نمونهٔ کامل برای دسته‌بندی تیکت پشتیبانی:

TICKET_SCHEMA = {
    "name": "ticket_triage",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "category": {
                "type": "string",
                "enum": ["پرداخت", "تحویل", "فنی", "سایر"],
            },
            "priority": {
                "type": "string",
                "enum": ["کم", "متوسط", "زیاد"],
            },
            "needs_human": {"type": "boolean"},
            "summary": {
                "type": "string",
                "description": "خلاصهٔ یک‌جمله‌ای مشکل کاربر به فارسی",
            },
            "order_id": {
                "type": ["string", "null"],
                "description": "اگر کاربر شمارهٔ سفارش داده بود، همان؛ وگرنه null",
            },
        },
        "required": ["category", "priority", "needs_human", "summary", "order_id"],
        "additionalProperties": False,
    },
}

response = client.chat.completions.create(
    model="gpt-5-mini",
    response_format={"type": "json_schema", "json_schema": TICKET_SCHEMA},
    messages=[
        {"role": "system", "content": "تیکت پشتیبانی را دسته‌بندی کن."},
        {"role": "user", "content": "سفارش NTZ-1042 را دیروز ثبت کردم و هنوز خبری نشده."},
    ],
)

ticket = json.loads(response.choices[0].message.content)
print(ticket["category"], ticket["priority"], ticket["order_id"])

چیزی که پشت پرده اتفاق می‌افتد این است که سرویس هنگام تولید، انتخاب توکن بعدی را به توکن‌هایی محدود می‌کند که اسکیما اجازه می‌دهد. برای همین برخلاف روش‌های قبلی، این یک درخواست از مدل نیست بلکه یک محدودیت واقعی است و خروجی نامعتبر عملاً غیرممکن می‌شود. در عوض باید حواستان به محدودیت‌های همین حالت باشد: زیرمجموعه‌ای از JSON Schema پشتیبانی می‌شود و ساختارهای خیلی آزاد مثل ترکیب‌های پیچیدهٔ چند نوعی معمولاً پذیرفته نمی‌شوند.

همان کار در Node، این بار با بررسی حالت‌هایی که ممکن است خروجی سالم نباشد:

import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://netarz.ir/api/ai/v1",
  apiKey: process.env.NETARZ_AI_KEY,
});

export async function triage(text) {
  const completion = await client.chat.completions.create({
    model: "gpt-5-mini",
    response_format: { type: "json_schema", json_schema: TICKET_SCHEMA },
    messages: [
      { role: "system", content: "تیکت پشتیبانی را دسته‌بندی کن." },
      { role: "user", content: text },
    ],
  });

  const choice = completion.choices[0];

  if (choice.finish_reason === "length") {
    throw new Error("خروجی وسط راه قطع شد؛ سقف توکن خروجی را بالا ببرید");
  }

  if (choice.message.refusal) {
    throw new Error(`مدل پاسخ نداد: ${choice.message.refusal}`);
  }

  return JSON.parse(choice.message.content);
}

یک اسکیمای خوب چه ویژگی‌هایی دارد؟

  • همهٔ کلیدها را در required بیاورید. در حالت سخت‌گیرانه، فیلد اختیاری به شکلی که در JSON Schema معمول است پشتیبانی نمی‌شود؛ به‌جایش فیلد را اجباری کنید و نوعش را قابل تهی بگذارید (["string", "null"]).
  • additionalProperties را روی false بگذارید تا مدل کلید خودساخته اضافه نکند.
  • هر جا می‌توانید از enum استفاده کنید. این بزرگ‌ترین برندهٔ ساختاردهی است: به‌جای امیدواری به اینکه مدل دقیقاً بنویسد «زیاد»، فهرست مقادیر مجاز را تحمیل می‌کنید.
  • از description استفاده کنید. توصیف هر فیلد بخشی از دستور شماست و روی کیفیت مقدارها اثر مستقیم دارد؛ «خلاصهٔ یک‌جمله‌ای به فارسی» خیلی بهتر از «خلاصه» عمل می‌کند.
  • اسکیما را کم‌عمق نگه دارید. ساختارهای تودرتوی چندلایه هم دقت را پایین می‌آورند و هم توکن بیشتری مصرف می‌کنند. اگر لازم شد، کار را به دو فراخوانی ساده‌تر بشکنید.

هیچ‌وقت خروجی مدل را بدون اعتبارسنجی وارد پایگاه داده نکنید

اسکیما تضمین می‌کند خروجی شکل درستی دارد، نه اینکه محتوا درست است. مدل می‌تواند اولویت را اشتباه تشخیص دهد یا شماره‌ای بسازد که وجود ندارد. لایهٔ اعتبارسنجی سمت خودتان همیشه لازم است:

from typing import Literal, Optional

from pydantic import BaseModel, ValidationError


class Ticket(BaseModel):
    category: Literal["پرداخت", "تحویل", "فنی", "سایر"]
    priority: Literal["کم", "متوسط", "زیاد"]
    needs_human: bool
    summary: str
    order_id: Optional[str] = None


def safe_triage(raw_content: str, known_order_ids: set):
    try:
        ticket = Ticket.model_validate_json(raw_content)
    except ValidationError as error:
        raise ValueError(f"خروجی با قرارداد ما نخواند: {error}")

    if ticket.order_id and ticket.order_id not in known_order_ids:
        ticket.order_id = None   # شمارهٔ ساختگی را دور می‌ریزیم

    return ticket

این الگو دو فایده دارد: اگر روزی مدل یا نسخهٔ سرویس عوض شد، خطا را همان‌جا می‌گیرید نه در گزارش‌های اشتباه هفتهٔ بعد. ضمناً همان قرارداد را می‌توانید در تست‌های خودکار هم استفاده کنید.

موارد لبه‌ای که در آموزش‌ها نمی‌بینید

  • قطع‌شدن به‌خاطر سقف توکن: اگر finish_reason برابر length باشد، خروجی وسط راه بریده شده و JSON ناقص است. حتماً این حالت را چک کنید؛ همین یک بررسی جلوی بخش بزرگی از خطاهای شبانه را می‌گیرد.
  • امتناع مدل: اگر مدل به دلیل محتوایی پاسخ ندهد، فیلد refusal پر می‌شود و content خالی می‌ماند. کدی که مستقیم روی content پارس می‌کند اینجا می‌شکند.
  • تأخیر اولین درخواست: بار اول که یک اسکیمای جدید می‌فرستید، پردازش اولیهٔ آن کمی زمان می‌برد. اگر اسکیما ثابت است این هزینه فقط یک بار پرداخت می‌شود.
  • استریم و JSON: در حالت استریم، تکه‌های میانی JSON ناقص‌اند. یا کل خروجی را جمع کنید و بعد پارس کنید، یا از پارسر تدریجی استفاده کنید.
  • هزینه: خود اسکیما بخشی از ورودی است و توکن مصرف می‌کند. اسکیمای بزرگ با ده‌ها فیلد را روی هر درخواست کوچک نفرستید؛ روش حساب‌کردن این هزینه در راهنمای محاسبهٔ توکن آمده است.
  • پشتیبانی مدل‌ها یکسان نیست: همهٔ مدل‌ها حالت سخت‌گیرانه را پشتیبانی نمی‌کنند. پیش از انتخاب، مدل موردنظرتان را در صفحهٔ مدل‌ها ببینید و یک درخواست آزمایشی بفرستید.

ساختاردهی خروجی یا Function Calling؟

این دو شبیه به هم به نظر می‌رسند چون هر دو از JSON Schema استفاده می‌کنند، اما هدفشان فرق دارد. Structured Output برای وقتی است که می‌خواهید پاسخ نهایی مدل ساختار مشخصی داشته باشد. Function Calling برای وقتی است که مدل باید تصمیم بگیرد کدام تابع شما صدا زده شود و با چه ورودی. اگر خروجی مستقیم وارد پایگاه دادهٔ شما می‌شود، روش اول را می‌خواهید؛ اگر مدل باید به سیستم شما کاری بسپارد، سراغ راهنمای Function Calling بروید.

ترکیب هر دو هم رایج است: مدل با ابزار اطلاعات را جمع می‌کند و در پایان خروجی نهایی را با اسکیمای مشخص می‌دهد. همین الگو در سیستم‌های پرسش و پاسخ روی اسناد هم کاربرد دارد، جایی که می‌خواهید پاسخ به‌همراه فهرست منابع در قالب ثابت برگردد؛ ساختار آن نوع سیستم را در راهنمای چت‌بات RAG روی اسناد فارسی نوشته‌ایم.

قدم بعدی

اگر همین حالا سرویسی دارید که خروجی مدل را پارس می‌کند، پیشنهاد ما یک تغییر کوچک است: اسکیمای فعلی‌تان را بنویسید، response_format را به حالت سخت‌گیرانه ببرید و لایهٔ تمیزکاری متن را حذف کنید. معمولاً کد کوتاه‌تر می‌شود و نرخ خطا به‌شکل محسوسی پایین می‌آید. بعد از آن، بررسی finish_reason و اعتبارسنجی با یک مدل داده را اضافه کنید؛ این دو کار جلوی اکثر خرابی‌های خاموش را می‌گیرد.

برای اجرای نمونه‌ها به یک کلید API و آدرس پایه نیاز دارید. مستندات فنی و نمونهٔ درخواست‌ها در مستندات API نِت اَرز است و هم‌اکنون مدل‌های OpenAI روی درگاه فعال‌اند و بقیهٔ ارائه‌دهندگان در حال اتصال. اعتبار حساب را می‌توانید از صفحهٔ افزایش اعتبار وب‌سرویس ChatGPT با قیمت لحظه‌ای همان صفحه شارژ کنید و اگر تازه‌کارید، راهنمای شارژ اعتبار OpenAI API مسیر را از ابتدا توضیح داده است.

آموزش‌های کاربردی در گیسو

گیسو برند هوش مصنوعی نِت اَرز است، با همین حساب و همین اعتبار. این راهنماها قدم‌به‌قدم نشان می‌دهند همین کار را چطور با هوش مصنوعی انجام دهید.

پرسش‌های پرتکرار

فرق json_object با json_schema چیست؟

حالت json_object فقط تضمین می‌کند خروجی یک JSON نحوی معتبر باشد، اما دربارهٔ کلیدها و نوع مقادیر هیچ تعهدی نمی‌دهد. حالت json_schema با strict علاوه بر معتبر بودن، مطابقت خروجی با اسکیمای شما را هم تضمین می‌کند و برای خط تولید گزینهٔ درست همین است.

چرا با وجود اسکیما باز هم باید خروجی را اعتبارسنجی کنم؟

چون اسکیما فقط شکل خروجی را تضمین می‌کند نه درستی محتوا را. مدل می‌تواند دسته‌بندی را اشتباه تشخیص دهد یا شماره‌ای بسازد که در سیستم شما وجود ندارد. یک لایهٔ اعتبارسنجی ساده با ابزارهایی مثل pydantic یا zod این خطاها را قبل از رسیدن به پایگاه داده می‌گیرد.

چطور فیلد اختیاری در حالت strict تعریف کنم؟

در حالت سخت‌گیرانه همهٔ کلیدها باید در فهرست required باشند، بنابراین فیلد اختیاری به شکل معمول پشتیبانی نمی‌شود. راه درست این است که فیلد را اجباری کنید و نوعش را قابل تهی بگذارید تا مدل در صورت نبود اطلاعات مقدار null برگرداند.

آیا استفاده از JSON Schema هزینه را بالا می‌برد؟

خود اسکیما بخشی از ورودی است و توکن مصرف می‌کند، بنابراین اسکیمای بزرگ روی درخواست‌های کوچک صرفه ندارد. در مقابل، حذف حلقهٔ تلاش مجدد برای پارس‌کردن خروجی معمولاً بیشتر از این مقدار صرفه‌جویی می‌کند و تأخیر را هم پایین می‌آورد.

محصولات مرتبط با این مقاله

مطالب مشابه

نظر خوانندگان

هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همین‌جا بنویسید.

نظرتان را بنویسید

این مقاله چقدر به کارتان آمد؟ (اختیاری)

نظرها را پیش از انتشار بررسی می‌کنیم. نقد صریح مشکلی ندارد؛ تبلیغ و توهین منتشر نمی‌شود.