هوش مصنوعی در Next.js؛ Route Handler، استریم و امنیت کلید
پروژهٔ Next.js آماده است و چت هم کار میکند. کلید را در .env.local گذاشتهاید و چون کامپوننت سمت کلاینت به آن دسترسی نداشت، جلوی نامش NEXT_PUBLIC_ اضافه کردهاید تا خطا برود. رفت. مشکل اینجاست که همان پیشوند به Next.js میگوید این مقدار را داخل بستهٔ جاوااسکریپت مرورگر بنویسد؛ یعنی کلید شما حالا در سورس صفحه، برای هر بازدیدکننده، قابل خواندن است.
در این راهنما مسیر درست را میسازیم: یک Route Handler در App Router که کلید را سمت سرور نگه میدارد، استریم با ReadableStream خام و با Vercel AI SDK، خواندن جریان در کامپوننت، و تصمیم Edge یا Node با معیارهای واقعی.
هر چیزی با NEXT_PUBLIC_ عمومی است
قاعدهٔ Next.js ساده است: متغیرهای محیطی فقط روی سرور خوانده میشوند، مگر اینکه نامشان با NEXT_PUBLIC_ شروع شود. در آن صورت مقدارشان هنگام ساخت، عیناً داخل فایلهای جاوااسکریپت مرورگر نوشته میشود. این ویژگی برای نشانی تصاویر یا شناسهٔ آنالیتیکس مفید است و برای کلید دسترسی فاجعه.
راه تشخیصش هم یک دقیقه وقت میبرد: پروژه را بسازید و در فایلهای خروجی دنبال پیشوند کلیدتان بگردید. اگر پیدا شد، قبل از انتشار همان کلید را باطل کنید. قاعدهٔ کلی همان است که برای اپ موبایل هم میگوییم: کلید فقط روی سروری میماند که خودتان کنترلش میکنید و مرورگر همیشه با سرور شما حرف میزند، نه با مدل.
مدل ذهنی درستش این است: در App Router کامپوننتها پیشفرض سمت سرور اجرا میشوند و هر فایلی که با use client شروع شود به مرورگر میرود. اگر کامپوننتی به کلید نیاز پیدا کرد، نشانهٔ این است که کار اشتباهی در همان کامپوننت انجام میشود، نه اینکه متغیر باید عمومی شود. تماس با مدل جایش در یک Route Handler یا یک Server Action است و کامپوننت فقط نتیجه را نشان میدهد.
Route Handler، سادهترین شکل ممکن
در App Router هر فایل route.ts یک مسیر سمت سرور است. این نسخه بدون استریم کار میکند و برای شروع کافی است:
// app/api/chat/route.ts
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
type Msg = { role: 'user' | 'assistant'; content: string };
export async function POST(req: Request) {
const { messages } = (await req.json()) as { messages: Msg[] };
if (!Array.isArray(messages) || messages.length === 0 || messages.length > 20) {
return Response.json({ error: 'bad_request' }, { status: 400 });
}
const upstream = await fetch('https://netarz.ir/api/ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.NETARZ_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
max_tokens: 500,
messages: [
{ role: 'system', content: 'You are the assistant of our shop. Reply in short, polite Persian.' },
...messages.map((m) => ({ role: m.role, content: String(m.content).slice(0, 4000) })),
],
}),
});
const body = await upstream.json();
if (!upstream.ok) {
console.error('ai', upstream.status, body?.error?.code);
return Response.json({ error: 'unavailable' }, { status: 503 });
}
return Response.json({ answer: body.choices[0].message.content.trim() });
}
سه تصمیم در همین فایل هست که بعداً به کارتان میآید. مدل و دستور سیستم در سرور تعیین میشوند، نه در بدنهای که مرورگر میفرستد؛ وگرنه هر کسی میتواند با گرانترین مدل درخواست بفرستد. طول هر پیام و تعداد پیامها محدود شده. و خطای بالادست به کاربر نشت نمیکند، فقط در لاگ سرور مینشیند.
این فایل باید دقیقاً در مسیر app/api/chat/route.ts بنشیند؛ نام پوشهها مسیر URL را میسازند و نام فایل باید route باشد. اگر هنوز روی Pages Router هستید، معادلش pages/api/chat.ts است و بهجای شیء استاندارد Request با req و res کار میکنید؛ منطق یکسان است و فقط امضای تابع فرق دارد.
استریم با ReadableStream خام
پاسخ یکجا برای جوابهای کوتاه خوب است، ولی کاربر برای متن بلند منتظر میماند و فکر میکند صفحه هنگ کرده. راهحل، پاس دادن جریان پاسخ به مرورگر است. اینجا رویدادهای SSE درگاه را میخوانیم و فقط متن را بیرون میدهیم:
// app/api/chat/stream/route.ts
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch('https://netarz.ir/api/ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.NETARZ_API_KEY}`,
},
body: JSON.stringify({ model: 'gpt-4o-mini', max_tokens: 700, stream: true, messages }),
});
if (!upstream.ok || !upstream.body) {
return new Response('unavailable', { status: 503 });
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const stream = new ReadableStream({
async start(controller) {
const reader = upstream.body!.getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const line = chunk.trim();
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') { controller.close(); return; }
try {
const delta = JSON.parse(payload)?.choices?.[0]?.delta?.content;
if (delta) controller.enqueue(encoder.encode(delta));
} catch { /* رویداد ناقص را رد کنید */ }
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
});
}
سربرگ X-Accel-Buffering: no را حذف نکنید. اگر پشت nginx هستید، بدون آن کل پاسخ بافر میشود و کاربر باز هم تا آخر کار چیزی نمیبیند. توضیح کامل SSE و رفتار مرورگرها در چتبات سایت با پاسخ استریمی آمده است.
چرا رویدادها را به متن ساده تبدیل میکنیم بهجای اینکه همان SSE را جلو بفرستیم؟ چون در آن حالت مرورگر باید دوباره همین تجزیه را انجام بدهد و شکل داخلی پاسخ درگاه به فرانتاند شما نشت میکند. با متن ساده، کلاینت فقط تکهها را به هم میچسباند و اگر روزی مدل یا ارائهدهنده عوض شد، هیچچیز در مرورگر تغییر نمیکند.
همان کار با Vercel AI SDK
اگر نمیخواهید خودتان رویدادها را تجزیه کنید، Vercel AI SDK همین کار را در چند خط انجام میدهد و چون درگاه ما با قالب OpenAI حرف میزند، فقط نشانی پایه را عوض میکنید:
// app/api/chat/sdk/route.ts
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
export const runtime = 'nodejs';
export const maxDuration = 60;
const netarz = createOpenAI({
baseURL: 'https://netarz.ir/api/ai/v1',
apiKey: process.env.NETARZ_API_KEY!,
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: netarz('gpt-4o-mini'),
system: 'You are the assistant of our shop. Reply in short, polite Persian.',
messages,
maxOutputTokens: 700,
});
return result.toTextStreamResponse();
}
یک هشدار عملی: نام پارامترها و متدهای خروجی این کتابخانه بین نسخههای اصلی عوض شدهاند. نسخهٔ نصبشده در پروژهٔ خودتان را نگاه کنید و اگر maxOutputTokens را نشناخت، معادلش را در همان نسخه بگذارید. هرچه هست، سقف خروجی را خالی نگذارید؛ بدون آن، رزرو اعتبار با فرض پاسخ بلند حساب میشود.
کدام یکی را انتخاب کنید؟ اگر فقط یک مسیر چت دارید و نمیخواهید وابستگی تازه اضافه کنید، نسخهٔ خام کوتاه و قابل فهم است. اگر چند مسیر، ابزاردهی و خروجی ساختاریافته در کار است، کتابخانه کلی کد تکراری را از دوشتان برمیدارد. هر دو روی همان نشانی پایه کار میکنند، پس بعداً هم میتوانید جابهجا شوید.
سمت کلاینت: خواندن جریان
کامپوننت کلاینت دیگر هیچچیزی دربارهٔ مدل یا کلید نمیداند. فقط با مسیر خودتان حرف میزند:
'use client';
import { useState } from 'react';
export default function Chat() {
const [answer, setAnswer] = useState('');
const [busy, setBusy] = useState(false);
async function ask(question: string) {
setAnswer('');
setBusy(true);
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [{ role: 'user', content: question }] }),
});
if (!res.ok || !res.body) {
setAnswer('الان نمیتوانیم جواب بدهیم؛ کمی بعد دوباره تلاش کنید.');
setBusy(false);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
setAnswer((prev) => prev + text);
}
setBusy(false);
}
return null; // رابط کاربری خودتان
}
دو کار کوچک تجربهٔ کاربر را محسوس بهتر میکند: یک AbortController بگذارید تا اگر کاربر صفحه را عوض کرد یا سؤال تازهای پرسید، درخواست قبلی بسته شود؛ و اسکرول را در انتهای متن نگه دارید تا کاربر دنبال متنی که در حال نوشته شدن است نگردد. یادتان باشد بستن جریان، هزینهٔ تولیدشده تا آن لحظه را برنمیگرداند.
Edge یا Node؟
| موضوع | Edge Runtime | Node.js Runtime |
|---|---|---|
| شروع سرد | تقریباً بدون تأخیر | کندتر |
| استریم | بسیار خوب | خوب |
| کتابخانههای Node | محدود | کامل |
| درایور دیتابیس معمولی | معمولاً کار نمیکند | کار میکند |
| مهلت اجرا | کوتاهتر | قابل تنظیمتر |
| مناسب برای | پروکسی سبک و بدون دیتابیس | مسیری که به دیتابیس و سنجش مصرف وصل است |
توصیهٔ عملی ما: اگر مسیر شما فقط درخواست را جلو میفرستد، Edge انتخاب خوبی است. اما به محض اینکه بخواهید کاربر را احراز هویت کنید، مصرف را در دیتابیس بشمارید یا محدودیت بگذارید، Node کمتر اذیتتان میکند. مقدار maxDuration را هم متناسب با طولانیترین پاسخ ممکن تنظیم کنید، وگرنه اجرای مسیر وسط استریم قطع میشود.
جلوگیری از سوءاستفاده
مسیر /api/chat شما عمومی است؛ هر کسی که صفحه را باز کند میتواند مستقیم به آن درخواست بفرستد. چهار محافظ کافی است:
- محدودیت نرخ. بر اساس شناسهٔ کاربر، یا اگر ورود ندارید بر اساس IP و یک کوکی نشست. چند درخواست در دقیقه برای یک چت عادی کافی است.
- اعتبارسنجی ورودی. سقف طول پیام، سقف تعداد پیامها، و پذیرفتن فقط نقشهای
userوassistant. - تعیین مدل در سرور. هیچوقت نام مدل یا دستور سیستم را از بدنهٔ کلاینت نخوانید.
- سقف هزینه روی کلید. در پنل برای کلید این پروژه سقف بگذارید و پروژه را جدا تعریف کنید تا مصرفش در گزارشها مستقل دیده شود.
الگوهای کاملتر مهار خرج در کنترل هزینهٔ API هوش مصنوعی و تفکیک خطاهایی که باید دوباره تلاش شوند در خطاهای رایج API هوش مصنوعی آمده است.
یک تلهٔ رایج دیگر در Next.js، کش شدن پاسخهاست. مسیرهای App Router در شرایطی بهصورت ایستا حساب میشوند و اگر مسیر چت شما گرفتار این حالت شود، همهٔ کاربران یک جواب میگیرند. مقدار dynamic را روی force-dynamic بگذارید و در پاسخ هم Cache-Control: no-store بفرستید. اگر پشت یک CDN هستید، همان سربرگ را در تنظیمات CDN هم بررسی کنید؛ بعضی پیکربندیها پاسخهای متنی را پیشفرض کش میکنند.
چکلیست پیش از دیپلوی
- هیچ متغیری با پیشوند
NEXT_PUBLIC_کلید ندارد و در خروجی ساخت، پیشوند کلید پیدا نمیشود. - مسیرهای هوش مصنوعی
dynamic = 'force-dynamic'دارند تا پاسخها کش نشوند. - مقدار
maxDurationاز طولانیترین پاسخ ممکن بیشتر است. - خطاهای بالادست در لاگ سرور مینشینند و به کاربر فقط یک جملهٔ روشن میرسد.
- محدودیت نرخ روی مسیر فعال است و مصرف هر کاربر شمرده میشود.
- در صفحهٔ حریم خصوصی نوشتهاید چه چیزی نگه میدارید. نزد ما متن پیامها و پاسخها ذخیره نمیشود و فقط فراداده برای صورتحساب و امنیت میماند.
قدم بعدی
مسیر پیشنهادی ما این است: اول نسخهٔ بدون استریم را بالا بیاورید تا مطمئن شوید کلید، مدل و دستور سیستم سر جایشان هستند؛ بعد استریم را اضافه کنید و در آخر محدودیت نرخ و شمارش مصرف را بگذارید. اگر پروژهتان قرار است به دادههای خودتان جواب بدهد، مرحلهٔ بعدی بازیابی متن مرتبط است، نه فرستادن کل مستندات در هر درخواست.
یک کار ساده هم معمولاً فراموش میشود: از همان روز اول شناسهٔ هر درخواست را از سربرگ X-Request-Id کنار شناسهٔ کاربر لاگ کنید. روزی که پاسخ عجیبی به دست کاربر برسد، همین لاگ تنها چیزی است که به کارتان میآید.
مفاهیم پایه در وبسرویس هوش مصنوعی چیست جمع شده، مستندات فنی کامل با فهرست خطاها و نمونههای بیشتر در مستندات وبسرویس هوش مصنوعی در دسترس است، و فهرست مدلهای فعال با نرخشان در صفحهٔ وبسرویس هوش مصنوعی منتشر میشود.
آموزشهای کاربردی در گیسو
گیسو برند هوش مصنوعی نِت اَرز است، با همین حساب و همین اعتبار. این راهنماها قدمبهقدم نشان میدهند همین کار را چطور با هوش مصنوعی انجام دهید.
پرسشهای پرتکرار
چرا نباید کلید را در NEXT_PUBLIC_ بگذارم؟
چون Next.js هر متغیری با این پیشوند را هنگام ساخت داخل فایلهای جاوااسکریپت مرورگر مینویسد. یعنی کلید در سورس صفحه برای هر بازدیدکننده قابل خواندن میشود. کلید باید بدون پیشوند بماند و فقط در Route Handler سمت سرور خوانده شود.
Edge بهتر است یا Node برای مسیر چت؟
اگر مسیر فقط درخواست را جلو میفرستد، Edge سریعتر بالا میآید و استریم را خوب مدیریت میکند. اما به محض اینکه به دیتابیس، احراز هویت یا شمارش مصرف نیاز پیدا کنید، Node دردسر کمتری دارد. شروع با Node انتخاب امنتری است.
استریم کار میکند ولی متن یکجا میآید؛ مشکل کجاست؟
تقریباً همیشه بافر شدن پاسخ در لایهٔ وبسرور یا CDN است. سربرگ X-Accel-Buffering با مقدار no را بفرستید، کش را با no-store خاموش کنید و مطمئن شوید هیچ میانافزاری پاسخ را کامل نمیخواند.
با Vercel AI SDK هم میتوانم به درگاه شما وصل شوم؟
بله. با createOpenAI یک نمونه بسازید و baseURL را روی نشانی پایهٔ ما بگذارید و کلید را از متغیر محیطی بخوانید. فقط توجه کنید نام بعضی پارامترها بین نسخههای اصلی SDK عوض شده، پس نسخهٔ نصبشده در پروژهٔ خودتان را ملاک بگیرید.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.