Structured Outputs در LLM: مقایسه عملی OpenAI، Anthropic و Gemini در ۲۰۲۶

راهنمای عملی Structured Outputs در OpenAI (strict)، Anthropic (tool use) و Gemini (responseSchema) با نمونه‌کد Python، الگوی Pydantic-first و چاله‌های تولیدی.

Structured Outputs در LLM: مقایسه ۲۰۲۶

به‌روزرسانی: ۲۵ ژوئن ۲۰۲۶

خروجی‌های ساختاریافته (Structured Outputs) قابلیتی در API مدل‌های زبانی بزرگ است که تضمین می‌کند خروجی مدل دقیقاً مطابق یک طرح JSON Schema از پیش تعریف‌شده باشد، بدون نیاز به پارس کردن متن آزاد یا تلاش‌های مکرر برای اصلاح خطا. در سال ۲۰۲۶، سه ارائه‌دهنده‌ی اصلی (OpenAI، Anthropic و Google Gemini) هر یک رویکرد متفاوتی برای حل این مسئله انتخاب کرده‌اند: OpenAI از دیکودینگ محدودشده (constrained decoding) با حالت strict استفاده می‌کند، Anthropic از مسیر ابزار (tool use) با Schema بهره می‌گیرد، و Gemini فیلد responseSchema را معرفی کرده است. در ادامه، تفاوت‌های عملی، الگوهای پیاده‌سازی و چاله‌های هر کدام را با کد قابل اجرا بررسی می‌کنم — همان‌هایی که در پروژه‌های واقعی خودم بارها به آن‌ها برخورده‌ام.

  • Structured Outputs در OpenAI با response_format از نوع json_schema و پرچم strict: true تطابق ۱۰۰٪ با Schema را تضمین می‌کند، اما زیرمجموعه‌ای محدود از JSON Schema را پشتیبانی می‌کند.
  • Anthropic ساختاردهی را از طریق tool_use با input_schema انجام می‌دهد؛ tool_choice را به نام ابزار خاص پین کنید تا مدل مجبور به فراخوانی شود.
  • Gemini از فیلدهای responseMimeType: "application/json" و responseSchema استفاده می‌کند و سینتکس آن شبیه OpenAPI 3.0 است، نه JSON Schema کامل.
  • کتابخانه‌ی Instructor با Pydantic روی هر سه ارائه‌دهنده انتزاع یکپارچه می‌سازد و خطاهای اعتبارسنجی را به‌صورت خودکار به مدل بازخورد می‌دهد.
  • کلید پایداری در تولید این است که Schema را به‌عنوان قراردادی ثابت در نظر بگیرید و قبل از استقرار، یک مجموعه‌ی eval شامل ورودی‌های مرزی و خصمانه (adversarial) اجرا کنید.
  • هزینه‌ی پنهان حالت strict در OpenAI تأخیر اولیه (cold compile) برای کامپایل گرامر است؛ آن را با کش‌کردن Schema کاهش دهید.

خروجی ساختاریافته دقیقاً چیست و چرا با JSON Mode فرق دارد؟

خروجی ساختاریافته یعنی مدل، خروجی خود را تحت یک قید نحوی (syntactic constraint) تولید می‌کند که تضمین می‌کند نتیجه‌ی نهایی مطابق طرح‌واره‌ای از پیش تعریف‌شده پارس شود. این با JSON Mode ساده فرق دارد. JSON Mode فقط تضمین می‌کند خروجی JSON معتبر نحوی است؛ یعنی پرانتزها بسته می‌شوند، رشته‌ها escape می‌شوند و کاما‌ها سرجای‌شان هستند. اما هیچ تضمینی وجود ندارد که فیلدهای مورد انتظار شما در آن باشند، نوع داده‌ی هر فیلد درست باشد، یا یک enum محدود به مقادیر مجاز بماند.

