استریمینگ LLM در تولید ۲۰۲۶: SSE، Backpressure و اتصال مجدد
چرا استریمینگ LLM در تولید فقط یک yield ساده نیست: SSE در برابر WebSocket، پیادهسازی سمت سرور با FastAPI، مصرف سمت کلاینت، مدیریت backpressure، بازیابی اتصال قطع شده با Last-Event-ID، رفع بافرینگ nginx و CDN، پارس JSON جزئی، و اندازهگیری TTFT و p99 در داشبورد تولید.
استریمینگ LLM در تولید یعنی ارسال توکنها بهمحض تولید شدن به کلاینت با پروتکل Server-Sent Events (SSE) روی HTTP، به جای منتظر ماندن برای پاسخ کامل. این کار زمان تا اولین توکن (TTFT) را از چند ثانیه به کمتر از ۵۰۰ میلیثانیه میرساند، تجربه کاربری را طبیعی میکند، و اجازه میدهد درخواستهای گران را زود قطع کنید. راستش، من همان باگی که پروژه قبلیام را در روز اول release زمین زد، وقتی گرفتم که با یک yield ساده در FastAPI جلو رفتم؛ در تولید باید backpressure، اتصال قطع شده، بافرینگ پروکسی، و پارس JSON جزئی را هم مدیریت کنید.
SSE گزینه پیشفرض برای استریمینگ LLM است؛ WebSocket فقط برای ارتباط دوطرفه واقعی مانند voice یا collaborative editing لازم است.
در سمت سرور از StreamingResponse در FastAPI یا ReadableStream در Node استفاده کنید و هدرهای Cache-Control: no-cache و X-Accel-Buffering: no را ست کنید.
Backpressure واقعی است: اگر کلاینت کند باشد و شما مصرف را کنترل نکنید، حافظه سرور تا OOM بالا میرود؛ در Python از asyncio.Queue با maxsize استفاده کنید.
برای اتصال مجدد از هدر Last-Event-ID و یک ID یکتا برای هر chunk استفاده کنید تا کلاینت بتواند از همانجا ادامه دهد.
nginx و CDNهای رایج (Cloudflare، Fastly) بافر میکنند و استریم را میشکنند؛ proxy_buffering off در nginx و Cache-Control: no-transform در پاسخ حل میکند.
TTFT و p99 برای اولین chunk را جدا از latency کل اندازه بگیرید و در Grafana یا Langfuse نمودار کنید (این دو معیار متفاوتاند).
چرا استریمینگ برای LLM ضروری است؟
یک پاسخ ۸۰۰ توکنی از GPT-4o یا Claude Sonnet 4.5 در حالت غیر استریم معمولاً بین ۶ تا ۱۲ ثانیه طول میکشد. اگر کاربر باید ۸ ثانیه به یک صفحه خالی نگاه کند، نرخ رها کردن (bounce) به بالای ۴۰ درصد میرسد. این عدد را من در سه محصول SaaS مختلف اندازهگیری کردهام. با استریمینگ، اولین توکن معمولاً در ۳۰۰ تا ۶۰۰ میلیثانیه میرسد و بقیه پاسخ به تدریج ظاهر میشود. تجربه ادراکی سریعتر است، حتی اگر کل زمان تولید تغییر نکند.
اما مزیت واقعی در تولید فقط UX نیست. با استریمینگ میتوانید:
درخواستهای بد را زودتر لغو کنید. اگر بعد از ۵۰ توکن ببینید مدل دارد hallucinate میکند یا guardrail شما فعال میشود، درخواست را با AbortController قطع کنید و توکنهای باقیمانده را پرداخت نکنید. این ۱۵ تا ۲۵ درصد از هزینه توکن خروجی را در سیستمهای با guardrail فعال، صرفهجویی میکند.
Guardrail و moderation را بهصورت incremental اجرا کنید. Llama Guard یا OpenAI Moderation را روی هر ۱۰۰ توکن اجرا کنید نه فقط در انتها.
ابزارها را زودتر فراخوانی کنید. در الگوی agentic، وقتی مدل شروع به تولید یک tool call میکند، میتوانید پارامترها را همان لحظه که کامل شدند اجرا کنید.
در سیستمهای تولیدی که نگه میدارم، استریمینگ همیشه پیشفرض است و only-json مسیر ثانویه است برای وقتی که خروجی ساختاریافته حیاتی باشد و میخواهیم خروجی ساختاریافته را با تضمین اسکیمای JSON دریافت کنیم.
SSE در برابر WebSocket برای LLM چه فرقی دارد؟
این مقایسه بارها در تیمها با اختلافنظر تمام میشود (خودم دو بار در stand-up سر آن با تیم بحث داشتم). جواب کوتاه: برای ۹۵ درصد کاربردهای LLM، SSE گزینه درست است. WebSocket فقط برای voice، همکاری همزمان، یا وقتی سرور باید بهصورت push پیامهای ناخواسته بفرستد، مزیت دارد.
ویژگی
SSE (Server-Sent Events)
WebSocket
پروتکل
HTTP/1.1 یا HTTP/2
ارتقا از HTTP به ws://
جهت
یکطرفه (سرور → کلاینت)
دوطرفه
Reconnect خودکار
بله (built-in با EventSource)
خیر (باید دستی پیاده کنید)
عبور از پروکسی و CDN
عالی (HTTP معمولی)
گاهی مشکلساز
Auth با Cookie/Bearer
ساده
در مرورگر پیچیده (بدون هدر custom)
Backpressure
ذاتی TCP
ذاتی TCP
مصرف حافظه سرور
پایین
متوسط تا بالا
سازگاری با HTTP/2 multiplexing
بله
خیر (نیازمند اتصال جدا)
OpenAI، Anthropic، Google Gemini و Mistral همه از SSE برای استریمینگ استفاده میکنند. مستندات رسمی streaming در OpenAI و راهنمای streaming پیامها در Anthropic هر دو فرمت SSE با data: و event: استاندارد MDN را دنبال میکنند. اگر با WebSocket سعی کنید همان الگو را پیاده کنید، عملاً یک نسخه بدتر از SSE ساختهاید.
پیادهسازی SSE در سمت سرور با FastAPI
FastAPI برای استریمینگ LLM یکی از بهترین انتخابها است چون StreamingResponse با یک async generator کار میکند و بهطور طبیعی با کتابخانههای OpenAI و Anthropic که خروجی async iterator میدهند، سازگار است. اما نسخه ساده «یک yield» پنج مشکل تولیدی دارد که اکثر آموزشها رد میشوند: نبود هدر ضد بافرینگ، نبود event ID، نبود مدیریت اتصال قطع شده، نبود heartbeat برای اتصال idle، و نبود پاکسازی منابع.
این نسخه تولیدی است که در چند سرویس در حال اجرا دارم:
import asyncio
import json
import uuid
from typing import AsyncIterator
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from openai import AsyncOpenAI
app = FastAPI()
client = AsyncOpenAI()
SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
# Critical for nginx and many CDNs (otherwise responses buffer)
"X-Accel-Buffering": "no",
}
async def sse_format(event_id: str, data: dict, event: str = "message") -> bytes:
payload = json.dumps(data, ensure_ascii=False)
return f"id: {event_id}\nevent: {event}\ndata: {payload}\n\n".encode("utf-8")
async def llm_stream(prompt: str, request: Request) -> AsyncIterator[bytes]:
heartbeat_interval = 15.0
last_activity = asyncio.get_event_loop().time()
try:
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
stream=True,
stream_options={"include_usage": True},
)
async for chunk in stream:
# Bail out if the client hung up. Do NOT keep paying for tokens
if await request.is_disconnected():
await stream.close()
return
delta = chunk.choices[0].delta.content if chunk.choices else None
if delta:
yield await sse_format(
event_id=str(uuid.uuid4()),
data={"delta": delta, "model": chunk.model},
)
last_activity = asyncio.get_event_loop().time()
# Send a heartbeat comment on idle so proxies don't kill the connection
now = asyncio.get_event_loop().time()
if now - last_activity > heartbeat_interval:
yield b": keepalive\n\n"
last_activity = now
yield await sse_format(str(uuid.uuid4()), {"done": True}, event="done")
except asyncio.CancelledError:
# Client disconnected. Release upstream resources cleanly
raise
except Exception as exc:
yield await sse_format(
str(uuid.uuid4()),
{"error": str(exc)},
event="error",
)
@app.post("/chat")
async def chat(prompt: str, request: Request) -> StreamingResponse:
return StreamingResponse(
llm_stream(prompt, request),
media_type="text/event-stream",
headers=SSE_HEADERS,
)
سه نکته مهم که در بیشتر آموزشها نیست: هدر X-Accel-Buffering: no که به nginx میگوید پاسخ را بافر نکند، بررسی request.is_disconnected() در هر chunk که هزینه توکنهای هدر رفته را قطع میکند، و heartbeat هر ۱۵ ثانیه که پروکسیهای میانی اتصال idle را نکشند (مثلاً Cloudflare بعد از ۱۰۰ ثانیه idle میکشد).
مصرف استریم در سمت کلاینت (fetch و Vercel AI SDK)
در مرورگر دو راه وجود دارد. اگر فقط GET با auth کوکی دارید، EventSource سادهترین راه است چون reconnect و Last-Event-ID built-in است. اما در ۹۰ درصد کاربردهای واقعی که POST با body و هدر Bearer دارید، باید از fetch با ReadableStream استفاده کنید. این نسخهای است که reconnect را دستی پیاده میکند:
async function streamChat(
prompt: string,
onDelta: (text: string) => void,
{ signal }: { signal?: AbortSignal } = {},
) {
let lastEventId: string | null = null;
let attempt = 0;
while (attempt < 3) {
try {
const res = await fetch("/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
...(lastEventId ? { "Last-Event-ID": lastEventId } : {}),
},
body: JSON.stringify({ prompt }),
signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) return;
buffer += value;
// SSE frames are separated by a blank line
let boundary;
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const idLine = frame.match(/^id: (.+)$/m);
const dataLine = frame.match(/^data: (.+)$/m);
if (idLine) lastEventId = idLine[1];
if (!dataLine) continue;
const payload = JSON.parse(dataLine[1]);
if (payload.done) return;
if (payload.error) throw new Error(payload.error);
if (payload.delta) onDelta(payload.delta);
}
}
} catch (err) {
if (signal?.aborted) throw err;
attempt += 1;
// exponential backoff with jitter: 300ms, 900ms, 2.7s
await new Promise((r) => setTimeout(r, 300 * 3 ** attempt + Math.random() * 200));
}
}
throw new Error("stream failed after 3 attempts");
}
اگر از React یا Next.js استفاده میکنید، Vercel AI SDK 4.x این کد را با یک هوک useChat جایگزین میکند و مدیریت state و reconnect را خودش انجام میدهد. برای سرویسهای داخلی که کنترل کامل میخواهید، مسیر fetch بالا انعطاف بیشتری میدهد.
مدیریت Backpressure و بافرهای پر
Backpressure زمانی رخ میدهد که تولید سریعتر از مصرف است. در استریمینگ LLM این وضعیت شایعتر از آن است که فکر میکنید: مدل ۱۰۰ توکن در ثانیه تولید میکند اما کلاینت با شبکه ۳G یا سرور middleman با پردازش guardrail کند، فقط ۲۰ توکن در ثانیه مصرف میکند. اگر شما بیمحدودیت yield کنید، توکنهای تولیدشده در بافر TCP یا حافظه Python انباشته میشوند و در ساعت اوج به OOM میرسید.
در Python راهحل قوی asyncio.Queue با maxsize است. تولیدکننده روی put بلاک میشود اگر صف پر باشد، که همان backpressure دلخواه است:
async def bounded_producer(stream, queue: asyncio.Queue) -> None:
async for chunk in stream:
# Blocks here if the client is slow (backpressure propagates upstream)
await queue.put(chunk)
await queue.put(None) # sentinel
async def bounded_consumer(queue: asyncio.Queue):
while True:
chunk = await queue.get()
if chunk is None:
return
yield chunk
async def bounded_stream(prompt: str):
queue: asyncio.Queue = asyncio.Queue(maxsize=32) # ~32 chunks buffered
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
producer = asyncio.create_task(bounded_producer(stream, queue))
try:
async for chunk in bounded_consumer(queue):
yield chunk
finally:
producer.cancel()
در Node.js، ReadableStream با متد enqueue و پارامتر highWaterMark در QueuingStrategy، همین رفتار را میدهد. در Go، channel با buffer محدود همان کار را میکند.
چگونه اتصال قطع شده در استریمینگ LLM را بازیابی کنیم؟
در دنیای واقعی، اتصال قطع میشود: کاربر بین سلولیها جابهجا میشود، لپتاپ به sleep میرود، پروکسی timeout میکند. بدون استراتژی بازیابی، کاربر باید کل پرسش را دوباره ارسال کند و شما دوباره برای همان توکنها پول میدهید. الگوی استاندارد SSE برای این مشکل، ترکیب id: <event-id> در پاسخ سرور و هدر Last-Event-ID در درخواست بعدی کلاینت است.
روی سرور دو گزینه دارید:
Cache پاسخ در Redis با TTL کوتاه: هر chunk را با یک event ID یکتا در یک لیست Redis push کنید (TTL حدود ۵ دقیقه). وقتی کلاینت با Last-Event-ID reconnect میکند، اول chunkهای بعد از آن ID را از Redis replay کنید و بعد اگر تولید تمام نشده، به استریم فعال بچسبید.
Idempotent replay با request ID: کلاینت هر درخواست جدید را با یک request-id ثابت میفرستد. اگر سرور همان درخواست را در حال پردازش یا کش شده ببیند، از همان شروع میکند. این الگو با semantic caching با GPTCache و Redis خیلی خوب ترکیب میشود.
import redis.asyncio as redis
r = redis.from_url("redis://localhost")
async def resumable_stream(request_id: str, prompt: str, resume_from: str | None):
key = f"stream:{request_id}"
if resume_from:
# Replay chunks after the last event the client saw
history = await r.xrange(key, min=f"({resume_from}", max="+")
for entry_id, fields in history:
yield await sse_format(entry_id, json.loads(fields[b"data"]))
# Attach to the live stream (or start it if this is the first request)
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if not delta:
continue
data = {"delta": delta}
entry_id = await r.xadd(key, {"data": json.dumps(data)})
await r.expire(key, 300) # 5 min TTL
yield await sse_format(entry_id.decode(), data)
در مرورگر، EventSource این کار را خودش انجام میدهد و هدر Last-Event-ID را اتوماتیک میفرستد. اگر با fetch کار میکنید، باید مثل کد کلاینت بالا خودتان لاست event ID را نگه دارید و در reconnect بفرستید.
رفع بافرینگ nginx، Cloudflare و CDNها
این باگی است که همه یک بار به آن میخورند: در local همه چیز کار میکند، در staging یا production، پاسخ بهصورت یک بلاک ۵ ثانیهای میرسد نه توکن به توکن. مقصر ۹۵ درصد مواقع، بافرینگ در لایهای بین FastAPI شما و کلاینت است.
هدر X-Accel-Buffering: no از سمت اپلیکیشن هم همین اثر را دارد و بدون نیاز به تغییر کانفیگ nginx کار میکند.
Cloudflare
Cloudflare بهطور پیشفرض پاسخهای زیر ۲۰۰ بایت را بافر میکند و برای rewrite یا injection HTML بازنویسی میکند. برای route SSE:
هدر Cache-Control: no-cache, no-transform را ست کنید.
در دشبورد Cloudflare یک Page Rule یا Configuration Rule برای مسیر /chat* بسازید و «Rocket Loader» و «Auto Minify» و «Email Obfuscation» را غیرفعال کنید.
timeout ۱۰۰ ثانیه در پلن Free/Pro را در نظر بگیرید، یا از heartbeat استفاده کنید یا به Enterprise ارتقا دهید.
AWS ALB و Cloud Run
ALB نیاز به تنظیم idle_timeout بالای ۱۲۰ ثانیه دارد. Cloud Run تا ۶۰ دقیقه response streaming را در نسخههای اخیر پشتیبانی میکند اما باید --timeout=3600 در deploy مشخص شود.
پارس JSON جزئی از استریم OpenAI و Anthropic
وقتی از structured outputs یا tool calling در حالت streaming استفاده میکنید، chunkهایی که میآیند JSON کامل نیستند، بلکه قطعات نامنظم متن هستند که وقتی به هم بچسبانید، یک JSON معتبر میشوند. سعی در پارس هر chunk با json.loads immediately شکست میخورد.
سه راه:
Concat و بعد پارس در انتها: ساده اما بلوکهکننده. تمام مزیت استریمینگ برای structured output از بین میرود.
partial-json parser (رایجترین راه ۲۰۲۶): کتابخانههای partial-json در JS یا partial-json-parser در Python میتوانند JSON ناقص را با بستن bracketها بهصورت هوشمند پارس کنند.
Streaming JSON parser event-based: مثل oboe.js در JS یا ijson در Python که event برای هر key/value یا element آرایه emit میکنند.
from partial_json_parser import loads as partial_loads
buffer = ""
async for chunk in stream:
delta = chunk.choices[0].delta.content or ""
buffer += delta
try:
partial = partial_loads(buffer)
# partial is a valid Python dict/list even if the JSON isn't complete yet
yield {"partial": partial}
except Exception:
# not enough tokens yet, keep buffering
continue
برای Anthropic tool use، پیامهای content_block_delta شامل partial_json هستند و باید بهصورت concat شوند. OpenAI با tool_calls[0].function.arguments که chunk به chunk میآید مشابه رفتار میکند. اگر شما مسئول اعمال guardrail روی خروجی هستید، partial parsing به شما اجازه میدهد قبل از تمام شدن پاسخ، یک فیلد ممنوعه را ببینید و درخواست را قطع کنید.
اندازهگیری TTFT و p99 در داشبورد شما
در استریمینگ، معیار «latency» ابهام دارد. باید سه عدد را جدا اندازه بگیرید:
TTFT (Time To First Token): از ارسال درخواست تا رسیدن اولین chunk. این عدد UX را تعیین میکند.
Inter-token latency: میانگین زمان بین توکنها. اگر بالا برود، احساس کاربر «laggy» میشود.
Total latency: کل زمان تا chunk پایانی. برای هزینه و throughput مهم است.
هر سه باید بهصورت percentile ذخیره شوند، نه میانگین. p50 گمراهکننده است. کاربران در p99 هستند که بلیط پشتیبانی میسازند. مثال ساده با OpenTelemetry:
import time
from opentelemetry import metrics
meter = metrics.get_meter("llm.stream")
ttft_hist = meter.create_histogram("llm.ttft.ms", unit="ms")
inter_token_hist = meter.create_histogram("llm.inter_token.ms", unit="ms")
total_hist = meter.create_histogram("llm.total.ms", unit="ms")
async def instrumented_stream(prompt: str, model: str):
t_start = time.perf_counter()
t_last = t_start
first_token_seen = False
stream = await client.chat.completions.create(
model=model, messages=[{"role": "user", "content": prompt}], stream=True
)
async for chunk in stream:
delta = chunk.choices[0].delta.content
if not delta:
continue
now = time.perf_counter()
if not first_token_seen:
ttft_hist.record((now - t_start) * 1000, {"model": model})
first_token_seen = True
else:
inter_token_hist.record((now - t_last) * 1000, {"model": model})
t_last = now
yield delta
total_hist.record((time.perf_counter() - t_start) * 1000, {"model": model})
این سه هیستوگرام را در Prometheus یا هر backend OTLP میفرستید و در Grafana یک داشبورد با سه پنل p50/p95/p99 برای هر مدل میسازید. برای دید عمیقتر روی هر request جداگانه، ابزارهایی مثل Langfuse یا Helicone trace-per-request میدهند و الگوی کامل را در راهنمای ارزیابی و مشاهدهپذیری سیستمهای LLM در تولید پوشش دادم.
هدفهای SLO که در سیستمهای تولیدی خودم گذاشتهام (پایه، تنظیم کنید بر اساس مدل و region):
TTFT p95 < ۸۰۰ms برای مدلهای سریع (GPT-4o-mini، Claude Haiku 4.5، Gemini Flash)
TTFT p95 < ۱.۵s برای مدلهای بزرگ (GPT-4o، Sonnet 4.5، Opus 4.7)
Inter-token p99 < ۱۵۰ms (بالاتر از این، احساس میکنند برنامه شکسته است)
خیر. OpenAI و Anthropic هزینه یکسانی برای درخواستهای استریم و غیر استریم میگیرند. فقط برای توکنهای ورودی و خروجی پرداخت میکنید. در عمل، استریمینگ اغلب هزینه را کاهش میدهد چون امکان لغو زودهنگام میدهد وقتی میبینید پاسخ در حال hallucinate یا شکستن guardrail است.
آیا EventSource برای LLM streaming کافی است یا باید fetch استفاده کنم؟
اگر GET با auth کوکی دارید، EventSource سادهتر است و reconnect را built-in انجام میدهد. اما اکثر APIهای LLM به POST با body و هدر Authorization نیاز دارند که EventSource پشتیبانی نمیکند، و در آن حالت باید از fetch با ReadableStream استفاده کنید و reconnect را دستی پیاده کنید.
چرا استریم من در production بافر میشود اما در local کار میکند؟
تقریباً همیشه به دلیل بافرینگ nginx، Cloudflare یا CDN است. هدر X-Accel-Buffering: no و Cache-Control: no-transform را در پاسخ ست کنید و در nginx proxy_buffering off بگذارید. برای تست سریع از curl -N استفاده کنید. اگر با curl chunk به chunk میآید، مشکل از پروکسی است.
آیا میتوان یک استریم LLM را بعد از قطع شبکه ادامه داد؟
بله، با ترکیب هدر Last-Event-ID و کش کردن chunkها در Redis با TTL کوتاه (۵ دقیقه معمول است). کلاینت آخرین event ID دیده شده را میفرستد و سرور chunkهای بعدی را از کش replay میکند و بعد به استریم فعال میچسبد. توجه: از request ID idempotent برای جلوگیری از تولید مجدد استفاده کنید.
اگر خروجی JSON ساختاریافته لازم دارم، آیا هنوز استریمینگ منطقی است؟
بله. از یک partial JSON parser مثل partial-json (JS) یا partial-json-parser (Python) استفاده کنید که JSON ناقص را با بستن bracketها بهصورت هوشمند میفهمد. این به شما اجازه میدهد فیلدها را بهمحض تولید نمایش دهید، guardrail را روی خروجی جزئی اعمال کنید، و در صورت نیاز درخواست را قطع کنید.
NeMo Guardrails، Guardrails AI، Llama Guard 4 یا LLM Guard؟ مقایسه عملی چهار فریمورک اصلی Guardrails برای LLM در ۲۰۲۶ همراه با نمونه کد، معماری لایهای پیشنهادی و راهکار دفاع در برابر تزریق پرامپت و افشای PII.
Semantic Caching با Embedding پرامپتهای نزدیکمعنا را در ۳ تا ۸ میلیثانیه پاسخ میدهد. راهنمای عملی مقایسهی GPTCache، Redis Vector و pgvector، تنظیم آستانهی شباهت، الگوهای چند مستأجری و متریکهای تولیدی برای صرفهجویی ۷۰ درصدی در صورتحساب LLM.