IlmHamroh
JavaScript Full-stack/31-qism. Prodakshn backend va AI integratsiya14/34-dars22 daqiqa
Mundarija (32)

LLM javoblarini streaming qilish: SSE, qisman JSON va so'rovni bekor qilish

Qisqacha: Streaming — javobni to'liq kutmasdan, model yozayotgan paytda bo'lakma-bo'lak olish. Backend provayderdan SSE oqimini o'qiydi, bo'laklarni yig'adi va tayyor bo'lgan har qismni darhol brauzerga uzatadi. Kanon o'lchovida birinchi qadam 730 ms da ko'rindi, to'liq javob esa 1 139 ms da tugadi. Foydalanuvchi sahifani yopsa, backend modelga so'rovni ham bekor qiladi — aks holda u hech kim o'qimaydigan javob uchun pul to'laydi.

Bu darsda

  • LLM oqimi qanday kelishini — SSE formati va bo'laklarni — tushuntira olasiz.
  • Tarmoq bo'laklarini to'g'ri qatorlarga yig'adigan SSE o'quvchini yozasiz.
  • Yarim kelgan JSON'dan tayyor qadamlarni ajratib olasiz.
  • Oqimni Nest controller'dan brauzerga SSE sifatida uzatasiz.
  • Mijoz uzilganda modelga so'rovni AbortController bilan to'xtatasiz.
  • Oqim o'rtasidagi xatoni to'g'ri ko'rsatasiz va kanon topgan "tushib qolgan apostrof" hodisasini tahlil qilasiz.

Oldin bilishingiz kerak: LLM API asoslari, Server-Sent Events, Streams API va javobni oqim bilan o'qish, Bekor qilish: AbortController, Server-Sent Events NestJS'da.

Oldingi darsda GeminiProvayder.yarat() ni yozdik: so'rov ketadi, javob to'liq kelguncha kutamiz. Bu darsda xuddi shu provayderga ikkinchi metod qo'shamiz — oqim(). SSE'ni 29-qismda bildirishnomalar uchun qurgan edik: endi u AI javobini tashiydi.

1. Nega bu kerak?

Aziz "Qadamlarga bo'lish" tugmasini bosdi. Ekranda aylanuvchi belgi. Bir soniya. Ikki soniya. Aziz tugmani yana bosadi — endi ikkita so'rov ketdi va ikki baravar pul.

Muammo tezlikda emas, jimlikda. Model javobni birdaniga emas, token-token yozadi. Birinchi qadam yozilgan paytda u allaqachon foydali — uni ko'rsatish mumkin. Butun javobni kutish esa foydalanuvchini eng sekin qismga bog'laydi.

ChatGPT kabi interfeyslarda matn "yozilayotgandek" chiqadi — bu streaming. Bizning holatda matn emas, qadamlar chiqadi: birinchi qadam tayyor bo'lishi bilan ro'yxatda paydo bo'ladi, qolganlari ketidan.

O'xshatish: oshxona. Bitta usulda oshpaz to'rt taomni tayyorlab, hammasini birga olib chiqadi. Ikkinchisida har taom tayyor bo'lishi bilan stolga keladi. Umumiy vaqt bir xil, lekin mehmon birinchi taomni ancha erta yeydi.

2. LLM oqimi qanday keladi

2.1 Ikki yo'l, bitta tana

Gemini'da oqim uchun boshqa yo'l chaqiriladi, tana esa o'sha-o'sha:

To'liq javob Oqim
Yo'l …:generateContent …:streamGenerateContent?alt=sse
Javob bitta JSON SSE: ko'p data: qatorlari
Token hisobi javobda oxirgi bo'laklarda

alt=sse parametri javobni Server-Sent Events formatida beradi. Har bo'lak — data: bilan boshlanadigan qator, ichida o'sha generateContent javobining kichik nusxasi: candidates[0].content.parts da yangi matn bo'lagi. Bo'laklar orasida bo'sh qator.

Gemini'ning yangi Interactions API'si ham oqimni xuddi shu ?alt=sse parametri bilan beradi (hujjatga ko'ra, 2026-11). OpenAI va Anthropic ham oqimni SSE bilan beradi, faqat bo'lak ichidagi JSON shakli boshqacha. Kanonning ularga mo'ljallangan kodi tip tekshiruvidan o'tadi, lekin haqiqiy API bilan sinalmagan.

2.2 Kanon o'lchovi

Kanon oqimni haqiqiy Gemini bilan sinadi: o'sha "Bitiruv malakaviy ishining kirish qismini yozish" vazifasi, kanon kodi bilan. Har to'liq qadam qachon paydo bo'lganini yozib bordi. +730 ms — so'rov yuborilgandan beri o'tgan vaqt; oxirgi qatorda esa butun javob qachon tugagani:

text
=== 2) oqim: qadamlar qachon paydo bo'ladi (to'liq javob kutmasdan)
  +730 ms: 1-qadam {"matn":"Mavzu boyicha ilmiy adabiyotlarni organish","daqiqa":60}
  +841 ms: 2-qadam {"matn":"Dolzarblik qismini yozish","daqiqa":45}
  +888 ms: 3-qadam {"matn":"Maqsad va vazifalarni belgilash","daqiqa":30}
  +960 ms: 4-qadam {"matn":"Tadqiqot obyektini aniqlash","daqiqa":30}
  +1060 ms: 5-qadam {"matn":"Ilmiy yangilikni shakllantirish","daqiqa":45}
  +1133 ms: 6-qadam {"matn":"Kirish qismini umumlashtirib tahrirlash","daqiqa":30}
  birinchi bo'lak: 612 ms; tugadi: 1139 ms, 206→139 token; Zod: OK