تفاوت در سطح پیاده‌سازی نیز عمیق است. در حالت strict، ارائه‌دهنده Schema را به یک گرامر FSM (Finite State Machine) یا context-free grammar کامپایل می‌کند و در زمان sampling، logit-های توکن‌هایی که از گرامر منحرف می‌شوند را به منفی بی‌نهایت ماسک می‌کند. نتیجه: ۱۰۰٪ تطابق، حتی روی Schemaهای تو در تو. در مقابل، JSON Mode صرفاً مدل را با پرامپت سیستمی تشویق می‌کند JSON تولید کند و سپس پاسخ را پارس می‌کند؛ اگر فیلدی جا افتاد، باید دوباره تلاش کنید.

در تجربه‌ی من، تیم‌هایی که از JSON Mode به Structured Outputs سختگیرانه مهاجرت می‌کنند، معمولاً ۳۰ تا ۵۰ درصد کاهش در نرخ retry می‌بینند، اما تأخیر اولیه‌ی هر Schema جدید چند صد میلی‌ثانیه افزایش می‌یابد (یک‌بار، برای کامپایل گرامر). این هزینه فقط در اولین درخواست با Schema جدید پرداخت می‌شود.

پیاده‌سازی Structured Outputs در OpenAI با حالت strict

OpenAI در آگوست ۲۰۲۴ Structured Outputs با تضمین ۱۰۰٪ را معرفی کرد و در ۲۰۲۶ آن را به همه‌ی مدل‌های خانواده gpt-4o، gpt-4.1 و o-series گسترش داد. این قابلیت دو مسیر دارد: یکی از طریق response_format برای پاسخ مستقیم، و دیگری از طریق پارامتر strict در تعریف function برای function calling.

الگوی Pydantic-first که پیشنهاد می‌کنم به این شکل است:

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal

client = OpenAI()

class SupportTicket(BaseModel):
    category: Literal["billing", "technical", "account", "other"]
    priority: Literal["low", "medium", "high", "urgent"]
    summary: str = Field(..., max_length=200)
    requires_human: bool

response = client.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "You classify customer support tickets."},
        {"role": "user", "content": "I can't log in and my subscription "
                                    "was charged twice this month."},
    ],
    response_format=SupportTicket,
)

ticket = response.choices[0].message.parsed
print(ticket.category, ticket.priority)
# billing urgent

متد parse() در SDK رسمی OpenAI، خروجی را مستقیماً به نمونه‌ی Pydantic تبدیل می‌کند. اگر مدل نتواند خروجی منطبق با Schema تولید کند (مثلاً به دلیل refusal از سمت لایه‌ی ایمنی)، فیلد parsed برابر None می‌شود و باید message.refusal را چک کنید.

همچنین، در حالت strict باید additionalProperties: false روی هر object تنظیم شود و همه‌ی فیلدها باید در آرایه‌ی required فهرست شوند. برای فیلدهای واقعاً اختیاری، آن‌ها را به‌صورت Union[T, None] در Pydantic تعریف کنید (که ترجمه می‌شود به {"type": ["T", "null"]}).

رویکرد Anthropic: ساختاردهی از مسیر Tool Use

Anthropic در API خود فیلد جداگانه‌ای برای Structured Output ندارد. در عوض، الگوی پذیرفته‌شده استفاده از tool use با input_schema است. شما یک «ابزار» تعریف می‌کنید که هرگز اجرا نمی‌شود؛ تنها هدفش این است که مدل را وادار کند پاسخ خود را در قالب پارامترهای آن ابزار قالب‌بندی کند. این الگو که گاهی «extraction tool pattern» نامیده می‌شود، در عمل بسیار قابل اعتماد است.

import anthropic, json

client = anthropic.Anthropic()

