# Stream LLM tới giao diện: SSE trước, WebSocket khi thật cần

> Phần lớn tính năng chat với LLM chỉ cần server đẩy token xuống trình duyệt. Chọn WebSocket theo thói quen có thể khiến bạn phải gánh thêm những đánh đổi hạ tầng mà khách hàng không cần.

Bản gốc: https://fdetimes.net/vi/bach-khoa/thuc-hanh-streaming-llm-sse-websocket/

Hướng dẫn streaming của OpenAI nói thẳng rằng tài liệu này tập trung vào HTTP streaming với `stream=true` qua server-sent events. Nhà cung cấp model đã chọn sẵn một cách đưa token ra ngoài. Phần còn lại là đưa token đó từ backend của bạn tới màn hình của khách, và đây là chỗ nhiều đội chọn sai.

Thử hình dung tuần đầu ở một khách hàng ngân hàng. Họ muốn một trợ lý nội bộ trả lời câu hỏi về quy trình, và nhân viên không muốn nhìn màn hình trắng trong lúc model suy nghĩ. Một kỹ sư quen làm app realtime sẽ dựng WebSocket ngay.

Nhưng nếu giao diện chỉ cần nhận chữ chảy xuống, lựa chọn đó kéo theo những đánh đổi hạ tầng mà bài toán không đòi hỏi.

Với FDE, chọn kênh stream là quyết định kiến trúc đầu tiên khách sẽ nhìn thấy, vì nó quyết định demo có mượt không. Hướng dẫn này đi qua ba lựa chọn theo thứ tự nên thử: SSE trước, WebSocket khi có lý do, polling khi không còn cách nào khác.

## Bạn sẽ dựng gì, và cần chuẩn bị gì

Bạn sẽ dựng một endpoint nhận câu hỏi và gọi LLM với `stream=true`, cùng một endpoint khác đẩy từng đoạn text xuống trình duyệt qua SSE. Phía trình duyệt dùng `EventSource` để in chữ ra dần. Sau đó bạn viết lại phần client bằng WebSocket để so sánh.

Bạn cần một backend bất kỳ mà bạn quen tay, một API key của nhà cung cấp LLM có hỗ trợ streaming và một trình duyệt có DevTools. Code bên dưới là bản phác thảo đã lược bớt xử lý lỗi và xác thực. Hãy coi nó là khung để dịch sang framework của bạn, đừng copy nguyên vào production.

## Bước 1: Tách việc gọi model khỏi việc đọc luồng

Theo MDN, script phía server gửi sự kiện phải phản hồi bằng MIME type `text/event-stream`. Thiếu header này thì `EventSource` không chạy, dù dữ liệu vẫn đi qua mạng.

MDN cũng mô tả SSE là kết nối một chiều, client không gửi được sự kiện ngược lên server. Vì vậy câu hỏi của người dùng đi bằng một request POST riêng để tạo lượt hội thoại. Endpoint GET chỉ đọc lại những gì model đã sinh ra, không bao giờ tự gọi model.

```text
# Phác thảo giản lược, không gắn với framework cụ thể
POST /chat/turns                 # tạo lượt, gọi model một lần duy nhất
turn_id = new_id()
run_in_background:
for chunk in llm.call(messages, stream=true):
buffer[turn_id].append(chunk)
return turn_id

GET /chat/stream?id=TURN_ID      # chỉ đọc bộ đệm, không gọi model
set header Content-Type: text/event-stream
for chunk in buffer[TURN_ID], chờ chunk mới cho tới khi xong:
write "data: " + chunk + "\n\n"
flush
```

Cách kiểm tra: mở tab Network trong DevTools, gọi endpoint GET và xem header `Content-Type`. Nếu response chỉ hiện ra một lần ở cuối thay vì nhỏ giọt từng phần, có thể backend hoặc một lớp proxy đang gom dữ liệu lại trước khi gửi. Hãy kiểm tra lệnh flush trước tiên.

## Bước 2: Phía trình duyệt chỉ cần vài dòng

