โ† Semua pembelajaran / Python untuk AI Engineer
Fase 5 ยท Bangun API dengan FastAPI

Server-Sent Events (SSE)

Meneruskan token LLM ke browser secara real-time memakai Server-Sent Events bawaan FastAPI.

Intisari

  • Cukup yield dari path operation dengan response_class=EventSourceResponse.
  • FastAPI mengurus keep-alive ping, header anti-cache, dan pencegahan buffering proxy.
  • SSE itu satu arah (server โ†’ klien) dan berjalan di atas HTTP biasa โ€” lebih sederhana dari WebSocket.
  • Browser otomatis menyambung ulang; Last-Event-ID memungkinkan melanjutkan dari titik putus.
  • Ini yang menghubungkan messages.stream() dari Fase 4 ke frontend-mu.

SSE vs WebSocket

SSEWebSocket
ArahServer โ†’ klien sajaDua arah
ProtokolHTTP biasaProtokol terpisah
Sambung ulangOtomatis di browserKamu yang tangani
Lewat proxy / CDNBiasanya lancarSering perlu konfigurasi
Format dataTeksTeks atau biner
Untuk chat LLMโœ… CocokBerlebihan

Untuk streaming jawaban LLM, SSE adalah pilihan yang tepat. Datanya hanya mengalir satu arah (token dari server), dan pesan pengguna berikutnya bisa dikirim lewat POST biasa. WebSocket menambah kompleksitas tanpa memberi manfaat di kasus ini.

Bentuk paling sederhana

from collections.abc import AsyncIterable
from fastapi import FastAPI
from fastapi.sse import EventSourceResponse

app = FastAPI()

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest) -> AsyncIterable[str]:
    async with async_client.messages.stream(
        model="claude-opus-5",
        max_tokens=16000,
        messages=[{"role": "user", "content": req.pesan}],
    ) as stream:
        async for teks in stream.text_stream:
            yield teks

Yang diurus FastAPI untukmu:

Event terstruktur

import json
from fastapi.sse import ServerSentEvent

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest) -> AsyncIterable[ServerSentEvent]:
    async with async_client.messages.stream(...) as stream:
        async for event in stream:
            if event.type == "content_block_delta" and event.delta.type == "text_delta":
                yield ServerSentEvent(
                    event="token",
                    data=json.dumps({"teks": event.delta.text}),
                )

        final = await stream.get_final_message()
        yield ServerSentEvent(
            event="selesai",
            data=json.dumps({
                "token_masuk": final.usage.input_tokens,
                "token_keluar": final.usage.output_tokens,
                "biaya": hitung_biaya(final.usage),
            }),
        )

Sisi browser

const res = await fetch("/chat/stream", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ pesan: teks, session_id: sid }),
});

const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  tampilkan(value);
}

Perhatikan: EventSource bawaan browser hanya mendukung GET. Karena permintaan chat biasanya POST (body-nya bisa panjang), pakai fetch dengan streaming body seperti di atas โ€” atau library seperti @microsoft/fetch-event-source yang mendukung POST plus sambung ulang otomatis.

Menangani error di tengah stream

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest) -> AsyncIterable[ServerSentEvent]:
    try:
        async with async_client.messages.stream(...) as stream:
            async for teks in stream.text_stream:
                yield ServerSentEvent(event="token", data=teks)
    except anthropic.RateLimitError:
        yield ServerSentEvent(event="error", data="layanan sedang sibuk")
    except Exception:
        logger.exception("stream gagal")
        yield ServerSentEvent(event="error", data="terjadi kesalahan")

Setelah streaming dimulai, status HTTP sudah terkirim sebagai 200. Kamu tidak bisa lagi membalas 500. Jadi error harus dikirim sebagai event di dalam stream, dan frontend-mu harus mendengarkan event error itu.

Klien memutus koneksi

from fastapi import Request

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest, request: Request) -> AsyncIterable[str]:
    async with async_client.messages.stream(...) as stream:
        async for teks in stream.text_stream:
            if await request.is_disconnected():
                logger.info("klien memutus, hentikan generasi")
                break
            yield teks

Ini menghemat uang nyata: kalau pengguna menutup tab di tengah jawaban panjang, tidak ada gunanya terus membayar token yang tidak akan pernah dibaca.

Sambung ulang dengan Last-Event-ID

@app.get("/kejadian", response_class=EventSourceResponse)
async def kejadian(request: Request) -> AsyncIterable[ServerSentEvent]:
    terakhir = request.headers.get("last-event-id")
    mulai = int(terakhir) + 1 if terakhir else 0

    for i, item in enumerate(ambil_kejadian(sejak=mulai), start=mulai):
        yield ServerSentEvent(id=str(i), data=json.dumps(item))

Browser mengirim header Last-Event-ID secara otomatis saat menyambung ulang. Berguna untuk feed peristiwa; untuk jawaban chat, biasanya lebih sederhana memulai ulang saja.

Di belakang nginx

location /chat/stream {
    proxy_pass http://api:8000;
    proxy_buffering off;         # โ† WAJIB, kalau tidak stream ditahan
    proxy_cache off;
    proxy_read_timeout 300s;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
}

Gejala yang paling sering muncul: stream jalan sempurna di lokal, tapi setelah di-deploy semua teks datang sekaligus di akhir. Penyebabnya hampir selalu proxy_buffering yang masih aktif.

Checkpoint Fase 5

from contextlib import asynccontextmanager
from collections.abc import AsyncIterable
from fastapi import FastAPI, Request
from fastapi.sse import EventSourceResponse, ServerSentEvent
import anthropic, json

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.llm = anthropic.AsyncAnthropic()
    yield
    await app.state.llm.close()

app = FastAPI(lifespan=lifespan)

@app.post("/chat/stream", response_class=EventSourceResponse)
async def chat_stream(req: ChatRequest, request: Request) -> AsyncIterable[ServerSentEvent]:
    client = request.app.state.llm
    async with client.messages.stream(
        model="claude-opus-5", max_tokens=16000,
        messages=[{"role": "user", "content": req.pesan}],
    ) as stream:
        async for teks in stream.text_stream:
            if await request.is_disconnected():
                break
            yield ServerSentEvent(event="token", data=teks)

        final = await stream.get_final_message()
        yield ServerSentEvent(
            event="selesai",
            data=json.dumps({"token_keluar": final.usage.output_tokens}),
        )

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.