extract_ticket = {
    "name": "extract_ticket",
    "description": "Extract structured ticket fields from a user message.",
    "input_schema": {
        "type": "object",
        "properties": {
            "category": {
                "type": "string",
                "enum": ["billing", "technical", "account", "other"],
            },
            "priority": {
                "type": "string",
                "enum": ["low", "medium", "high", "urgent"],
            },
            "summary": {"type": "string", "maxLength": 200},
            "requires_human": {"type": "boolean"},
        },
        "required": ["category", "priority", "summary", "requires_human"],
    },
}

resp = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=[extract_ticket],
    tool_choice={"type": "tool", "name": "extract_ticket"},
    messages=[{"role": "user", "content": "I can't log in and my subscription "
                                          "was charged twice this month."}],
)

tool_block = next(b for b in resp.content if b.type == "tool_use")
ticket = tool_block.input
print(json.dumps(ticket, indent=2))

نکته‌ی کلیدی این الگو، تنظیم tool_choice به نوع tool با نام مشخص است. اگر این کار را نکنید، Claude ممکن است تصمیم بگیرد متن آزاد پاسخ بدهد یا اصلاً ابزار را فراخوانی نکند. با پین کردن نام ابزار، مدل را به فراخوانی اجباری می‌رسانیم.

برخلاف OpenAI، Anthropic در سال ۲۰۲۶ هنوز constrained decoding سختگیرانه را به‌صورت رسمی ارائه نمی‌دهد. در عمل، Claude Sonnet 4.6 و Opus 4.x نرخ تطابق Schema بالای ۹۹٪ دارند، اما این یک تضمین احتمالی است نه نحوی. برای مسیرهای بحرانی، یک لایه‌ی اعتبارسنجی Pydantic روی tool_block.input ضروری است. تجربه‌ی من می‌گوید الگوهایی که در آن‌ها مدل با ورودی مبهم مواجه می‌شود (مثلاً enum‌ای که به‌خوبی شرح داده نشده) همانند OpenAI پایدار رفتار می‌کنند، اما زیر فشار توکن (نزدیک به سقف max_tokens) ممکن است JSON ناقص برگرداند.

Gemini و فیلد responseSchema در سال ۲۰۲۶

Google Gemini در نسل ۲ و ۳ خود مسیر سومی انتخاب کرده است: ترکیب responseMimeType و responseSchema در پیکربندی تولید. سینتکس Schema در Gemini مبتنی بر زیرمجموعه‌ای از OpenAPI 3.0 است، نه JSON Schema استاندارد. این تفاوت ظریف اما مهم است: nullable به‌جای "type": ["T", "null"] استفاده می‌شود، و enum فقط روی نوع string پشتیبانی می‌شود.

from google import genai
from google.genai import types

client = genai.Client()

schema = types.Schema(
    type=types.Type.OBJECT,
    properties={
        "category": types.Schema(
            type=types.Type.STRING,
            enum=["billing", "technical", "account", "other"],
        ),
        "priority": types.Schema(
            type=types.Type.STRING,
            enum=["low", "medium", "high", "urgent"],
        ),
        "summary": types.Schema(type=types.Type.STRING),
        "requires_human": types.Schema(type=types.Type.BOOLEAN),
    },
    required=["category", "priority", "summary", "requires_human"],
    property_ordering=["category", "priority", "summary", "requires_human"],
)

resp = client.models.generate_content(
    model="gemini-2.5-pro",
    contents="I can't log in and my subscription was charged twice this month.",
    config=types.GenerateContentConfig(
        response_mime_type="application/json",
        response_schema=schema,
    ),
)

import json
ticket = json.loads(resp.text)
print(ticket)

یک ویژگی منحصربه‌فرد Gemini فیلد property_ordering است. مدل‌های Gemini نسبت به OpenAI و Anthropic به ترتیب فیلدها در Schema بیشتر حساس‌اند، و اگر ترتیب را تعیین نکنید، خروجی ممکن است بین درخواست‌ها متفاوت باشد. این برای تست‌های determinism و کش‌گذاری اهمیت دارد. در پروژه‌های واقعی، همیشه property_ordering را به‌صورت صریح تنظیم می‌کنم.

