با pydantic-ai نسخه‌ی 2.51.0 می‌توانید ایجنتی بنویسید که خروجی‌اش یک مدل پایتون با قید است، نه یک رشته‌ی آزاد. در این نوشته یک ایجنت لجستیک واقعی می‌سازیم، سه اجرای واقعی از آن می‌گیریم و دقیقاً می‌بینیم چه اتفاقی می‌افتد وقتی مدل قید را می‌شکند. عددها و متن خطاها در این پست خروجی همین کد روی همین ماشین است، نه بازنویسیِ آن.

رشته‌ی آزاد، بدهی پنهان هر ایجنت

بیشتر ایجنت‌ها امروز متن برمی‌گردانند. متن را می‌خوانید، با json.loads تلاش می‌کنید، اگر نشد یک چندخطی الگوریتم برای بیرون‌کشیدن JSON می‌نویسید، و بعد امید دارید بقیه‌اش درست باشد. این امید در یک نشست واقعی، یک روز، به یک ساعت وقت تبدیل می‌شود که صرف این می‌کنید بفهمید چرا ستون درست خالی است.

مشکل این نیست که مدل بد جواب می‌دهد. مشکل این است که قراردادی که بین شما و مدل بسته می‌شود، در هیچ‌جا نوشته نشده است. مدل یک رشته می‌دهد و شما امید دارید رشته، همان چیزی باشد که فردا نیاز دارید. این امید، در یک کد ۲۰ خطی، پرهزینه‌ترین واژه‌ی ممکن است.

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

# نصب بسته‌ی کتابخانه و مدل اعتبارسنجی:
$ pip install "pydantic-ai==2.51.0" pydantic
# نسخه‌ی نصب‌شده روی این ماشین: pydantic-ai 2.51.0 و pydantic 2.13.5

# ذخیره به نام logistics.py و اجرا:
$ python3 logistics.py

from typing import Literal
from typing_extensions import Annotated
from pydantic import BaseModel, Field, ValidationError
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel


class Parcel(BaseModel):
    # قید الگو: هشت رقم بعد از حروف IR
    tracking: Annotated[str, Field(pattern=r"^IR\d{8}$")]
    # بازه‌ی عددی: نه صفر، نه بیشتر از هفتاد کیلوگرم
    weight_kg: Annotated[float, Field(ge=0.1, le=70.0)]
    # سه مقدار مجاز، نه هر رشته‌ی دلخواه
    service: Literal["standard", "express", "overnight"]


class Route(BaseModel):
    parcels: Annotated[list[Parcel], Field(min_length=1, max_length=6)]
    zone: Literal["north", "south", "center"]
    insured_value: Annotated[int, Field(ge=0)]


agent = Agent(
    TestModel(),              # مدل تست: بدون تماس شبکه
    output_type=Route,        # این یک خط، کل نوشته است
    system_prompt="تو یک برنامه‌ریز لجستیک هستی.",
)

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

اجرای نخست: مدل تست، با قید واقعی

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

$ python3 logistics.py

=== اجرای ۱: مدل تست، خروجی تایپ‌دار ===
UnexpectedModelBehavior : Exceeded maximum output retries (1)

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

آنچه گرفتید، یک خطای اعتبارسنجی بود که قبل از رسیدن داده به شما، در داخل هارنس متوقف شد. این تفاوت ظریف اما تعیین‌کننده است: در الگوی رشته‌ی آزاد، همین داده به دست شما می‌رسید و آنجا یا خطا می‌داد یا بدتر، بی‌صدا رد می‌شد. همین ترتیب در مشخصات نسخه‌ی ۲۰۲۶-۰۷-۲۸ هم قراردادی است: آرگومان نادرست پیش از رسیدن به کد شما روی اسکیمای ورودی رد می‌شود.

اجرای دوم: وقتی قید شکسته می‌شود

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

=== اجرای ۲: ورودی که قرارداد را می‌شکند ===
وزن بالای سقف: less_than_equal -> Input should be less than or equal to 70
کد رهگیری کوتاه: string_pattern_mismatch -> String should match pattern '^IR\d{8}$'
سرویس ناشناخته: literal_error -> Input should be 'standard', 'express' or 'overnight'

