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.
- 1POST tạo lượt hội thoạiCâu hỏi đi bằng request riêng vì SSE không gửi được dữ liệu từ client lên
- 2Gọi model với stream=trueBackend gọi LLM đúng một lần và ghi từng đoạn text vào bộ đệm của lượt
- 3GET trả text/event-streamEndpoint stream chỉ đọc bộ đệm, ghi từng dòng data: rồi flush
- 4EventSource in chữ ra dầnTrình duyệt nhận từng sự kiện và nối vào khung trả lời
- 5Mất mạng thì tự kết nối lạiTrình duyệt chờ theo trường retry rồi đọc lại bộ đệm, model không bị gọi thêm
Chỉ request POST mới gọi model; luồng SSE chỉ đọc bộ đệm, nên khi trình duyệt tự kết nối lại cũng không tốn thêm lần gọi nào.
Đồ hoạ: FDE Times
Tóm tắt nhanh
- Tài liệu streaming của OpenAI xoay quanh HTTP streaming với stream=true qua SSE, nên SSE là điểm xuất phát tự nhiên cho chat với LLM.
- Chỉ nên dùng WebSocket khi giao diện thật sự cần gửi tín hiệu ngược lên giữa lúc đang stream, và phải chấp nhận việc không có backpressure cũng như không cache được qua CDN/proxy.
- Polling tốn tài nguyên vì phần lớn request trả về rỗng, chỉ nên giữ làm phương án dự phòng.
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.
# 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.
// 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.
// 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.
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.
7 nguồn
- Using server-sent events - MDN
- The WebSocket API (WebSockets) - MDN · 2026-09-12
- What are realtime APIs and when to use them? · 2023-10-16
- WebSocket vs. HTTP: Which protocol should you use?
- When to Use Webhooks, WebSocket, Pub/Sub, and Polling
- Streaming Data with REST APIs · 2025-09-12
- Streaming API responses - OpenAI