ویژگیOpenAI (strict)Anthropic (tool use)Gemini (responseSchema)
تضمین تطابق۱۰۰٪ نحوی~۹۹٪ احتمالی~۹۹٪ احتمالی
سینتکس SchemaJSON Schema (زیرمجموعه)JSON Schema (زیرمجموعه)OpenAPI 3.0 (زیرمجموعه)
پشتیبانی enumروی همه‌ی typesروی همه‌ی typesفقط روی string
کامپایل اولیهدارد (cold)نداردندارد
اعتبارسنجی محتوایی (min/max)نادیده گرفته می‌شودنادیده گرفته می‌شودنادیده گرفته می‌شود
پشتیبانی Refusalدارد (message.refusal)ندارد (با محتوای ابزار)ندارد
ترتیب فیلدهاغیرحساسغیرحساسحساس (property_ordering)

کتابخانه Instructor و الگوی Pydantic-first

اگر روی چند ارائه‌دهنده کار می‌کنید، نوشتن سه نسخه‌ی Schema برای یک قرارداد داده‌ای، تله است. کتابخانه‌ی Instructor با Pydantic به‌عنوان منبع حقیقت کار می‌کند و انتزاع یکپارچه روی OpenAI، Anthropic، Gemini و حتی مدل‌های متن‌باز (Ollama، vLLM) فراهم می‌کند. در نسخه‌ی ۱.۸+ که در ۲۰۲۶ پایدار است، یک ویژگی حیاتی به نام max_retries با retry اصلاحی دارد:

import instructor
from anthropic import Anthropic
from pydantic import BaseModel, Field, field_validator

class SupportTicket(BaseModel):
    category: str
    priority: str
    summary: str = Field(..., max_length=200)

    @field_validator("category")
    @classmethod
    def normalize_category(cls, v: str) -> str:
        allowed = {"billing", "technical", "account", "other"}
        if v.lower() not in allowed:
            raise ValueError(f"category must be one of {allowed}")
        return v.lower()

client = instructor.from_anthropic(Anthropic())

ticket = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    max_retries=3,
    response_model=SupportTicket,
    messages=[{"role": "user", "content": "..."}],
)

زیبایی این الگو در این است که اگر field_validator شکست بخورد، Instructor به‌صورت خودکار خطای اعتبارسنجی Pydantic را به مدل بازخورد می‌دهد و درخواست را تکرار می‌کند. این الگو که به آن «self-healing» می‌گویند، در سناریوهایی که اعتبارسنجی محتوایی فراتر از قابلیت‌های Schema strict ضروری است، حیاتی است. برای جزئیات بیشتر می‌توانید مقاله‌ی ما درباره‌ی آموزش Function Calling و Tool Use در مدل‌های زبانی بزرگ را نیز مطالعه کنید.

تفاوت Structured Outputs و Function Calling چیست؟

این سؤال در «People Also Ask» گوگل بسیار پرسیده می‌شود و پاسخ کوتاهش این است: Function Calling هدف اصلی‌اش اجرای ابزار است، در حالی که Structured Outputs هدف اصلی‌اش قالب‌بندی پاسخ. در Function Calling، مدل می‌تواند تصمیم بگیرد یک یا چند ابزار را با چه آرگومان‌هایی فراخوانی کند، یا به‌جای آن متن آزاد برگرداند. در Structured Outputs، مدل مجبور است خروجی نهایی را در قالب Schema بدهد.

به لحاظ زیرساختی، در OpenAI هر دو روی همان مکانیزم constrained decoding ساخته شده‌اند و پارامتر strict: true برای هر دو در دسترس است. در Anthropic، Function Calling و الگوی «extraction tool» عملاً از مسیر یکسانی استفاده می‌کنند؛ تفاوت در این است که در Function Calling شما به مدل اختیار انتخاب می‌دهید، و در Structured Outputs با tool_choice به نام، آن را مجبور می‌کنید.

