با Pydantic AI می‌توانید ایجنت خود را بدون یک کلید API و بدون شبکه تست کنید: دو مدل داخلی به نام TestModel و FunctionModel جای مدل واقعی را می‌گیرند، ALLOW_MODEL_REQUESTS=False جلوی هر درخواست واقعی را می‌گیرد، و یک تست pytest در حدود ۱٫۵ ثانیه مسیر ابزار و اعتبارسنجی خروجی را می‌سنجد. در این پست یک ایجنت صورتحساب می‌سازیم و چهار تست می‌نویسیم که همگی سبز می‌شوند، از جمله یک اثبات منفی که نشان می‌دهد قفل ایمنی واقعا جلوی خرج کردن پول را می‌گیرد.

ایجنت را با mock دستی تست نکنید

تست دستی یعنی سه تا پنج نمونه را در پنجره‌ی چت بیندازید و با چشم داوری کنید. این روش تا بیست مورد جواب می‌دهد و در بیست‌ویکم مورد شکست می‌خورد، چون حافظه‌ی شما در همان لحظه‌ای که بهترین حالت را می‌بینید عوض می‌شود [1].

pydantic-ai یک ریپوی MIT با ۲۰٬۴۷۷ ستاره در لحظه‌ی خواندن این پست است که دقیقا همین لایه را مکانیزه می‌کند: تست ایجنت در آن یک تست معمولی پایتون است و هیچ چارچوب خاصی لازم ندارد [1][6].

نکته‌ای که این ریپو را از یک SDK معمولی جدا می‌کند، مدل‌های تستی‌اش است. این‌ها هوش مصنوعی نیستند؛ کد رویه‌ای پایتون‌اند که فقط سعی می‌کنند داده‌ای بسازند که در چارچون JSON ابزار صدق کند [1].

تفاوت با پست اتصال هارنس pi به مدل محلی و پست حلقه‌ی ابزار Mastra بدون کلید این است که آن دو یک حلقه‌ی کامل را با یک مدل ساختگی می‌سازند، و این‌جا بحث فقط از لایه‌ی تست است.

نصب و فایلی که قرار است تست شود

نصب در یک پوشه‌ی خالی و بدون ساختن حساب کاربری انجام می‌شود. نسخه‌ای که در این پست نصب شد 2.51.0 است؛ آخرین نسخه‌ی منتشرشده در لحظه‌ی خواندن این پست 2.54.0 است که ۳ اکتبر ۲۰۲۶ روی PyPI قرار گرفته [5][7].

# یک پوشه‌ی تازه بساز و فقط داخل همان پوشه نصب کن
$ mkdir pai && cd pai
$ python3 -m venv .venv && . .venv/bin/activate
$ pip install pydantic-ai==2.51.0 pytest
$ python3 -c "import pydantic_ai; print(pydantic_ai.__version__)"
2.51.0

عدد ۳۴۴ یعنی این بسته از ۲۰ مه ۲۰۲۴ تا لحظه‌ی خواندن این پست ۳۴۴ نسخه روی PyPI داشته است [7]. اگر پروژه‌ی شما هنوز روی پایتون ۳٫۹ است، این مسیر نیست: بسته به نسخه‌ی ۳٫۱۰ به بالا نیاز دارد [5][7].

حالا اپلیکیشنی که می‌خواهیم تست کنیم. نکته‌ی مهم این است که مدل پیش‌فرض را "test" می‌گذاریم، نه نام یک ارائه‌دهنده؛ وگرنه ایجنت در لحظه‌ی import به کلید API نیاز پیدا می‌کند، حتی وقتی هر تست بعدا مدل را جایگزین می‌کند.

from pydantic import BaseModel
from pydantic_ai import Agent, RunContext

CATALOG = {"api": 3, "web": 5, "worker": 8}   # قیمت هر نسخه به دلار


class Plan(BaseModel):
    """ساختاری که ایجنت باید در پایان برگرداند."""
    service: str
    replicas: int
    cost_usd: float


billing_agent = Agent(
    "test",                      # مدل پیش‌فرض: بی‌شبکه و بی‌هزینه
    deps_type=str,
    output_type=Plan,
    instructions=(
        "قیمت هر نسخه را با ابزار price_of بگیر و تعداد نسخه را "
        "از بودجه‌ی کاربر کم کن تا از بودجه بیشتر نشود."
    ),
)


@billing_agent.tool
async def price_of(ctx: RunContext[str], service: str) -> int:
    """قیمت هر نسخه برای یک سرویس را برمی‌گرداند."""
    if service not in CATALOG:
        raise ValueError(f"سرویس ناشناخته: {service}")
    return CATALOG[service]

اینجا یک دام واقعی وجود دارد که من هم در آن افتادم. اگر به‌جای @billing_agent.tool از @billing_agent.tool_plain استفاده کنید و پارامتر ctx را در امضا نگه دارید، کتابخانه در لحظه‌ی import خطا می‌دهد: RunContext annotations can only be used with tools that take context. یعنی دکوراتور plain با پارامتر زمینه جمع نمی‌شود و باید یکی را انتخاب کنید، نه هر دو [4].