Sau khi POST trả về `turnId`, trình duyệt mở luồng để nhận câu trả lời.

```javascript
// Phác thảo giản lược
const es = new EventSource('/chat/stream?id=' + turnId);
es.onmessage = (e) => { output.textContent += e.data; };
```

Cách kiểm tra quan trọng nhất là tắt mạng vài giây giữa lúc đang stream rồi bật lại. Theo MDN, mặc định trình duyệt sẽ tự kết nối lại khi kết nối đóng, và thời gian chờ được điều chỉnh qua trường `retry`. Mở log backend và xác nhận model không bị gọi thêm lần nào.

Lưu ý rằng bản phác thảo trên đọc lại bộ đệm từ đầu khi kết nối lại, nên chữ trên màn hình có thể bị in lặp. Ở bản thật, bạn cần xoá phần đã in trước khi đọc lại, hoặc ghi nhớ vị trí đã đọc.

Phil Sturgeon, người viết blog APIs You Won't Hate, nhận xét rằng SSE hoạt động rất tốt bên trong HTTP/REST API để gửi cập nhật. Vì SSE vẫn là HTTP, endpoint stream có thể nằm cạnh các API khác của bạn.

Nhưng như Bước 1 đã cảnh báo, proxy của khách vẫn có thể gom luồng lại, nên hãy thử trên đúng hạ tầng của họ trước buổi demo.

## Bước 3: Khi nào đáng chuyển sang WebSocket?

WebSocket mở một phiên giao tiếp hai chiều giữa trình duyệt và server. roadmap.sh mô tả nó là kênh full-duplex bền vững trên một kết nối TCP. Ably cũng chia ranh giới theo cách tương tự: SSE hợp với việc server đẩy cập nhật một chiều, còn WebSocket hợp với giao tiếp hai chiều như game hay chat.

```javascript
// Phác thảo giản lược
const ws = new WebSocket('wss://example.com/chat');
ws.onmessage = (e) => { output.textContent += e.data; };
ws.send(JSON.stringify({ type: 'question', text: q }));
```

Câu hỏi để quyết định là: giữa lúc model đang trả lời, giao diện có cần gửi gì lên không? Một agent dừng lại hỏi người dùng xác nhận trước khi thực hiện thao tác, hay một màn hình nhiều người cùng xem một luồng trả lời, là những lý do chính đáng. Còn nút "Dừng" thì thường chỉ cần một request riêng để huỷ lượt đó.

Đổi sang WebSocket thì bạn mất một vài thứ. MDN ghi rằng WebSocket API chuẩn không hỗ trợ backpressure, nên nếu client xử lý chậm hơn tốc độ token đến, dữ liệu sẽ dồn lại. roadmap.sh lưu ý rằng CDN hay proxy không cache được kết nối WebSocket.

Cũng vì những lý do này, roadmap.sh khuyên dùng SSE qua HTTP thay cho WebSocket khi bạn chỉ cần server đẩy dữ liệu xuống.

**Điểm mấu chốt:** Nếu giao diện chỉ cần nhận chữ chảy xuống, đừng trả giá cho một kênh hai chiều.

## Polling: phương án dự phòng, đừng chọn làm mặc định

Với polling, client định kỳ gửi request hỏi server xem đã có thêm chữ chưa. Hookdeck nhận xét cách này tốn tài nguyên, và bạn phải liên tục cân nhắc xem lần gọi tiếp theo có thu được gì không. Với LLM, phần lớn request sẽ trả về rỗng hoặc chỉ thêm vài token.

Polling vẫn có chỗ đứng khi môi trường của khách không cho kết nối dài sống sót. Khi đó hãy dùng nó như một chế độ dự phòng có điều kiện, và nói rõ với khách rằng trải nghiệm sẽ giật hơn.

