Mundarija (36)
- Bu darsda
- 1. Nega bu kerak?
- 2. Narx: tokenlar
- 3. Kunlik kvota
- 3.1 Qayerda saqlash
- 3.2 Poyga holati
- 3.3 Atomik SQL
- 3.4 Retry-After
- 4. Javob keshi
- 4.1 Nimani keshlaymiz
- 4.2 Kesh kaliti
- 4.3 Kesh zaharlash
- 4.4 Prompt caching
- 5. Provayder limitlari: 429 va 529
- 6. Maxfiylik va xavfsizlik
- 6.1 Shaxsiy ma'lumot
- 6.2 Prompt injection va moderatsiya
- 7. Loglar va baholash
- 7.1 Matnsiz loglar
- 7.2 Baholash va prompt versiyasi
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): bo'shliqlarni to'ldiring
- 2-mashq (o'rta): yarim tungacha
- 3-mashq (qiyin): PII maskalash
- 10. Loyiha qadami: kvota, kesh va AI hodisalari
- 10.1 sql/304-ai-sarf.sql
- 10.2 src/ai/kvota.ts
- 10.3 src/ai/nazorat.ts
- 10.4 src/ai/qadamlar.ts
- 10.5 Yordamchi, modul va controller
- 10.6 Test
- 10.7 Tekshirish
- 11. Real ishda
- Xulosa
- Manbalar
AI xususiyatlarini prodakshnda ishlatish: kunlik kvota, javob keshi, narx va maxfiylik
Qisqacha: LLM chaqiruvi — pulli, sekin va tashqi xizmatga bog'liq. Prodakshnda unga uchta "to'siq" qo'yiladi: kunlik kvota (har foydalanuvchiga N so'rov va M token), javob keshi (bir xil savolga LLM qayta chaqirilmaydi) va kuzatuv (matnsiz loglar va hodisalar). Kanon kvotani bitta atomik SQL bilan band qiladi: 10 ta parallel so'rov va 5 chegarada aniq 5 tasi o'tdi, 5 tasi 429 oldi. Keshdan javob 0.034 s, LLM'dan — 1.6 s. Kesh kaliti foydalanuvchi bo'yicha — boshqa birovning javobi hech qachon qaytmaydi.
Bu darsda
- AI xususiyatining narxini tokenlar orqali hisoblaysiz va kunlik kvota qanday tuzilishini bilasiz.
- Nega "o'qib, keyin yozish" kvotani buzishini brauzerda ko'rasiz va atomik SQL bilan tuzatasiz.
- Javob keshi kalitini to'g'ri tuzasiz: foydalanuvchi, model, prompt versiyasi, matn.
- Provayder 429/529 berganda, foydalanuvchining shaxsiy ma'lumoti va loglarda nima qilishni bilasiz.
vazifalar-apiga kvota, kesh va AI hodisalarini qo'shasiz.
Oldin bilishingiz kerak: RAG asoslari, Rate limiting, Caching naqshlari va invalidatsiya, UPSERT va MERGE.
O'tgan uch darsda loyiha AI bilan uchta ish qila boshladi: qadamlarga bo'lish, ma'no bo'yicha qidirish, savolga javob. Endi ularni haqiqiy foydalanuvchilarga ochishdan oldingi tayyorgarlik.
1. Nega bu kerak?
Tasavvur qiling: yordamchini ishga tushirdingiz. Ertasi kuni kimdir skript yozib, kechasi 20 000 marta "Qadamlarga bo'l" tugmasini bosdi. Ertalab provayder hisobida katta summa va kunlik limit tugagan. Qolgan foydalanuvchilar kun bo'yi 503 ko'radi.
Oddiy API'da bunday holatdan Rate limiting himoya qiladi — daqiqasiga 600 so'rov. AI uchun bu yetmaydi: bitta AI so'rovi oddiy GET dan minglab marta qimmat. Shuning uchun alohida, kunlik va tokenli hisob kerak.
O'xshatish: kafedagi bepul choy. Har mehmonga bir piyola — muammo yo'q. Lekin kimdir termos olib kelsa, choy hammaga yetmaydi. Kafe "har mehmonga kuniga 3 piyola" qoidasini qo'yadi va kim necha piyola olganini daftarga yozadi. Bir xil buyurtmani qayta tayyorlamaslik uchun esa tayyor choyni issiq saqlaydi — bu kesh.
2. Narx: tokenlar
LLM provayderlari token bo'yicha haq oladi — kirish (prompt) va chiqish (javob) alohida, chiqish odatda qimmatroq. Narxlar provayder va modelga qarab o'zgarib turadi — joriy narxni provayder sahifasidan oling. Biz bu yerda narxni emas, token sonini o'lchaymiz.
Kanon jonli o'lchovlaridan:
| Amal | Kirish | Chiqish |
|---|---|---|
| Qadamlarga bo'lish (bitta vazifa) | 227 | 87–238 |
| Yordamchi (RAG, 8 vazifa kontekst) | 449–478 | 29–48 |
Tokenlarga ta'sir qiladigan ikki kanon topilmasi:
- Til. Bir xil ma'noli gap o'zbekchada 29 token, inglizchada 17 token — ~1.7 baravar ko'p (LLM API asoslari darsidagi o'lchov). O'zbek tilidagi xizmat bir xil ish uchun ko'proq to'laydi.
- Fikrlash (thinking) tokenlari. Kanon tanlamagan kattaroq model bir xil so'rovga 85 ta javob tokeni va yana 423 ta fikrlash tokeni sarfladi — ular ham chiqish narxida hisoblanadi.
Kanon sukut chegaralari: kuniga 30 so'rov va 60 000 token (AI_KUNLIK_SOROV, AI_KUNLIK_TOKEN). 30 ta qadamlarga bo'lish ~14 000 token — chegaraning chorak qismi. Token chegarasi uzun matnlar bilan "kam so'rov, lekin qimmat" hujumidan himoya qiladi.
3. Kunlik kvota
3.1 Qayerda saqlash
Kvota — har foydalanuvchi uchun har kun bitta qator: (foydalanuvchi_id, kun) → so'rovlar va tokenlar. Kanon jadvali ai_sarf. Kun — foydalanuvchi mintaqasida: Toshkentda yarim tun UTC bo'yicha 19:00. UTC kuni bilan hisoblasangiz, Toshkentdagi foydalanuvchining kvotasi kechki 19:00 da "yangilanib" qolardi.
Ishlash tartibi uch qadam:
- LLM chaqiruvidan oldin — bitta so'rov band qilinadi. Chegarada — 429.
- Provayder xatosi bo'lsa (503, timeout) — band qilingan so'rov qaytariladi. Foydalanuvchi o'zi aybdor bo'lmagan xato uchun jazolanmaydi.
- Javobdan keyin — tokenlar qo'shiladi. Ular oldindan noma'lum.
3.2 Poyga holati
Eng oddiy kod "o'qi, tekshir, yoz" bo'lardi. Muammo — poyga holati (race condition): bir foydalanuvchining 10 ta so'rovi bir vaqtda keladi, hammasi "hozir 0" ni o'qiydi, hammasi o'tadi. Brauzerda modellaymiz: 10 ta parallel so'rov, chegara 5. kut() — bazaga borib kelish vaqti.
const kut = () => new Promise((r) => setTimeout(r, 5));
async function sinov(nom, band) {
const baza = { sorovlar: 0 };
const natija = await Promise.all(
Array.from({ length: 10 }, () => band(baza, 5)));
const ok = natija.filter(Boolean).length;
const b = baza.sorovlar;
console.log(`${nom}: ruxsat ${ok}, rad ${10 - ok}, bazada ${b}`);
}
// ❌ avval o'qish, keyin yozish — oraliqda boshqalar ham o'qiydi
async function oddiy(baza, chegara) {
const hozir = baza.sorovlar;
await kut();
if (hozir >= chegara) return false;
baza.sorovlar = hozir + 1;
return true;
}
// ✅ tekshirish va oshirish bitta qadamda (ON CONFLICT … WHERE kabi)
async function atomik(baza, chegara) {
await kut();
if (baza.sorovlar >= chegara) return false;
baza.sorovlar++;
return true;
}
await sinov("oddiy", oddiy);
await sinov("atomik", atomik);Konsolda:
oddiy: ruxsat 10, rad 0, bazada 1
atomik: ruxsat 5, rad 5, bazada 5Oddiy usulda 10 tasi ham o'tdi, bazada esa 1 yozildi — 9 ta yozuv bir-birini yo'qotdi. Atomik usulda — aniq 5. Modelda atomiklik oddiy: JavaScript bitta oqimda ishlaydi, tekshirish va oshirish orasida await yo'q. Haqiqiy serverda esa so'rovlar turli ulanishlardan keladi — atomiklikni baza beradi.
3.3 Atomik SQL
Kanon band qilishni bitta INSERT … ON CONFLICT DO UPDATE … WHERE bilan qiladi. Qator yo'q bo'lsa — yaratadi (1). Bor bo'lsa — faqat chegaradan kichik bo'lsa oshiradi. Chegarada WHERE yolg'on bo'ladi va RETURNING hech narsa qaytarmaydi. Kanon so'rovini o'z bazamizda sinadik. Chegara 3, kun qotirilgan, PREPARE — bir xil so'rovni nom bilan qayta chaqirish uchun (biz ishga tushirdik, PostgreSQL 17.6):
PREPARE band(bigint, date) AS
INSERT INTO ai_sarf AS s (foydalanuvchi_id, kun, sorovlar)
VALUES ($1, $2, 1)
ON CONFLICT (foydalanuvchi_id, kun)
DO UPDATE SET sorovlar = s.sorovlar + 1
WHERE s.sorovlar < 3 AND s.tokenlar < 60000
RETURNING sorovlar;
EXECUTE band(1, '2026-11-03');
EXECUTE band(1, '2026-11-03');
EXECUTE band(1, '2026-11-03');
EXECUTE band(1, '2026-11-03');
EXECUTE band(1, '2026-11-04');
SELECT foydalanuvchi_id, kun, sorovlar FROM ai_sarf ORDER BY kun;PREPARE
sorovlar
----------
1
(1 row)
INSERT 0 1
sorovlar
----------
2
(1 row)
INSERT 0 1
sorovlar
----------
3
(1 row)
INSERT 0 1
sorovlar
----------
(0 rows)
INSERT 0 0
sorovlar
----------
1
(1 row)
INSERT 0 1
foydalanuvchi_id | kun | sorovlar
------------------+------------+----------
1 | 2026-11-03 | 3
1 | 2026-11-04 | 1
(2 rows)To'rtinchi chaqiruv (0 rows) va INSERT 0 0 qaytardi — kvota tugadi, kod buni 429 ga aylantiradi. Ertasi kuni (2026-11-04) — yangi qator, yana 1. Atomiklik qayerdan? ON CONFLICT mavjud qatorni qulflaydi. Ikkinchi parallel so'rov birinchisi tugashini kutadi va yangilangan qiymatni ko'radi (UPSERT va MERGE). Kanon testi buni haqiqiy bazada tekshirdi: chegara 5, 10 ta parallel so'rov — aniq 5 × 200 va 5 × 429.
Kanon kodida kun qotirilmagan: (now() AT TIME ZONE $4)::date — foydalanuvchi mintaqasidagi bugun.
3.4 Retry-After
429 javobida mijozga "qachon qayta urinish mumkin" deyiladi — Retry-After sarlavhasi, soniyada. Kvota yarim tunda tiklanadi, demak — yarim tungacha qolgan vaqt. Toshkentda soat 21:30 bo'lsa:
SELECT ceil(extract(epoch FROM
(('2026-11-03 21:30'::timestamp)::date + 1)
- '2026-11-03 21:30'::timestamp))::int AS retry_after_s; retry_after_s
---------------
9000
(1 row)9000 soniya — 2.5 soat. Kanon xuddi shu formulani now() AT TIME ZONE bilan hisoblaydi. Uni JavaScript'da yozish — 2-mashq.
Tekshirib ko'ring: Provayder 503 qaytardi. Nega kanon band qilingan so'rovni qaytaradi, lekin tokenlarni qo'shmaydi?
Javob
503 — provayderning xatosi, foydalanuvchiniki emas. Kvotani yeb qo'ysak, u xizmat ishlamagan paytda limitini yo'qotadi. Tokenlar esa faqat muvaffaqiyatli javobda ma'lum — xatoda provayder ularni qaytarmaydi. Kanon testida aynan shu tekshiriladi: 503 dan keyin 3 ta muvaffaqiyatli so'rov hali ham o'tadi.
4. Javob keshi
4.1 Nimani keshlaymiz
Aziz bitta vazifani bugun uch marta "qadamlarga bo'l" qildi. Vazifa matni o'zgarmagan, model va prompt ham bir xil. Har safar LLM'ni chaqirish — pul va 1.6 s kutish. Javob keshi birinchi javobni saqlaydi va keyingilarini darhol qaytaradi. Kanon jonli o'lchovida:
--- POST /v1/vazifalar/1/qadamlar (JSON)
…
HTTP 200, 1.607296 s
--- POST /v1/vazifalar/1/qadamlar (Accept: text/event-stream)
…
HTTP 200, birinchi bayt 0.013318 s, jami 0.033723 sJavob tanalarini … bilan qisqartirdik. Birinchi so'rov — LLM, 1.6 s, "kesh":false. Ikkinchisi — oqim rejimida, lekin javob keshdan: 0.034 s, oxirgi hodisada "kesh":true. Keshdan kelgan javob kvotani ham sarflamaydi.
Server logi buni tasdiqlaydi:
{"daraja":"info","ilova":"vazifalar-api","sorovId":"90550518-6009-4a27-b66d-ca6c47866f9b","ai":{"amal":"qadamlar","model":"gemini-3.5-flash-lite","tokenlar":{"kirish":227,"chiqish":198},"urinishlar":1,"kesh":false},"msg":"ai javobi"}
{"daraja":"info","ilova":"vazifalar-api","sorovId":"4d5ec2dd-25a8-4775-ba15-f704a4935176","ai":{"amal":"qadamlar","model":"gemini-3.5-flash-lite","tokenlar":{"kirish":227,"chiqish":198},"urinishlar":1,"kesh":true},"msg":"ai javobi"}Ikkinchi qator keyingi darsdagi MCP sinovidan — u ham keshdan javob oldi. Logda vazifa matni ham, qadamlar ham yo'q — faqat model, tokenlar va kesh. Bu haqida «Loglar va baholash» bo'limida.
4.2 Kesh kaliti
Kesh kaliti nimaga bog'liq bo'lishi kerak? Javobni o'zgartiradigan hamma narsaga:
- foydalanuvchi — boshqa birovning javobi qaytmasin;
- model — boshqa model boshqacha javob beradi;
- prompt versiyasi — tizim ko'rsatmasi yoki sxema o'zgarsa, eski javoblar yaroqsiz;
- vazifa matni va muddati — kirish ma'lumoti.
Kanon bularni birlashtirib SHA-256 oladi. Brauzerda Web Crypto bilan:
async function keshKaliti(egasi, ...qismlar) {
const b = new TextEncoder().encode(qismlar.join("\u0000"));
const h = new Uint8Array(await crypto.subtle.digest("SHA-256", b));
const b64 = btoa(String.fromCharCode(...h));
const url = b64.replaceAll("+", "-").replaceAll("/", "_")
.replace(/=+$/, "");
return `vz:ai:${egasi}:${url}`;
}
const m = "gemini-3.5-flash-lite";
const q = (egasi, matn, v = "qadamlar-v1") =>
keshKaliti(egasi, "qadamlar", m, v, matn, "");
console.log(await q(1, "Non olish"));
console.log(await q(1, "Non olish"));
console.log(await q(2, "Non olish"));
console.log(await q(1, "Non olish", "qadamlar-v2"));Konsolda:
vz:ai:1:LQ9LDMiZ0SdCcgf7HUUVscVgYbikOJVDYApGSz_NJe8
vz:ai:1:LQ9LDMiZ0SdCcgf7HUUVscVgYbikOJVDYApGSz_NJe8
vz:ai:2:LQ9LDMiZ0SdCcgf7HUUVscVgYbikOJVDYApGSz_NJe8
vz:ai:1:2f50ad-ov_dr7L_s3eqWOUXFyJxZjMk-KJHWtuOL-CgNatijani Node'ning createHash("sha256").digest("base64url") bilan solishtirdik — bir xil. Qatorlarni o'qiymiz:
- Birinchi ikkitasi teng — ikkinchi so'rov keshga tushadi.
- Uchinchisi — boshqa foydalanuvchi (
vz:ai:2:). Xesh qismi bir xil, lekin kalit boshqa. Foydalanuvchi id'si kalit boshida ochiq turibdi — Redis'da bitta foydalanuvchining hamma AI keshinivz:ai:2:*bilan topish mumkin. - To'rtinchisi — prompt versiyasi o'zgardi, xesh butunlay boshqa. Eski javoblar o'z-o'zidan "unutiladi" — 7 kunlik TTL (yashash muddati) tugaganda Redis ularni o'chiradi.
Kodning o'zida ikki yangi narsa bor. Birinchisi — Web Crypto'ning crypto.subtle.digest metodi. U brauzerda ham, Node'da ham bor va baytlardan 32 baytli SHA-256 xeshini qaytaradi. Ikkinchisi — base64url: baytlarni harf va raqamlarga aylantirishning URL va kalitlar uchun xavfsiz shakli: + o'rniga -, / o'rniga _, oxiridagi = lar tashlanadi. Shuning uchun kalitda faqat 43 ta "oddiy" belgi bor.
\u0000 — ajratuvchi belgi. Usiz "ab" + "c" va "a" + "bc" bir xil satr beradi; nol belgi matnda uchramaydi.
4.3 Kesh zaharlash
Nega foydalanuvchi bo'yicha? Keshlash: CacheModule darsidagi saboqni eslang: umumiy kalitli kesh — begona javob va kesh zaharlash (cache poisoning) xavfi. Ikki foydalanuvchida bir xil matnli vazifa bo'lishi mumkin. Lekin birining vazifa matnida yashirin ko'rsatma bo'lsa va model "aldansa", buzilgan javob boshqasiga ham ketardi. Foydalanuvchi bo'yicha kesh tejamni kamaytiradi, lekin xavfsiz.
4.4 Prompt caching
Javob keshidan farqli narsa — prompt caching (prompt keshi). Uni provayderning o'zi qiladi: so'rov boshidagi bir xil qism (uzun tizim ko'rsatmasi, katta hujjat) provayder tomonida saqlanadi. Keyingi so'rovlarda bu qism uchun kamroq to'lanadi va javob tezroq keladi. Model baribir chaqiriladi — faqat kirish arzonlashadi.
Gemini va Anthropic hujjatlarida bu imkoniyat tasvirlangan. Kanon uni sinamadi: bizning tizim ko'rsatmasi va kontekst qisqa (~227–478 token). Provayderlar odatda minimal uzunlik talab qiladi — aniq chegarani joriy hujjatdan tekshiring. Uzun umumiy kontekstli RAG (masalan, 50 betlik qo'llanma) uchun prompt caching jiddiy tejaydi.
Tekshirib ko'ring: Vazifa matni o'zgarmadi, lekin uning muddati ertaga ko'chirildi. Kesh qayta ishlatiladimi?
Javob
Yo'q. Muddat ham kesh kalitida bor (v.muddat) — u promptga ham kiradi ("Muddat: …"). Muddat o'zgarsa, LLM boshqacha reja tuzishi mumkin. Kalitga promptga kiradigan hamma narsa qo'shiladi — aks holda eski, endi noto'g'ri javob qaytadi.
5. Provayder limitlari: 429 va 529
Bizning kvotadan tashqari provayderning ham limiti bor. U oshsa, provayder 429 qaytaradi; yuklangan paytda — 503 (Gemini) yoki 529 (Anthropic).
Kanon bunga uch qatlam bilan javob beradi:
- Mijozga 503, 429 emas. Provayderning 429'i — bizning mijozimiz uchun 503 +
Retry-After. Mijoz o'z limitiga urilmagan, xizmat band (LLM API asoslari darsidagiaiXatosi()). Bizning kvota esa haqiqiy 429ai-kvotaqaytaradi — ikki holat aralashmaydi. - Zaxira (fallback) model — Vercel AI SDK darsida: vaqtinchalik xatoda boshqa model bir marta.
- Qayta urinish va circuit breaker — eksponensial kutish bilan qayta urinish va "provayder yiqilgan bo'lsa, umuman chaqirmaslik". Buni Resilience naqshlari darsida loyihaga qo'shamiz. Bitta so'rov ichida tartib shunday: avval asosiy model qayta urinishlar bilan chaqiriladi, u baribir yiqilsa — zaxira model. Kanonda ikkala model ham bitta umumiy breaker va qayta urinish qatlamidan o'tadi. Shu sababli zaxiraning har muvaffaqiyati breaker hisobini nolga qaytaradi — bu tuzoqni Resilience naqshlari darsidagi «Biz topgan tuzoq: zaxira va breaker tartibi» bo'limida o'lchaymiz. Bu yerdagi "zaxira" — boshqa model, ma'lumotlar bazasining zaxira nusxasi (backup) emas.
Fon navbati — yana bir yo'l. Foydalanuvchi natijani darhol kutmasa (kunlik hisobot, minglab hujjat embedding'i), ish BullMQ navbatiga qo'yiladi. Navbat ishchilari provayder limitiga moslab, sekinroq ishlaydi va 429 da kutadi. Kanonda AI ishlari sinxron (va SSE oqim), chunki foydalanuvchi javobni shu zahoti kutadi.
flowchart TD
S["POST …/qadamlar"] --> K{"Keshda bormi?"}
K -->|"ha"| J["Javob (kesh: true)"]
K -->|"yo'q"| B{"Kvota band?"}
B -->|"chegara"| R["429 ai-kvota"]
B -->|"ha"| L["LLM"]
L -->|"xato"| Q["Kvota qaytariladi"]
Q --> E["503 / 504 / 502"]
L -->|"javob"| T["Tokenlar qo'shiladi"]
T --> W["Keshga yoziladi"]
W --> J2["Javob (kesh: false)"]Diagrammada tartib muhim: kesh kvotadan oldin. Keshdan kelgan javob kvotaga tegmaydi. Xato yo'lida kvota qaytadi, javob yo'lida tokenlar qo'shiladi va javob keshga yoziladi.
6. Maxfiylik va xavfsizlik
6.1 Shaxsiy ma'lumot
Vazifa matnida telefon, email, karta raqami bo'lishi mumkin: "Alisherga qo'ng'iroq: +998 90 …". LLM chaqirilganda bu matn provayderga ketadi. Bu — PII (shaxsga doir ma'lumot — Ma'lumotlar bazasi xavfsizligi darsida ko'rgansiz) sizishi xavfi.
Uch himoya darajasi bor:
- Kamroq yuborish. Faqat kerakli maydon. Kanon faqat vazifa matni va muddatini yuboradi — email, ism, boshqa vazifalar ketmaydi.
- Maskalash. Yuborishdan oldin telefon, email, karta raqamini
[telefon]kabi belgilar bilan almashtirish. Kanonda yo'q — buni 3-mashqda yozasiz. Narxi: model "Alisherga qo'ng'iroq: [telefon]" ni ko'radi va qadamlarda raqamni yoza olmaydi. - Shartnoma va oshkoralik. Provayder ma'lumotni qanday saqlashi va o'qitishda ishlatishi — uning shartlarida. Foydalanuvchiga ma'lumot AI provayderiga yuborilishi maxfiylik siyosatida aytiladi (Shaxsiy ma'lumotlar qonunchiligi darsidagi
MAXFIYLIK.md).
6.2 Prompt injection va moderatsiya
Prompt injection himoyasi o'tgan darslarda qurildi: tizim va ma'lumot ajratilgan, teglar, sxema, manba tekshiruvi, yozuvchi vosita yo'q. Bu darsda yangi qatlam qo'shilmaydi — lekin kvota ham himoya: hujumchi cheksiz urinish qila olmaydi.
Moderatsiya — so'rov yoki javobda zararli kontent (haqorat, zo'ravonlik) bormi, tekshirish. Gemini o'zining xavfsizlik filtriga ega: bloklangan so'rovda kanon LlmXatosi("rad") beradi. Alohida moderatsiya API'si kanonda yo'q va sinalmagan. Vazifalar ilovasi uchun bu yetarli — foydalanuvchi faqat o'zi uchun yozadi. Ommaviy chat yoki izohlar bo'lsa — alohida moderatsiya kerak.
7. Loglar va baholash
7.1 Matnsiz loglar
Logga nima yoziladi? Kanon qoidasi: matn yo'q. Faqat amal, model, tokenlar, urinishlar, kesh, manbalar soni. Sabab: loglar ko'p joyga ketadi (fayl, monitoring xizmati, dasturchi ekrani), uzoq saqlanadi va ularga ko'p odam kiradi. Foydalanuvchining savoli va vazifasi u yerda bo'lmasligi kerak.
Statistika uchun esa — AI hodisalari. Partitioning darsidagi vazifa_hodisalari jadvalining tur cheklovida (CHECK) uchta AI turi boshidan turgan edi: ai_qadamlar, ai_savol, semantik_qidiruv. Endi ular ishlatila boshlaydi — jadval o'zgarmaydi. Ularda ham matn yo'q: { model, tokenlar, kesh }. Kanon testi buni tekshiradi: hodisalarda "Vazifa" va "qadam" so'zlari yo'q. Shu hodisalardan "kunlik AI so'rovlari", "kesh ulushi", "o'rtacha tokenlar" kabi grafiklar quriladi.
7.2 Baholash va prompt versiyasi
Prompt o'zgarishi — kod o'zgarishi kabi. Kanonda PROMPT_VERSIYA = "qadamlar-v1": prompt yoki sxema o'zgarsa, versiya oshiriladi, eski kesh o'z-o'zidan eskiradi. Versiya loglarga ham yozilsa, "yangi prompt yaxshiroq ishladimi?" degan savolga hodisalar orqali javob berish mumkin.
Har prompt o'zgarishidan oldin baholash to'plami yurgiziladi (RAG asoslari darsidagi recall@k). LLM javobi deterministik emasligi uchun bitta yugurish yetmaydi — har savolni bir necha marta berib, natijani o'rtacha baholash kerak.
8. Ko'p uchraydigan xatolar
| Belgi | Sabab | Davosi |
|---|---|---|
| Kvota chegarasidan oshib ketdi | "o'qib, keyin yozish" | bitta atomik INSERT … ON CONFLICT … WHERE |
| Kvota kechki 19:00 da tiklanadi | kun UTC bo'yicha | foydalanuvchi mintaqasidagi kun |
| Provayder xatosi kvotani yedi | qaytar() yo'q |
xatoda band qilingan so'rov qaytariladi |
| Boshqa foydalanuvchi javobi keldi | kesh kalitida foydalanuvchi yo'q | vz:ai:<egasi>:… |
| Prompt o'zgardi, javoblar eski | kalitda prompt versiyasi yo'q | PROMPT_VERSIYA |
| Logda foydalanuvchi savoli | matn loglangan | faqat model, tokenlar, kesh |
| Mijoz "limitim tugadi" deb o'yladi, aslida provayder band edi | provayder 429'i mijozga o'zgarishsiz uzatildi | mijozga 503 + Retry-After; 429 faqat o'z kvotamizda |
9. Mashqlar
Mashqlar kurs/mashqlar/31/19-ai-prodakshn/ da. 2- va 3-mashq yechimlari Node 24.21.0 da tekshirilgan (biz ishga tushirdik).
1-mashq (oson): bo'shliqlarni to'ldiring
- Kanon sukut kunlik so'rov chegarasi —
- Kvota tugaganda HTTP holat kodi —
- Provayderning 429'i mijozga qaysi kod bilan qaytadi? —
- Keshdan javob kvotani sarflaydimi? —
- Prompt o'zgarganda oshiriladigan doimiy —
Yechim
- 30 —
AI_KUNLIK_SOROV; token chegarasi 60 000. - 429 —
ai-kvota,Retry-Afteryarim tungacha. - 503 — mijoz o'z limitiga urilmagan, xizmat band.
- yo'q — kesh kvotadan oldin tekshiriladi.
- PROMPT_VERSIYA — kesh kalitining bir qismi.
2-mashq (o'rta): yarim tungacha
yarimTungacha(hozirUtc) ni yozing: Toshkent vaqti bo'yicha keyingi yarim tungacha qolgan soniya (yuqoriga yaxlitlab, kamida 1). Toshkent — UTC+5, yozgi vaqt yo'q. Ishora: vaqtga 5 soat qo'shing va Date.UTC(yil, oy, kun + 1) (Backend'da sana va vaqt).
Yechim
// Toshkent — UTC+5, yozgi vaqt yo'q
function yarimTungacha(hozirUtc) {
const t = new Date(hozirUtc.getTime() + 5 * 3600_000);
const tun = Date.UTC(t.getUTCFullYear(), t.getUTCMonth(),
t.getUTCDate() + 1);
return Math.max(1, Math.ceil((tun - t.getTime()) / 1000));
}
console.log(yarimTungacha(new Date("2026-11-03T16:30:00Z")));
console.log(yarimTungacha(new Date("2026-11-03T18:59:59Z")));
console.log(yarimTungacha(new Date("2026-11-03T19:00:00Z")));Konsolda:
9000
1
86400Birinchisi — Toshkentda 21:30, SQL natijasi bilan bir xil (9000). Ikkinchisi — 23:59:59, bir soniya qoldi. Uchinchisi — aynan yarim tun: yangi kun boshlandi, keyingi tiklanish 24 soatdan keyin. Yozgi vaqti bor mintaqalar uchun bu usul yaramaydi — u yerda Intl yoki bazadagi AT TIME ZONE kerak.
3-mashq (qiyin): PII maskalash
maskala(matn) ni yozing: email → [email], O'zbekiston telefoni (+998 90 123 45 67, bo'shliq yoki chiziqcha bilan) → [telefon], 16 xonali karta raqami → [karta]. Ishora: RegExp, replace global bayroq bilan.
Yechim
function maskala(matn) {
return matn
.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[email]")
.replace(/\+?998[\s-]?\d{2}[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2}/g,
"[telefon]")
.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g,
"[karta]");
}
console.log(maskala("Alisherga qo'ng'iroq: +998 90 123 45 67"));
console.log(maskala("Hisobotni ali@example.com ga yuborish"));
console.log(maskala("8600 1234 5678 9012 kartaga 50 000 so'm"));
console.log(maskala("Non olish"));Konsolda:
Alisherga qo'ng'iroq: [telefon]
Hisobotni [email] ga yuborish
[karta] kartaga 50 000 so'm
Non olish"50 000 so'm" o'zgarmadi — 16 xonali emas. Maskalash hech qachon mukammal emas: "to'qson 123 45 67" kabi so'z bilan yozilgan raqamni regex topmaydi. Shuning uchun asosiy himoya — kamroq yuborish, maskalash esa qo'shimcha qatlam.
10. Loyiha qadami: kvota, kesh va AI hodisalari
Bu va keyingi dars — bitta bosqich. Hali commit qilmang — commit MCP server yozish darsida.
10.1 sql/304-ai-sarf.sql
Yangi fayl va aynan shu matnli migratsiya prisma/migrations/20261102130000_ai_sarf/migration.sql. Izohlar qisqartirilgan:
-- 304-ai-sarf.sql — AI xarajati: foydalanuvchi kunlik kvotasi
-- (so'rovlar va tokenlar). Kun — APP_TZ (ilova) bo'yicha, kod beradi.
-- Bitta qator = (foydalanuvchi, kun): band qilish — atomik
-- INSERT … ON CONFLICT DO UPDATE … WHERE chegara.
CREATE TABLE ai_sarf (
foydalanuvchi_id bigint NOT NULL,
kun date NOT NULL,
sorovlar integer NOT NULL DEFAULT 0,
tokenlar integer NOT NULL DEFAULT 0,
CONSTRAINT ai_sarf_pkey PRIMARY KEY (foydalanuvchi_id, kun),
CONSTRAINT ai_sarf_foydalanuvchi_id_fkey
FOREIGN KEY (foydalanuvchi_id)
REFERENCES foydalanuvchilar (id) ON DELETE CASCADE,
CONSTRAINT ai_sarf_sorovlar_check
CHECK (sorovlar >= 0 AND tokenlar >= 0)
);
GRANT SELECT, INSERT, UPDATE ON ai_sarf TO vazifalar_ilova;
ALTER TABLE ai_sarf ENABLE ROW LEVEL SECURITY;
CREATE POLICY ai_sarf_egasi ON ai_sarf TO vazifalar_ilova
USING (foydalanuvchi_id = joriy_foydalanuvchi())
WITH CHECK (foydalanuvchi_id = joriy_foydalanuvchi());
COMMENT ON TABLE ai_sarf
IS 'AI kunlik kvotasi — so''rovlar va tokenlar';ON DELETE CASCADE ning qo'shimcha foydasi bor. Saqlash muddati va anonimlashtirish darsidagi hisob_anonimlashtir() foydalanuvchiga CASCADE FK bilan bog'langan jadvallarni katalogdan o'zi topadi. Kanon testida hisob o'chirilganda ai_sarf qatori ham o'chdi — funksiyaga tegilmagan holda. DELETE huquqi API roliga berilmagan: eski qatorlar tozalash ishida o'chiriladi.
prisma/schema.prisma ga model (Prisma sxemasi migratsiya bilan mos bo'lishi uchun; kod jadval bilan SQL orqali ishlaydi):
/// AI kunlik kvotasi — so'rovlar va tokenlar (kun — APP_TZ)
model AiSarf {
foydalanuvchiId BigInt @map("foydalanuvchi_id")
kun DateTime @db.Date
sorovlar Int @default(0)
tokenlar Int @default(0)
foydalanuvchi Foydalanuvchi @relation(fields: [foydalanuvchiId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([foydalanuvchiId, kun])
@@map("ai_sarf")
}Foydalanuvchi modeliga esa aiSarf AiSarf[] qatori. @relation qatori uzun — Prisma sxemasida atribut qatorini bo'lib bo'lmaydi, shuning uchun aynan qoldirdik.
10.2 src/ai/kvota.ts
// kvota.ts — foydalanuvchi kunlik AI kvotasi (sql/304 `ai_sarf`).
// Kun — APP_TZ bo'yicha (Toshkent yarim tuni). Band qilish — LLM
// chaqiruvidan OLDIN, bitta atomik SQL bilan (parallel 10 so'rov ham
// chegaradan oshmaydi — test). Provayder xatosi bo'lsa — qaytariladi
// (foydalanuvchi aybi bo'lmagan xato uchun jazolanmaydi); tokenlar —
// javobdan keyin qo'shiladi (oldindan noma'lum).
import type { Pool } from "pg";
import { egasiSifatida } from "../umumiy/rls.ts";
import { IlovaXatosi } from "../umumiy/xatolar.ts";
export type KvotaSozlamasi = {
kunlikSorov: number; kunlikToken: number; tz: string;
};
export class KvotaTugadiXatosi extends IlovaXatosi {
constructor(retryAfterS: number) {
super(429,
"Bugungi AI so'rovlari chegarasi tugadi — ertaga qayta urinib "
+ "ko'ring", {
kod: "ai-kvota", sarlavha: "AI kvotasi tugadi",
httpSarlavhalar: { "Retry-After": String(retryAfterS) },
});
}
}
export class AiKvota {
constructor(
private readonly pool: Pool,
private readonly s: KvotaSozlamasi,
) {}
/** kvotadan bitta so'rov band qiladi; chegarada — KvotaTugadiXatosi
* (429, Retry-After — mintaqa yarim tunigacha) */
async band(egasi: number): Promise<void> {
type Qator = { sorovlar: number; qolgan_s: number };
const qator = await egasiSifatida(this.pool, egasi,
async (c) => (await c.query<Qator>(
`INSERT INTO ai_sarf AS s (foydalanuvchi_id, kun, sorovlar)
VALUES ($1, (now() AT TIME ZONE $4)::date, 1)
ON CONFLICT (foydalanuvchi_id, kun)
DO UPDATE SET sorovlar = s.sorovlar + 1
WHERE s.sorovlar < $2 AND s.tokenlar < $3
RETURNING sorovlar, 0 AS qolgan_s`,
[egasi, this.s.kunlikSorov, this.s.kunlikToken, this.s.tz],
)).rows[0]);
if (qator !== undefined) return;
const { rows: [q] } = await this.pool.query<{ s: number }>(
`SELECT ceil(extract(epoch FROM (((now() AT TIME ZONE $1)::date
+ 1) - (now() AT TIME ZONE $1))))::int AS s`, [this.s.tz]);
throw new KvotaTugadiXatosi(Math.max(1, q?.s ?? 3600));
}
async qaytar(egasi: number): Promise<void> {
await egasiSifatida(this.pool, egasi, (c) => c.query(
`UPDATE ai_sarf SET sorovlar = greatest(sorovlar - 1, 0)
WHERE foydalanuvchi_id = $1
AND kun = (now() AT TIME ZONE $2)::date`,
[egasi, this.s.tz]));
}
async tokenQosh(egasi: number, tokenlar: number): Promise<void> {
await egasiSifatida(this.pool, egasi, (c) => c.query(
`UPDATE ai_sarf SET tokenlar = tokenlar + $3
WHERE foydalanuvchi_id = $1
AND kun = (now() AT TIME ZONE $2)::date`,
[egasi, this.s.tz, tokenlar]));
}
async holat(egasi: number): Promise<{
sorovlar: number; tokenlar: number;
chegara: { sorovlar: number; tokenlar: number };
}> {
type Sarf = { sorovlar: number; tokenlar: number };
const q = await egasiSifatida(this.pool, egasi,
async (c) => (await c.query<Sarf>(
`SELECT sorovlar, tokenlar FROM ai_sarf
WHERE foydalanuvchi_id = $1
AND kun = (now() AT TIME ZONE $2)::date`,
[egasi, this.s.tz])).rows[0]);
return {
sorovlar: q?.sorovlar ?? 0, tokenlar: q?.tokenlar ?? 0,
chegara: {
sorovlar: this.s.kunlikSorov, tokenlar: this.s.kunlikToken,
},
};
}
}Ikki tafsilot. greatest(sorovlar - 1, 0) — CHECK (sorovlar >= 0) buzilmasligi uchun. Qaytarish xato bilan ikki marta chaqirilsa ham son manfiy bo'lmaydi. qaytar va tokenQosh kunni qayta hisoblaydi. So'rov 23:59:59 da band qilinib, javob 00:00:01 da kelsa, tokenlar yangi kunga yoziladi. Bu — kanonda qabul qilingan kichik noaniqlik.
10.3 src/ai/nazorat.ts
Kesh, kvota o'rami va hodisalar bitta faylda:
// nazorat.ts — AI xususiyatini prodakshnda boshqarish — javob keshi,
// kvota, hodisa (statistika) bir joyda.
// Kesh: bir xil (foydalanuvchi, model, prompt versiyasi, matn) —
// bir xil javob; LLM qayta chaqirilmaydi, kvota sarflanmaydi. Kalit
// foydalanuvchi bo'yicha (umumiy kalitli kesh — begona javob/
// zaharlash xavfi). Redis bo'lsa — Redis (TTL), bo'lmasa — jarayon
// xotirasi (LRU emas, oddiy chegara).
// Hodisa: vazifa_hodisalari (301) ga `ai_qadamlar` / `ai_savol` /
// `semantik_qidiruv` — MATNSIZ (model, tokenlar, kesh).
import { createHash } from "node:crypto";
import type { Pool } from "pg";
import { egasiSifatida } from "../umumiy/rls.ts";
import type { RedisKlient } from "../ombor-yarat.ts";
import type { AiKvota } from "./kvota.ts";
export interface AiKesh {
ol(kalit: string): Promise<string | undefined>;
yoz(kalit: string, qiymat: string, ttlS: number): Promise<void>;
}
export class XotiraKesh implements AiKesh {
readonly #m = new Map<string, { q: string; tugash: number }>();
constructor(private readonly maks = 1000) {}
ol(k: string): Promise<string | undefined> {
const x = this.#m.get(k);
if (x === undefined || x.tugash < Date.now()) {
this.#m.delete(k);
return Promise.resolve(undefined);
}
return Promise.resolve(x.q);
}
yoz(k: string, q: string, ttlS: number): Promise<void> {
if (this.#m.size >= this.maks) {
this.#m.delete(this.#m.keys().next().value ?? "");
}
this.#m.set(k, { q, tugash: Date.now() + ttlS * 1000 });
return Promise.resolve();
}
}
export class RedisKesh implements AiKesh {
constructor(private readonly r: RedisKlient) {}
async ol(k: string): Promise<string | undefined> {
return (await this.r.get(k)) ?? undefined;
}
async yoz(k: string, q: string, ttlS: number): Promise<void> {
await this.r.set(k, q, { EX: ttlS });
}
}
export const keshKaliti = (
egasi: number, ...qismlar: string[]
): string => `vz:ai:${String(egasi)}:${createHash("sha256")
.update(qismlar.join("\u0000")).digest("base64url")}`;
export type AiHodisa =
"ai_qadamlar" | "ai_savol" | "semantik_qidiruv";
export type AiNazorat = {
kvota?: AiKvota | undefined;
kesh?: AiKesh | undefined;
/** kesh yashash vaqti, s (sukut 7 kun) */
keshTtlS?: number;
pool?: Pool | undefined;
};
/** statistika hodisasi (matnsiz). Xato — asosiy javobni buzmaydi
* (log faqat chaqiruvchida) */
export async function aiHodisa(
pool: Pool | undefined, egasi: number, tur: AiHodisa,
vazifaId: number | null, malumot: Record<string, unknown>,
): Promise<void> {
if (pool === undefined) return;
await egasiSifatida(pool, egasi, (c) => c.query(
`INSERT INTO vazifa_hodisalari (egasi_id, vazifa_id, tur, malumot)
VALUES ($1, $2, $3, $4)`, [egasi, vazifaId, tur, malumot]));
}
type Tokenli = { tokenlar: { kirish: number; chiqish: number } };
/** LLM chaqiruvini kvota bilan o'raydi: oldin band, provayder
* (vaqtinchalik/ichki) xatosida qaytariladi, keyin tokenlar */
export async function kvotaBilan<T extends Tokenli>(
kvota: AiKvota | undefined, egasi: number, fn: () => Promise<T>,
): Promise<T> {
if (kvota === undefined) return fn();
await kvota.band(egasi);
let natija: T;
try {
natija = await fn();
} catch (x) {
await kvota.qaytar(egasi);
throw x;
}
const { kirish, chiqish } = natija.tokenlar;
await kvota.tokenQosh(egasi, kirish + chiqish);
return natija;
}AiKeshinterfeysi — ikki amalga oshirish: Redis (REDIS_URLbo'lsa, TTL Redis'ningEXi bilan) va jarayon xotirasi. Xotira keshi oddiy: 1000 tadan oshsa, eng birinchi qo'shilgan o'chadi (LRU kesh emas — "qachon ishlatilgani" hisobga olinmaydi). Server bir nechta nusxada ishlasa, xotira keshi har nusxada alohida bo'ladi — prodakshnda Redis.kvotaBilan— «Kunlik kvota» bo'limidagi uch qadam bitta funksiyada.try/catchfaqat LLM chaqiruvini o'raydi: band qilishning o'z xatosi (429) qaytarishni chaqirmaydi.aiHodisa—vazifa_hodisalariga yozadi. Kanondagivazifa_hodisalaritur CHECK cheklovida bu uch tur avvaldan bor.
10.4 src/ai/qadamlar.ts
Turlar — natijaga kesh maydoni, bog'liqliklarga nazorat. Kanondagi bitta uzun qatorlarni bo'ldik va metod argumentlari uchun Vazifa va OqimHodisasi tur taxalluslarini qo'shdik (ma'no o'zgarmagan):
import { aiHodisa, keshKaliti, kvotaBilan } from "./nazorat.ts";
import type { AiNazorat } from "./nazorat.ts";
// prompt yoki sxema o'zgarsa — oshiriladi (kesh kaliti)
export const PROMPT_VERSIYA = "qadamlar-v1";
export type QadamlarNatijasi = {
vazifaId: number; qadamlar: QadamT[]; model: string;
tokenlar: Tokenlar; urinishlar: number;
/** javob keshdan (LLM chaqirilmadi, kvota sarflanmadi) */
kesh: boolean;
};
export type QadamlarBogliqliklari = {
pool: Pool; provayder: LlmProvayder; tz: string;
/** kvota, kesh, hodisalar (berilmasa — yo'q) */
nazorat?: AiNazorat | undefined;
};
type Vazifa = { matn: string; muddat: string | null };
type OqimHodisasi =
| { tur: "qadam"; indeks: number; qadam: QadamT }
| { tur: "tugadi"; natija: QadamlarNatijasi };QadamlarXizmati ichida — kesh yordamchilari va yangi qadamla. Avvalgi qadamla tanasi o'zgarmagan holda #yarat(id, v, signal) xususiy metodiga ko'chadi, faqat oxirgi return ga kesh: false qo'shiladi:
/** kesh kaliti — foydalanuvchi + model + prompt versiyasi + matn
* + muddat (prompt o'zgarsa — PROMPT_VERSIYA oshiriladi, eski kesh
* o'z-o'zidan eskiradi) */
#keshKaliti(egasi: number, v: Vazifa): string {
return keshKaliti(egasi, "qadamlar", this.d.provayder.model,
PROMPT_VERSIYA, v.matn, v.muddat ?? "");
}
async #keshdan(
egasi: number, v: Vazifa,
): Promise<QadamlarNatijasi | undefined> {
const kalit = this.#keshKaliti(egasi, v);
const x = await this.d.nazorat?.kesh?.ol(kalit);
if (x === undefined) return undefined;
return { ...(JSON.parse(x) as QadamlarNatijasi), kesh: true };
}
async #yakunla(
egasi: number, id: number, v: Vazifa, n: QadamlarNatijasi,
): Promise<void> {
const z = this.d.nazorat;
if (!n.kesh) {
await z?.kesh?.yoz(this.#keshKaliti(egasi, v),
JSON.stringify(n), z.keshTtlS ?? 7 * 86_400);
}
await aiHodisa(z?.pool, egasi, "ai_qadamlar", id,
{ model: n.model, tokenlar: n.tokenlar, kesh: n.kesh });
}
/** to'liq javob: kesh → (kvota) → 1 so'rov + kerak bo'lsa 1
* tuzatish so'rovi */
async qadamla(
egasi: number, id: number, signal?: AbortSignal,
): Promise<QadamlarNatijasi> {
const v = await this.vazifa(egasi, id);
const eski = await this.#keshdan(egasi, v);
if (eski !== undefined) {
await this.#yakunla(egasi, id, v, eski);
return eski;
}
const n = await kvotaBilan(this.d.nazorat?.kvota, egasi,
() => this.#yarat(id, v, signal));
await this.#yakunla(egasi, id, v, n);
return n;
}Oqim rejimi murakkabroq: kvotaBilan oqimga mos kelmaydi — natija bitta qiymat emas, bo'laklar. Shuning uchun band qilish oqimdan oldin, qaytarish esa finally da:
/** oqim: har qadam tayyor bo'lishi bilan; oxirida to'liq javob
* tekshiriladi (tuzatish so'rovi yo'q — mijoz qadamlarni ko'rdi).
* Keshda bo'lsa — qadamlar darhol; kvota — oqim boshlanishidan
* oldin band, xato bo'lsa qaytariladi */
async *oqimla(
egasi: number, id: number, signal?: AbortSignal,
): AsyncIterable<OqimHodisasi> {
const v = await this.vazifa(egasi, id);
const eski = await this.#keshdan(egasi, v);
if (eski !== undefined) {
for (const [indeks, qadam] of eski.qadamlar.entries()) {
yield { tur: "qadam", indeks, qadam };
}
await this.#yakunla(egasi, id, v, eski);
yield { tur: "tugadi", natija: eski };
return;
}
const kvota = this.d.nazorat?.kvota;
await kvota?.band(egasi);
let bufer = "", berildi = 0, tugadi = false;
try {
const sorov = this.#sorov(v, signal);
for await (const b of this.d.provayder.oqim(sorov)) {
if (b.tur === "matn") {
bufer += b.matn;
for (const x of toliqQadamlar(bufer, berildi)) {
const q = Qadam.safeParse(x);
if (!q.success) {
const xabar = q.error.issues[0]?.message ?? "";
throw new LlmXatosi("notogri",
`qadam sxemaga mos emas: ${xabar}`);
}
yield { tur: "qadam", indeks: berildi++, qadam: q.data };
}
} else {
const t = javobniTekshir(bufer);
if (!t.ok) {
throw new LlmXatosi("notogri",
`model javobi sxemaga mos emas: ${t.xato}`);
}
const n: QadamlarNatijasi = {
vazifaId: id, qadamlar: t.qiymat.qadamlar,
model: b.javob.model, tokenlar: b.javob.tokenlar,
urinishlar: 1, kesh: false,
};
tugadi = true;
const { kirish, chiqish } = n.tokenlar;
await kvota?.tokenQosh(egasi, kirish + chiqish);
await this.#yakunla(egasi, id, v, n);
yield { tur: "tugadi", natija: n };
}
}
} finally {
// xato yoki mijoz uzildi — kvota qaytariladi
if (!tugadi) await kvota?.qaytar(egasi);
}
}finally uch holatda ishlaydi: oqim normal tugadi (tugadi = true — qaytarish yo'q), xato bo'ldi va mijoz uzildi. Oxirgisi qiziq: mijoz SSE ulanishini yopsa, controller generatorni to'xtatadi. Async generator to'xtatilganda finally bajariladi. Keshdagi qadamlar esa oqim kabi birin-ketin beriladi — frontend farqni sezmaydi.
Bu yerda halol aytish kerak bo'lgan teshik bor. LLM javoblarini streaming qilish darsida ko'rgandik: oqim o'rtada uzilsa, token hisobi kelmaydi, lekin provayder yozilgan tokenlar uchun baribir pul oladi. Kanon esa mijoz uzilganda band qilingan so'rovni qaytaradi. Oddiy foydalanuvchi uchun bu adolatli: tarmoq uzildi, u javob olmadi. Lekin ataylab har oqimni oxirgi qadamdan oldin uzadigan skript kvotaga umuman tegmasdan LLM'ni cheksiz chaqira oladi — tokenlar ham yozilmaydi. Tuzatish varianti: mijoz uzilganda so'rovni qaytarmaslik yoki kamida birinchi qadam yuborilgandan keyin qaytarmaslik. Kanon buni qilmagan — loyihada shunday qoladi, o'z xizmatingizda shu holatni test bilan tekshiring.
10.5 Yordamchi, modul va controller
src/ai/yordamchi.ts — konstruktorga to'rtinchi parametr private readonly nazorat?: AiNazorat, LLM chaqiruvi kvotaBilan ichida va return dan oldin hodisa:
const j = await kvotaBilan(this.nazorat?.kvota, egasi,
() => this.provayder.yarat({
tizim: RAG_TIZIM,
xabarlar: [{ rol: "user", matn }],
jsonSxema: YORDAMCHI_JSON_SXEMA, harorat: 0.1, maksToken: 800,
signal,
}));
// … manbalar tekshiruvi o'zgarmagan …
await aiHodisa(this.nazorat?.pool, egasi, "ai_savol", null, {
model: j.model, tokenlar: j.tokenlar, manbalar: manbalar.length,
});Yordamchi javobi keshlanmaydi: savollar har xil va kontekst (vazifalar ro'yxati) tez o'zgaradi.
src/ai/ai.module.ts — AiBogliqliklari ga kvota? va kesh?, AiXizmatlari ga nazorat. xizmatlar():
const nazorat: AiNazorat = {
pool: b.pool, kesh: b.kesh ?? new XotiraKesh(),
kvota: b.kvota === undefined
? undefined : new AiKvota(b.pool, { ...b.kvota, tz }),
};
// qaytariladigan obyektda: nazorat,
// yordamchi: new Yordamchi(b.provayder, qidiruv, tz, nazorat),
// qadamlar: new QadamlarXizmati({ …, tz, nazorat }),Config rejimida kvota config'dan, kesh — Redis bo'lsa Redis:
return xizmatlar({
pool: s.pool, provayder, tz: c.APP_TZ,
embeddingModel: c.AI_EMBEDDING_MODEL,
kvota: {
kunlikSorov: c.AI_KUNLIK_SOROV,
kunlikToken: c.AI_KUNLIK_TOKEN,
},
...(s.redis === undefined
? {} : { kesh: new RedisKesh(s.redis) }),
}, log);Testlar kvota bermasa — cheklovsiz. Shuning uchun avvalgi AI testlari o'zgarmadi.
src/ai/ai.controller.ts — uch joy: qadamlar logiga kesh: n.kesh; qidiruvdan keyin hodisa; yangi GET /v1/yordamchi/kvota:
await aiHodisa(this.ai.nazorat.pool, egasi, "semantik_qidiruv",
null, { rejim: s.rejim, natijalar: topilgan.length });
/** bugungi AI sarfi va chegarasi (kvota yo'q bo'lsa — null) */
@Get("kvota")
async kvota(@Egasi() egasi: number): Promise<object> {
const holat = await this.ai.nazorat.kvota?.holat(egasi);
return { kvota: holat ?? null };
}GET /v1/yordamchi/kvota — frontend "bugun 30 tadan 12 tasi ishlatildi" deb ko'rsatishi uchun. kvota metodi YordamchiController ichida, savol dan oldin.
10.6 Test
test/ai-nazorat.test.ts — yangi fayl, 4 ta test, bazali (soxta provayder). Eng muhimi — parallel so'rovlar (kanon testi, qatorlari bo'lingan):
test("kvota atomik: chegara 5, 10 ta PARALLEL so'rov — aniq 5 ta "
+ "200, 5 ta 429", async () => {
const p = new SoxtaProvayder({ javob: () => QADAMLAR,
kechikishMs: 50 });
const { app, aziz, idlar } = await ilova(p, 5, 10);
const javoblar = await Promise.all(
idlar.map((id) => qadam(app, aziz, id)));
const kodlar = javoblar.map((r) => r.status).sort();
assert.deepEqual(kodlar,
[200, 200, 200, 200, 200, 429, 429, 429, 429, 429]);
});QADAMLAR — fayl boshidagi tayyor javob (ikki qadamli JSON). ilova(p, 5, 10) — kvota 5 va 10 ta vazifali ilova; qadam() — POST /v1/vazifalar/:id/qadamlar yuboradigan yordamchi. kechikishMs: 50 — soxta LLM 50 ms "o'ylaydi", shunda 10 ta so'rov haqiqatan bir vaqtda bazaga boradi. Qolgan uch test:
- kvota 3: 503 xatosidan keyin ham 3 ta so'rov o'tadi, 4-chisi 429 (
Retry-After1…86 400,type—…/ai-kvota), provayder jami 4 marta chaqirilgan; - kesh: bir xil vazifa —
kesh: false, keyintrue, SSE hamtrue; matn o'zgarsa — yanafalse; LLM jami 2 marta, kvotada 2 so'rov; hodisalardakeshqiymatlari[false, true, true, false]va matn yo'q; - hisob o'chirilsa —
hisob_anonimlashtir()ai_sarfdan 1 qator o'chirdi.
10.7 Tekshirish
npm run tip
npm run lint
npm testBiz loyiha nusxasida (Node 24.21.0, bazasiz): tsc --noEmit va eslint — 0 xato; bazasiz AI testlari o'tdi, bazali testlar SKIP. Kanon to'liq muhitda tekshirdi — natijasi keyingi darsning commit'i bilan birga.
11. Real ishda
- Qayerda uchraydi: har AI'li SaaS mahsulotida — "oyiga 100 ta AI so'rov" kabi tarif cheklovlari aynan kvota jadvali bilan quriladi. Kesh va prompt caching — xarajatni sezilarli kamaytiradigan birinchi vositalar.
- Monitoring: kunlik tokenlar, kesh ulushi, 429/503 soni — AI xususiyatining asosiy ko'rsatkichlari (Observability darsida metrikalar).
- Intervyu savollari: "LLM xarajatini qanday nazorat qilasiz?", "Kvota parallel so'rovlarda qanday buziladi?", "Kesh kalitiga nimalar kiradi?", "Loglarga foydalanuvchi matnini yozasizmi?"
- Kod review'da: "kvota avval o'qilib, keyin yozilyapti — poyga holati", "kesh kalitida foydalanuvchi yo'q", "provayder xatosi kvotani yeyapti", "log'da prompt matni".
Xulosa
- AI so'rovi qimmat: tokenlar (o'zbekcha ~1.7×, fikrlash tokenlari ham) — kunlik so'rov va token kvotasi kerak.
- Kvota — bitta atomik
INSERT … ON CONFLICT DO UPDATE … WHERE; kun foydalanuvchi mintaqasida; provayder xatosida qaytariladi. - Javob keshi — kalit: foydalanuvchi + model + prompt versiyasi + kirish; keshdan javob kvotani sarflamaydi (0.034 s vs 1.6 s).
- Provayder 429 — mijozga 503; qayta urinish va circuit breaker, ular yordam bermasa — zaxira model; uzoq ishlar — navbatga.
- Maxfiylik: kamroq yuborish, kerak bo'lsa maskalash; loglar va hodisalar — matnsiz.
Keyingi dars: MCP server yozish — vazifalar-api imkoniyatlarini Claude kabi AI agentlarga standart protokol orqali ochamiz.
Manbalar
- Gemini API hujjati — Context caching: https://aistudio.google.com/docs/caching (2026-11-03 da ochildi)
- Anthropic hujjati — Prompt caching: https://platform.claude.com/docs/en/build-with-claude/prompt-caching (2026-11-03 da ochildi)
- PostgreSQL 18 hujjati — INSERT … ON CONFLICT: https://www.postgresql.org/docs/18/sql-insert.html
- RFC 9110 — Retry-After: https://www.rfc-editor.org/rfc/rfc9110.html#field.retry-after
- OWASP — LLM02:2025 Sensitive Information Disclosure: https://genai.owasp.org/llmrisk/llm022025-sensitive-information-disclosure/ (2026-11-03 da ochildi)
- Kanon o'lchovi (IlmHamroh, 2026-11-03): jonli server, gemini-3.5-flash-lite — kesh 0.034 s / 1.6 s,
ai-nazorattestlari (atomik kvota 5/5). Kvota SQL'i,Retry-After, JS modellari, loyiha nusxasidagi tip/lint/test — biz ishga tushirdik (2026-11-03, PostgreSQL 17.6, Node 24.21.0).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!