Kamu mengirim prompt. Lalu menatap spinner selama delapan detik. Model sebenarnya sudah mulai generate, tapi user nggak lihat apa-apa sampai jawaban selesai semua. Itu bedanya chat yang terasa hidup sama chat yang terasa kayak mesin faks.
Respons LLM itu lambat menurut standar web. Jawaban panjang bisa makan waktu beberapa detik, dan user nggak akan sabar nunggu semuanya. Streaming ngebenerin ini dengan cara push token ke client begitu tiap token diproduksi. Token pertama tiba cepat, sisanya nyusul, dan pembaca ngikutin alurnya daripada nunggu diam.
Server-Sent Events (SSE) adalah tool paling sederhana buat kerjaan ini. Post ini ngasih setup lengkap yang beneran jalan: backend FastAPI yang stream dari API OpenAI-compatible, plus client browser yang render token pas dateng. Copy kodenya, ganti nama model, selesai.
Prasyarat
- Python 3.10+
pip install "fastapi[standard]"(0.135.0+, udah ada native SSE),openai,uvicorn- API key untuk endpoint OpenAI-compatible mana pun. Server lokal kayak Ollama juga jalan, karena dia ngomong pake protokol
stream=Trueyang sama
Cara kerja SSE
SSE itu respons HTTP biasa dengan media type text/event-stream. Server naroh koneksi tetap kebuka dan push blok teks kecil, tiap blok punya field kayak data dan event, dipisah baris kosong.
data: {"delta": "Hello"}
event: token
data: {"delta": " world"}
event: token
data: [DONE]
event: done
FastAPI nambahin native SSE di versi 0.135.0, lewat EventSourceResponse dan ServerSentEvent yang di-import dari fastapi.sse. Sebelumnya kamu harus pake package pihak ketiga sse-starlette. Browser udah dukung format ini bertahun-tahun, dan API native EventSource bisa baca langsung.
Backend: endpoint chat yang streaming
Ini server lengkapnya. Dua perubahan yang penting: response_class=EventSourceResponse dan async generator yang nge-yield tiap chunk.
import json
import os
from collections.abc import AsyncIterable
from fastapi import FastAPI, Request
from fastapi.sse import EventSourceResponse, ServerSentEvent
from openai import AsyncOpenAI
from pydantic import BaseModel
app = FastAPI()
client = AsyncOpenAI(api_key=os.environ.get("OPENAI_API_KEY"))
class ChatRequest(BaseModel):
message: str
history: list = []
async def tokens(req: ChatRequest, request: Request) -> AsyncIterable[ServerSentEvent]:
messages = req.history + [{"role": "user", "content": req.message}]
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
stream=True,
)
async for chunk in stream:
if await request.is_disconnected():
await stream.aclose()
return
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
yield ServerSentEvent(data=json.dumps({"delta": delta}), event="token")
if chunk.choices and chunk.choices[0].finish_reason == "stop":
yield ServerSentEvent(raw_data="[DONE]", event="done")
return
@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest, request: Request):
return EventSourceResponse(
tokens(req, request),
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
Dua detail kecil yang nanggung beban paling banyak.
request.is_disconnected()ngeberhentiin loop lebih awal kalau user nutup tab, jadi kamu berhenti buang token buat penonton yang udah pergi.chunk.choices[0].delta.contenttempat tiap token tiba. Pasfinish_reasonnyentuh"stop", kamu kirim marker[DONE]terus tutup.
Jalankan dan test pake curl:
uvicorn main:app --host 0.0.0.0 --port 8000
curl -N -X POST http://localhost:8000/chat/stream \
-H "Content-Type: application/json" -d '{"message":"Explain SSE in two sentences."}'
Flag -N bikin curl nggak buffering, jadi kata-katanya muncul di terminal satu per satu pas dateng.
Client: render token pake fetch
Kamu mungkin ngarepin EventSource. Tapi dia punya dua batasan yang bikin dia salah buat endpoint chat: dia cuma bisa GET dan nggak bisa set custom headers. Buat POST yang juga bawa auth, fetch ke ReadableStream itu jalur yang aman buat production. Kamu parse baris SSE-nya sendiri.
const controller = new AbortController();
const res = await fetch("/chat/stream", {
method: "POST",
signal: controller.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: input, history: history }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") break;
assistantText += JSON.parse(payload).delta;
render(assistantText);
}
}
// dari handler tombol stop: controller.abort();
Loop-nya baca body dalam bentuk chunk, pecah per baris baru, dan nyimpen baris terakhir yang belum lengkap di buffer sampai data berikutnya dateng. Karena kamu pegang ReadableStream, kamu dapet cancel gratis: stop generation di tengah jalan cukup panggil controller.abort().
Kapan pilih SSE daripada WebSockets
SSE jalan di atas HTTP biasa, ngirim data satu arah (server ke client), dan otomatis reconnect kalau koneksinya putus. WebSockets dua arah. Pilih itu cuma kalau client harus push di tengah stream, kayak chat di mana user mau interupsi dan menganu model. Kalau yang kamu butuh cuma "balikin token, nggak usah yang lain", SSE jauh lebih ringan.
Satu catatan jujur dari dokumen MDN: lewat HTTP/1.1, browser cuma ngizinin sekitar enam koneksi EventSource yang kebuka per origin di semua tab. HTTP/2 negosiasi limit yang jauh lebih tinggi. Reader berbasis fetch di atas tetep ngelewat limit itu, karena dia bukan objek EventSource sama sekali.
Catatan produksi
- Reverse proxy buffering secara default. Di belakang Nginx, Cloudflare, atau ALB, proxy bisa nahan seluruh body dulu sebelum diterusin. Header
X-Accel-Buffering: nodi kode itu nyuruh mereka lewatin chunk langsung. Tanpa itu, streaming diam-diam berubah jadi nunggu lama lagi. - Perhatikan timeout proxy. Beberapa proxy default sekitar 60 detik dalam keadaan diam. Kalau model kamu bisa jeda antar token, kirim komentar keepalive kecil secara berkala atau naikin read timeout proxy.
- Pertahankan header
Cache-Control: no-cachedanConnection: keep-alivebiar nggak ada yang nyimpen stream yang emang nggak boleh di-cache. - Lacak usage kalau kamu billing token. Tambah
stream_options={"include_usage": true}di panggilan create; total token dateng di chunk terakhir.
Langkah selanjutnya
Setelah token ngucur, tambahin bertahap: event bertipe tool_call dan tool_result biar agent multi-step render progresif daripada beku pas lagi manggil fungsi, tombol abort di sisi client biar user bisa stop generation yang lari kencang, dan counter time-to-first-token biar kamu bisa buktiin perubahannya beneran ngebantu.
Referensi
- Dokumentasi FastAPI, Server-Sent Events: https://fastapi.tiangolo.com/tutorial/server-sent-events/
- MDN, EventSource API: https://developer.mozilla.org/en-US/docs/Web/API/EventSource
- Dokumentasi OpenAI, How to stream completions: https://developers.openai.com/cookbook/examples/how_to_stream_completions