Grafikda — foydalanuvchi qachon nimani ko'radi, to'liq javob vaqtiga nisbatan:

Oqimda nima qachon ko'rinadi: to'liq javob = 100 %
  • birinchi bo'lak612 ms54 %
  • 1-qadam730 ms64 %
  • 3-qadam888 ms78 %
  • 6-qadam (oxirgi)1 133 ms99 %
  • tugadi1 139 ms100 %

Manba: IlmHamroh kanon o'lchovi, 2026-11-03: Gemini gemini-3.5-flash-lite, streamGenerateContent, Toshkentdan; bitta yugurish

Birinchi bo'lak 612 ms da keldi — model shu paytgacha "o'ylardi" va tarmoq kechikishi. Keyin qadamlar har 50–100 ms da qo'shildi. Xuddi shu vazifa uchun oqimsiz so'rov o'sha yugurishda 1 543 ms oldi. Ya'ni foydalanuvchi birinchi qadamni taxminan ikki baravar tezroq ko'rdi. Bu ikki alohida so'rov va LLM deterministik emas — raqamlar har yugurishda boshqacha. Nisbat esa barqaror mantiqqa tayanadi: oqimda birinchi natija oxirgisidan doim oldin keladi.

Bir narsaga e'tibor bering: "boyicha", "organish" — apostroflar yo'q. Bu tasodif emas, uni «Topilma: tushib qolgan apostrof» bo'limida tahlil qilamiz.

3. SSE o'quvchi: bo'laklardan qatorlar

3.1 Muammo: tarmoq qatorni bilmaydi

fetch javobining tanasi — ReadableStream. Undan baytlar bo'lak-bo'lak keladi (Streams API darsi). Bo'lak chegarasi esa SSE qatorlariga mos kelmaydi: bitta bo'lak ikki yarim qatorni olib kelishi, yoki bitta qator uch bo'lakka bo'linishi mumkin. Undan ham yomoni — ko'p baytli UTF-8 belgi ("—", emoji, kirill harfi) ikki bo'lak orasida qolishi mumkin.

Kanonning SSE o'quvchisi uchala muammoni hal qiladi. Uni brauzerda, ataylab noqulay joylardan bo'lingan baytlar bilan sinab ko'ring:

js
async function* sseMalumot(tana) {
  const dekoder = new TextDecoder();
  const oquvchi = tana.getReader();
  let bufer = "";
  for (;;) {
    const { done, value } = await oquvchi.read();
    if (done) break;
    bufer += dekoder.decode(value, { stream: true });
    let i;
    while ((i = bufer.indexOf("\n")) >= 0) {
      const qator = bufer.slice(0, i).replace(/\r$/, "");
      bufer = bufer.slice(i + 1);
      if (qator.startsWith("data:")) yield qator.slice(5).trimStart();
    }
  }
}

// Server matni — baytlarga aylantirib, noqulay joylardan bo'lamiz
const matn = 'data: {"t":"Ish — boshlandi"}\r\n\r\n'
  + 'data: {"t":"Tugadi"}\r\n\r\n';
const baytlar = new TextEncoder().encode(matn);
const kesimlar = [9, 17, 40, baytlar.length]; // 17 — "—" ning o'rtasi
const tana = new ReadableStream({
  start(c) {
    let bosh = 0;
    for (const k of kesimlar) {
      c.enqueue(baytlar.slice(bosh, k));
      bosh = k;
    }
    c.close();
  },
});
for await (const data of sseMalumot(tana)) {
  console.log(JSON.parse(data).t);
}

Konsolda:

text
Ish — boshlandi
Tugadi

Baytlar to'rt bo'lakka kesildi: birinchi qatorning o'rtasidan, "—" belgisining (3 bayt) ichidan va ikkinchi qator o'rtasidan. Natija baribir to'g'ri.

3.2 Uchta hiyla

  1. bufer — to'liq bo'lmagan qator shu yerda kutadi. \n kelgandagina qator kesib olinadi. Qolgan qism keyingi bo'lak bilan qo'shiladi.
  2. decode(value, { stream: true }) — dekoderga "bu oxiri emas" deyiladi. U yarim belgining baytlarini ichida saqlaydi va keyingi bo'lak bilan birlashtiradi. Bu bayroqsiz "—" o'rnida � (almashtirish belgisi) chiqardi — mashqlarda o'zingiz ko'rasiz.
  3. replace(/\r$/, "") — SSE qatori \n bilan ham, \r\n bilan ham tugashi mumkin — standart ikkalasiga ham ruxsat beradi. O'quvchi ikkalasini qabul qiladi.

