بعد درس 109: Conversation State، السؤال التالي: كيف أضمن أنّ مخرج النموذج يطابق بنية معيّنة؟ هل يكفي أن أكتب في الـ prompt "أعِد JSON"؟ الجواب: لا — لأن النموذج احتمالي. الحلّ: Structured Outputs.
المشكلة: JSON في الـ prompt ضعيف
# طلب JSON في prompt فقط
response = client.responses.create(
model=model,
input="أعِد JSON بمفتاح name و age...",
)
text = response.output_text
data = json.loads(text) # ← قد يفشل: ليس JSON، حقل ناقص، نوع خاطئ، حقول إضافية
ما قد يحدث:
1. النموذج يرجع نصًّا عاديًا بدلاً من JSON.
2. يرجع JSON ناقصًا ("name": "أحمد" بدون age).
3. يرجع JSON مع حقل زائد لم تطلبه.
4. يرجع age كنصّ "25" بدل رقم 25.
5. يكتب JSON داخل ```json ... ``` markdown wrapper → parsing يفشل.
الحلّ: إجبار النموذج على الالتزام بـ schema صارم على مستوى الـ decoding.
Structured Outputs: ما الفكرة؟
المدخل: prompt + JSON Schema (أو Pydantic model)
↓
المزوّد يقيّد decoding
↓
المخرج: نصّ يطابق الـ schema حرفيًا (مضمون)
الفرق الجوهري:
Prompt-based JSON:
- النموذج يقرأ تعليماتك ويحاول الالتزام.
- لا ضمان. يحتاج parsing + validation + retry.
Structured Outputs (API-level):
- المزوّد يقيد vocabulary المسموح في كل خطوة decoding.
- المخرج مطابق للـ schema بنسبة عالية جدًا (غالبًا 100% للنماذج المدعومة).
- لا parsing معقّد. لا retry للنوع/البنية.
مثال Pydantic + OpenAI Responses API
المصدر المرجعي: مثال من openai-python الرسمي (examples/responses/structured_outputs.py) — تحقّقت أثناء كتابة هذا الدرس.
1. عرّف schema بـ Pydantic
import os
from pydantic import BaseModel
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
# لا قيمة افتراضية ثابتة — اختر نموذجًا يقدّم Structured Outputs.
model = os.environ["OPENAI_MODEL"]
class Person(BaseModel):
name: str
age: int
occupation: str
languages: list[str]
2. استخدم responses.parse مع text_format
response = client.responses.parse(
model=model,
input="استخرج معلومات الشخص من الجملة التالية: أحمد مطوّر عمره 28 سنة، يتحدث العربية والإنجليزية.",
text_format=Person,
)
# response.output_parsed = instance من Person، ليس نصًّا خامًا.
person: Person = response.output_parsed
print(person.name) # "أحمد"
print(person.age) # 28 (int، ليس "28")
print(person.languages) # ["العربية", "الإنجليزية"]
ما يحدث داخليًا:
- SDK يحوّل Pydantic class إلى JSON Schema.
- يرسله مع الطلب.
- المزوّد يقيد decoding.
- المخرج JSON صالح → يُحلَّ تلقائيًا إلى Person instance.
3. تحقق من المخرج
from openai import APIError
# 1. خطأ في الطلب (schema غير مدعوم، مفتاح خاطئ، rate limit، ...)
# → SDK يرفع APIError. عالجه قبل قراءة output_parsed.
# 2. الطلب نجح لكن الردّ لا يحوي مخرجًا منظّمًا صالحًا:
# - refusal: النموذج امتنع عن توليد المخرج.
# - incomplete: بلوغ حدّ التوكنات أو انقطاع في التوليد.
# في هذه الحالات output_parsed يكون None.
# 3. الطلب نجح واكتمل → output_parsed instance من Pydantic model.
try:
response = client.responses.parse(
model=model,
input="...",
text_format=Person,
)
except APIError as e:
print(f"[api-error] {e}")
else:
if response.output_parsed is None:
print("لا مخرج منظّم (refusal أو incomplete). أعِد المحاولة.")
else:
person = response.output_parsed
# أنت متأكّد أن person.age هو int و person.languages قائمة نصوص.
الحقول الاختيارية و Nullable Semantics
في Structured Outputs الصارم:
- حقول الـ schema المطلوبة تكون دائمًا موجودة في المخرج.
- إذا أراد الموديل قول "غير موجود"، استخدم نوع nullable:
string | None
integer | None
list[T] (قائمة فارغة [] تعني "لا عناصر"، لا None)
- لا تطلب من النموذج "ضع null أو اترك الحقل فارغًا" حين الـ schema يتوقّع
خاصيّة مطلوبة — هذا يكسر تطابق الـ schema.
مثال — حقل اختياري بشكل nullable
from typing import Literal
from pydantic import BaseModel, Field
class Review(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
confidence: float = Field(ge=0.0, le=1.0)
topics: list[str] = Field(default_factory=list)
# الحقل أدناه اختياري لكن يجب أن يكون موجودًا في المخرج
# كقيمة None عندما لا تتوفّر بيانات كافية.
summary: str | None = None
قواعد Pydantic للقيود:
- ge / le: أكبر/أصغر من أو يساوي (للأرقام).
- min_length / max_length: طول النصوص.
- pattern: regex (مثلاً للتحقق من بريد إلكتروني).
- Literal: enum صارم — فقط هذه القيم مسموحة.
- Optional / | None: الحقل قد يحمل None بدل قيمة من النوع.
ملاحظة مهمّة: Structured Outputs يضمن تطابق البنية، لا صحّة المحتوى. إذا طلبت email وهو غير موجود بالنصّ، يضع None. إذا كان منطق تجاري معيّن (مثلاً لا تُقبل وظائف بريدها لا يطابق regex)، أضف validation يدويًا بعد التحليل.
- ge / le: أكبر/أصغر من أو يساوي (للأرقام).
- min_length / max_length: طول النصوص.
- pattern: regex (مثلاً للتحقق من بريد إلكتروني).
- Literal: enum صارم — فقط هذه القيم مسموحة.
- Optional / | None: حقل قد لا يكون موجودًا.
متى تفشل Structured Outputs؟
✅ ينجح في:
- استخراج حقول من نصّ.
- تصنيف (مع Literal enum).
- تحويل/إعادة هيكلة.
- توليد JSON متوافق مع schema معقّد.
⚠️ قد يتعثّر في:
- استعلامات لا تتضمّن بيانات كافية (حقول تبقى فارغة).
- النصوص شديدة الغموض (Model يحاول best guess).
- الـ schema المعقّد جدًا (أكثر من 100 خاصية — قد يرفض المزوّد).
❌ يفشل في:
- مخرجات غير منظّمة (نصّ حرّ بدون بنية).
- مهام تتطلّب إبداعًا (خلاصة، قصة).
Structured Outputs ≠ "JSON Mode"
JSON Mode (أقدم):
- المخرج JSON صالح (لا markdown، لا نصّ خارج).
- لكن لا يضمن بنية محدّدة.
Structured Outputs (أقوى):
- JSON صالح + مطابق للـ schema المعطى.
- المزوّد يفرض ذلك على مستوى الـ decoding.
JSON Mode كان الخطوة الأولى. Structured Outputs هو الخطوة الثانية. استخدم الأحدث حين يتوفّر.
مثال منظّم: مصنّف تذاكر دعم
import os
import json
from typing import Literal
from pydantic import BaseModel, Field
from openai import OpenAI
from openai import APIError
class SupportTicketClassification(BaseModel):
category: Literal["bug", "billing", "account", "other"]
urgency: Literal["low", "medium", "high"]
needs_human: bool
reasoning: str = Field(min_length=10, max_length=300)
def classify_ticket(ticket_text: str) -> SupportTicketClassification | None:
"""صنّف تذكرة دعم باستخدام structured outputs."""
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
model = os.environ["OPENAI_MODEL"]
try:
response = client.responses.parse(
model=model,
input=[
{
"role": "system",
"content": "أنت موظّف دعم يصنّف تذاكر المستخدمين.",
},
{"role": "user", "content": ticket_text},
],
text_format=SupportTicketClassification,
)
except APIError as e:
print(f"[api-error] {e}")
return None
return response.output_parsed
# اختبار
ticket = "التطبيق يتعطّل كلّما أفتح الإعدادات. هذا يحدث منذ أمس."
result = classify_ticket(ticket)
if result:
print(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
بدون SDK: JSON Schema خام
# إذا كنت تستخدم HTTP مباشرة (ليس SDK)، أرسل JSON Schema ضمن الـ request.
# البنية الدقيقة تختلف بين المزودين — راجع docs المزوّد.
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer", "minimum": 0},
},
"required": ["name", "age"],
"additionalProperties": False,
}
ملاحظة: additionalProperties: False يمنع النموذج من إضافة حقول غير مطلوبة — ضبط مهمّ في Structured Outputs.
أخطاء شائعة
- "يكفي أن أكتب في الـ prompt أعِد JSON": لا. استخدم Structured Outputs حين تحتاج ضمان بنية.
- "schema معقّد جدًا = نتائج أبطأ": قد يحدث. ابدأ بـ schema ضروري فقط.
- "Structured Outputs = قاعدة بيانات": لا. ما زلت تحتاج validation خاصة بقواعد عملك (مثلاً: لا تثق في
emailكحقّ صحّ قبل التحقق منه regex). - "استخدم JSON Mode القديم": إن كان Structured Outputs متاحًا، استخدمه.
- "تحقّق بعد المخرج": لا تثق 100% في المخرج. validation خفيف إضافي (regex، منطق مجال) فكرة جيدة دائمًا.
متى لا تستخدم Structured Outputs؟
❌ كتابة إبداعية (قصة، شعر، رسالة).
❌ شرح أو تدريس (نصّ حرّ).
❌ تلخيص بدون بنية جامدة.
❌ محادثة طبيعية.
✅ استخراج حقول.
✅ تصنيف بقيم ثابتة (enum).
✅ توليد SQL/JSON/XML بصياغة محددة.
✅ تحويل بين صيغ منظّمة.
✅ كل ما تحتاجه قابل تمثيله بـ schema.
الخطوات التالية
- Function Calling / Tool Calling — كيف يستدعي النموذج أدواتك.
- LLM API Engineering Basics — streaming، retry، logging.
ملاحظة النطاق: هذا الدرس حدّه Structured Outputs الأساسية. تطبيقات متقدّمة (مثل streaming مع schema، partial results) تُغطّى لاحقًا.