الحصول على JSON موثوق من الوكيل (Schemas، الفك المقيد، إعادة المحاولة)
انهار pipeline بنيته العام الماضي في الساعة الثانية فجرًا لأن النموذج قرر أن يلف JSON الخاص به في مقدمة ثرثارة: "بالتأكيد! إليك البيانات التي طلبتها:" تلتها كتلة كود. مُحلّل البيانات لديّ، الذي توقّع أن يبدأ جسم الاستجابة بـ {، رمى استثناءً. ضربت إعادة المحاولة النموذج الودود نفسه، فحصلت على المقدمة الودودة ذاتها، ورمت استثناءً مجددًا. ثلاث محاولات، ثلاثة إخفاقات، إنسان أُيقظ من نومه.
المزعج في الأمر أن JSON داخل كتلة الكود كان مثاليًا. أنجز النموذج الجزء الصعب — الاستخراج — بشكل صحيح. غير أنه لم يستطع مقاومة توجيه التحية أولًا. كنت قد صببت جهدي في الـ prompt دون أن أولي العقد أي اهتمام.
هذه هي المشكلة بأكملها في حكاية واحدة. النموذج عادةً قادر على إنتاج البيانات التي تريدها. الإخفاق يكمن في الدروز: المقدمة، والفاصلة المتذيلة، والحقل الذي أعيد تسميته، وقيمة الـ enum التي اخترعها، والاستجابة التي انقطعت في منتصف الكائن لأنك نسيت رفع حد الـ tokens. لا شيء من هذا إخفاق في التفكير. كلها إخفاقات في العقد، وتتصدى لها بالآلية لا بتحسين الـ prompt.
الخلاصة المختصرة: لا تطلب JSON، بل قيّد النموذج لينتجه. استخدم وضع الإخراج المنظم المقيّد بالـ schema لدى مزودك (أصبح معياريًا الآن في OpenAI وAnthropic وGoogle)، وتحقق من النتيجة بـ schema تملكه أنت، واحتفظ بحلقة إصلاح وإعادة محاولة للحالات التي تتسلل رغم ذلك. في ما يلي السلّم من الأضعف إلى الأقوى، وإلى أين أصعد فعليًا.
السلّم، من الأضعف إلى الأقوى
ثمة ما يقارب خمس درجات، ومعظم الناس يقفون درجةً أسفل مما يظنون.
الدرجة الأولى: prompt-and-pray. تكتب "استجب بـ JSON فقط دون أي نص آخر" وتأمل. ينجح هذا في العروض التجريبية بما يكفي لأن يكون مضللًا بخطورة. يفشل مع المقدمات، وكتل الكود، والنثر الختامي، وأي نموذج يمر بيوم تعبيري. لا تشحنه إلى الإنتاج.
الدرجة الثانية: وضع JSON. علامة من المزوّد تضمن أن الإخراج JSON صحيح نحويًا — سيُحلَّل. لا تضمن أن الإخراج يطابق الشكل الذي تريده. وثائق OpenAI صريحة في أن هذا الخيار أقدم وأضعف: "Structured Outputs هو تطور لـ JSON mode. بينما يضمن الاثنان إنتاج JSON صالح، تضمن Structured Outputs فقط الالتزام بالـ schema" (دليل Structured Outputs في OpenAI). وضع JSON يوقف مشكلة المقدمة. لكنه لا يفعل شيئًا حيال حقل مفقود أو enum مُخترَع.
الدرجة الثالثة: الإخراج المنظم المقيّد بالـ schema. تسلّم المزوّد JSON Schema ويقيّد التوليد بحيث يتوافق الإخراج — كل حقل مطلوب موجود، كل نوع صحيح، كل قيمة enum مسحوبة من قائمتك. هذه هي الدرجة التي ينبغي أن تعيش عليها لاستخراج البيانات.
الدرجة الرابعة: استدعاء الأدوات/الوظائف. يُصدر النموذج استدعاءً لوظيفة بارامتراتها JSON Schema. ميكانيكيًا هذا نفس القيد في الدرجة الثالثة، مُطبَّق على معطيات الأداة بدلًا من جسم الاستجابة. تحتاجه حين يختار النموذج أي إجراء يتخذ، لا مجرد ملء نموذج.
الدرجة الخامسة: الفك المقيّد/المستند إلى قواعد نحوية تُشغّله بنفسك. مع النماذج مفتوحة الأوزان تتحكم في المُفكك، فيمكنك إخفاء logits الـ tokens في كل خطوة لمنع أي token يخترق القواعد النحوية. هذه الضمانة الأقوى — النموذج لا يستطيع حرفيًا إصدار token غير صالح — وهي الأكثر تعقيدًا.
معظم كود الإنتاج ينبغي أن يقع في الدرجة الثالثة أو الرابعة باستخدام مزوّد مستضاف. دعني أريك كيف يبدو كل منهما.
الدرجة الثالثة: الإخراج المنظم المقيّد بالـ schema
أصبحت المزودات الثلاثة الكبرى توفّر هذا الآن، والشكل متشابه بما يكفي لتتعلمه مرة واحدة.
يستخدم OpenAI الخاصية response_format مع json_schema وstrict: true. علامة strict هي ما يحوّل "من فضلك" إلى "مضمون". متطلباتها محددة وسهلة الإخفاق: additionalProperties يجب أن يكون false على كل كائن، وكل خاصية يجب أن تظهر في مصفوفة required. الحقول الاختيارية تُعبَّر عنها كاتحاد مع null، لا بحذفها (دليل Structured Outputs في OpenAI).
أضافت Anthropic الميزة ذاتها إلى واجهة Claude البرمجية — أصبحت متاحة عمومًا الآن، ولم تعد تحتاج إلى header التجريبي (structured-outputs-2025-11-13). تمرّر output_config.format لجسم الاستجابة JSON، وتضع strict: true على تعريفات الأدوات لضمان صحة مدخلاتها. مجموعة الـ schema الفرعية بنفس نكهة OpenAI: additionalProperties: false على كل كائن، وكل خاصية مدرجة في required (وثائق Structured Outputs في Anthropic).
يفعل Gemini من Google ذلك عبر إعداد التوليد: اضبط responseMimeType على application/json ومرّر الـ schema كـresponseSchema. تتيح لك SDK هاتها تعريف الـ schema بـPydantic في Python أو Zod في TypeScript بدلًا من كتابة JSON Schema يدويًا (وثائق Structured Output في Gemini).
إليك شكل TypeScript مع OpenAI. أعرّف الـ schema في Zod وأحوّله، لأن صيانة JSON Schema يدويًا هي الطريقة التي يتباعد فيها الـ schema عن أنواعك.
import OpenAI from "openai";
import * as z from "zod";
const Ticket = z.object({
priority: z.enum(["low", "medium", "high", "urgent"]),
category: z.enum(["billing", "bug", "feature", "other"]),
summary: z.string().max(280),
needs_human: z.boolean(),
});
const client = new OpenAI();
const res = await client.chat.completions.create({
model: "gpt-4o-2024-08-06",
messages: [
{ role: "system", content: "Classify the support ticket." },
{ role: "user", content: ticketText },
],
response_format: {
type: "json_schema",
json_schema: {
name: "ticket",
strict: true,
schema: z.toJSONSchema(Ticket),
},
},
});
const parsed = Ticket.parse(JSON.parse(res.choices[0].message.content));
z.toJSONSchema() مدمجة في Zod 4 وتستهدف Draft 2020-12 افتراضيًا (وثائق Zod JSON Schema). لاحظ أن حقلَي priority وcategory enums — مع وضع strict المفعّل، لا يمكن للنموذج إرجاع "critical" حتى لو أراد، لأن هذا الـ token ليس ضمن المجموعة المسموح بها. هذه الخاصية الواحدة تقضي على فئة كاملة من الأخطاء.
ما لا يحميك منه القيد: يضمن الشكل لا المعنى. نموذج مقيّد سيضع بكل سرور فئة حقيقية على تذكرة فهمها خطأ. لذلك ما زلت أُشغّل Ticket.parse() في النهاية — جزئيًا للحماية من انجراف الـ schema بين أنواعي وتنسيق السلك، وجزئيًا لأنني أضيف أحيانًا فحوصات دلالية لا يستطيع JSON Schema التعبير عنها.
الدرجة الرابعة: استدعاء الأدوات، ومتى تُفضّله
استدعاء الأدوات هو نفس القيد موجَّهًا نحو هدف مختلف. بدلًا من "أكمل جسم الاستجابة هذا"، هو "إذا استدعيت أداةً، يجب أن تتطابق معطياتك مع schema." الاستخدام الصارم للأدوات في Anthropic يجعل هذا الضمان صريحًا: مع strict: true، "تتطابق مدخلات أدوات Claude تمامًا مع schema" (وثائق Structured Outputs في Anthropic).
سبب اختيار الأدوات على استجابة منظمة عادية هو الاختيار. حين يختار النموذج بين search_orders وissue_refund وescalate، لكل منها شكل معطيات مختلف، تريد استدعاء الوظائف — الاتحاد التمييزي لأي أداة، مضافًا إليه المعطيات المُتحقَّق منها لتلك الأداة، هو بالضبط عنصر حلقة الوكيل البدائي. حين تحتاج فقط إلى شكل ثابت من استدعاء واحد، جسم الاستجابة المنظمة أبسط وتتجنب مراسم استدعاء الأداة.
مخطط عملي للحلقة بأسلوب TypeScript:
// model returns tool_calls; each has a name + JSON arguments
for (const call of message.tool_calls ?? []) {
const tool = tools[call.function.name];
if (!tool) throw new Error(`hallucinated tool: ${call.function.name}`);
// strict mode guarantees this parses to the tool's shape,
// but validate anyway — providers differ, and you own the contract
const args = tool.schema.parse(JSON.parse(call.function.arguments));
const result = await tool.run(args);
// feed result back into the conversation, continue the loop
}
أبقي على حارس hallucinated tool حتى مع وضع strict، لأن مجموعة الأدوات الموجودة ومجموعة المعطيات الصالحة لكل أداة تُطبَّق من أجزاء مختلفة من الـ stack، ولا أريد افتراض أن كلتيهما محكمتا الإغلاق لدى كل مزوّد.
الدرجة الخامسة: الفك المقيّد الذي تُشغّله بنفسك
حين تُشغّل نموذجًا مفتوح الأوزان، تمتلك المُفكك، مما يعني أنك تستطيع تطبيق القواعد النحوية على مستوى الـ token: في كل خطوة، أخفِ أي token قد يجعل الإخراج الجزئي غير قابل للتحليل وفق schema. لا يمكن للنموذج إنتاج JSON غير صالح لأن الـ tokens غير الصالحة ليست على القائمة أبدًا.
المكتبة التي يبدأ بها معظم الناس هي Outlines، التي تدعم التعابير النمطية وJSON Schema والقواعد النحوية الحرة من السياق بصيغة EBNF. هذه قدرة حقيقية بتكلفة حقيقية: نهج آلة الحالة المحدودة المبكرة قد تقضي وقتًا طويلًا في تجميع schemas معقدة، ووجد معيار JSON-schema أن مهل التجميع أضرّت بمعدل التوافق لديها مع الـ schemas الثقيلة. الـ backend الذي انتقلت إليه معظم stacks الخدمة عالية الإنتاجية هو XGrammar — backend التوليد المنظم الافتراضي في محركات مثل vLLM وSGLang، مع عبء منخفض جدًا لكل token (XGrammar). إذا كنت تستضيف ذاتيًا والإخراج المنظم على المسار الساخن، فهذا هو الوجهة التي ذهب إليها النظام البيئي.
تحذير يستحق المعرفة قبل اللجوء إليه: قد تتفاعل القيود النحوية الصارمة تفاعلًا سيئًا مع التفكير. ثمة أعمال منشورة تحتجّ بأن فرض البنية مبكرًا قد يضر جودة إجابة النموذج الفعلية مقارنةً بتركه يفكر نثرًا ثم يُنظّم لاحقًا — ما يُعرف بضريبة المحاذاة للفك المقيّد (arXiv). القراءة العملية: قيّد الإخراج، لكن أعطِ النموذج مساحة للتفكير أولًا إن كانت المهمة صعبة. لا تُجبر النموذج على التفكير بـJSON.
أوضاع الإخفاق، وكيف تتصدى لها فعليًا
القيد يتعامل مع الشكل. هذه هي الأشياء التي لا يتعامل معها، وهي ما يُيقظك في الليل.
الاقتطاع. الأكثر شيوعًا. يبلغ النموذج حد الـ tokens في منتصف الكائن فتحصل على نصف مستند JSON لا يستطيع أي قيد إنقاذه — كان صالحًا حتى توقف. الدفاعات: اضبط max_tokens بسخاء للحالة الأسوأ في الـ schema، وعند فشل التحليل تحقق مما إذا كانت الاستجابة قد توقفت بسبب الطول قبل الاستسلام للإعادة العمياء. إعادة محاولة الاقتطاع بنفس الحد مجرد حرق للمال.
انجراف الـ schema. يخرج الشكل المتوقع في كودك والـ schema الذي أرسلته للنموذج عن التزامن — عادةً لأن أحدهم عدّل JSON Schema يدويًا. الحل ألا تكتبه يدويًا قط: اشتق JSON Schema من نفس نوع Zod أو Pydantic الذي تُحلّل به، بحيث تكون هناك مصدر وحيد للحقيقة. Instructor يعتمد على هذا بالضبط، يُغلّف العميل بحيث يُعرّف نموذج Pydantic الـ schema ويُتحقق من الاستجابة ويُعيد المحاولة عند الإخفاق — كل ذلك في كائن واحد.
قيم الـ enum المُخترَعة. نموذج في وضع prompt-and-pray يخترع "critical" حين يكون الـ enum low|medium|high. الإخراج المنظم الصارم يقضي على هذا تمامًا في الدرجة الثالثة — الـ token غير الصالح غير قابل للوصول. إذا لم تكن في وضع مقيّد، يحوّله parser مُتحقِّق إلى خطأ قابل للالتقاط بدلًا من مفاجأة لاحقة.
الـ over-nesting وإعادة التسمية الصامتة. يُعيد النموذج البيانات الصحيحة تحت customer_name حين طلبت name، أو يلف كل شيء في طبقة إضافية { "result": ... }. الخاصية additionalProperties: false مضافًا إليها قائمة required شاملة هي ما يمنع النموذج من إضافة الحقول أو إعادة تسميتها؛ هذا بالضبط سبب جعل OpenAI وAnthropic هذين القيدين إلزاميين في وضع strict.
حلقة الإصلاح تجمع كل هذا. حين تفشل التحقق، لا تُعد المحاولة بنفس الاستدعاء — أعد تغذية الخطأ:
import instructor
from pydantic import BaseModel
client = instructor.from_provider("openai/gpt-4o")
class Ticket(BaseModel):
priority: str
summary: str
# on a validation error, Instructor sends the Pydantic error message
# back to the model and asks it to fix the specific field — then re-validates
ticket = client.chat.completions.create(
response_model=Ticket,
max_retries=2,
messages=[{"role": "user", "content": ticket_text}],
)
إظهار النموذج لخطأ التحقق المحدد (يجب أن تكون priority إحدى القيم: low أو medium أو high) أكثر فاعلية بكثير من الإعادة العمياء، لأنك أخبرته بالضبط ماذا يُصلح. لكن قيّد المحاولات — اثنتان تكفيان. نموذج يفشل التحقق ثلاث مرات متتالية يفشل عادةً لسبب لن تحله محاولات أكثر، وتريد التدهور بأناقة: أعد خطأً مكتوبًا، ارجع إلى قيمة افتراضية، أو وجّه إلى إنسان. التكرار إلى ما لا نهاية على مدخل مسموم هو ما أوجد تنبيهي في الساعة الثانية فجرًا.
ما أختاره فعليًا، حسب حالة الاستخدام
لـاستخراج البيانات أو التصنيف مع نموذج مستضاف — الحالة الشائعة — استخدم وضع schema المقيّد الصارم لدى مزوّدك، اشتق الـ schema من Zod أو Pydantic، وتحقق من النتيجة بالنوع ذاته. هذه درجة ثلاثة مضافًا إليها parser متحقق، وتغطي معظم ما يبنيه الناس.
لـالوكلاء الذين يختارون إجراءات، استخدم استدعاء الأدوات الصارم. شكل أي-أداة-مضافًا-إليه-معطيات-مُتحقَّق-منها هو ما تريده، وعلامة strict تسد فجوة التحقق من المعطيات. احتفظ بحارس لأسماء الأدوات التي لا تعرفها.
لـالنماذج مفتوحة الأوزان المستضافة ذاتيًا على مسار ساخن، استخدم الفك المقيّد نحويًا عبر محرك خدمتك — backends من فئة XGrammar سريعة الآن بما يكفي لدرجة أنه لا يوجد سبب يُذكر لبنائه يدويًا. دع النموذج يستدل نثرًا أولًا إذا كانت المهمة صعبة، ثم قيّد الجزء المنظم.
وبغض النظر عن الدرجة: تحقق عند الحدود بـ schema تملكه، واحتفظ بحلقة إصلاح محدودة مع تدهور أنيق. القيد يمنع النموذج من إصدار قمامة. المُتحقِّق يمنع افتراضاتك من أن تكون القمامة. تحسّنت كلٌّ من خطي وقيلولتي حين توقفت عن الثقة بالـ prompt وبدأت في إنفاذ العقد.
