هوش مصنوعی در اپ موبایل و فلاتر؛ کلید را داخل اپ نگذارید
اپ فلاتر آماده است و روی TestFlight رفته. کلید وبسرویس هم داخل یک فایل ثابت نشسته تا اپ مستقیم با مدل حرف بزند و سرور اضافهای لازم نباشد. سه هفته بعد، صورتحساب چند برابر میشود و در لاگها درخواستهایی میبینید که از اپ شما نیامدهاند. کسی فایل نصب را باز کرده، رشتههای داخلش را بیرون کشیده و کلید را برداشته است.
این سناریو نادر نیست و راهحلش هم پیچیده نیست. در این راهنما الگوی امن را میسازیم: یک پروکسی نازک روی سرور خودتان که کلید فقط آنجا میماند، کلاینت فلاتر و ریاکت نیتیو مقابل همان پروکسی، استریم روی موبایل، و رفتار درست وقتی اینترنت کاربر قطع و وصل میشود.
هر چیزی که داخل اپ باشد، عمومی است
فایل نصب اپ، چه APK باشد چه IPA، یک بستهٔ فشرده است که هر کسی میتواند بازش کند. رشتههای متنی داخل باینری با یک دستور ساده بیرون میآیند و کلید هم فقط یک رشتهٔ متنی است. سه باور رایج که کار نمیکنند:
- «کلید را در
--dart-defineمیگذارم». این مقدار هنگام ساخت داخل باینری کامپایل میشود و همانجا قابل خواندن است. - «رشته را رمز میکنم». کلیدِ رمزگشایی هم باید داخل اپ باشد، پس فقط یک قدم اضافه کردهاید.
- «اپ را مبهمسازی میکنم». مبهمسازی نام توابع را سختخوان میکند، نه ترافیک شبکه را. یک ابزار رهگیری ساده، سربرگ
Authorizationرا همانطور که هست نشان میدهد.
قاعده یک خط است: کلید فقط روی سروری میماند که خودتان کنترلش میکنید. اپ با سرور شما حرف میزند و سرور شما با وبسرویس. همین قاعده برای وب هم برقرار است و دلیلش را در حریم خصوصی و امنیت داده هنگام استفاده از API باز کردهایم.
اگر کلیدی لو رفت، سه کار به ترتیب انجام میشود: همان کلید را باطل کنید، کلید تازه بسازید، و اپ را با نسخهٔ تازه منتشر کنید. تا وقتی نسخهٔ قدیمی روی گوشیها مانده، سقف هزینهٔ کلید قدیمی را روی عدد کوچکی نگه دارید. همین ترتیب نشان میدهد چرا الگوی «کلید داخل اپ» از اساس اشتباه است: انتشار نسخهٔ تازه در فروشگاهها زمان میبرد و در آن فاصله هیچ کنترلی روی مصرف ندارید.
الگوی درست: پروکسی نازک
پروکسی «نازک» یعنی یک مسیر ساده که ورودی را اعتبارسنجی میکند، دستور سیستم و مدل را خودش تعیین میکند و پاسخ را برمیگرداند. چیزی که نباید بسازید، یک پروکسی «شفاف» است که هر بدنهای را عیناً جلو بفرستد؛ آن وقت فقط جای کلید را عوض کردهاید و هر کسی میتواند با گرانترین مدل، متنهای طولانی بفرستد.
| مسئولیت | داخل اپ | روی سرور شما |
|---|---|---|
| نگهداری کلید وبسرویس | هرگز | در متغیر محیطی |
| انتخاب مدل و دستور سیستم | خیر | بله، ثابت و کنترلشده |
| احراز هویت کاربر | توکن ورود کاربر | بررسی همان توکن |
| سقف مصرف هر کاربر | نمایش پیام | شمارش و اعمال سقف |
| نمایش و حافظهٔ گفتوگو | بله | در صورت نیاز به همگامسازی |
| تلاش مجدد | یک بار، با فاصله | برای خطاهای موقتی |
نمونهٔ لاراولی این مسیر، با اعتبارسنجی ورودی و سقف روزانه:
Route::middleware('auth:sanctum')->post('/mobile/chat', function (Request $request) {
$user = $request->user();
if ($user->aiTokensUsedToday() >= 30000) {
return response()->json(['message' => 'سهمیهٔ امروز شما تمام شد.'], 429);
}
$data = $request->validate([
'messages' => ['required', 'array', 'max:20'],
'messages.*.role' => ['required', 'in:user,assistant'],
'messages.*.content' => ['required', 'string', 'max:4000'],
]);
$response = Http::withToken(config('services.netarz.key'))
->timeout(120)
->post('https://netarz.ir/api/ai/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'max_tokens' => 500,
'messages' => array_merge(
[['role' => 'system', 'content' => 'You are the in-app assistant. Reply in short, polite Persian.']],
$data['messages'],
),
]);
if ($response->failed()) {
report(new RuntimeException('ai '.$response->status().' '.$response->json('error.code')));
return response()->json(['message' => 'الان نمیتوانیم جواب بدهیم؛ کمی بعد دوباره تلاش کنید.'], 503);
}
$user->addAiTokens((int) $response->json('usage.total_tokens', 0));
return response()->json([
'answer' => trim((string) $response->json('choices.0.message.content')),
]);
});
سه تصمیم در همین چند خط هست که بعداً نجاتتان میدهد: سقف طول هر پیام، سقف تعداد پیامهای تاریخچه، و ثبت مصرف به نام کاربر. بدون مورد سوم هیچوقت نمیفهمید هزینه از کجا آمده است.
پروکسی را هم بدون محافظ رها نکنید. دستکم سه لایه بگذارید: سقف درخواست در دقیقه برای هر کاربر، سقف مصرف روزانه، و بررسی اینکه درخواست از کاربر واردشدهٔ همان اپ میآید. اگر اپ شما ورود اجباری ندارد، یک شناسهٔ دستگاه بسازید و مصرف را به آن ببندید؛ وگرنه همان مشکلی که از کلید لو رفته فرار کردید، از در دیگری برمیگردد.
کلاینت فلاتر مقابل پروکسی
سمت اپ دیگر هیچچیزی دربارهٔ مدل نمیداند. فقط با سرور خودتان حرف میزند:
import 'dart:convert';
import 'package:http/http.dart' as http;
class ChatApi {
ChatApi(this.baseUrl, this.sessionToken);
final String baseUrl; // نشانی سرور خودتان
final String sessionToken; // توکن ورود همان کاربر در اپ شما
Future<String> ask(List<Map<String, String>> messages) async {
final res = await http
.post(
Uri.parse('$baseUrl/api/mobile/chat'),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $sessionToken',
},
body: jsonEncode({'messages': messages}),
)
.timeout(const Duration(seconds: 60));
if (res.statusCode == 429) throw QuotaReachedException();
if (res.statusCode != 200) throw ChatUnavailableException(res.statusCode);
final body = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
return (body['answer'] as String).trim();
}
}
یک ریزهکاری فارسی: پاسخ را با utf8.decode(res.bodyBytes) بخوانید، نه با res.body. در بعضی محیطها حالت دوم متن فارسی را خراب نشان میدهد و ساعتها دنبال باگی میگردید که در سرور نیست.
در سمت رابط کاربری هم خطاها را به زبان آدم ترجمه کنید. کاربر نباید عدد ۴۲۹ ببیند؛ باید بخواند «سهمیهٔ امروز شما تمام شد» یا «ارتباط برقرار نشد؛ دوباره تلاش کنید». همین ترجمهٔ ساده، بیشتر تیکتهای پشتیبانی یک اپ تازه را از بین میبرد.
استریم روی موبایل
کاربر موبایل صبر کمتری دارد و ده ثانیه سکوت را «هنگ کرد» میفهمد. پاسخ تکهتکه این حس را عوض میکند. سمت سرور، استریم درگاه را عیناً به اپ پاس میدهید و در nginx سربرگ X-Accel-Buffering: no را میگذارید، وگرنه همهچیز بافر میشود و کاربر باز هم منتظر میماند. سمت فلاتر:
Stream<String> askStream(List<Map<String, String>> messages) async* {
final request = http.Request('POST', Uri.parse('$baseUrl/api/mobile/chat/stream'))
..headers.addAll({
'Content-Type': 'application/json',
'Authorization': 'Bearer $sessionToken',
'Accept': 'text/event-stream',
})
..body = jsonEncode({'messages': messages});
final response = await http.Client().send(request);
if (response.statusCode != 200) throw ChatUnavailableException(response.statusCode);
await for (final line in response.stream.transform(utf8.decoder).transform(const LineSplitter())) {
if (!line.startsWith('data: ')) continue;
final payload = line.substring(6).trim();
if (payload.isEmpty || payload == '[DONE]') continue;
final json = jsonDecode(payload) as Map<String, dynamic>;
final delta = json['choices']?[0]?['delta']?['content'];
if (delta is String && delta.isNotEmpty) yield delta;
}
}
در ویجت، این جریان را در یک StreamBuilder بگذارید و متن را روی هم جمع کنید. دو کار کوچک تجربه را بهتر میکند: نگه داشتن اسکرول در انتهای پیام، و ذخیرهٔ متن نیمهکاره وقتی کاربر صفحه را میبندد. پیادهسازی سمت سرور و جزئیات SSE را در چتبات سایت با پاسخ استریمی کامل نوشتهایم.
شبکهٔ ناپایدار، همان چیزی که روی وایفای دفتر نمیبینید
موبایل بین آنتنها جابهجا میشود، از وایفای به دیتا میپرد و گاهی چند ثانیه هیچچیز رد و بدل نمیشود. چهار رفتاری که اپ باید داشته باشد:
- تایماوت جدا برای اتصال و برای پاسخ. اتصال باید سریع شکست بخورد، ولی پاسخ مدل میتواند طول بکشد.
- تلاش مجدد فقط برای خطاهای موقتی. ۴۲۹ از جنس سرعت و ۵۰۳ ارزش تلاش دوباره دارند؛ ۴۰۱ و ۴۰۲ و ۴۰۰ هرگز. تفکیکشان در خطاهای رایج API هوش مصنوعی.
- نگه داشتن متن نیمهکاره. اگر استریم وسط راه قطع شد، آنچه رسیده را نشان بدهید و یک دکمهٔ «ادامه» بگذارید، نه اینکه صفحه خالی شود.
- یک پرسش در هر لحظه. اگر کاربر دکمه را سه بار زد، فقط یکی را بفرستید؛ سه درخواست همزمان یعنی سه برابر هزینه برای یک جواب.
یک واقعیت هزینه را هم بدانید: اگر استریم را نیمهکاره ببندید، آنچه تا آن لحظه تولید شده محاسبه میشود. پس «لغو» در اپ، هزینه را به صفر برنمیگرداند و بهتر است بهجای لغو زیاد، سقف خروجی را واقعبینانه بگذارید.
ریاکت نیتیو در چند خط
الگو دقیقاً همان است و فقط کلاینت عوض میشود. برای پاسخ یکجا، fetch معمولی کافی است. برای استریم، پیادهسازی پیشفرض fetch در ریاکت نیتیو بدنهٔ پاسخ را تکهتکه نمیدهد، پس یا از کتابخانهٔ آمادهٔ رویدادهای سرور استفاده کنید یا پاسخ را در سرور خودتان به قطعههای کوچک تبدیل و با وبسوکت بفرستید. هر دو مسیر جواب میدهند؛ مسیر دوم اگر از قبل وبسوکت دارید سادهتر است.
یک تفاوت کوچک دیگر در ریاکت نیتیو وقت میگیرد: مدیریت وضعیت پیامها. چون متن تکهتکه میرسد، اگر در هر تکه کل فهرست پیامها را از نو بسازید، روی گوشیهای ضعیفتر پرش میبینید. متن در حال رسیدن را در یک وضعیت جدا نگه دارید و فقط در پایان به فهرست اصلی اضافهاش کنید.
پیش از فرستادن اپ به بازبینی فروشگاه، این فهرست را یک بار با دقت مرور کنید؛ هر ردیفش از یک اشتباه واقعی آمده است.
چکلیست پیش از انتشار
- در فایل نصب دنبال پیشوند کلید بگردید؛ اگر پیدا شد، هنوز آماده نیستید.
- مسیر پروکسی فقط برای کاربر واردشده باز است و سقف تعداد درخواست در دقیقه دارد.
- مدل و دستور سیستم در سرور تعیین میشوند، نه در بدنهای که اپ میفرستد.
- مصرف توکن هر کاربر ثبت میشود و یک سقف روزانه دارد. الگوهای بیشتر در کنترل هزینهٔ API هوش مصنوعی.
- برای کلید این اپ در پنل سقف هزینه گذاشتهاید تا یک اشکال در کد، بودجهٔ بقیه را نخورد.
- در صفحهٔ حریم خصوصی اپ نوشتهاید چه چیزی نگه میدارید. نزد ما متن پیامها و پاسخها ذخیره نمیشود و فقط فراداده برای صورتحساب و امنیت میماند.
یک نکتهٔ آخر دربارهٔ نسخهها: اپ منتشرشده ماهها روی گوشی کاربران میماند. هر قراری که بین اپ و پروکسی میگذارید باید سازگار با نسخههای قدیمی بماند، وگرنه یک تغییر کوچک در پاسخ سرور، اپ کسانی را که بهروزرسانی نکردهاند از کار میاندازد.
قدم بعدی
اگر تازه شروع میکنید، اول پروکسی را با پاسخ یکجا بالا بیاورید و اپ را به آن وصل کنید؛ استریم را بگذارید برای وقتی که جریان کار درست شد. بعد سراغ چیزهایی بروید که تجربه را واقعاً بهتر میکنند: ذخیرهٔ تاریخچه روی دستگاه، حالت آفلاین با پیام روشن، و یک دکمهٔ گزارش پاسخ بد که به تیم شما میرسد.
مفاهیم پایه در وبسرویس هوش مصنوعی چیست آمده و فهرست مدلهای فعال با نرخشان در صفحهٔ وبسرویس هوش مصنوعی منتشر میشود. برای دستیار داخل اپ معمولاً یک مدل سبک کافی است؛ سرعت پاسخ روی موبایل بیشتر از دقت چند درصدی به چشم میآید.
پرسشهای پرتکرار
واقعاً نمیشود کلید را با رمزگذاری داخل اپ گذاشت؟
نه به شکل امن. کلیدِ رمزگشایی هم باید جایی داخل همان اپ باشد، پس فقط یک قدم اضافه شده است. ضمن اینکه حتی بدون باز کردن فایل نصب، با یک ابزار رهگیری ترافیک میشود سربرگ Authorization را دید. کلید باید روی سرور خودتان بماند.
پروکسی روی سرور خودم یعنی یک سرور دیگر هم باید بخرم؟
نه لزوماً. همان سروری که اپ برای ورود و دادههایش به آن وصل است کافی است و یک مسیر تازه اضافه میکنید. اگر اپ هیچ بکاندی ندارد، یک تابع بدون سرور هم کار را راه میاندازد.
استریم در فلاتر با کتابخانهٔ http کار میکند؟
بله. بهجای http.post از http.Request و Client().send استفاده کنید تا به بدنهٔ پاسخ بهصورت جریان دسترسی داشته باشید، بعد خطها را با LineSplitter جدا کنید و خطهای data را بخوانید. در سرور هم بافر کردن پاسخ را خاموش کنید.
اگر کاربر وسط پاسخ اپ را ببندد، هزینهاش چه میشود؟
هر مقداری که تا آن لحظه تولید شده محاسبه میشود و بقیه نه. برای همین بهجای اینکه به لغو کردن تکیه کنید، max_tokens را واقعبینانه بگذارید تا پاسخها از ابتدا کوتاهتر و ارزانتر بمانند.
نظر خوانندگان
هنوز نظری ثبت نشده. اگر این مقاله پرسشتان را جواب داد یا جای چیزی در آن خالی ماند، همینجا بنویسید.