خروجی ساخت‌یافته هم یک کلاس Pydantic ساده است که همان در output_type داده می‌شود [8]. نوع Plan باعث می‌شود نتیجه‌ی ایجنت به‌جای متن آزاد، یک شیء با فیلدهای تضمین‌شده باشد.

تست با TestModel: مسیر را می‌سنجد، نه آرگومان را

TestModel به‌طور پیش‌فرض همه‌ی ابزارهای ایجنت را صدا می‌زند، بعد یا پاسخ متنی می‌دهد یا یک خروجی ساخت‌یافته مطابق چارچون [1][2].

import pytest
from pydantic_ai import models
from pydantic_ai.models.test import TestModel
from billing_app import billing_agent

# قفل ایمنی: هر فراخوانی واقعی مدل در تست خطا می‌دهد
models.ALLOW_MODEL_REQUESTS = False

pytestmark = pytest.mark.anyio


@pytest.fixture
def anyio_backend():
    return "asyncio"


async def test_tools_and_schema():
    # فقط plan_deploy را صدا می‌زنیم تا داده‌ی ساختگی price_of وارد مسیر نشود
    with billing_agent.override(
        model=TestModel(call_tools=["plan_deploy"]), deps="acct-7"
    ):
        result = await billing_agent.run("api را با 2 نسخه بیاور", deps="acct-7")

    called = [
        part.tool_name
        for msg in result.all_messages()
        for part in msg.parts
        if part.part_kind == "tool-call"
    ]
    print("ابزارهای صدا زده‌شده:", called)
    assert called == ["plan_deploy", "final_result"]

پارامتر call_tools نکته‌ی ظریفی است: مقدار پیش‌فرض "all" است و یعنی هر دو ابزار صدا زده می‌شوند [2]. برای همین آن را به یک فهرست محدود کردیم.

و final_result یک ابزار واقعی نیست؛ ابزار داخلی خود pydantic-ai برای برگرداندن خروجی ساخت‌یافته است و همیشه در فهرست فراخوانی‌ها دیده می‌شود. اگر انتظار داشتید فقط نام ابزارهای خودتان را ببینید، تست‌تان با همین یک عضو اضافه رد می‌شود.

اجازه دهید همان تست قبلی را با مقدار پیش‌فرض TestModel() اجرا کنیم، یعنی هر دو ابزار صدا زده شوند. این بار آرگومان ابزار price_of می‌شود "a" و ابزار خطا می‌دهد:

async def test_all_tools_with_garbage_args():
    with billing_agent.override(model=TestModel(), deps="acct-7"):
        with pytest.raises(Exception) as exc:
            await billing_agent.run("api را با 2 نسخه بیاور", deps="acct-7")
    print("نوع خطا:", type(exc.value).__name__)
    assert "سرویس ناشناخته" in str(exc.value)

این شکست یک پیام است، نه یک باگ. TestModel برای هر رشته یک "a" می‌سازد تا در اعتبارسنجی چارچون گیر نکند [1]. اگر ابزار شما بررسی دامنه دارد، تست پیش‌فرض شکست می‌خورد.

درس عملی این است که تستی که فقط می‌پرسد «آیا ابزار صدا زده شد؟» ارزش کمی دارد، چون آرگومان‌های نامعتبر را هم می‌پذیرد. برای دیدن آرگومان واقعی باید به FunctionModel برویم.

تست سوم: آرگومان واقعی با FunctionModel

FunctionModel به‌جای مدل، یک تابع پایتون شما را صدا می‌زند و آن تابع خودش تصمیم می‌گیرد مدل چه بگوید. تابع هم پیام‌های اجرا را می‌بیند و هم AgentInfo را [3].

import re
from pydantic_ai import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import FunctionModel, AgentInfo


def call_our_model(messages: list, info: AgentInfo) -> ModelResponse:
    """به جای LLM، خودمان تصمیم می‌گیریم مدل چه بگوید."""
    if len(messages) == 1:
        prompt = messages[0].parts[-1].content
        service = re.search(r"(api|web|worker)", prompt).group(1)
        return ModelResponse(parts=[ToolCallPart("price_of", {"service": service})])
    price = int(messages[-1].parts[0].content)
    return ModelResponse(
        parts=[TextPart(f'{{"service": "api", "replicas": 2, "cost_usd": {price * 2}}}')]
    )


async def test_real_arguments():
    with billing_agent.override(model=FunctionModel(call_our_model), deps="acct-7"):
        result = await billing_agent.run("قیمت 2 نسخه‌ی api چقدر است؟", deps="acct-7")

    returns = [
        part.content
        for msg in result.all_messages()
        for part in msg.parts
        if part.part_kind == "tool-return"
    ]
    print("نتیجه‌ی price_of:", returns)
    print("طرح نهایی:", result.output)
    assert returns == [3]
    assert result.output.cost_usd == 6.0

