Streaming Responses — AI Web

Streaming AI Responses LLM response bisa panjang dan lambat. Streaming menampilkan token satu per satu saat dihasilkan — seperti ChatGPT typing effect. UX…

Streaming artinya jawaban model dikirim sepotong demi sepotong selagi dibuat, bukan sekaligus setelah selesai. Bayangin makan di restoran yang mengantar tiap hidangan begitu matang. Kamu sudah bisa mulai makan sementara dapur masih memasak sisanya, dan kalau sudah kenyang kamu bisa bilang "stop", dapur berhenti. Tanpa streaming, semua hidangan ditahan di dapur sampai yang terakhir matang. Total waktu masaknya sama, tapi rasanya jauh lebih lama.

🧪 Tebakan cepat: server mengirim teks “Cantik Itu Luka” dalam potongan byte, dan tiap potongan kamu ubah jadi teks sendiri-sendiri dengan decoder.decode(value). Apa yang bisa muncul di layar? Tebak dulu, jawabannya ada di bagian "Membaca stream dengan benar".

Kenapa streaming

Model menghasilkan jawaban token demi token. Tanpa streaming, pelanggan Toko Buku menatap spinner sampai token terakhir selesai. Dengan streaming, kata pertama muncul hampir seketika dan sisanya mengalir. Model gak jadi lebih cepat selesai; yang berubah, pengguna gak perlu menunggu semuanya selesai untuk mulai membaca. Bonusnya, pengguna bisa menghentikan jawaban yang salah arah di tengah jalan.

SSE dari OpenAI: event demi event

Kirim stream: true di body request ke POST /v1/responses. Jawabannya datang sebagai SSE (Server-Sent Events): standar HTTP untuk aliran satu arah dari server ke client, dengan Content-Type: text/event-stream. Tiap event berupa baris event: dan data:, lalu satu baris kosong:

event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_demo1","output_index":0,"content_index":0,"delta":"“","sequence_number":4,"logprobs":[]}

event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_demo1","output_index":0,"content_index":0,"delta":"Laskar","sequence_number":5,"logprobs":[]}

Urutan event Responses API (API reference OpenAI, dicek Oktober 2026): response.created, response.in_progress, response.output_item.added, response.content_part.added, lalu response.output_text.delta berkali-kali (potongan teksnya di field delta), response.output_text.done, response.content_part.done, response.output_item.done, dan terakhir response.completed yang membawa usage. Kalau gagal, datang event error atau response.failed. Gak ada baris data: [DONE] seperti di Chat Completions.

Provider lain beda nama: Anthropic memakai message_start, content_block_delta (teksnya di delta.text), dan message_stop. Ollama malah gak memakai SSE, tapi satu JSON per baris.

Di Lab, jawaban stand-in "“Laskar Pelangi” masih ada, stoknya 12. Mau lihat “Cantik Itu Luka” juga? Stoknya 5." dikirim dalam format persis ini: 30 event, 22 di antaranya delta, total 6.501 byte.

Membaca stream dengan benar

res.body adalah ReadableStream berisi byte. Tiap reader.read() memberi satu potongan (Uint8Array) yang ukurannya ditentukan jaringan, bukan oleh batas event atau batas huruf. Versi naif ini kena dua masalah:

// SALAH
const teks = decoder.decode(value); // tiap potongan di-decode sendiri
for (const baris of teks.split("\n")) {
  if (baris.startsWith("data: ")) onEvent(JSON.parse(baris.slice(6))); // baris bisa terpotong
}
  1. Huruf terbelah. ” itu 3 byte (E2 80 9D). Dengan potongan 89 byte, potongan ke-45 berakhir tepat setelah E2. Didecode sendiri-sendiri, byte yang gak lengkap jadi karakter pengganti �. Itu jawaban tebakannya: layarnya menampilkan “Cantik Itu Luka���.
  2. Baris terbelah. Satu baris data: delta panjangnya sekitar 150 byte. Kalau potongan berakhir di tengahnya, JSON.parse melempar SyntaxError dan delta itu hilang.

