چتبات سایت با پاسخ استریمی؛ آموزش کامل بکاند و فرانت
هر چتبات خوبی که دیدهاید پاسخ را کلمهبهکلمه مینویسد، و این تصادفی نیست: کاربری که بعد از نیم ثانیه اولین کلمه را میبیند صبر میکند، و کاربری که هشت ثانیه به یک اسپینر نگاه میکند صفحه را میبندد. مشکل اینجاست که اگر فارسی دنبال روش ساختش بگردید، یا به صفحهٔ فروش ویجتهای آماده میرسید یا به هیچ. این مقاله همان چیزی است که پیدا نمیشود: ساخت یک چتبات استریمی از صفر، با کدی که اجرا میشود.
اگر هنوز تصمیم نگرفتهاید که اصلاً بسازید یا سرویس آماده بخرید، آن حسابوکتاب — هزینهٔ هر گفتگو، زمان توسعه و نقطهٔ سربهسر — جای دیگری است: چتبات پشتیبانی سایت؛ بسازیم یا اشتراک بخریم. این مقاله فرض میکند تصمیم گرفتهاید بسازید.
چرا کلید API هرگز نباید به مرورگر برسد
وسوسهانگیزترین اشتباه این است که مستقیم از جاوااسکریپت سمت کاربر به مدل درخواست بزنید. هر چیزی که در مرورگر است، برای هر بازدیدکنندهای قابل خواندن است؛ فرقی نمیکند در متغیر پنهان باشد یا کد را مبهم کرده باشید. یک کلید لو رفته یعنی اعتبار شما را دیگران خرج میکنند و شما تازه از روی صورتحساب میفهمید.
معماری درست یک واسطهٔ سرور است: مرورگر به سرور خودتان درخواست میدهد، سرور با کلید به مدل وصل میشود و پاسخ را همانطور که میآید به مرورگر پس میدهد. این لایه سه فایدهٔ دیگر هم دارد: میتوانید پیام سیستم و دانش سایت را اضافه کنید بیآنکه کاربر ببیندش، میتوانید سقف نرخ و طول پیام بگذارید، و میتوانید مصرف را ثبت کنید.
کدام روش انتقال؟ SSE، WebSocket یا نظرسنجی
| روش | جهت داده | پیچیدگی | مناسب چه چیزی | نکتهٔ منفی |
|---|---|---|---|---|
| EventSource (SSE استاندارد) | یکطرفه از سرور | کم | اعلانهای ساده | فقط GET؛ برای ارسال تاریخچهٔ طولانی مناسب نیست |
| fetch با POST و خواندن جریانی | یکطرفه از سرور، ارسال با بدنهٔ POST | کم | چتبات؛ انتخاب پیشنهادی | باید خودتان بستهها را از بافر جدا کنید |
| WebSocket | دوطرفه | زیاد | چت زنده با اپراتور انسانی | نیاز به سرور پایدار و مدیریت اتصال |
| نظرسنجی دورهای | درخواستهای پیاپی | کم | کارهای پسزمینهٔ طولانی | حس استریم نمیدهد و بار اضافه میسازد |
برای چتبات، گزینهٔ دوم برنده است: قالب پیام همان Server-Sent Events است (بستههای data: ... که با یک خط خالی از هم جدا میشوند) اما بهجای EventSource از fetch با متد POST استفاده میکنید تا بتوانید تاریخچهٔ گفتگو را در بدنه بفرستید.
بکاند: یک endpoint که مدل را پروکسی میکند
نمونهٔ زیر با Node و Express است. اگر پروژهتان لاراول است، معادل PHP همین کار را در راهنمای API هوش مصنوعی در PHP و لاراول نوشتهایم.
import express from "express";
import OpenAI from "openai";
const app = express();
app.use(express.json({ limit: "64kb" }));
const client = new OpenAI({
apiKey: process.env.NETARZ_AI_KEY,
baseURL: "https://netarz.ir/api/ai/v1",
});
const SYSTEM = "You are the support assistant of an online shop. Answer in Persian, briefly. If you do not know, say so.";
app.post("/api/chat", async (req, res) => {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const history = Array.isArray(req.body.messages) ? req.body.messages.slice(-10) : [];
const controller = new AbortController();
req.on("close", () => controller.abort());
try {
const stream = await client.chat.completions.create(
{
model: "gpt-5-mini",
stream: true,
messages: [{ role: "system", content: SYSTEM }, ...history],
},
{ signal: controller.signal }
);
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? "";
if (delta) {
res.write(`data: ${JSON.stringify({ t: delta })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
} catch (error) {
if (error.name !== "APIUserAbortError") {
res.write(`data: ${JSON.stringify({ error: "stream_failed" })}\n\n`);
}
} finally {
res.end();
}
});
app.listen(3000);
چند تصمیم عمدی در این کد: تاریخچه سمت سرور به ده پیام آخر بریده میشود تا کاربر نتواند با فرستادن یک تاریخچهٔ عظیم، هزینهٔ شما را بالا ببرد؛ محدودیت حجم بدنه گذاشته شده؛ و پیام سیستم فقط روی سرور است، پس کاربر نمیتواند آن را ببیند یا دور بزند. یک لایهٔ محدودیت نرخ بر اساس IP یا نشست را هم حتماً اضافه کنید.
فرانتاند: نمایش توکنها همانطور که میرسند
سمت مرورگر باید جریان بایت را بخوانید، آن را به بستههای SSE بشکنید و هر تکه متن را به حباب پاسخ اضافه کنید. نکتهٔ ظریف این است که یک بستهٔ کامل ممکن است بین دو بار خواندن نصف شود، پس باید بافر نگه دارید:
let controller = null;
const messages = [];
async function send(text, bubble) {
messages.push({ role: "user", content: text });
controller = new AbortController();
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal: controller.signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let answer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop();
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const payload = part.slice(6);
if (payload === "[DONE]") break;
const packet = JSON.parse(payload);
if (packet.error) throw new Error(packet.error);
answer += packet.t;
bubble.textContent = answer;
bubble.scrollIntoView({ block: "end" });
}
}
messages.push({ role: "assistant", content: answer });
controller = null;
}
سه نکتهٔ رابط کاربری که تفاوت زیادی میسازند: از textContent استفاده کنید نه innerHTML، وگرنه اگر مدل چیزی شبیه تگ تولید کند در صفحهتان تزریق میشود؛ اگر خروجی مارکداون است، آن را فقط در پایان و با یک کتابخانهٔ پاکسازی رندر کنید؛ و برای فارسی، حباب پاسخ را dir="rtl" بگذارید تا متن در حین تایپ نپرد.
تاریخچهٔ گفتگو بدون ترکاندن هزینه
مدل حافظه ندارد؛ هر بار کل گفتگو دوباره فرستاده میشود و دوباره هزینه دارد. یعنی پیام دهم شما چند برابر پیام اول خرج برمیدارد. سه راهکار ساده:
- پنجرهٔ لغزان: فقط چند تبادل آخر را نگه دارید (در کد بالا ده پیام). برای اکثر چتباتهای پشتیبانی کافی است.
- خلاصهٔ فشرده: وقتی گفتگو طولانی شد، پیامهای قدیمی را با یک درخواست جداگانه خلاصه کنید و خلاصه را جای آنها بگذارید.
- پیام سیستم کوتاه و ثابت: دستور طولانی در هر درخواست دوباره حساب میشود؛ نگه داشتن آن ثابت باعث میشود کش ورودی هم بتواند کمکتان کند. اصول نوشتن دستور مؤثر و کوتاه را در راهنمای پرامپتنویسی جمع کردهایم.
تاریخچه را سمت سرور به نشست کاربر گره بزنید، نه فقط سمت مرورگر؛ اینکه کلاینت تصمیم بگیرد چه چیزی در تاریخچه باشد یعنی کاربر میتواند هرچه خواست به مدل بخوراند. محاسبهٔ هزینهٔ هر گفتگو را با مثال در توکن چیست و هزینهٔ API چطور حساب میشود آوردهایم.
محدودیت نرخ و ثبت مصرف؛ قبل از اینکه لازم شود
یک چتبات باز روی اینترنت، دیر یا زود مورد سوءاستفاده قرار میگیرد. لازم نیست کسی نیت بدی داشته باشد؛ کافی است یک اسکریپت ساده پیدایش کند و شبانه چند هزار درخواست بفرستد. سه لایهٔ دفاعی ساده تقریباً همهٔ این ریسک را میپوشاند و هر سه چند خط کد بیشتر نیستند.
- سقف نرخ بهازای نشست و IP: مثلاً بیست پیام در ساعت برای هر بازدیدکننده. عدد را از روی رفتار واقعی کاربران واقعیتان انتخاب کنید، نه از روی حدس.
- محدودیت طول ورودی: هم حجم بدنهٔ درخواست و هم طول پیام کاربر را ببندید. اگر کسی بتواند ده هزار کلمه در یک پیام بفرستد، هزینهٔ همان یک درخواست چند برابر یک گفتگوی عادی میشود.
- سقف خرج روزانه روی خود کلید: این آخرین خط دفاع است و مهمترینشان. حتی اگر همهٔ لایههای بالا شکست بخورند، سقف هزینه جلوی صورتحساب غافلگیرکننده را میگیرد.
در کنار اینها، هر گفتگو را با تعداد توکن ورودی و خروجی، مدل و شناسهٔ نشست ثبت کنید. این لاگ دو کار میکند: به شما میگوید هزینهٔ متوسط هر گفتگو چقدر است — عددی که بدون آن نمیتوانید دربارهٔ مقیاس تصمیم بگیرید — و الگوهای غیرعادی را زودتر از صورتحساب نشان میدهد. اگر مصرف یک نشست ناگهان ده برابر بقیه شد، همانجا میبینیدش.
لغو درخواست و خطاهای رایج
دکمهٔ توقف فقط یک قابلیت ظاهری نیست؛ وقتی کاربر پاسخ را نمیخواهد، ادامهٔ تولید یعنی پول سوخته. با controller.abort() سمت مرورگر اتصال بسته میشود، رویدادِ close سمت سرور فعال میشود و همان سیگنال، درخواست به مدل را هم قطع میکند. همین زنجیره باید کامل باشد وگرنه فقط ظاهر را متوقف کردهاید و تولید در پسزمینه ادامه دارد.
| نشانه | علت محتمل | راهحل |
|---|---|---|
| پاسخ یکباره و کامل میآید | بافر شدن در وبسرور | هدر X-Accel-Buffering برابر no و خاموش کردن proxy_buffering |
| اتصال بعد از یک دقیقه قطع میشود | سقف زمان پروکسی | افزایش proxy_read_timeout و ارسال دورهای یک بستهٔ خالی بهعنوان ضربان |
| حروف فارسی شکسته نمایش داده میشوند | بریده شدن بایتهای UTF-8 وسط بسته | استفاده از TextDecoder با گزینهٔ stream و نگهداشتن بافر |
| خطای ۴۲۹ در ساعات شلوغ | عبور از سقف نرخ | صف کردن درخواستها و تلاش مجدد با فاصلهٔ فزاینده |
پیکربندی انجینایکس برای این مسیر هم سه خط بیشتر نیست:
location /api/chat {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
}
قدم بعدی
اگر سایت شما وردپرسی است، اصلهای همین آموزش — کلید فقط روی سرور، ویجت سبک در مرورگر و سقف مصرف — در افزونهٔ هوش مصنوعی وردپرس نِت اَرز آماده است و کدش را در گیتهاب میخوانید.
وقتی نسخهٔ پایه کار کرد، ترتیب منطقی بهبود این است: اول محدودیت نرخ و ثبت مصرف، بعد اتصال به دانش سایت (سؤالهای متداول و توضیح محصولات) و در آخر انتقال گفتگو به پشتیبان انسانی وقتی مدل مطمئن نیست. اگر میخواهید همین منطق را بهجای سایت روی تلگرام پیاده کنید، نمونهٔ کامل پایتون در ساخت ربات تلگرام فارسی با API هوش مصنوعی آماده است.
برای اجرای این کد به یک کلید و کمی اعتبار نیاز دارید؛ روش تهیه از داخل ایران و بدون کارت ارزی در خرید API چتجیپیتی در ایران توضیح داده شده و جزئیات مسیرها و پارامترها در مستندات API نِت اَرز است. روی درگاه هوش مصنوعی نِت اَرز امروز مدلهای OpenAI فعال است و بقیهٔ ارائهدهندهها در حال اتصالاند؛ قیمت لحظهای اعتبار پیش از تأیید پرداخت نمایش داده میشود.
پرسشهای پرتکرار
چرا از EventSource استفاده نکنیم؟
EventSource فقط درخواست GET میفرستد و برای ارسال تاریخچهٔ گفتگو در بدنه مناسب نیست. با fetch و متد POST همان قالب Server-Sent Events را دریافت میکنید، اما کنترل کامل روی هدرها، بدنه و لغو درخواست دارید.
پاسخ بهجای کلمهبهکلمه، یکباره و کامل میآید. مشکل کجاست؟
تقریباً همیشه وبسرور یا پروکسی پاسخ را بافر میکند. هدر X-Accel-Buffering با مقدار no را بفرستید و در انجینایکس روی همان مسیر proxy_buffering را خاموش کنید؛ در PHP هم بعد از هر بسته بافر خروجی را تخلیه کنید.
آیا میتوانم کلید API را در فایل جاوااسکریپت سایت بگذارم؟
خیر، هر چیزی که به مرورگر برسد برای بازدیدکننده قابل خواندن است و کلید لو رفته یعنی اعتبار شما را دیگران خرج میکنند. کلید باید فقط روی سرور بماند و مرورگر همیشه با endpoint خودتان صحبت کند.
وقتی کاربر دکمهٔ توقف را میزند، هزینه متوقف میشود؟
فقط اگر زنجیرهٔ لغو کامل باشد. لغو سمت مرورگر باید اتصال را ببندد، سرور رویداد بسته شدن را بگیرد و همان سیگنال را به درخواست ارسالی به مدل بدهد؛ در غیر این صورت تولید در پسزمینه ادامه پیدا میکند و توکنهایش حساب میشود.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.