اگر میخواهید ایجنت خود را بدون هزینهی API آزمایش کنید، Mastra یک راه رسمی دارد: مدل ساختگی خودش در مسیر @mastra/core/test-utils/llm-mock که بهجای تماس با شبکه، پاسخهایی را که به آن میدهید برمیگرداند. در این نوشته همان ایجنت هواشناسی را با openai/gpt-5.6-sol میسازیم، سپس بدون کلید اجرا میکنیم و یک حلقهی ابزار واقعی را میبینیم. در پایان به یک تلهی واقعی در همین mock میرسیم که هر تستی را بیسروصدا خراب میکند.
اول یک ایجنت واقعی، بعد کلید
اول کدی مینویسیم که با کلید واقعی کار کند، بعد همان کد را با مدل ساختگی اجرا میکنیم. ترتیب برعکس، تستی میسازد که فقط با مدل ساختگی کار میکند و در پروداکشن میشکند.
نصب روی این ماشین با @mastra/core@1.74.0 و zod@4.6.5 انجام شد: ۱۵۲ بسته، ۱۱ ثانیه، صفر آسیبپذیری. لحظهی خواندن: ۴ اکتبر ۱۴۰۵.
همین نسخه در ۱ اکتبر ۱۴۰۵ در رجیستری npm منتشر شد. رجیستری از اکتبر ۲۰۲۴ تا حالا ۱۶۴۳ نسخه از این بسته را ثبت کرده است. اگر این بازه را حدود ۱۸ ماه بگیریم، میانگین بیش از ۹۱ نسخه در ماه میشود. پس هر عددی در مستندات را با نسخهی نصبشدهی خودتان بسنجید.
$ npm install @mastra/core@1.74.0 zod@4.6.5
added 152 packages, and audited 153 packages in 11s
found 0 vulnerabilities
mastra-weather@0.0.0
├── @mastra/core@1.74.0
└── zod@4.6.5
پروژه سه فایل زیر است و روی همین ماشین اجرا شده.
ابزار با createTool ساخته میشود و هر دو سمت آن با zod قفل است: اگر ورودی مدل غلط باشد، خطا پیش از اجرای کد شما دیده میشود.
// src/tools/get-temp.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const getTempTool = createTool({
id: "get-temp",
// توضیح، بخشی از پرامپت است: مدل بر اساس همین تصمیم میگیرد ابزار را صدا بزند یا نه.
description: "دمای فعلی یک شهر را برمیگرداند",
// اگر مدل کلید اشتباه بفرستد، اینجا رد میشود و execute صدا زده نمیشود.
inputSchema: z.object({
city: z.string().describe("نام شهر به انگلیسی، مثلا Tehran"),
}),
// خروجی هم اعتبارسنجی میشود، پس شکل جواب مدل ثابت میماند.
outputSchema: z.object({
city: z.string(),
celsius: z.number(),
}),
// امضای درست: execute(ورودی، context). آرگومان دوم اختیاری است.
execute: async ({ city }) => {
// در پروژهی واقعی اینجا یک fetch به API هواشناسی مینشیند.
return { city, celsius: 21 };
},
});
ایجنت مدل را بهصورت رشتهی provider/model میگیرد، نه آبجکت پرواینده. همین تصمیم باعث میشود تعویض مدل در تست به یک خط تغییر تبدیل شود، نه بازنویسی زنجیرهی وابستگی.
// src/agents/weather-agent.ts
import { Agent } from "@mastra/core/agent";
import { getTempTool } from "../tools/get-temp.ts";
export const weatherAgent = new Agent({
// id در نشست و لاگ مینشیند؛ name فقط برای نمایش است.
id: "weather-agent",
name: "Weather Agent",
// دستورها. هرچه اینجا دقیقتر باشد، مدل کمتر ابزار بیربط را صدا میزند.
instructions: `
تو یک دستیار هواشناسی هستی.
برای هر پرسش دما، حتما از get-temp استفاده کن و عدد را همانطور که برگشته نقل کن.
`,
// رشته باعث میشود Mastra خودش OPENAI_API_KEY را از محیط پیدا کند.
model: "openai/gpt-5.6-sol",
// کلید، نام متغیر است؛ مقدار، شیء createTool.
tools: { getTempTool },
});
// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { weatherAgent } from "../agents/weather-agent.ts";
// ثبت ایجنتها تا با id پیدا شوند.
export const mastra = new Mastra({ agents: { weatherAgent } });
اجرای اول: چه چیزی بدون کلید میشکند
حالا همان کد را با مدل واقعی اجرا کنیم. روی این ماشین کلیدی در محیط نیست، پس خطا همان چیزی است که تازهکار میبیند.
$ node --experimental-strip-types run.ts
No `storage` configured on Mastra — falling back to an in-memory store.
[ModelRouter] Failed to resolve supportedUrls for "openai/gpt-5.6-sol".
Error: Could not find API key process.env.OPENAI_API_KEY for model id openai/gpt-5.6-sol
این شکست دقیقاً میگوید کدام متغیر کم است.
دقت کنید خطا اول دربارهی storage است و بعد کلید. آن هشدار در هر اجرا هست، حتی وقتی همهچیز درست است.
اجرای بدون کلید با مدل ساختگی خود فریمورک
راهحل در خود بستهی نصبشده بود. مسیر @mastra/core/test-utils/llm-mock یک ماژول ساختگی میدهد که فهرستی از پاسخهای آماده میگیرد و هر بار که مدل صدا زده شود، یکی را برمیگرداند.
// run-offline.ts
import { Agent } from "@mastra/core/agent";
import { MastraLanguageModelV2Mock } from "@mastra/core/test-utils/llm-mock";
import { getTempTool } from "./src/tools/get-temp.ts";
// مصرفی که هر اجرای ابزار به آن اضافه میشود، تا اجرای واقعی را ببینیم.
const executed = [];
const wrappedTool = {
...getTempTool,
execute: async (input, context) => {
executed.push(input.city);
return getTempTool.execute(input, context);
},
};
const usage = { inputTokens: 1180, outputTokens: 42, totalTokens: 1222 };
const turn = (content, finishReason = "stop") => ({
content, finishReason, usage, warnings: [], request: {}, response: undefined,
});
// نکتهی مهم: input حتما باید رشتهی JSON باشد، نه آبجکت.
// اگر آبجکت بدهید، خطای input.replace is not a function میگیرید.
const toolCall = (city) => ({
type: "tool-call",
toolCallId: "call_1",
toolName: "get-temp",
input: JSON.stringify({ city }),
});
مدل دیگر از رشتهی openai/gpt-5.6-sol خوانده نمیشود، بلکه یک آبجکت ساختگی است. بقیهی کد، از جمله همان ابزار، دستنخورده میماند.
const model = new MastraLanguageModelV2Mock({
provider: "mock",
modelId: "weather-mock",
doGenerate: [
// این عضو هرگز خوانده نمیشود. دلیلش را در بخش بعد میگویم.
turn([{ type: "text", text: "unused" }]),
turn([toolCall("Tehran")], "tool-calls"),
turn([{ type: "text", text: "دمای تهران ۲۱ درجه است." }]),
],
});
const agent = new Agent({
id: "weather-agent",
name: "Weather Agent",
instructions: "برای هر پرسش دما از get-temp استفاده کن.",
model,
tools: { getTempTool: wrappedTool },
});
const response = await agent.generate("هوای تهران چند درجه است؟");
console.log("پاسخ:", response.text);
console.log("شهرهایی که ابزار واقعا اجرا شد:", executed);
console.log("تعداد نوبتهای مدل:", model.doGenerateCalls.length);
$ node --experimental-strip-types run-offline.ts
پاسخ: دمای تهران ۲۱ درجه است.
شهرهایی که ابزار واقعا اجرا شد: [ 'Tehran' ]
تعداد نوبتهای مدل: 2
عدد سوم مهمتر از دو عدد دیگر است. دو یعنی حلقه بسته شد: مدل در نوبت اول ابزار خواست و در نوبت دوم جواب را نوشت. اگر این عدد ۱ بود، مدل هرگز ابزار را صدا نزده و تست شما چیزی نسنجیده است.
تلهی خاموش: عضو صفرم هرگز اجرا نمیشود
در توضیح بالا یک عضو اضافه در آرایه گذاشتم و گفتم هرگز خوانده نمیشود. این حدس نبود؛ از سورس فریمورک در بستهی نصبشده درآمده است:
this.doGenerate = async (options) => {
this.doGenerateCalls.push(options); // اول شمارنده یکی اضافه میشود
if (typeof doGenerate === "function") return doGenerate(options);
else if (Array.isArray(doGenerate)) return doGenerate[this.doGenerateCalls.length];
else return doGenerate;
};
شمارنده پیش از اندیسگذاری یکی اضافه میشود، پس اولین نوبت با doGenerate[1] جواب میگیرد نه doGenerate[0]. عضو صفرم برای همیشه خواندهنشده میماند.
با ترتیب طبیعی، نوبت اول بهجای فراخوانی ابزار متن جواب نهایی را برمیگرداند، حلقه میبندد و executed خالی میماند. تست سبز است و هیچ ابزاری اجرا نشده.
// ترتیب طبیعی: تست سبز میشود ولی هیچ ابزاری اجرا نمیشود
doGenerate: [
turn([toolCall("Tehran")], "tool-calls"), // هرگز خوانده نمیشود
turn([{ type: "text", text: "دمای تهران ۲۱ درجه است." }]),
]
// آرایه برعکس: همان تست، واقعا ابزار را اجرا میکند
doGenerate: [
turn([{ type: "text", text: "دمای تهران ۲۱ درجه است." }]),
turn([toolCall("Tehran")], "tool-calls"),
]
تفاوت این دو آرایه نه در حافظه پیدا میشود و نه در خروجی. هر دو یک متن برمیگردانند. پس تستی که فقط متن را چک کند، هر دو را قبول میکند. تنها راه دیدن تفاوت، شمردن اجرای ابزار است.
تست منفی: دو حالتی که واقعا خراب میشوند
تستی که همیشه سبز میشود هیچ اطلاعاتی نمیدهد. برای همین دو حالت خراب را عمدا ساختم و هر دو در نسخهی 1.74.0 رفتار درستی داشتند.
حالت اول: مدل نام ابزاری را صدا میزند که ایجنت ندارد. هیچ خطایی پرتاب نمیشود، هیچ ابزاری اجرا نمیشود، و نوبت دوم با همان متن ساختگی برمیگردد. اگر تست شما فقط متن نهایی را چک کند، این خرابی را نمیبیند.
// نام ابزار اشتباه در فراخوانی مدل
turn([toolCall("ghost", { city: "Tehran" })], "tool-calls"),
// نتیجه: executed = [] ، بدون خطا ، finishReason: stop
حالت دوم: نام درست است اما ورودی با inputSchema نمیخواند.
[AGENT] Tool input validation failed {
agent: 'Weather Agent',
tool: 'getTempTool',
validationError: 'Tool input validation failed for getTempTool:
- city: Invalid input: expected string, received undefined
Provided arguments: {
"wrong": 1
}'
}
پاسخ درست به این تفاوت، طراحی تست است. خرابی بیصدا را با شمردن اجرا بگیرید، نه با خواندن پیام خطا. با یک شمارندهی اجرا، هر دو خرابی گرفته میشود.
مرز این روش: چه چیزی را اصلا نسنجیدهایم
این حلقهی ابزار واقعی بود، اما دربارهی کیفیت پاسخ مدل چیزی ثابت نمیکند: جواب از پیش نوشته شده بود.
مستندات Mastra چند شناسهی نمونه فهرست کرده، از جمله openai/gpt-5.6-sol و anthropic/claude-sonnet-4-6. عددهای هزینه و کیفیت را خودتان بسنجید.
Mastra هشدار میدهد که storage پیکربندی نشده و به حافظهی موقت میافتد. مستندات میگویند این حافظه با هر ریاستارت پاک میشود و برای پروداکشن مناسب نیست. برای تست محلی بیاشکال است.
| چیزی که فایل اجرا میکند | بدون کلید | با کلید واقعی |
|---|---|---|
| ساخت ایجنت و ابزار | بله | بله |
| اعتبارسنجی ورودی با zod | بله | بله |
| چرخهی فراخوانی ابزار | بله | بله |
| تصمیم مدل برای صدا زدن ابزار | نه | بله |
| کیفیت متن نهایی | نه | بله |
| هزینهی واقعی هر نوبت | نه | بله |
این روش کجا به کار میآید
اگر تیم شما روی منطق ایجنت کار میکند و نه کیفیت متن، این روش معمولا انتخاب درست است. سه جای مشخص:
- در خط لولهی CI که باید بدون کلید و بدون هزینه سبز بماند.
- در تست منطق ابزار، جایی که میخواهید بدانید آیا ابزار اصلا صدا زده میشود.
- در بازبینی کد، جایی که باید مطمئن شوید یک تغییر، مسیر حلقه را نشکسته است.
برای سنجش کیفیت پاسخ، تستهای ارزیابی خود Mastra در مسیر evals میآید. برای هزینهی واقعی هر نوبت هم باید با کلید واقعی اجرا کنید.
در نوشتهی تست ایجنت بدون کلید API همین ایده را در OpenAI Agents SDK دیدهایم، و در بودجهی کانتکست چرا اندازهگیری مصرف از حدس زدن ارزانتر است. تفاوت اینجاست که مدل ساختگی Mastra در خود بستهی اصلی تحویل داده میشود و نصب چیز دیگری لازم ندارد.
منابع
- راهنمای شروع Mastra — ساخت ایجنت، ابزار با createTool و مدل رشتهای
- مخزن mastra-ai/mastra — ۲۸٬۵۴۶ ستاره، آخرین کامیت ۳ اکتبر ۱۴۰۵
- بستهی @mastra/core در npm — اجزای اصلی شامل Agent و Workflows و Tools و Memory
- فهرست نسخههای @mastra/core در npm — شمارش ۱۶۴۳ نسخه و تاریخ انتشار 1.74.0
- مستندات Storage — دامنههای حافظه و اینکه چرا حافظهی موقت برای پروداکشن مناسب نیست
- انتشارهای Mastra — نسخهی 1.74.0 در ۱ اکتبر ۱۴۰۵
- فهرست مدلهای Mastra و نام متغیرهای محیطی پروایندهها
دیدگاهها
۰ موردهنوز دیدگاهی ثبت نشده. اولین نفر باشید.