Streaming LLM Infrastructure 2026 - Kiến trúc Token-Level Streaming với SSE, Redis Streams, Resumable Streams và ClickHouse cho Multi-Agent Production
Posted on: 4/15/2026 3:13:40 AM
Table of contents
- 1. Vì sao streaming là yếu tố quyết định UX của multi-agent
- 2. SSE, WebSocket, HTTP/3 — lựa chọn giao thức 2026
- 3. Kiến trúc cơ bản của một pipeline streaming Claude
- 4. Redis Streams — message bus cho LLM token
- 5. Redis Pub/Sub — khi cần fan-out không lưu state
- 6. Resumable streams — thiết kế cho mạng thực tế
- 7. Multi-agent parallel streaming — fan-out và conductor
- 8. Backpressure và rate control — bài học đau đớn
- 9. ClickHouse — đo TTFT, TPOT và p95 tail
- 10. Progressive UI — biến stream thành trải nghiệm
- 11. Production hardening — checklist khắc nghiệt
- 12. So sánh các framework streaming tham chiếu 2026
- 13. Checklist đưa streaming layer vào sản xuất
- 14. Kết luận
- Nguồn tham khảo
Khi nói về hiệu năng của một hệ thống LLM, hầu hết bài viết chỉ đo tổng thời gian phản hồi — một con số tròn, dễ so sánh, nhưng che khuất điều quan trọng nhất với người dùng cuối: tokens trên màn hình tới tay họ lúc nào. Một câu trả lời dài 10 giây có thể "mượt như chảy" nếu token đầu tiên hiện ra trong 200 ms và các token kế tiếp nhỏ giọt đều đặn; hoặc có thể "tê liệt" nếu người dùng nhìn vào con trỏ chớp tắt suốt 10 giây rồi cả bức tường chữ đổ ầm xuống. Cùng tổng thời gian, trải nghiệm khác một trời một vực. Đây là lý do streaming infrastructure — lớp hạ tầng vận chuyển token từ LLM tới màn hình theo thời gian thực — đã trở thành một trong những thành phần quyết định UX của mọi hệ thống multi-agent năm 2026. Bài viết này đi sâu vào kiến trúc streaming sản xuất: lựa chọn giao thức (SSE vs WebSocket vs HTTP/3), vai trò Redis Streams và Pub/Sub, resumable streams, fan-out cho multi-agent song song, backpressure và telemetry ClickHouse để đo TTFT, TPOT, p95 tail latency.
1. Vì sao streaming là yếu tố quyết định UX của multi-agent
Hãy hình dung hai phiên trò chuyện với một agent code review. Agent A nhận request, chạy im lặng 8 giây, rồi trả về một báo cáo 1.200 từ. Agent B nhận cùng request, trong 300 ms đã bắt đầu in ra "Đang đọc file src/auth.ts...", vài giây sau "Phát hiện một vấn đề ở hàm verifyJwt...", và dần dần phơi bày kết quả theo từng đoạn. Cả hai về đích cùng lúc. Nhưng Agent B được người dùng đánh giá "nhanh hơn", "đáng tin hơn" và "có hồn hơn" trong gần như mọi nghiên cứu UX được công bố nửa cuối 2025. Lý do không nằm ở tốc độ thật — lý do nằm ở chuỗi thời gian nhận thức: con người không đo bằng giây, con người đo bằng "có gì xảy ra không".
Với multi-agent, sức ép lên streaming còn lớn hơn rất nhiều. Một luồng deep-research có thể huy động năm agent song song (search, fetch, summarize, fact-check, synthesize) và cuộn qua ba vòng phản hồi. Nếu chỉ stream token của mỗi agent con mà không có một "nhạc trưởng" gom lại theo trật tự có ý nghĩa, người dùng sẽ thấy một mớ token hỗn độn. Ngược lại, nếu chờ tất cả agent xong rồi mới bắt đầu hiển thị, người dùng nhìn vào skeleton trắng suốt 20-40 giây.
Tóm lại, streaming infrastructure của một hệ thống multi-agent sản xuất phải giải quyết bốn bài toán cùng lúc:
- Token-level transport — vận chuyển từng chunk từ LLM tới client trong vài mili-giây.
- Durability — nếu kết nối đứt giữa chừng, client có thể reconnect và nhận tiếp từ chỗ đã dừng, không phải bắt đầu lại từ đầu.
- Coordination — khi nhiều agent chạy song song, có một kênh điều phối trật tự hiển thị.
- Observability — mọi token đi qua đều có thể đo TTFT, TPOT, tail latency, drop rate cho từng phiên.
2. SSE, WebSocket, HTTP/3 — lựa chọn giao thức 2026
Có ba họ giao thức thực sự phổ biến cho streaming LLM ra client, và mỗi họ có điểm mạnh khác nhau. Bảng dưới tổng hợp các đánh giá từ Ably, Cloudflare, Redis và các đội engineering công khai đầu 2026:
| Giao thức | Hướng dữ liệu | Trạng thái server | Horizontal scale | Phù hợp với LLM |
|---|---|---|---|---|
| SSE (text/event-stream) | Một chiều server → client | Tối thiểu (chỉ một HTTP response mở) | Rất tốt, không cần sticky session | Mặc định cho token streaming |
| WebSocket | Hai chiều toàn song công | Cao (mỗi socket là một state object) | Cần Redis Pub/Sub hoặc NATS làm broker xuyên pod | Khi có input real-time từ client (voice, cursor) |
| HTTP/3 chunked (QUIC) | Một chiều hoặc hai chiều qua stream ID | Thấp (QUIC tự quản connection state) | Xuất sắc, head-of-line blocking biến mất | Gần tối ưu lý thuyết, cần runtime hỗ trợ HTTP/3 |
Quan điểm của cộng đồng đã rõ ràng vào đầu 2026: SSE thắng cho phần lớn trường hợp token streaming LLM. Lý do ngắn gọn: chat và agent response là luồng một chiều bản chất, SSE tận dụng đúng giao thức HTTP hiện có, reconnect tự động bằng Last-Event-ID, và không cần một bộ điều phối socket phức tạp. Ably đã công bố phép đo cho thấy SSE + Last-Event-ID cho phép xây resumable stream trên infra có sẵn mà gần như không có overhead. Redis thậm chí xuất bản hẳn một tutorial dùng Redis Streams + FastAPI + SSE làm hình mẫu tham chiếu.
WebSocket vẫn còn chỗ đứng — nhưng chỉ trong hai tình huống: (1) khi client cần gửi signal real-time ngược (voice streaming, cursor sharing, thao tác đa người dùng) và (2) khi bạn đã có một hệ WebSocket sẵn và không muốn duy trì hai đường dẫn song song. Với WebSocket, cần thêm một lớp fan-out qua Redis Pub/Sub hoặc NATS để các pod khác nhau có thể đẩy message tới đúng socket — đây là nguồn cơn của 80% sự cố streaming mà các team báo cáo trong các postmortem công khai gần đây.
HTTP/3 chunked là "tương lai gần": với 85% client-server traffic đã chạy QUIC, phần lớn khách hàng đã có sẵn băng thông và multiplexing miễn phí. Tuy nhiên runtime SDK LLM (Anthropic, OpenAI, Google) vẫn phát hành streaming qua text/event-stream trên HTTP/1.1 hoặc HTTP/2, nên HTTP/3 ở phía back-end vẫn còn là đích đến chứ chưa phải đường đi mặc định.
Quy tắc ngón tay cái 2026
Bắt đầu với SSE cho mọi luồng LLM → client. Chuyển qua WebSocket chỉ khi bạn cần kênh client → server real-time đồng thời. Đừng dùng polling. Đừng gộp stream với REST. Và đừng triển khai streaming mà không có Last-Event-ID — đây là dòng code quan trọng nhất bảo vệ UX khi mạng rung.
3. Kiến trúc cơ bản của một pipeline streaming Claude
Trước khi nói tới multi-agent, cần nắm đúng hình dạng đơn giản nhất: một client, một request, một mô hình, một kênh SSE. Tất cả các kiến trúc phức tạp đều là tổ hợp của pattern này nhân lên.
sequenceDiagram
participant C as Client (browser)
participant A as API server (FastAPI)
participant L as Claude API
C->>A: POST /chat { stream: true }
A->>L: client.messages.stream(...)
L-->>A: event: message_start
A-->>C: SSE event: message_start
L-->>A: event: content_block_delta (token)
A-->>C: SSE data: {"delta":"Xin"}
L-->>A: event: content_block_delta (token)
A-->>C: SSE data: {"delta":" chào"}
L-->>A: event: message_stop
A-->>C: SSE event: done
A->>C: Close connection
Nhìn tuần tự mà đơn giản. Cái khó ẩn trong bốn chi tiết: (1) API server không được giữ toàn bộ message trong bộ nhớ trước khi flush — phải stream từng chunk qua một async generator; (2) mỗi event cần có ID để client replay được; (3) connection close phải sạch (flush, cleanup, cancel upstream LLM call); (4) LLM call phải được timeout cứng, nếu không một request zombie có thể giữ chỗ tài nguyên hàng giờ.
Một implementation tham chiếu với FastAPI và Anthropic SDK trông như sau:
import json
import uuid
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from anthropic import AsyncAnthropic
app = FastAPI()
claude = AsyncAnthropic()
async def stream_chat(prompt: str, last_event_id: str | None):
start_from = int(last_event_id) + 1 if last_event_id else 0
idx = 0
async with claude.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": prompt}],
) as stream:
async for event in stream:
if event.type != "content_block_delta":
continue
idx += 1
if idx < start_from:
continue
payload = json.dumps({"delta": event.delta.text})
yield f"id: {idx}\nevent: delta\ndata: {payload}\n\n"
yield f"id: {idx + 1}\nevent: done\ndata: {{}}\n\n"
@app.post("/chat")
async def chat(req: Request):
body = await req.json()
last = req.headers.get("last-event-id")
gen = stream_chat(body["prompt"], last)
return StreamingResponse(
gen,
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
Ba dòng quan trọng: X-Accel-Buffering: no tắt buffering ở reverse proxy (Nginx mặc định gom response 8 KB trước khi flush — đủ để giết streaming); header id: cho mỗi event là xương sống của resumable stream; và idx < start_from cho phép tua tới token đã dừng khi client reconnect. Đây là trạng thái ngây thơ nhất — vẫn có lỗ hổng nếu bạn không lưu các token đã sinh ở đâu đó, nhưng đủ để minh họa hình dạng cơ bản.
4. Redis Streams — message bus cho LLM token
Pattern ngây thơ ở trên chỉ hoạt động khi process API server không chết giữa chừng. Trong sản xuất, điều ngược lại mới là bình thường: pod có thể bị autoscaler thu hồi, OOM có thể xảy ra, một bản deploy mới có thể dừng process cũ. Đây là lúc Redis Streams bước vào — không còn để API server trực tiếp giữ stream, mà tách hẳn quá trình sinh token khỏi quá trình truyền token cho client.
graph LR
Client["Browser
SSE EventSource"] --> Edge["API Edge
stateless"]
Edge --> Redis["Redis Stream
chat:{session_id}"]
Worker["Generator Worker
calls Claude SDK"] --> Redis
Redis -->|XREAD BLOCK| Edge
Worker -->|XADD delta 'Xin'| Redis
Worker -->|XADD delta ' chào'| Redis
Worker -->|XADD done| Redis
style Redis fill:#dc3545,stroke:#fff,color:#fff
style Worker fill:#0f3460,stroke:#fff,color:#fff
style Edge fill:#4CAF50,stroke:#fff,color:#fff
Lợi ích của việc tách hai vai trò:
- Generation không bị gián đoạn bởi client. Nếu người dùng đóng tab, LLM vẫn chạy xong (hoặc bị hủy chủ động), token vẫn được lưu vào stream, và người dùng quay lại có thể đọc trọn vẹn.
- Reconnect trở thành tầm thường. Client gửi
Last-Event-ID, API Edge chỉ cầnXREAD COUNT N FROM {last_id}và replay phần còn thiếu, sau đó chuyển sang theo dõi real-time vớiXREAD BLOCK. - Chia sẻ stream cho nhiều client. Khi hai tab cùng mở một phiên (hoặc một người dùng mobile + desktop), cả hai có thể đọc cùng một stream mà không cần nhân đôi chi phí LLM.
- Audit trail tự nhiên. Mỗi chunk đã qua Redis Stream có thể batch-flush xuống ClickHouse, tạo thành telemetry đầy đủ mà không cần thêm instrumentation.
Đoạn code minh họa worker và edge tương ứng:
# --- generator worker ---
import redis.asyncio as redis
from anthropic import AsyncAnthropic
claude = AsyncAnthropic()
r = redis.Redis()
async def generate(session_id: str, prompt: str):
key = f"chat:{session_id}"
async with claude.messages.stream(
model="claude-sonnet-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": prompt}],
) as stream:
async for event in stream:
if event.type == "content_block_delta":
await r.xadd(key, {"type": "delta", "text": event.delta.text})
elif event.type == "message_stop":
await r.xadd(key, {"type": "done"})
await r.expire(key, 3600) # giữ 1 giờ cho reconnect
# --- API edge ---
async def sse_stream(session_id: str, last_id: str | None):
key = f"chat:{session_id}"
cursor = last_id or "0-0"
while True:
resp = await r.xread({key: cursor}, block=15000, count=50)
if not resp:
yield ": keepalive\n\n"
continue
for _, entries in resp:
for entry_id, fields in entries:
cursor = entry_id
payload = fields[b"text"].decode() if b"text" in fields else ""
event_type = fields[b"type"].decode()
yield f"id: {entry_id.decode()}\nevent: {event_type}\ndata: {payload}\n\n"
if event_type == "done":
return
Lưu ý một chi tiết nhỏ nhưng quan trọng: dòng yield ": keepalive\n\n" giữ connection sống qua các load balancer cấu hình idle-timeout. Không có keepalive, một stream "im lặng" 60 giây sẽ bị AWS ALB hoặc Cloudflare đóng — người dùng thấy phản hồi đột ngột cắt đứt dù LLM vẫn đang chạy.
Tại sao Redis Stream, không phải Kafka
Kafka xuất sắc cho log pipeline có throughput hàng triệu event/giây, nhưng độ trễ per-message cao hơn (5-15 ms) và overhead vận hành (ZooKeeper hoặc KRaft, partition) không tương xứng với một chat stream. Redis Stream có độ trễ sub-millisecond trên một node, API đơn giản, và đã có sẵn trong hầu hết stack multi-agent vì các vai trò khác (cache, bandit state). Khi nhu cầu ingest vượt vài chục nghìn message/giây bền vững, việc bổ sung Kafka làm lớp trung chuyển mới có ý nghĩa.
5. Redis Pub/Sub — khi cần fan-out không lưu state
Pub/Sub khác Stream ở một điểm duy nhất nhưng cốt lõi: Pub/Sub không lưu message. Client nào không subscribe tại thời điểm publish thì mất tin. Nhưng chính cái thiếu đó làm Pub/Sub trở thành lựa chọn đúng cho một số kịch bản:
- Tín hiệu điều phối giữa các pod WebSocket. Khi pod A đang giữ socket của user X và agent worker chạy ở pod B, Pub/Sub là cách rẻ nhất để B đẩy message tới A.
- Broadcast trạng thái của agent con. Ví dụ "search agent đã hoàn thành", "summarize agent bắt đầu" — đây là tín hiệu nhất thời, không cần lưu.
- Telemetry real-time cho dashboard. Một canvas Grafana muốn xem request đang chạy qua router, không cần lưu sự kiện.
Trong một stack sản xuất 2026, Pub/Sub và Streams không đối lập — chúng bổ sung. Một pattern hay gặp: dùng Stream cho token (cần durability), và Pub/Sub cho meta-events (agent started, tool called, plan updated) mà client hiển thị ở một bảng side-panel.
6. Resumable streams — thiết kế cho mạng thực tế
Mạng di động rơi kết nối, pin cạn, người dùng chuyển tab — đều là sự kiện hàng giờ. Trong một agent chạy 15 giây để trả lời, khoảng 3-8% phiên gặp ít nhất một lần ngắt mạng. Nếu không có resumable stream, mỗi ngắt = một lần gọi LLM lại = gấp đôi chi phí và thời gian chờ.
Chuẩn SSE có sẵn cơ chế cho việc này: browser gửi header Last-Event-ID khi EventSource tự động reconnect. Server chỉ cần tôn trọng header đó và trả về phần còn thiếu. Nhưng để làm được điều đó, server phải nhớ các token đã gửi — và đây là lúc Redis Stream trở thành "trí nhớ ngắn hạn" hoàn hảo.
sequenceDiagram
participant C as Client
participant E as API Edge
participant R as Redis Stream
participant W as Worker
C->>E: GET /chat/stream
E->>R: XREAD BLOCK from 0-0
W->>R: XADD id=1 delta="Xin"
R-->>E: id=1 delta="Xin"
E-->>C: id:1 data:"Xin"
W->>R: XADD id=2 delta=" chào"
R-->>E: id=2 delta=" chào"
E-->>C: id:2 data:" chào"
Note over C,E: Kết nối rớt ở đây
W->>R: XADD id=3 delta=", bạn"
W->>R: XADD id=4 delta=" khỏe?"
C->>E: GET /chat/stream (Last-Event-ID: 2)
E->>R: XREAD from id=2
R-->>E: id=3, id=4
E-->>C: id:3, id:4 (replay)
W->>R: XADD id=5 done
R-->>E: id=5 done
E-->>C: id:5 event:done
Điều tinh tế: server không cần phân biệt "đây là replay" hay "đây là real-time" — Redis Stream hợp nhất cả hai thành một API duy nhất (XREAD từ một cursor). Client chỉ cần gửi Last-Event-ID, logic server là "đọc từ cursor đó trở đi". Đây là lý do các triển khai production (Upstash Realtime, LibreChat, Ably AI Transport) gần như đồng thuận với pattern Redis Stream + SSE + Last-Event-ID.
Một biến thể nâng cao: nếu prompt rất dài và worker vẫn còn quyền tạo token mới, có thể tận dụng chính Last-Event-ID để quyết định dừng hay tiếp tục generation — nếu không ai đang nghe, worker có thể cancel LLM call để tiết kiệm chi phí. Pattern này còn gọi là "listener-driven generation" và giảm 10-20% chi phí ở các ứng dụng có tỷ lệ bỏ session cao.
7. Multi-agent parallel streaming — fan-out và conductor
Khi một câu hỏi được giao cho nhiều agent chạy song song, bạn có ba lựa chọn về cách hiển thị:
- Chỉ stream agent cuối (synthesizer). Đơn giản nhưng để lại một khoảng im lặng dài trong lúc các agent con chạy.
- Stream tất cả agent cùng lúc, tag bằng agent_id. UI tách ra nhiều pane để hiển thị từng dòng stream riêng. Tốt cho "agent viewer" hoặc dev console, nhưng rối với end user.
- Conductor-mediated streaming. Một agent "nhạc trưởng" đọc output từ các agent con qua Redis Stream, chọn thời điểm hiển thị, tổng hợp lại thành dòng text có trật tự, và chỉ conductor stream ra client.
Pattern conductor là lựa chọn đúng cho ứng dụng user-facing. Sơ đồ hình dung:
graph TB
Client["Client SSE"] --> Edge["API Edge"]
Edge --> Conductor["Conductor Agent
đọc và tổng hợp"]
Conductor --> Stream["Redis Stream
chat:final"]
Search["Search Agent"] --> S1["Redis Stream
agent:search"]
Fetch["Fetch Agent"] --> S2["Redis Stream
agent:fetch"]
Sum["Summarize Agent"] --> S3["Redis Stream
agent:summarize"]
S1 --> Conductor
S2 --> Conductor
S3 --> Conductor
Stream --> Edge
style Conductor fill:#e94560,stroke:#fff,color:#fff
style Stream fill:#dc3545,stroke:#fff,color:#fff
style S1 fill:#dc3545,stroke:#fff,color:#fff
style S2 fill:#dc3545,stroke:#fff,color:#fff
style S3 fill:#dc3545,stroke:#fff,color:#fff
Conductor làm ba việc: đọc XREAD từ nhiều stream con cùng lúc, quyết định trật tự hiển thị theo logic "nguồn nào xong trước hiển thị trước" hoặc "theo thứ tự logic của pipeline", và viết dòng chữ đã tổng hợp vào stream cuối. Trong các triển khai nâng cao, conductor còn sinh các status event kiểu "đang tổng hợp 3 nguồn...", "đã tìm thấy 14 trang liên quan..." để lấp chỗ trống tâm lý trong lúc các agent con đang chạy.
Một mẫu conductor đơn giản với async fan-in:
import asyncio
import redis.asyncio as redis
r = redis.Redis()
async def tail(stream_key: str, q: asyncio.Queue):
cursor = "$" # chỉ nhận entry mới
while True:
resp = await r.xread({stream_key: cursor}, block=5000, count=20)
if not resp:
continue
for _, entries in resp:
for entry_id, fields in entries:
cursor = entry_id
await q.put((stream_key, entry_id, fields))
if fields.get(b"type") == b"done":
return
async def conductor(session_id: str, agent_streams: list[str]):
q: asyncio.Queue = asyncio.Queue()
tasks = [asyncio.create_task(tail(k, q)) for k in agent_streams]
final_key = f"chat:{session_id}:final"
done_count = 0
while done_count < len(agent_streams):
src, eid, fields = await q.get()
if fields.get(b"type") == b"done":
done_count += 1
continue
text = fields.get(b"text", b"").decode()
prefix = src.split(":")[-1]
merged = f"[{prefix}] {text}"
await r.xadd(final_key, {"type": "delta", "text": merged})
await r.xadd(final_key, {"type": "done"})
for t in tasks:
t.cancel()
Đây là phiên bản minh họa — trong sản xuất, conductor thường gọi thêm một LLM nhỏ để diễn đạt lại các chunk gộp (tránh show thô "[search] Found 14 pages..."). Luồng "summarize-as-you-go" này là điểm khác biệt giữa một agent viewer kỹ thuật và một sản phẩm user-facing.
8. Backpressure và rate control — bài học đau đớn
Một trong những sự cố streaming phổ biến nhất: client chậm hơn LLM. Claude Sonnet 4.6 có thể sinh 150-200 token/giây, nhưng một trình duyệt mobile yếu đang render markdown + syntax highlight có thể chỉ "tiêu hoá" 80-100 token/giây. Nếu không có backpressure, buffer ở phía server phình lên, TCP window đầy, và cuối cùng OOM hoặc dead-lock.
Các lớp backpressure trong một pipeline Redis Stream-based:
- Giới hạn size stream bằng MAXLEN.
XADD stream MAXLEN ~ 10000 * ...tự động xén entry cũ khi vượt ngưỡng. Không mất dữ liệu gần, chỉ mất lịch sử xa. - Backpressure qua pending entries list. Consumer group của Redis Stream lưu các entry đang chờ xác nhận; nếu số pending vượt ngưỡng, generator tạm dừng.
- Chunk batching. Thay vì XADD mỗi token, gom 8-16 token thành một entry rồi mới đẩy. Giảm overhead và latency mạng. Ably đo được với batching này, mỗi SSE event có thể chứa 10-30 token mà vẫn giữ TTFT dưới 250 ms.
- Client-side pacing. Browser có thể yêu cầu server "tạm chậm lại" bằng một endpoint control, nhưng thường không cần nếu batching đã đủ mịn.
Cạm bẫy buffer vô tận
Đừng bao giờ để generator worker ghi vào một stream không có MAXLEN. Khi một client treo giữa chừng và không còn ai đọc stream, Redis vẫn lưu mọi entry, và một phiên dài có thể ngốn vài MB bộ nhớ. Nhân với vài chục nghìn phiên mỗi ngày, OOM Redis chỉ là chuyện vài giờ.
9. ClickHouse — đo TTFT, TPOT và p95 tail
Streaming thay đổi định nghĩa của "hiệu năng". Một số chỉ số bạn cần theo dõi không còn là "tổng response time", mà là bộ metric chuyên biệt cho streaming:
- TTFT (Time-To-First-Token): từ lúc request chạm API đến lúc token đầu tiên được gửi về client. Đây là chỉ số UX quan trọng nhất — người dùng đang chờ "có gì đó xảy ra".
- TPOT (Time-Per-Output-Token): thời gian trung bình giữa các token. Quyết định cảm giác "mượt" của stream. Claude Sonnet 4.6 thường ở 5-8 ms/token.
- Tail latency (p95, p99): 5% user xui xẻo trải nghiệm gì. SLA sản xuất gần như luôn viết theo p95.
- Stream duration distribution: phiên nào bất thường dài, bất thường ngắn.
- Disconnect / resume rate: tỷ lệ phiên bị ngắt kết nối và tỷ lệ phiên reconnect thành công.
Schema ClickHouse tối giản nhưng đủ trả lời mọi câu hỏi trên:
CREATE TABLE stream_events (
event_time DateTime64(3),
session_id String,
request_id String,
user_id String,
model LowCardinality(String),
event_type LowCardinality(String), -- start | delta | done | disconnect | resume
ttft_ms Nullable(UInt32),
tpot_ms Nullable(UInt32),
tokens_sent UInt32,
bytes_sent UInt32,
client_ua LowCardinality(String),
region LowCardinality(String),
error String
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, session_id)
TTL event_time + INTERVAL 60 DAY;
-- p95 TTFT theo model trong 1 giờ
SELECT model,
quantile(0.50)(ttft_ms) AS p50,
quantile(0.95)(ttft_ms) AS p95,
quantile(0.99)(ttft_ms) AS p99
FROM stream_events
WHERE event_type = 'start'
AND event_time >= now() - INTERVAL 1 HOUR
GROUP BY model;
-- Tỷ lệ reconnect thành công trong 24 giờ
SELECT countIf(event_type = 'resume') / countIf(event_type = 'disconnect') AS resume_rate
FROM stream_events
WHERE event_time >= now() - INTERVAL 24 HOUR;
ClickHouse xuất sắc ở đây vì hai lý do: aggregation sub-second trên hàng trăm triệu event/ngày, và compression 15-30x — một điểm đo hàng tuần có thể giữ 60 ngày mà tốn vài GB. Pipeline ingest điển hình: Redis Stream → một consumer Python hoặc Vector.dev → HTTP INSERT vào ClickHouse theo batch 1-5 giây. Không cần Kafka cho đa số triển khai dưới 100k event/giây.
10. Progressive UI — biến stream thành trải nghiệm
Hạ tầng streaming chỉ là một nửa. Nửa còn lại là cách render. Một vài pattern UI đang trở thành tiêu chuẩn đầu 2026:
- Skeleton-free first paint. Thay vì spinner, hiển thị một placeholder text kiểu "Đang suy nghĩ...", và thay bằng token thật ngay khi TTFT hoàn tất. Người dùng cảm thấy hành động tức thì dù TTFT thật là 600 ms.
- Markdown incremental parsing. Parse markdown từng chunk thay vì đợi end-of-stream. Các thư viện như streaming-markdown (TypeScript) và marked-stream (React) giải quyết bài toán chunk cắt giữa một dấu
**. - Status events bên cạnh text. Conductor agent emit "đang tìm kiếm 3 nguồn...", "đã phân tích 2 đoạn code..." vào một kênh phụ; UI hiển thị chúng ở side panel hoặc inline chip.
- Auto-scroll lock. Trong lúc stream chạy, scroll luôn dính đáy, nhưng nếu user cuộn lên giữa chừng, khóa scroll lại để không "giật" khi token mới xuống.
- Copy-while-streaming. Cho phép user bấm copy ngay cả khi stream chưa xong — copy phần đã hiện. Vấn đề đơn giản nhưng ít team làm đúng.
Giảm TTFT cảm nhận, không cần giảm TTFT thật
Một cú "cheat" hiệu quả: ngay khi API server nhận request, flush ngay một chunk giả kiểu "Đang xử lý..." trong 20-50 ms đầu, sau đó xoá bằng token thật khi tới nơi. Người dùng cảm thấy TTFT ≈ 50 ms dù server vẫn đang chờ LLM. Kỹ thuật này quen thuộc trong các sản phẩm chat cao cấp và không đòi hỏi hạ tầng mới — chỉ đòi hỏi một dòng code ở đầu handler.
11. Production hardening — checklist khắc nghiệt
Những thứ không có ở demo mà vẫn hại nếu không xử lý:
- Idle timeout reverse proxy. AWS ALB mặc định 60s, Cloudflare 100s, Nginx 60s. Keepalive SSE comment (
: keepalive) mỗi 15-20s là tối thiểu. - Cancellation upstream. Khi client disconnect, server phải hủy luôn LLM call — không chỉ đóng SSE. Anthropic SDK hỗ trợ abort signal, dùng nó.
- Deadline cứng. Mỗi stream có timeout tuyệt đối (thường 60-120s). Quá ngưỡng emit
event: timeoutvà đóng. - Dead connection detection. Dùng heartbeat ping-pong hoặc kiểm tra write lỗi để phát hiện client đã biến mất trước khi TCP báo cho bạn.
- Memory quota mỗi phiên. Giới hạn số chunk trong stream (MAXLEN ~ 10k) và tổng byte (~2MB) — phiên vượt ngưỡng bị cắt và log.
- Content length limits. Giới hạn max_tokens cứng theo tier người dùng — không bao giờ cho phép một prompt single-shot sinh 16k token.
- Quota theo IP, user, tenant. Kiểm tra tại API Edge trước khi bắt đầu stream, không phải giữa chừng.
- Graceful reload. Khi deploy pod mới, pod cũ phải cho stream hiện tại chạy xong thêm N giây rồi mới shutdown. Không kill đột ngột.
- Error events trong SSE. Lỗi không được ném HTTP 500 giữa stream — phải emit
event: errorvới JSON chi tiết và để client tự quyết định retry. - CSRF / CORS cho SSE. EventSource không tự động gửi cookie nếu không set
withCredentials; CORS phải cho phép credentials nếu dùng session auth.
12. So sánh các framework streaming tham chiếu 2026
| Giải pháp | Lớp | Điểm mạnh | Điểm yếu |
|---|---|---|---|
| FastAPI + Anthropic SDK | API framework | Nhẹ, native async, dễ tùy biến, tương thích SSE | Tự lo resumable, tự lo multi-agent fan-in |
| Vercel AI SDK v5 | Full-stack TS | Resume streams built-in, UI hook sẵn, tích hợp Next.js | Node/Edge runtime only, khó tách generator |
| Upstash Realtime | Managed Redis realtime | Redis Streams dùng sẵn, có sample Resumable LLM | Vendor lock, tính tiền theo message |
| Ably AI Transport | Managed pub/sub realtime | Token rollup, replay tự động, global edge | Vendor lock, chi phí ở quy mô cao |
| LangGraph + Redis | Orchestration + state | Conductor pattern có sẵn, checkpoint built-in | Nặng cho use case đơn agent |
| Claude Managed Agents (2026-04) | Fully managed | SSE + + tools tích hợp, không cần tự host | Beta, phạm vi tùy biến hạn chế |
Khuyến nghị thực dụng: nếu bạn đang tự host stack và đã có Redis, viết streaming layer bằng FastAPI + Redis Streams — kiểm soát tối đa và mỗi dòng code có một lý do. Nếu bạn đang xây một sản phẩm Next.js và không muốn quản lý hạ tầng, Vercel AI SDK v5 + Upstash là cặp đôi có thể vào production trong một ngày. Nếu bạn cần mở rộng toàn cầu với replay tự động và không muốn viết conductor, Ably AI Transport trả lời được câu hỏi đó.
13. Checklist đưa streaming layer vào sản xuất
- Đã chọn giao thức chính (SSE mặc định, WebSocket chỉ khi cần real-time input hai chiều).
- Đã tách generator worker khỏi API edge — worker không phụ thuộc vào client còn sống.
- Mỗi SSE event có
id:; server tôn trọngLast-Event-IDcho reconnect. - Redis Stream có MAXLEN và TTL để không phình bộ nhớ.
- Keepalive comment mỗi 15-20 giây để sống qua reverse proxy idle timeout.
- Client disconnect → cancel Anthropic SDK call qua abort signal.
- Hard deadline 60-120 giây cho mỗi stream; emit event timeout rồi đóng.
- Cho multi-agent: conductor pattern có status event lấp chỗ trống tâm lý.
- ClickHouse đã có
stream_events, partition theo tháng, TTL 60-90 ngày. - Dashboard theo dõi TTFT p50/p95, TPOT, disconnect rate, resume rate.
- Kill-switch để tạm chuyển fallback sang non-streaming khi Redis hoặc upstream có sự cố.
- Markdown parser incremental, auto-scroll lock, copy-while-streaming đã test ở UI.
- Quota per-user và per-tenant kiểm tra trước khi stream bắt đầu.
- Graceful shutdown cho pod đang stream — chờ N giây trước khi kill.
14. Kết luận
Streaming infrastructure không phải "một dòng chat app", nó là lớp hạ tầng quyết định hai chỉ số người dùng quan tâm nhất: bắt đầu thấy kết quả khi nào và có cảm thấy hệ thống sống không. Với multi-agent, áp lực còn gấp đôi vì bạn phải phối hợp nhiều nguồn token song song thành một dòng chảy có trật tự. Bộ công cụ 2026 — SSE làm giao thức mặc định, Redis Stream làm message bus, Last-Event-ID làm cơ chế resume, conductor agent làm người điều phối, ClickHouse làm lớp telemetry — đã đạt độ chín đủ để một team ba người có thể dựng một pipeline streaming resumable đa agent trong vài ngày.
Nếu bạn đang xây agent và phản hồi của bạn vẫn "hiện ra một cục" sau khi LLM xong, đây là nơi nâng cấp có ROI cao nhất trong toàn bộ stack. Không phải vì nó tăng throughput — mà vì nó thay đổi cách người dùng cảm nhận hệ thống. Và trong thế giới nơi hàng trăm agent cạnh tranh cùng một câu hỏi, cảm nhận là thứ quyết định ai ở lại.
Nguồn tham khảo
- Redis, Stream LLM Output to Browser in Real-Time with Redis Streams — redis.io streaming llm output
- Ably Blog, Resume tokens and last-event IDs for LLM streaming: How they work & what they cost to build — dev.to ablyblog resume tokens
- Ably, AI UX: Reliable, resumable token streaming — ably.com/blog/token-streaming-for-ai-ux
- Upstash Blog, How to Build LLM Streams That Survive Reconnects, Refreshes, and Crashes — upstash.com/blog/resumable-llm-streams
- Upstash Blog, Resumable AI SDK v5 Streams with Upstash Realtime — upstash.com/blog/realtime-ai-sdk
- Procedure Blog, The Streaming Backbone of LLMs: Why Server-Sent Events (SSE) Still Wins in 2026 — procedure.tech sse still wins
- JetBI, Streaming in 2026: SSE vs WebSockets vs RSC — jetbi.com streaming architecture 2026
- Hivenet, Streaming for LLM Apps: SSE vs WebSockets — compute.hivenet.com llm streaming
- Stardrift Blog, Is resumable LLM streaming hard? No, it's just annoying. — stardrift.ai streaming resumptions
- Vercel AI SDK, Chatbot Resume Streams — ai-sdk.dev chatbot-resume-streams
- LibreChat, Resumable Streams Feature — librechat.ai docs resumable_streams
- Anthropic, Streaming Messages (Claude API Docs) — platform.claude.com streaming docs
- Anthropic Release Notes, Managed Agents Public Beta (April 2026) — releasebot.io anthropic april 2026
- ClickHouse, LLM observability with ClickStack, OpenTelemetry, and MCP — clickhouse.com llm observability clickstack
- ClickHouse, How to choose a database for real-time analytics in 2026 — clickhouse.com real-time analytics 2026
- Redis Blog, AI Agent Architecture Patterns: Single & Multi-Agent Systems — redis.io ai agent architecture patterns
- Medium (Dani Akabani), How We Used SSE to Stream LLM Responses at Scale — medium.com sse stream llm at scale
- Medium (Pranav Prakash), How to Use Redis Pub/Sub for Real-Time GenAI Chat Backends — medium.com redis pubsub genai chat
DSPy 2026 - Lập trình LLM thay vì Prompt Engineering với Signatures, Modules, MIPROv2 và BootstrapFinetune cho Multi-Agent Production
Realtime Voice AI Agents 2026 - Kiến trúc Speech-to-Speech Multi-Agent với LiveKit, Pipecat, gpt-realtime, Deepgram, Cartesia, Redis và ClickHouse
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.