این تنها تستی است که ارقام را واقعا می‌سنجد: قیمت api برابر ۳ است، پس دو نسخه ۶ دلار می‌شود و assert result.output.cost_usd == 6.0 با حساب کتابخانه می‌خواند، نه با عددی که خودمان در تست نوشته‌ایم.

الگوی ساخت پاسخ ساده است: وقتی فقط یک پیام در messages است یعنی هنوز ابزاری صدا نشده و باید ToolCallPart برگردانیم؛ در غیر این صورت آخرین پیام نتیجه‌ی ابزار است و باید متن نهایی را بدهیم [3].

تست چهارم: قفل ایمنی را خودتان امتحان کنید

مهم‌ترین تست این پست اثبات منفی است. تا این‌جا فرض کردیم قفل کار می‌کند؛ حالا ثابت می‌کنیم که یک درخواست واقعی مدل واقعا خطا می‌دهد.

async def test_the_lock_blocks_a_real_request():
    """اثبات منفی: قفل ایمنی جلوی رفتن به شبکه را می‌گیرد."""
    from pydantic_ai import Agent

    real_agent = Agent("openai:gpt-4o-mini")
    with pytest.raises(Exception) as exc:
        await real_agent.run("سلام")
    print("متن خطا:", exc)
    assert "ALLOW_MODEL_REQUESTS" in str(exc.value)

خروجی واقعی اجرای هر چهار تست روی پایتون ۳٫۱۴ و pydantic-ai نسخه‌ی 2.51.0:

$ OPENAI_API_KEY=sk-not-real python3 -m pytest test_billing.py -s -q
ابزارهای صدا زده‌شده: ['plan_deploy', 'final_result']
خروجی ساخت‌یافته: service='a' replicas=0 cost_usd=0.0
نوع خطا: ValueError
نتیجه‌ی price_of: [3]
طرح نهایی: service='api' replicas=2 cost_usd=6.0
متن خطا: RuntimeError('Model requests are not allowed, since ALLOW_MODEL_REQUESTS is False')
4 passed in 1.53s

متغیر OPENAI_API_KEY در آن اجرا فقط یک کلید ساختگی بود تا سازنده‌ی ایجنت بتواند بدون خطای زودهنگام بسازد. اثبات اینکه قفل کار می‌کند، خود پیام خطاست که نام متغیر را می‌آورد [1].

کارچه چیزی را ثابت می‌کندزمان اجرا
کل تست‌ها با FunctionModelآرگومان واقعی ابزار و حساب درست۱٫۵ ثانیه
تست با TestModelفقط مسیر فراخوانی و اعتبارسنجی چارچونکمتر از ۰٫۱ ثانیه
تست شکست TestModelبررسی دامنه در ابزار کار می‌کندکمتر از ۰٫۱ ثانیه
اثبات منفی قفلدرخواست واقعی به شبکه نمی‌رودکمتر از ۰٫۱ ثانیه

کل چهار تست در ۱٫۵۳ ثانیه تمام شد و هیچ درخواستی به شبکه نرفت [1][3]. اگر همین تست‌ها با یک مدل واقعی اجرا می‌شدند، به کلید، اعتبار و چند ثانیه انتظار برای هر اجرا نیاز داشتند.

جمع‌بندی: چه چیزی را از این پست بردارید

مدل تستی خودِ کتابخانه نیست؛ یک جایگزین برای مدل است که در پروسه‌ی تست از راه override تزریق می‌شود و به همین دلیل نیازی به تغییر در کد تولید ندارد [1].

  1. مدل پیش‌فرض ایجنت را روی "test" بگذارید تا import بدون کلید API ممکن شود.
  2. models.ALLOW_MODEL_REQUESTS = False را در فایل تست بگذارید تا یک فراموشی به هزینه تبدیل نشود.
  3. از TestModel برای مسیر و چارچون استفاده کنید و از FunctionModel برای آرگومان و عدد واقعی.
  4. نام final_result را در انتظار فراخوانی‌های خودتان حساب کنید.

همین الگو را در تست قطعی ایجنت در SDK ایجنت‌های OpenAI به‌کار برده‌ایم؛ آنجا مدل شبیه‌سازی‌شده یک اسکریپت بیرونی بود و این‌جا یک تابع پایتون درون‌فرایندی. هر دو یک کار می‌کنند: هزینه و تکرارپذیری را صفر می‌کنند.

منابع

  1. مستندات تست واحد در Pydantic AI
  2. مرجع API مدل TestModel
  3. مرجع API مدل FunctionModel
  4. ابزارهای تابعی در Pydantic AI
  5. نصب Pydantic AI و نسخه‌های slim
  6. ریپوی pydantic-ai روی گیت‌هاب
  7. بسته‌ی pydantic-ai روی PyPI
  8. مفهوم ایجنت و خروجی ساخت‌یافته