Versi yang benar: satu TextDecoder untuk seluruh stream dengan { stream: true }, dan buffer yang cuma memproses event yang sudah lengkap (sampai baris kosong):

async function bacaSSE(body, onEvent) {
  const reader = body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true }); // byte yang belum lengkap disimpan dulu
    let batas;
    while ((batas = buffer.indexOf("\n\n")) !== -1) {
      const blok = buffer.slice(0, batas); // satu event utuh
      buffer = buffer.slice(batas + 2); // sisanya menunggu potongan berikutnya
      const data = blok.split("\n").filter((b) => b.startsWith("data: ")).map((b) => b.slice(6)).join("\n");
      if (data) onEvent(JSON.parse(data));
    }
  }
}

Hasilnya di Lab, dari stream yang sama:

Ukuran potongan Pembaca Yang tampil
89 byte naif kosong: 29 kali SyntaxError, semua delta hilang
89 byte buffer, tanpa { stream: true } ... Mau lihat “Cantik Itu Luka��� juga? Stoknya 5.
1.024 byte naif Laskar Pelangi” masih ada stoknya 12. Mau “Cantik Itu Luka”? Stoknya 5. (6 delta hilang)
berapa pun bacaSSE di atas “Laskar Pelangi” masih ada, stoknya 12. Mau lihat “Cantik Itu Luka” juga? Stoknya 5.

Baris 1.024 byte paling berbahaya: kalimatnya terlihat hampir benar, jadi bug-nya lolos saat dites.

Meneruskan ke browser

Server Toko Buku membaca stream OpenAI dengan bacaSSE, lalu meneruskan potongan teksnya ke browser sebagai SSE miliknya sendiri:

app.post("/api/assistant/stream", async (req, res) => {
  const kontrol = new AbortController();
  res.on("close", () => {
    if (!res.writableFinished) kontrol.abort(); // browser pergi: hentikan juga request ke OpenAI
  });
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  try {
    const upstream = await fetch("https://api.openai.com/v1/responses", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OPENAI_API_KEY}` },
      body: JSON.stringify({ model: "gpt-6-luna", reasoning: { effort: "none" }, input: req.body.pertanyaan, stream: true }),
      signal: kontrol.signal,
    });
    await bacaSSE(upstream.body, (event) => {
      if (event.type === "response.output_text.delta") res.write(`data: ${JSON.stringify({ teks: event.delta })}\n\n`);
    });
  } catch (err) {
    if (err.name !== "AbortError") console.error(err); // AbortError: browser-nya sudah pergi
  }
  res.end();
});

Di browser, pakai fetch lagi (bukan EventSource, yang cuma bisa GET tanpa body), plus AbortController untuk tombol Stop:

const kontrol = new AbortController();
tombolStop.onclick = () => kontrol.abort();
const res = await fetch("/api/assistant/stream", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ pertanyaan: "Stok Laskar Pelangi masih ada?" }),
  signal: kontrol.signal,
});
try {
  await bacaSSE(res.body, (event) => {
    jawabanEl.textContent += event.teks; // textContent, bukan innerHTML
  });
} catch (err) {
  if (err.name !== "AbortError") throw err; // Stop ditekan: bukan error
}

Waktu Stop ditekan, reader.read() menolak dengan AbortError, koneksi ke server tertutup, res.on("close") jalan, dan request ke OpenAI ikut dibatalkan.

⚠️ Jebakan yang sering ketemu

TL;DR: Streaming mengirim jawaban sepotong demi sepotong, jadi pengguna mulai membaca tanpa menunggu semuanya selesai dan bisa menghentikannya. OpenAI memakai SSE (text/event-stream): event: + data: + baris kosong, dengan event response.created, response.output_text.delta (teks di delta), sampai response.completed (membawa usage); gak ada [DONE]. Baca res.body dengan getReader(), satu TextDecoder dengan { stream: true }, dan buffer sampai "\n\n"; tanpa itu muncul � dan delta hilang. Server meneruskan delta ke browser sebagai SSE sendiri dan membatalkan request ke OpenAI saat browser pergi. Browser memakai fetch + AbortController untuk Stop dan menulis dengan textContent.

Yang akan kamu pelajari