پرتکرارترین اشتباه در generateText از پکیج ai این است که یک فراخوانی ابزار می‌بینی و فکر می‌کنی ایجنت تمام شد. چیزی نیست: مقدار پیش‌فرض stopWhen همان isStepCount(1) است، یعنی حلقه بعد از نخستین گام بسته می‌شود و متن نهایی خالی می‌ماند. در این نوشته یک ایجنت ابزارمحور را با MockLanguageModelV4 بالا می‌آورم که بدون هیچ کلید API و با ۱۱ بسته اجرا می‌شود، حلقه‌ی دو گامی واقعی را می‌بینی، و سه حالت خراب را بازتولید می‌کنی.

این پکیج دقیقاً چه چیزی هست

پکیج ai همان AI SDK شرکت Vercel است، یک جعبه‌ابزار vercel/ai که رابط تولید متن و فراخوانی ابزار را از نام مدل جدا می‌کند. لحظه‌ی خواندن این نوشته نسخه‌ی نصب‌شده 7.0.124 است با لایسنس Apache-2.0 و وابستگی node >=22. سه وابستگی‌اش هم @ai-sdk/gateway، @ai-sdk/provider و @ai-sdk/provider-utils هستند.

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

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

عددمقدارمنبع
نسخه‌ی نصب‌شده7.0.124خروجی npm
نسخه‌ی zod4.6.5خروجی npm
تعداد بسته‌های نصب‌شده۱۱خروجی npm
حجم node_modules۱۸٫۶ مگابایتدستور du
سقف پیش‌فرض گام۱مرجع generateText
سقف پیش‌فرض ToolLoopAgent۲۰مستندات کنترل حلقه

نصب واقعی و اجرای اول

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

# نصب پکیج اصلی و اسکیمای زود؛ zod وابستگی همتاست نه اختیاری
$ npm init -y
Wrote package.json
$ npm i ai zod
added 11 packages, and audited 12 packages in 2s
1 package is looking for funding
found 0 vulnerabilities

# نسخه‌ی واقعی نصب‌شده، نه آنچه در README نوشته شده
$ node -p "require('./node_modules/ai/package.json').version"
7.0.124
$ node -p "require('./node_modules/zod/package.json').version"
4.6.5

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

حلقه‌ی دو گامی، و عددی که همه چیز به آن بستگی دارد

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

import { generateText, tool, stepCountIs } from "ai";
import { z } from "zod";
import { MockLanguageModelV4 } from "ai/test";

// ساختاردهی مصرف توکن، چون اسکیمای نسخه‌ی چهارم توکن‌ها را تودرتو می‌خواهد
const usage = (inp, out) => ({
  inputTokens: { total: inp, noCache: inp, cacheRead: 0, cacheWrite: 0 },
  outputTokens: { total: out, text: out, reasoning: 0 },
});

// مدل ساختگی دو پاسخ پشت سر هم برمی‌گرداند: اول ابزار، بعد متن
const model = new MockLanguageModelV4({
  doGenerate: [
    {
      content: [{ type: "tool-call", toolCallId: "call_1", toolName: "weather",
                   input: JSON.stringify({ city: "تهران" }) }],
      finishReason: { unified: "tool-calls", raw: "tool_use" },
      usage: usage(42, 18), warnings: [],
    },
    {
      content: [{ type: "text", text: "دمای تهران ۲۱ درجه است." }],
      finishReason: { unified: "stop", raw: "stop" },
      usage: usage(88, 9), warnings: [],
    },
  ],
});

// اسکیمای zod همان دروازه‌ای است که ورودی مدل را می‌سنجد
const weather = tool({
  description: "دمای یک شهر را برمی‌گرداند",
  inputSchema: z.object({ city: z.string() }),
  execute: async ({ city }) => ({ city, tempC: 21 }),
});