من معمولاً این تقسیم را پیشنهاد می‌کنم: اگر خروجی نهایی برای کاربر یا یک سیستم پایین‌دستی است (مثل ذخیره در دیتابیس یا نمایش در UI)، از Structured Outputs استفاده کنید. اگر مدل باید تصمیم بگیرد چه ابزاری اجرا شود (مثل جست‌وجوی وب، فراخوانی API)، از Function Calling استفاده کنید. ترکیب این دو هم رایج است: ابتدا Function Calling برای یک یا چند گام، سپس Structured Outputs برای پاسخ نهایی به کاربر.

ارزیابی و پایداری در تولید: چه چیزی را تست کنیم؟

یک Schema به‌خوبی تعریف‌شده هنوز یک نیمه‌ی معادله است. در عمل، نرخ تطابق نحوی ۱۰۰٪ می‌تواند با کیفیت محتوایی پایین همراه باشد. مثلاً مدل ممکن است هر تیکت را به "other" دسته‌بندی کند، که از نظر Schema معتبر است اما در عمل بی‌فایده. به همین دلیل، قبل از استقرار باید یک مجموعه‌ی eval ساختاریافته بسازید.

چارچوب پیشنهادی من سه لایه دارد. لایه‌ی اول، تست‌های نحوی: ۱۰۰ نمونه‌ی ورودی مختلف را اجرا کنید و مطمئن شوید BaseModel.model_validate() روی همه موفق است. لایه‌ی دوم، تست‌های محتوایی روی نمونه‌های دارای برچسب طلایی: F1، accuracy و confusion matrix روی فیلدهای enum محاسبه کنید. لایه‌ی سوم، تست‌های خصمانه: ورودی‌های مبهم، چندزبانه، با نویز، یا با تلاش برای prompt injection. برای جزئیات کامل ابزارها و معیارها، به مقاله‌ی ارزیابی و مشاهده‌پذیری سیستم‌های LLM در تولید مراجعه کنید.

import pytest
from pydantic import ValidationError

@pytest.mark.parametrize("user_msg, expected", [
    ("I was charged twice", {"category": "billing"}),
    ("Can't log in", {"category": "account"}),
    ("API returns 500", {"category": "technical"}),
])
def test_ticket_categorization(user_msg, expected):
    ticket = classify_ticket(user_msg)
    for key, value in expected.items():
        assert getattr(ticket, key) == value, f"{key} mismatch"

چاله‌های رایج و چگونگی اجتناب از آن‌ها

در پروژه‌های مختلفی که با Structured Outputs کار کرده‌ام، چند الگوی شکست تکراری دیده‌ام که اغلب با تنظیمات ساده قابل اجتناب‌اند.

۱. اعتماد بیش‌ازحد به enum بدون توضیح

اگر مقادیر enum معنای واضحی ندارند، مدل به آخرین مقدار یا یک مقدار «نزدیک» سقوط می‌کند. در توصیف فیلد، با کلمات طبیعی توضیح دهید چه زمانی کدام مقدار باید انتخاب شود. در Pydantic این کار با Field(..., description="...") انجام می‌شود و در JSON Schema تولیدی به‌عنوان description ظاهر می‌شود.

۲. Schema تو در توی عمیق

OpenAI در حالت strict، حداکثر ۵ سطح nesting و ۵۰۰۰ کاراکتر برای کل Schema را پشتیبانی می‌کند. Anthropic و Gemini محدودیت رسمی اعلام نکرده‌اند اما در عمل پس از ۴ سطح کیفیت افت می‌کند. برای ساختارهای پیچیده، آن‌ها را به چند فراخوانی متوالی تقسیم کنید.

۳. نادیده گرفتن refusal