| | SSE | WebSocket | Polling |
|---|---|---|---|
| Chiều giao tiếp | Server → client | Hai chiều | Client hỏi định kỳ |
| Điểm yếu cần nhớ | Giới hạn 6 kết nối nếu không chạy HTTP/2 | Không có backpressure, không cache được qua CDN/proxy | Tốn tài nguyên, nhiều request rỗng |
| Dùng khi | Chat, trả lời một lượt | Agent cần hỏi lại, nhiều người dùng chung | Kết nối dài bị chặn |

## Ba lỗi khiến buổi demo đổ vỡ

Lỗi thứ nhất đến từ chính tính năng tự kết nối lại. Nếu endpoint stream vừa nhận yêu cầu vừa gọi model, mỗi lần trình duyệt kết nối lại có thể sinh ra một câu trả lời mới từ đầu, vừa tốn token vừa in chữ trùng lặp. Cách an toàn hơn là tách việc tạo lượt hội thoại khỏi việc đọc luồng, như đã làm ở Bước 1.

Lỗi thứ hai là giới hạn kết nối. MDN cảnh báo rằng khi không chạy trên HTTP/2, SSE bị giới hạn ở mức rất thấp là 6 kết nối cho mỗi trình duyệt. Nếu người dùng mở nhiều tab cùng trang chat, tab thứ bảy có thể bị treo chờ kết nối. Hãy hỏi đội hạ tầng của khách xem server có chạy HTTP/2 không trước khi demo.

Lỗi thứ ba liên quan đến quy trình hơn là kỹ thuật. OpenAI cảnh báo rằng stream output của model trong production khiến việc kiểm duyệt nội dung khó hơn, vì một câu trả lời dở dang rất khó đánh giá.

Với khách hàng tài chính hay y tế, hãy hỏi sớm xem nội dung có phải qua bộ lọc trước khi hiển thị không, vì câu trả lời có thể buộc bạn đổi cả thiết kế.

## Ba câu hỏi trước dòng code đầu tiên

Ở site khách, phần khó không nằm ở việc viết `EventSource`. Trước khi viết dòng code đầu tiên, bạn nên hỏi đủ ba câu: giao diện có cần gửi gì lên giữa chừng, hạ tầng có HTTP/2 và những lớp proxy nào, và nội dung có phải kiểm duyệt không.

Khi đọc JD cho vị trí FDE hay AI engineer, hãy để ý các cụm như "streaming", "realtime UI" hay "production LLM app". Trong CV, thay vì ghi "dùng WebSocket", hãy viết một dòng nêu lý do: chọn SSE cho chat một chiều, tách lượt hội thoại khỏi luồng đọc để kết nối lại không gọi model thêm lần nào.

Một dòng như vậy cho thấy bạn hiểu đánh đổi chứ không chỉ biết dùng công cụ.

Chọn cách đơn giản nhất mà vẫn đáp ứng được nhu cầu, và hãy chuẩn bị sẵn lý do khi có người hỏi vì sao bạn không dùng WebSocket.

**Thử ngay tuần này:**

- Dựng endpoint SSE tối giản theo Bước 1–2, rồi tắt mạng giữa chừng để xem trình duyệt có kết nối lại không và model có bị gọi thêm lần nào không
- Mở cùng một trang chat trong 7 tab trên server không chạy HTTP/2 và ghi lại tab nào bị treo
- Viết một đoạn 5 câu cho portfolio giải thích vì sao bạn chọn SSE hay WebSocket cho một dự án, kèm đánh đổi bạn chấp nhận

## Nguồn

- [Using server-sent events - MDN](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events)

- [The WebSocket API (WebSockets) - MDN](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)

- [What are realtime APIs and when to use them?](https://ably.com/topic/what-is-a-realtime-api)

- [WebSocket vs. HTTP: Which protocol should you use?](https://roadmap.sh/network-engineer/websocket-vs-http)

- [When to Use Webhooks, WebSocket, Pub/Sub, and Polling](https://hookdeck.com/webhooks/guides/when-to-use-webhooks)

- [Streaming Data with REST APIs](https://apisyouwonthate.com/blog/streaming-data-with-rest-apis/)

- [Streaming API responses - OpenAI](https://developers.openai.com/api/docs/guides/streaming-responses)