Funksiya async function* — asinxron generator: har yield bitta data: qiymatini beradi, chaqiruvchi esa for await bilan o'qiydi. Iste'molchi sekin o'qisa, generator ham kutadi — ortiqcha xotira to'planmaydi.

Bu soddalashtirilgan o'quvchi: u faqat data: ni oladi va bir necha qatorli data: ni birlashtirmaydi. Gemini oqimida har hodisa bitta data: qatori — bu yetarli. Ko'p qatorli to'liq variantni mashqda yozasiz.

4. Provayderda oqim

4.1 GeminiProvayder.oqim()

Endi provayderning oqim() metodi. U shartnomadagi LlmBolak turini qaytaradi — ikki xil bo'lak:

ts
export type LlmBolak =
  | { tur: "matn"; matn: string }
  | { tur: "tugadi"; javob: LlmJavob };

Kanon kodi (telefon uchun qatorlarga bo'lindi, ma'nosi aynan):

ts
async *oqim(s: LlmSorov): AsyncIterable<LlmBolak> {
  const model = s.model ?? this.model;
  const signal = chegaraliSignal(this.#s.timeoutMs, s.signal);
  const t0 = performance.now();
  let matn = "";
  let oxirgi: GeminiJavob = {};
  try {
    const r = await this.#post(
      `models/${model}:streamGenerateContent?alt=sse`,
      this.#tana(s), signal);
    if (r.body === null) {
      throw new LlmXatosi("provayder", "oqim tanasi yo'q");
    }
    for await (const data of sseMalumot(r.body)) {
      const j = JSON.parse(data) as GeminiJavob;
      oxirgi = { ...oxirgi, ...j };
      const bolak = (j.candidates?.[0]?.content?.parts ?? [])
        .filter((p) => p.thought !== true)
        .map((p) => p.text ?? "").join("");
      if (bolak !== "") {
        matn += bolak;
        yield { tur: "matn", matn: bolak };
      }
    }
  } catch (x) {
    throw xatogaAylantir(x, s.signal);
  }
  const ms = Math.round(performance.now() - t0);
  const javob = this.#javob(oxirgi, model, ms);
  yield { tur: "tugadi", javob: { ...javob, matn } };
}

4.2 Token hisobi oqimda

oxirgi = { ...oxirgi, ...j } — har bo'lak oldingisining ustiga yoziladi. Nega? Token hisobi (usageMetadata) va finishReason faqat oxirgi bo'laklarda keladi. Ularni yo'qotmaslik uchun hamma bo'laklar bitta obyektga "yig'iladi". Oqim tugagach, #javob() — oldingi darsdagi o'sha funksiya — undan tokenlarni va tugash sababini oladi. Matn esa alohida, bo'laklardan yig'ilgan.

Bu muhim nuansni keltirib chiqaradi: oqim o'rtada uzilsa, token hisobi kelmaydi. Provayder baribir yozilgan tokenlar uchun pul oladi, lekin siz qanchaligini bilmaysiz. Kvota hisobida buni hisobga olish kerak — AI xususiyatlarini prodakshnda ishlatish darsida.

Timeout ham oqimda boshqacha ishlaydi. chegaraliSignal(20 000 ms) butun oqimga qo'yilgan: 20 soniyada tugamasa — bekor. Uzoq javob beradigan modellarda bu yetmasligi mumkin. Shunda "birinchi bo'lakkacha" va "bo'laklar orasidagi jimlik" uchun alohida chegaralar qo'yiladi. Kanonning javobi qisqa — bitta chegara yetadi.

5. Qisman JSON'dan qadamlar

5.1 Muammo

Bizning javob — erkin matn emas, JSON: {"qadamlar":[{…},{…}]}. Oqimda esa JSON yarim keladi: {"qadamlar":[{"matn":"Ilmiy adab. JSON.parse bunga xato beradi. Butun javobni kutsak — oqimning ma'nosi yo'qoladi.

Yechim: buferda massiv ichidagi yopilgan obyektlarni sanab, yangilarini ajratib olish. Buning uchun qavslar chuqurligini kuzatish kerak — lekin satr ichidagi { va } ni hisobga olmasdan.

5.2 toliqQadamlar()

Kanon funksiyasi toza — tarmoqsiz, paketsiz. Uni brauzerda sinab ko'ring: javob 40 belgilik bo'laklarda keladi, ikkinchi qadam matnida ataylab {qisqa} qavslari bor:

js
function toliqQadamlar(bufer, boshlash) {
  const m = /"qadamlar"\s*:\s*\[/.exec(bufer);
  if (m === null) return [];
  const natija = [];
  let chuqurlik = 0, satrda = false, qochish = false;
  let bosh = -1, sanoq = 0;
  for (let i = m.index + m[0].length; i < bufer.length; i++) {
    const c = bufer[i];
    if (satrda) {
      if (qochish) qochish = false;
      else if (c === "\\") qochish = true;
      else if (c === '"') satrda = false;
      continue;
    }
    if (c === '"') satrda = true;
    else if (c === "{") { if (chuqurlik++ === 0) bosh = i; }
    else if (c === "}" && --chuqurlik === 0) {
      if (sanoq++ >= boshlash) {
        natija.push(JSON.parse(bufer.slice(bosh, i + 1)));
      }
    } else if (c === "]" && chuqurlik === 0) break;
  }
  return natija;
}

const javob = JSON.stringify({ qadamlar: [
  { matn: "Ilmiy adabiyotlarni o'rganish", daqiqa: 60 },
  { matn: "Dolzarblik qismini {qisqa} yozish", daqiqa: 45 },
  { matn: "Maqsad va vazifalarni belgilash", daqiqa: 30 },
] });
let bufer = "", berildi = 0;
for (let i = 0; i < javob.length; i += 40) {
  bufer += javob.slice(i, i + 40); // tarmoqdan kelgan bo'lak
  for (const q of toliqQadamlar(bufer, berildi)) {
    berildi++;
    console.log(`${bufer.length} belgi: ${q.daqiqa} daq — ${q.matn}`);
  }
}
console.log("jami:", javob.length, "belgi");

Konsolda:

text
80 belgi: 60 daq — Ilmiy adabiyotlarni o'rganish
160 belgi: 45 daq — Dolzarblik qismini {qisqa} yozish
179 belgi: 30 daq — Maqsad va vazifalarni belgilash
jami: 179 belgi

Birinchi qadam 80 belgi kelganda tayyor bo'ldi — javobning yarmidan kamida. Ikkinchi qadamdagi {qisqa} chuqurlikni buzmadi: funksiya satr ichida ekanini biladi.

5.3 Qanday ishlaydi

  • satrda — qo'shtirnoq ichidamizmi. Ichida bo'lsak, {, }, ] — oddiy harflar.
  • qochish — \" uchun: teskari chiziqdan keyingi qo'shtirnoq satrni yopmaydi. Kanon testida aynan shu holat bor: "a {qavs} \"x\"".
  • chuqurlik — { da oshadi, } da kamayadi. Nolga qaytsa — bitta obyekt yopildi va uni JSON.parse qilsa bo'ladi.
  • boshlash — oldin nechta qaytarilgan. Har yangi bo'lakda funksiya buferni boshidan o'qiydi, lekin eski qadamlarni qayta bermaydi.

Har bo'lakda buferni boshidan o'qish — O(n²) ga o'xshaydi: javob ikki baravar uzaysa, ish taxminan to'rt baravar ko'payadi. Lekin javob ~1 000 belgi va ~20 bo'lak — ahamiyatsiz. Uzun matnlarda holatni bo'laklar orasida saqlaydigan tahlilchi kerak bo'lardi.

Ajratilgan har obyekt yana tekshiriladi: Qadam.safeParse(x) — 100 belgidan uzun yoki daqiqasi son bo'lmagan qadam foydalanuvchiga ketmaydi. Zod sxemasini keyingi darsda batafsil ko'ramiz.

Tekshirib ko'ring: Model {"qadamlar":[{"matn":"a","daqiqa":5},{"matn":"b","daqiqa":"ko'p"}]} qaytardi. Oqimda foydalanuvchi nimani ko'radi?

Javob

Birinchi qadamni ("a", 5 daqiqa) — u sxemaga mos va darhol yuboriladi. Ikkinchisi yopilganda Qadam.safeParse yiqiladi: daqiqa son emas. Server event: xato yuboradi va oqimni yopadi. Kanon testida aynan shu holat bor: hodisalar ketma-ketligi — qadam, xato. Birinchi qadam allaqachon ekranda — uni "qaytarib olib" bo'lmaydi. Oqimning narxi shu: yaroqsiz javob o'rtada aniqlanadi.

6. Brauzerga SSE

6.1 Controller

Endi qadamlarni brauzerga uzatamiz. Yo'l bitta — POST /v1/vazifalar/:id/qadamlar, mijoz Accept: text/event-stream sarlavhasi bilan oqim so'raydi. Sarlavhasiz so'rovga oddiy JSON qaytadi (keyingi dars). Controller'ning oqim qismi (bo'lindi, ma'nosi aynan; uzun tur uchun OqimHodisasi nomini berdik):

ts
// vazifa topilmasa (404) — sarlavhalar yuborilishidan OLDIN
await this.ai.qadamlar.vazifa(egasi, id);
res.status(200).set({
  "Content-Type": "text/event-stream; charset=utf-8",
  "Cache-Control": "no-cache, no-transform",
  "X-Accel-Buffering": "no",
});
res.flushHeaders();
const yoz = (hodisa: string, malumot: unknown) => res.write(
  `event: ${hodisa}\ndata: ${JSON.stringify(malumot)}\n\n`);
const oqim = this.ai.qadamlar.oqimla(egasi, id, signal);
try {
  for await (const h of oqim) {
    if (h.tur === "qadam") {
      yoz("qadam", { indeks: h.indeks, ...h.qadam });
    } else {
      const { model, tokenlar } = h.natija;
      log.info({ ai: { amal: "qadamlar-oqim", model, tokenlar } },
        "ai javobi");
      yoz("tugadi", h.natija);
    }
  }
} catch (x) {
  if (signal.aborted) {
    log.info({ ai: { amal: "qadamlar-oqim", uzildi: true } },
      "mijoz uzildi — LLM so'rovi bekor qilindi");
    return;
  }
  const e = aiXatosi(x);
  if (e instanceof IlovaXatosi) {
    yoz("xato", problemYasa(e.holat,
      { kod: e.kod, sarlavha: e.sarlavha, detail: e.message }));
  } else {
    log.error({ err: e }, "ai oqimi: kutilmagan xato");
    yoz("xato", problemYasa(500, { detail: "Kutilmagan xato" }));
  }
} finally {
  res.end();
}

Uch hodisa turi:

Hodisa Qachon data
qadam har to'liq qadam { indeks, matn, daqiqa }
tugadi javob to'liq va tekshirilgan to'liq natija: qadamlar, model, tokenlar
xato oqim o'rtasidagi xato Problem Details obyekti

6.2 Tafsilotlar

404 — sarlavhalardan oldin. Birinchi qator vazifani bazadan o'qiydi. Vazifa yo'q yoki begona bo'lsa — oddiy 404 Problem Details qaytadi. Bu tekshiruv flushHeaders() dan oldin turishi shart. Sarlavhalar ketgach status 200 bo'lib qoladi, uni o'zgartirib bo'lmaydi.

Xato — hodisa sifatida. Model o'rtada yaroqsiz qadam berdi yoki provayder 503 qaytardi — status allaqachon 200. Shuning uchun xato event: xato bilan, ichida odatdagi Problem Details (status, title, kod). Mijoz uni oddiy HTTP xatosi kabi ko'rsatadi.

Proxy buferi. X-Accel-Buffering: no — nginx'ga "bu javobni yig'ib turma" degan belgi. Busiz nginx bo'laklarni to'plab, hammasini oxirida yuborishi mumkin — oqim jimgina "oddiy" javobga aylanadi. no-transform siqish proxylariga ham shuni aytadi. Bu muammo lokalda ko'rinmaydi, faqat deploy'dan keyin chiqadi (33-qism).

Log — matnsiz. log.info ga model va tokenlar ketadi, qadamlar matni esa yo'q. Shaxsiy ma'lumotlar qonunchiligi darsidagi qoida.

6.3 Kanon jonli tekshiruvi

Kanon haqiqiy serverni haqiqiy rollar va Gemini bilan ishga tushirib, ikkala rejimni so'radi. Vazifa: "JavaScript kursining 26-qismini boshlash #o'qish":

text
--- POST /v1/vazifalar/1/qadamlar (Accept: text/event-stream)
event: qadam
data: {"indeks":0,"matn":"Ish joyini tayyorlang va shaxsiy chalg'ituvchi narsalarni olib tashlang","daqiqa":5}

event: qadam
data: {"indeks":1,"matn":"JavaScript kursi 26-qismining video materialini boshidan oxirigacha diqqat bilan ko'ring","daqiqa":45}

event: qadam
data: {"indeks":2,"matn":"Video davomida muhim joylarni konspekt qiling va asosiy tushunchalarni yozib oling","daqiqa":20}

event: qadam
data: {"indeks":3,"matn":"Kod yozish muhitini ochib, videodagi amaliy misollarni o'zingiz ham yozib ko'ring","daqiqa":40}

event: qadam
data: {"indeks":4,"matn":"Dars oxiridagi kichik topshiriq yoki savollarni bajarib bilimingizni mustahkamlang","daqiqa":20}

event: tugadi
data: {"vazifaId":1,"qadamlar":[…],"model":"gemini-3.5-flash-lite","tokenlar":{"kirish":227,"chiqish":261},"urinishlar":1}

HTTP 200, birinchi bayt 0.010540 s, jami 1.542900 s

tugadi hodisasidagi qadamlar ro'yxatini […] bilan qisqartirdik — u yuqoridagi beshta qadamning takrori. Ikki raqamga qarang: birinchi bayt 10.5 ms da keldi — flushHeaders() sarlavhalarni modelni kutmasdan yubordi. Brauzer ulanish ochilganini darhol biladi. Jami 1.54 s. Xuddi shu vazifa uchun oqimsiz JSON so'rovi shu tekshiruvda 1.80 s oldi.

Server logidagi AI qatori — matnsiz:

text
{"daraja":"info","ilova":"vazifalar-api","sorovId":"ff7c7778-9a0b-4951-aba5-d991b2cbe18b","ai":{"amal":"qadamlar-oqim","model":"gemini-3.5-flash-lite","tokenlar":{"kirish":227,"chiqish":261}},"msg":"ai javobi"}

sorovId — UUID, har so'rovda boshqa.

7. Bekor qilish: mijoz ketsa, pul to'xtasin

7.1 Muammo

Aziz qadamlarni kutmay sahifani yopdi. Brauzer ulanishni uzdi. Lekin serverda fetch hali Gemini'dan bo'laklarni o'qiyapti — model yozishda davom etadi, tokenlar hisoblanadi. Natija hech kimga yetib bormaydi, hisob-fakturaga esa tushadi. Yuzta foydalanuvchi sahifani yangilab tursa — yuzta "arvoh" so'rov.

7.2 Signal zanjiri

Kanon brauzer uzilishini provayder so'roviga ulaydi:

ts
function uzilishSignali(req: Request, res: Response): AbortSignal {
  const a = new AbortController();
  const uz = () => {
    a.abort(new DOMException("mijoz uzildi", "AbortError"));
  };
  res.on("close", () => { if (!res.writableFinished) uz(); });
  req.on("aborted", uz);
  return a.signal;
}

Zanjir to'rt bo'g'indan iborat:

flowchart LR
  A["Brauzer<br/>ulanishni uzdi"] --> B["res 'close'<br/>(javob tugamagan)"]
  B --> C["AbortController<br/>.abort()"]
  C --> D["AbortSignal.any<br/>(timeout + mijoz)"]
  D --> E["fetch Gemini'ga<br/>bekor qilindi"]

res.on("close") javob yopilganda chaqiriladi — normal tugaganda ham. Shuning uchun !res.writableFinished tekshiruvi bor: javob to'liq yozilgan bo'lsa, bu uzilish emas. Signal oqimla() orqali provayderga, u yerda oldingi darsdagi chegaraliSignal bilan timeout signaliga qo'shiladi. fetch bekor bo'lgach, for await sikli AbortError bilan to'xtaydi. Controller signal.aborted ni ko'radi, faqat bitta log yozadi va jim chiqadi: yozadigan joy yo'q — mijoz ketgan.

Muhim farq: xatogaAylantir timeout'ni (LlmXatosi("timeout") → 504) mijoz uzilishidan ajratadi. Timeout — xato, uzilish esa — normal hodisa.

Kanon testi buni soxta provayder bilan tekshiradi: provayder 400 ms "o'ylaydi", mijoz so'rov ketgach uni bekor qiladi, keyin test provayderga kelgan signalni ko'radi:

ts
a.abort();
await so.catch(() => undefined);
// … signal bekor bo'lishini kutish …
assert.equal(p.chaqiruvlar[0]?.signal?.aborted, true);

Test haqiqiy HTTP server ochadi (listen(0)), chunki uzilishni faqat haqiqiy ulanish ko'rsatadi.

Diqqat: Bekor qilish provayderning hisob-kitobini aniq to'xtatadimi — provayderga bog'liq. Bizning tomonda kafolat bitta: ulanishni yopdik va qolgan bo'laklarni o'qimaymiz. Provayder allaqachon yaratgan tokenlar uchun pul olishi mumkin. Kanon buni o'lchamadi.

8. Topilma: tushib qolgan apostrof

«Kanon o'lchovi» bo'limidagi oqim chiqishiga qaytamiz: "Mavzu boyicha ilmiy adabiyotlarni organish". To'g'risi — "bo'yicha", "o'rganish". Xuddi shu yugurishdagi oqimsiz (JSON) javoblarda apostroflar joyida edi: "to'plang", "Do'konga". Jonli serverdagi oqim javobida ham to'g'ri: "chalg'ituvchi", "ko'ring". Bitta prompt injection sinovi javobida esa yana yo'q: "malumotlarni toplash".

Bu kodning xatosi emas — toliqQadamlar qo'shtirnoqni to'g'ri ishlaydi, apostrofga esa umuman tegmaydi. Bu model xulqi: bir yugurishda bo'ladi, boshqasida yo'q. Ehtimoliy sabab — o'zbekcha apostrofning ko'p shakli bor (', ʻ, ’), model ba'zan ularning hech birini yozmaydi. Kanon bu xatoni tuzatmadi va barqaror qayta takrorlay olmadi.

Bundan uchta amaliy saboq chiqadi:

  1. LLM chiqishi — foydalanuvchi kiritgani kabi ishonchsiz. Shaklini Zod tekshiradi, mazmunini esa hech narsa kafolatlamaydi.
  2. Normallashtirish. Model ʻ yoki ’ yozsa, ularni bitta apostrofga keltirish oson va xavfsiz. Tushib qolganini tiklash esa lug'atsiz imkonsiz: "organish" — xato, lekin "orasida" — to'g'ri so'z.
  3. Foydalanuvchi tahriri. Kanonda qadamlar — taklif, saqlanmaydi. Mijoz ularni ko'rsatadi, foydalanuvchi tuzatib, oddiy POST /v1/vazifalar bilan qo'shadi. Model xatosi bazaga to'g'ridan tushmaydi.

9. Ko'p uchraydigan xatolar

Noto'g'ri tasavvur Haqiqat
"Har bo'lak — bitta SSE qatori" Bo'lak chegarasi tasodifiy; bufer kerak
"TextDecoder().decode(bayt) yetadi" Ko'p baytli belgi bo'linadi; { stream: true }
"Yarim JSON'ni JSON.parse qilamiz" Yiqiladi; yopilgan obyektlarni ajrating
"Oqimda xato bo'lsa — 500 qaytaramiz" Status allaqachon 200; event: xato
"Mijoz ketsa — server o'zi to'xtaydi" fetch davom etadi; signalni ulang
"Lokalda oqim ishladi — prodakshnda ham ishlaydi" Proxy buferlaydi; X-Accel-Buffering: no
"Token hisobi har doim keladi" Faqat oxirgi bo'laklarda; uzilsa — yo'q

10. Mashqlar

Mashqlar kurs/mashqlar/31/14-oqim/ da. 2- va 3-mashq yechimlari Node 24.21.0 da tekshirilgan (biz ishga tushirdik).

1-mashq (oson): bo'shliqlarni to'ldiring

  • Gemini oqim yo'lining parametri — ?alt=
  • Yarim UTF-8 belgini saqlab turish uchun decode ga beriladigan bayroq — { : true }
  • nginx buferini o'chiruvchi sarlavha — X-Accel-: no
  • Oqim o'rtasidagi xato uchun kanon hodisasi — event:
  • Kanon o'lchovida birinchi qadam necha millisekundda ko'rindi —
Yechim
  • sse — streamGenerateContent?alt=sse.
  • stream — keyingi bo'lak bilan birlashtirish uchun.
  • Buffering — nginx uchun; no-transform esa siqish uchun.
  • xato — ichida Problem Details.
  • 730 — to'liq javob 1 139 ms da tugadi.

2-mashq (o'rta): to'liq SSE o'quvchi

Kanon o'quvchisi faqat bitta qatorli data: ni oladi. SSE standarti esa bitta hodisada bir necha data: qatoriga ruxsat beradi. Ular \n bilan birlashadi, hodisa esa bo'sh qatorda tugaydi. : bilan boshlangan qator — izoh (masalan, ping). sseAjrat(bolaklar) funksiyasini yozing: satr bo'laklari massividan to'liq hodisalarning data qiymatlarini qaytarsin. Tugallanmagan oxirgi hodisa qaytmasin. Ishora: bufer va indexOf("\n"), bo'sh qator — hodisa chegarasi.

Yechim
js
function sseAjrat(bolaklar) {
  const natija = [];
  let bufer = "";
  let data = [];
  for (const b of bolaklar) {
    bufer += b;
    let i;
    while ((i = bufer.indexOf("\n")) >= 0) {
      const qator = bufer.slice(0, i).replace(/\r$/, "");
      bufer = bufer.slice(i + 1);
      if (qator === "") {
        if (data.length > 0) natija.push(data.join("\n"));
        data = [];
      } else if (qator.startsWith("data:")) {
        data.push(qator.slice(5).replace(/^ /, ""));
      }
    }
  }
  return natija;
}

const a = ["data: bir\r\n\r", "\ndata: ikki\n", "data: qator\n\n"];
const b = [": izoh (ping)\n\n", "data:{\"a\":1}\n\n", "data: yarim"];
console.log(sseAjrat(a));
console.log(sseAjrat(b));

Konsolda:

text
[ 'bir', 'ikki\nqator' ]
[ '{"a":1}' ]

Birinchi misolda \r\n ikki bo'lakka bo'lingan (\r birinchisida, \n ikkinchisida) — bufer buni yig'di. "ikki" va "qator" bitta hodisaning ikki data: qatori — ular birlashdi. Ikkinchi misolda izoh qatori o'tkazildi, "yarim" esa bo'sh qator kelmagani uchun qaytmadi. replace(/^ /, "") — standart bo'yicha data: dan keyingi faqat bitta bo'sh joy olib tashlanadi; kanon esa trimStart() bilan hammasini oladi. JSON uchun farqi yo'q.

3-mashq (qiyin): mijoz tomonda hodisalarni o'qish

Brauzerdagi EventSource faqat GET so'rov yuboradi. Bizning yo'l esa POST — shuning uchun mijoz fetch bilan oqimni o'zi o'qiydi. hodisalar(matn) funksiyasini yozing: serverdan kelgan to'liq SSE matnini { event, data } obyektlari massiviga aylantirsin (data — JSON.parse qilingan, event bo'lmasa — "message"). data ichida : bo'lishi mumkin — faqat birinchi : ajratuvchi. Ishora: split("\n\n"), rest parametr (spread), join(":").

Yechim
js
function hodisalar(matn) {
  return matn
    .split("\n\n")
    .filter((blok) => blok.trim() !== "")
    .map((blok) => {
      const h = { event: "message", data: "" };
      for (const qator of blok.split("\n")) {
        const [nom, ...qolgan] = qator.split(":");
        const qiymat = qolgan.join(":").trimStart();
        if (nom === "event") h.event = qiymat;
        if (nom === "data") h.data += qiymat;
      }
      return { event: h.event, data: JSON.parse(h.data) };
    });
}

const javob = [
  "event: qadam",
  'data: {"indeks":0,"matn":"Ish joyini tayyorlang","daqiqa":5}',
  "",
  "event: xato",
  'data: {"status":502,"title":"AI javobi yaroqsiz"}',
  "",
  "",
].join("\n");
for (const h of hodisalar(javob)) {
  const d = h.data;
  if (h.event === "qadam") {
    console.log(`qadam ${d.indeks}: ${d.matn}`);
  } else {
    console.log(`${h.event}: ${d.status} ${d.title}`);
  }
}

Konsolda:

text
qadam 0: Ish joyini tayyorlang
xato: 502 AI javobi yaroqsiz

qator.split(":") JSON ichidagi har : da ham bo'ladi — shuning uchun birinchi bo'lakdan keyingilarini qolgan ga yig'ib, qaytadan ":" bilan qo'shamiz. Bu yechim to'liq matnni kutadi. Haqiqiy mijozda u fetch tanasidan bo'lak-bo'lak keladi — 2-mashqdagi bufer bilan birlashtiriladi. xato hodisasi — oddiy Problem Details: mijoz uni odatdagi xato xabari kabi ko'rsatadi.

11. Loyiha qadami: oqim qatlami

Bu darsda src/ai/ dagi fayllarga oqim qo'shiladi:

  1. llm.ts — LlmBolak turi va sseMalumot() (Gemini, OpenAI va Anthropic uchun umumiy).
  2. gemini.ts — oqim() metodi.
  3. qadamlar.ts — toliqQadamlar() va xizmatning oqimla() metodi. U provayder bo'laklarini buferga yig'adi, har yangi qadamni Qadam sxemasi bilan tekshiradi va qadam hodisasi sifatida beradi. Oxirida butun javob qayta tekshiriladi va tugadi keladi:
ts
async *oqimla(
  egasi: number, id: number, signal?: AbortSignal,
): AsyncIterable<OqimHodisasi> {
  const v = await this.vazifa(egasi, id);
  let bufer = "", berildi = 0;
  const so = this.#sorov(v, signal);
  for await (const b of this.d.provayder.oqim(so)) {
    if (b.tur === "matn") {
      bufer += b.matn;
      for (const x of toliqQadamlar(bufer, berildi)) {
        const q = Qadam.safeParse(x);
        if (!q.success) {
          throw new LlmXatosi("notogri", "qadam sxemaga mos emas: "
            + (q.error.issues[0]?.message ?? ""));
        }
        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}`);
      }
      yield { tur: "tugadi", natija: {
        vazifaId: id, qadamlar: t.qiymat.qadamlar,
        model: b.javob.model, tokenlar: b.javob.tokenlar,
        urinishlar: 1,
      } };
    }
  }
}

Oqimda tuzatish so'rovi yo'q: mijoz qadamlarni allaqachon ko'rdi, javobni boshqasiga almashtirib bo'lmaydi. Oqimsiz rejimda esa bitta tuzatish urinishi bor — keyingi darsda.

  1. ai.controller.ts — uzilishSignali() va oqim qismi (yuqorida).

Qadam, javobniTekshir va #sorov — keyingi darsning mavzusi: sxema, tizim ko'rsatmasi va tekshiruv. Shuning uchun bu bosqichda loyiha hali to'liq yig'ilmaydi. Hali commit qilmang — commit keyingi darsda, testlar bilan birga.

Docker'siz va kalitsiz takrorlash: darsdagi sseMalumot va toliqQadamlar bloklarini o'zgartirib ko'ring. Bo'lak uzunligini 40 dan 7 ga tushiring — qadamlar o'sha joylarda chiqadimi? { stream: true } ni olib tashlang — "—" o'rnida nima chiqadi?

12. Real ishda

  • Qayerda uchraydi: har chat interfeysida, AI qidiruvda, kod yordamchilarida. Frontend tomoni — AI chat interfeyslari: oqimli javoblar darsida.
  • Jamoada: "birinchi token vaqti" (time to first token) — AI xususiyatlarining asosiy tezlik metrikasi. To'liq javob vaqtidan ko'ra foydalanuvchi uni ko'proq sezadi.
  • Intervyu savollari: "LLM javobini qanday stream qilasiz?", "SSE va WebSocket — AI uchun qaysi biri?", "Foydalanuvchi uzilsa nima bo'ladi?", "Oqim o'rtasidagi xatoni qanday bildirasiz?"
  • Vositalar: provayder SDK'larining oqim iteratorlari, Vercel AI SDK streamText — Vercel AI SDK va provayderlarni almashtirish darsida. Ular shu darsdagi bufer va SSE ishini yashiradi — ichida nima borligini endi bilasiz.

Xulosa

  • Streaming foydalanuvchini eng sekin qismdan qutqaradi: kanonda birinchi qadam 730 ms, to'liq javob 1 139 ms.
  • SSE o'quvchi bufer bilan ishlaydi: bo'lak chegarasi qatorga ham, UTF-8 belgiga ham mos kelmaydi.
  • Qisman JSON'dan faqat yopilgan obyektlar ajratiladi; satr ichidagi qavslar va \" hisobga olinadi.
  • Oqimda status allaqachon 200 — xato event: xato bilan, 404 esa sarlavhalardan oldin.
  • Mijoz uzilsa — AbortController → AbortSignal.any → provayder fetch i bekor; timeout esa alohida xato.
  • LLM chiqishi ishonchsiz: apostrof tushib qolishi mumkin; qadamlar — taklif, foydalanuvchi tasdiqlaydi.

Keyingi dars: Strukturali javob va tool calling — modeldan aniq JSON olamiz, uni Zod bilan tekshiramiz, noto'g'ri bo'lsa tuzatish so'raymiz va prompt injection'dan himoyalanamiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
LLM javoblarini streaming qilish: SSE, qisman JSON va so'rovni bekor qilish — IlmHamroh