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 dengandecoder.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
}
- Huruf terbelah.
”itu 3 byte (E2 80 9D). Dengan potongan 89 byte, potongan ke-45 berakhir tepat setelahE2. Didecode sendiri-sendiri, byte yang gak lengkap jadi karakter pengganti�. Itu jawaban tebakannya: layarnya menampilkan“Cantik Itu Luka���. - Baris terbelah. Satu baris
data:delta panjangnya sekitar 150 byte. Kalau potongan berakhir di tengahnya,JSON.parsemelemparSyntaxErrordan 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
decode(value)tanpa{ stream: true }. Huruf multi-byte (“ ” édan emoji) yang terbelah di batas potongan jadi�.JSON.parseper baris tanpa buffer. Delta yang barisnya terpotong hilang diam-diam, dan di potongan besar kalimatnya cuma kurang beberapa kata.innerHTML += teks. Jawaban model adalah teks dari luar: bisa merusak HTML atau membawa script. PakaitextContent, atau render sebagai teks di React.- Proxy yang menahan stream. Nginx (dipakai Laravel Forge, misalnya) bisa menampung potongan sampai selesai. Kirim header
X-Accel-Buffering: nodi response stream-mu.
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.