در OpenAI، اگر مدل تشخیص دهد ورودی ناامن است، فیلد parsed برابر None و refusal پر می‌شود. در Anthropic این به‌صورت پاسخ متنی به‌جای tool_use ظاهر می‌شود. در هر دو حالت، کد شما باید این مسیر را به‌صورت صریح هندل کند. برای الگوهای پیشرفته‌تر در طراحی پرامپت‌های ایمن، می‌توانید به مهندسی پرامپت پیشرفته برای عامل‌های هوش مصنوعی مراجعه کنید.

۴. حساسیت Gemini به ترتیب فیلدها

همان‌طور که گفتم، در Gemini همیشه property_ordering را صریح کنید. در غیر این صورت، خروجی بین درخواست‌ها متفاوت می‌شود و کش‌های شما کارایی‌شان را از دست می‌دهند.

۵. فراموشی additionalProperties در OpenAI

پر تکرارترین خطایی که در PR-ها می‌بینم. در حالت strict، باید روی هر object تو در تو، صریحاً additionalProperties: false تنظیم شود. اگر از Pydantic استفاده می‌کنید، با model_config = ConfigDict(extra="forbid") این اتفاق به‌صورت خودکار رخ می‌دهد.

پرسش‌های متداول

آیا Structured Outputs در مدل‌های متن‌باز هم در دسترس است؟

بله. کتابخانه‌هایی مانند Outlines، XGrammar و LMFE روی مدل‌های میزبانی‌شده در vLLM یا Ollama، constrained decoding واقعی فراهم می‌کنند. Instructor با همین موتورها یکپارچه می‌شود و همان API را روی Llama، Mistral و سایر مدل‌ها فراهم می‌کند.

آیا حالت strict هزینه‌ی توکن را افزایش می‌دهد؟

نه به‌صورت مستقیم. توکن‌های ورودی و خروجی همان هزینه را دارند. اما کامپایل اولیه‌ی گرامر در OpenAI، در اولین درخواست با Schema جدید، حدود ۲۰۰ تا ۸۰۰ میلی‌ثانیه تأخیر اضافه می‌کند که از درخواست‌های بعدی کش می‌شود.

چه زمانی نباید از Structured Outputs استفاده کرد؟

وقتی خروجی به‌طور ذاتی متنی آزاد است (مانند تولید مقاله، پاسخ خلاقانه، خلاصه‌سازی روایی). اعمال Schema روی این موارد کیفیت زبانی را کاهش می‌دهد. همچنین در سناریوهای streaming که نیاز به نمایش تدریجی متن دارید، Structured Outputs پیچیدگی اضافی ایجاد می‌کند.

آیا می‌توان Schema را در زمان اجرا به‌صورت پویا ساخت؟

بله، در هر سه ارائه‌دهنده. در OpenAI و Anthropic می‌توانید JSON Schema را به‌صورت dict در هر درخواست بسازید. اما توجه کنید که هر Schema جدید در OpenAI نیازمند کامپایل اولیه است، بنابراین Schemaهای کاملاً پویا تأخیر شما را افزایش می‌دهند. اگر تعداد محدودی الگو دارید، آن‌ها را به‌صورت ثابت تعریف کنید.

چگونه با Schemaهای بسیار بزرگ مقابله کنیم؟

اگر Schema از ۵۰۰۰ کاراکتر فراتر می‌رود (محدودیت OpenAI)، آن را به چند فراخوانی متوالی تقسیم کنید. مثلاً ابتدا فیلدهای سطح بالا را استخراج کنید، سپس بر اساس مقدار یک فیلد، Schema تخصصی‌تری برای جزئیات اعمال کنید. این الگوی «hierarchical extraction» در عمل پایدارتر از یک Schema غول‌پیکر است.

Daichi Watanabe
درباره نویسنده Daichi Watanabe

LLM integration specialist with a strong opinion about function calling and an even stronger one about evaluations.