const r = await generateText({
  model,
  prompt: "هوای تهران چند درجه است؟",
  tools: { weather },
  stopWhen: stepCountIs(5),
# اجرای همان فایل
$ node post-toolloop.mjs
text: دمای تهران ۲۱ درجه است.
steps: 2
toolResults: [{"type":"tool-result","toolCallId":"call_1","toolName":"weather","input":{"city":"تهران"},"output":{"city":"تهران","tempC":21},"dynamic":false}]
inputTokens: 130 outputTokens: 27 totalTokens: 157

چهار خط این خروجی را باید بلد باشی بخوانی. steps: 2 یعنی حلقه دو بار مدل را صدا زد: بار اول ابزار خواست، بار دوم با دیدن نتیجه متن نهایی را نوشت. toolResults خروجی واقعی تابع execute است، یعنی tempC: 21 از کد خودم آمده نه از مدل.

عدد ۱۵۷ جمع درستی دارد و همین‌جا یک نکته‌ی هزینه را نشان می‌دهد: ۴۲ توکن ورودی در گام اول، ۸۸ توکن در گام دوم. یعنی پیام پاسخ ابزار دوباره به مدل داده می‌شود و ورودی گام دوم بیش از دو برابر گام اول شده است. جمع ورودی هر دو گام ۱۳۰ می‌شود که همان چیزی است که در totalUsage می‌بینی.

چرا متن خالی برمی‌گردد: سقف پیش‌فرض یک گام

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

// همان کد، فقط بدون stopWhen
$ node default.mjs
بدون stopWhen -> steps: 1 | text: ""
model calls: 1

مرجع generateText علت را صریح می‌نویسد: مقدار پیش‌فرض stopWhen همان isStepCount(1) است. یعنی یک فراخوانی ابزار بدون متن نهایی، حالت پیش‌فرض و رسمی این تابع است، نه اشکال. برای همین steps: 1 و متن خالی، دو نشانه‌ی یک ایجنت نیمه‌کاره‌اند نه یک باگ.

نام تابع هم دو شکل دارد که در تست خودم یکی بودند: isStepCount و نام مستعار stepCountIs در اجرای من دقیقاً یک ارجاع‌اند. مستندات کنترل حلقه شکل اصلی را با isStepCount می‌نویسد.

وقتی مدل ورودی غلط می‌دهد

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

// مدل به‌جای نام شهر، عدد می‌فرستد
$ node post-badinput.mjs
type: tool-call
error: AI_InvalidToolInputError
type: tool-error
error: undefined
steps: 2 | text: ورودی ابزار نامعتبر بود.

نکته‌ی مهم این خط آخر است: حلقه دو گام دارد و پیام به گام دوم رسیده. یعنی SDK خطا را به مدل می‌دهد تا خودش اصلاح کند، و در اجرای من مدل پاسخ داد که ورودی نامعتبر بوده. خطای واقعی AI_InvalidToolInputError است که از دل خطای اسکیما آمده، نه یک استثنای مدیریت‌نشده.

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

سقف را خودت پایین بیاور

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

$ node post-stepcap.mjs
steps: 1
text: ""
model calls: 1
last finishReason: tool-calls

finishReason برابر tool-calls مانده، یعنی SDK می‌داند مدل هنوز می‌خواسته ابزار دیگری صدا بزند و تو جلویش را گرفته‌ای. این تفاوت با خرابی است: در خرابی پیام خطا می‌بینی، اینجا فقط یک متن خالی می‌بینی که علتش سقف توست.

قاعده‌ی کاربردی این نوشته

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

سه نشانه را با هم نگاه کن تا بدانی کجا ایستاده‌ای: steps برابر یک و متن خالی یعنی سقف را نزدی، یا اینکه اصلاً ندادی و پیش‌فرض کار کرده است. finishReason برابر tool-calls یعنی SDK هنوز منتظر ادامه بوده و تو قطعش کرده‌ای. خطای AI_InvalidToolInputError یعنی اسکیما کار کرده و مدل باید ورودی را درست کند.

تست بدون کلید API ارزش عملی دارد: همین سه حالت را می‌توانی در آزمون واحد نگه داری و هر تغییر در حلقه را بدون تماس شبکه و بدون هزینه بسنجی. همین الگو را در نوشته‌ی خروجی تایپ‌دار با pydantic-ai روی سمت پایتون دیده‌ای، و در اقتصاد کش پرامپت می‌بینی چرا کوچک نگه‌داشتن ورودی هر گام روی هزینه اثر مستقیم دارد.

منابع

  1. مرجع generateText در مستندات AI SDK
  2. مستندات تست با مدل‌های ساختگی و ai/test
  3. کنترل حلقه و شرط‌های توقف
  4. مفاهیم پایه‌ی ایجنت
  5. فراخوانی ابزار در AI SDK Core
  6. تولید داده‌ی ساخت‌یافته با اسکیما
  7. مدیریت خطا
  8. مخزن vercel/ai روی گیت‌هاب
  9. صفحه‌ی پکیج ai در npm
  10. مشخصات زبان‌مدل نسخه‌ی چهارم در مخزن