ملی ای‌پی‌آی

ساخت چت‌بات فارسی با پاسخ جریانی و Gemini Flash

چت‌بات خوب جواب را همان‌طور که تولید می‌شود نشان می‌دهد. این راهنما با Gemini 3.5 Flash یک چت کوچک با پاسخ جریانی می‌سازد، با کد Node و مدیریت تاریخچه و خطای ۴۲۹.

چت‌بات فارسی خوب، جواب را همان‌طور که تولید می‌شود نشان می‌دهد. کاربری که سه ثانیه به صفحهٔ خالی نگاه می‌کند فکر می‌کند چیزی خراب شده، ولی اگر کلمات یکی‌یکی بیایند، همان سه ثانیه را تحمل می‌کند. این راهنما با Gemini 3.5 Flash چت‌بات کوچکی با پاسخ جریانی می‌سازد.

چهار قدم پاسخ جریانی: درخواست با stream برابر true، دریافت خط‌های data، افزودن هر delta به صفحه با textContent، و تکهٔ پایانی با done و صورتحساب و سپس DONE
چرخهٔ دریافت پاسخ جریانی

با stream پاسخ تکه‌تکه می‌آید

با فرستادن "stream": true، پاسخ به‌صورت text/event-stream می‌رسد و هر تکه یک خط data: است. هر تکه یک delta دارد؛ تکهٔ پایانی done و صورتحساب را می‌آورد و آخر جریان [DONE] است. توضیح کامل در بخش پاسخ جریانی مستندات آمده است.

کد کامل یک چت ساده (Node)

const URL = "https://melliapi.net/api/v1/ai/chat/completions/";
const history = [
  { role: "system", content: "پاسخ‌ها را کوتاه و فارسی بده." },
];

async function ask(question) {
  history.push({ role: "user", content: question });
  const res = await fetch(URL, {
    method: "POST",
    headers: {
      Authorization: "Bearer " + process.env.MELLIAPI_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "google-gemini-3.5-flash",
      messages: history,
      max_tokens: 600,
      stream: true,
    }),
  });

  // خطای پیش از شروع جریان، JSON معمولی است، نه جریان.
  if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
    throw new Error(JSON.stringify(await res.json()));
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  let answer = "";
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop();
    for (const line of lines) {
      if (!line.startsWith("data: ")) continue;
      const payload = line.slice(6);
      if (payload === "[DONE]") break;
      const event = JSON.parse(payload);
      if (event.delta) {
        answer += event.delta;
        process.stdout.write(event.delta);
      }
    }
  }
  history.push({ role: "assistant", content: answer });
}

دو جزئیات که زیاد جا می‌افتند:

  • بررسی content-type: اگر کلید نامعتبر باشد یا موجودی نباشد، پاسخ جریان نیست و JSON است. بدون این بررسی، برنامه سعی می‌کند JSON را مثل جریان بخواند و گیج می‌شود.
  • نگه‌داشتن تکه‌های ناقص: یک خط ممکن است بین دو بستهٔ شبکه نصف شود. خط آخرِ ناتمام را در buffer نگه دارید و با بستهٔ بعدی کامل کنید.

نمایش در صفحه: چند نکتهٔ مخصوص فارسی

کد بالا خروجی را در ترمینال می‌نویسد، ولی در صفحهٔ وب چند نکته هست که برای فارسی مهم‌تر است:

  • جهت متن را خودکار بگذارید. برای حباب پیام از dir="auto" استفاده کنید. کاربر گاهی فارسی می‌نویسد و گاهی انگلیسی، و جهت ثابت، یکی از آن دو را به‌هم می‌ریزد.
  • کلمه را به‌صورت متن اضافه کنید، نه HTML. هر delta را با textContent به صفحه بیفزایید. اگر آن را در innerHTML بگذارید، هر چه مدل بنویسد (یا کاربر به مدل بقبولاند) به‌عنوان HTML اجرا می‌شود.
  • اسکرول را به پایین نگه دارید، ولی فقط اگر کاربر بالا نرفته باشد. کسی که وسط جواب بالا می‌رود تا چیزی را بخواند، نباید هر ثانیه به پایین پرت شود.
  • دکمهٔ توقف بگذارید. با بستن ارتباط، تولید متوقف می‌شود و کاربر هزینهٔ جوابی را که نمی‌خواهد نمی‌دهد.

تاریخچه را کوتاه نگه دارید

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

ترافیک بالا و خطای ۴۲۹

برای چت‌بات پرمخاطب، سقف هر کلید ۶۰ فراخوان در دقیقه است. وقتی rate_limited گرفتید، هدر Retry-After را بخوانید و همان‌قدر صبر کنید؛ تلاش فوری فقط خطا را تکرار می‌کند. برای کاربر، یک پیام ساده مثل «چند لحظه دیگر دوباره بپرسید» بهتر از قطع بی‌صدای گفت‌وگوست.

اگر کاربر وسط جواب صفحه را بست

مدل تا آن لحظه کار کرده است، پس بابت همان مقدار تولیدشده هزینه کسر می‌شود. اگر این برایتان مهم است، به کاربر دکمهٔ «توقف» بدهید که ارتباط را سریع ببندد، نه اینکه تا پایان جریان رها شود.

آنچه این روش انجام نمی‌دهد

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

پرسش و تجربه کاربران

اگر این راهنما جوابتان را نداد، همین‌جا بپرسید. پرسش‌ها را تیمملی ای‌پی‌آی پاسخ می‌دهد و هر نظر پیش از انتشار بررسی می‌شود.

هنوز پرسشی ثبت نشده، اولین نفر باشید.