=== اجرای ۳: فهرست مرسله‌ی خالی ===
too_short -> List should have at least 1 item after validation, not 0

به متن هر خطا دقت کنید، چون این متن‌ها قرار است به خودِ مدل برگردند و کاری هستند که مدل از آن‌ها یاد می‌گیرد. هر خطا دو تکه دارد: یک شناسه‌ی ماشین‌خوان مثل less_than_equal و یک جمله‌ی خوانا. جمله‌ی خوانا همان چیزی است که مدل می‌بیند، و همان چیزی است که تلاش می‌کند در نوبت بعد رعایت کند. همین تفکیک در راهنمای خطای سرور هم تکرار شده است: کد خطا به‌جای عدد خام از بسته‌ی mcp-types گرفته می‌شود، و پرسشی که آن راهنما برای انتخاب می‌دهد دقیقاً همین است: آیا یک مدل هوشمندتر می‌توانست از این خطا پرهیز کند؟

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

from pydantic import ValidationError

# وزن نود و دو کیلوگرمی از سقف هفتاد رد می‌شود
try:
    Parcel(tracking="IR12345678", weight_kg=92.0, service="standard")
except ValidationError as e:
    first = e.errors()[0]
    print(first["type"], "->", first["msg"])

# خروجی واقعی همین بلوک روی همین ماشین:
# less_than_equal -> Input should be less than or equal to 70

سه اجرا و آنچه از آن‌ها می‌فهمیم

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

اجراورودینتیجهخطای شناسایی‌شده
یکمدل تست با فیلدهای جانشینمتوقف شدالگوی کد رهگیری نقض شد
دووزن ۹۲ کیلوگرمرد شدless_than_equal
سهفهرست خالیرد شدtoo_short
چهاربدون قید، همان پرسش۲۳ نویسه متنهیچ

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

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

در پیکربندی پیش‌فرض، سقف تلاش یکی است. این عدد را کم و زیاد نکنید بی‌دلیل. زیادش یعنی یک مرسله‌ی غیرممکن، پنج نوبت کامل و پنج برابر هزینه؛ کم و یعنی ایجنتی که با یک خطای ساده می‌میرد و شما نمی‌فهمید چرا.

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

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

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

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

چه چیزی را تایپ‌دار کنیم

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

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

دو چیز را فراموش نکنید. نخست، برای هر قید پیام خطای خوانا بنویسید، چون همین متن به مدل برمی‌گردد. دوم، سقف تلاش را آگاهانه انتخاب کنید و بدانید هر تلاش اضافه یک نوبت کامل هزینه دارد.

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

منابع

  1. مستندات رسمی pydantic-ai درباره‌ی output_type، مدل‌های خروجی و ابزارهای ساخت‌یافته
  2. مستندات Pydantic درباره‌ی قیدهای Field، بازه‌های عددی، الگوها و انواع Literal
  3. مرجع مدل تست در pydantic-ai، شامل رفتار آن با قیدهای عددی و الگویی
  4. راهنمای خطای سرور MCP — خطایی که مدل می‌بیند در برابر خطایی که پروتکل می‌بیند
  5. بسته‌ی mcp-types — کدهای خطا به‌جای عدد خام
  6. مشخصات نسخه‌ی ۲۰۲۶-۰۷-۲۸ — تعریف ابزار، اسکیمای ورودی و گزارش خطا
  7. بسته‌ی mcp در رجیستری — نسخه‌ی پایدار و وابستگی اعتبارسنجی
  8. تغییرات نسخه‌ی دو — بازسازی موتور و ابزار یکپارچه
  9. مرجع دستورهای افزونه — بررسی نحو و اسکیما پیش از سنجش رفتار
  10. نوشته‌ی خروجی ابزار و سطح اقتدار از همین مجموعه — چرا محتوای کنترل‌نشده از تاریخچه بیرون می‌ماند
  11. نوشته‌ی گرفتن تایید از ایجنت از همین مجموعه — جایی که توقف واقعی اتفاق می‌افتد
  12. نوشته‌ی لایه‌ی مجوز ایجنت از همین مجموعه — عددهای یک بازبین خودکار
  13. نوشته‌ی ارزیابی سرور MCP از همین مجموعه — جایی که اسکیمای ابزار هزینه دارد