Truyền phản hồi LLM theo luồng với Server-Sent Events
Cách truyền token LLM theo luồng qua SSE hoạt động: định dạng text/event-stream, phân tích dòng data và dấu kết thúc [DONE], vì sao fetch vượt trội hơn EventSource, và hủy luồng bằng AbortController.
Các API của LLM truyền đầu ra theo luồng, từng token một, bằng Server-Sent Events (SSE), một giao thức văn bản một chiều trong đó máy chủ gửi dữ liệu text/event-stream còn máy khách đọc dữ liệu ngay khi nó đến. Khi bạn đặt stream: true, phản hồi không còn là một thân JSON duy nhất mà là một chuỗi các sự kiện nhỏ, mỗi sự kiện là một dòng data: mang theo một mảnh JSON, cách nhau bởi các dòng trống, và (đối với Chat Completions) kết thúc bằng một dòng data: [DONE] theo nghĩa đen. Để hiển thị token ngay khi chúng được sinh ra, bạn phân tích luồng này theo từng phần thay vì chờ nguyên một phản hồi. Bài viết này giải thích định dạng trên đường truyền, vì sao bạn nên dùng fetch thay cho EventSource sẵn có của trình duyệt khi gọi LLM, và cách hủy một luồng gọn gàng bằng AbortController.
Truyền luồng xứng đáng với chi phí phân tích tăng thêm, vì nó thu gọn độ trễ mà người dùng cảm nhận. Thời gian đến token đầu tiên (time-to-first-token) là thứ người dùng cảm thấy; một câu trả lời truyền theo luồng bắt đầu xuất hiện chỉ trong vài trăm mili-giây thay vì sau khi sinh xong toàn bộ. Cái giá phải trả là máy khách của bạn giờ đây phải tự sở hữu một bộ phân tích và một đường hủy.
Định dạng SSE trên đường truyền, một cách chính xác
SSE được định nghĩa bởi một tập nhỏ các quy tắc. Máy chủ phản hồi với Content-Type: text/event-stream, thân là văn bản UTF-8, và các thông điệp được ngăn cách bởi một cặp ký tự xuống dòng (\n\n). Mỗi thông điệp gồm một hoặc nhiều dòng field: value. Chỉ có bốn trường được định nghĩa:
| Trường | Ý nghĩa |
|---|---|
data | Phần tải trọng. Các dòng data: liên tiếp trong cùng một thông điệp được nối lại với một ký tự xuống dòng ở giữa. |
event | Tên của loại sự kiện; được điều phối tới trình lắng nghe tương ứng. Nếu bỏ trống, đó là message mặc định. |
id | Đặt "ID sự kiện cuối" của máy khách, được gửi lại khi kết nối lại qua header Last-Event-ID. |
retry | Độ trễ kết nối lại tính bằng mili-giây. |
Một dòng bắt đầu bằng dấu hai chấm là một chú thích và bị bỏ qua — các nhà cung cấp dùng chúng làm ping giữ kết nối để ngăn các proxy nhàn rỗi đóng kết nối. Một luồng Chat Completions thô trông như thế này trên đường truyền:
data: {"id":"chatcmpl-x","choices":[{"delta":{"role":"assistant"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":"Hello"}}]}
data: {"id":"chatcmpl-x","choices":[{"delta":{"content":" world"}}]}
data: [DONE]
Mỗi khối là một đối tượng chat.completion.chunk; văn bản nằm trong choices[0].delta.content, và bạn tích lũy các delta để dựng nên thông điệp. Luồng kết thúc bằng dấu kết thúc data: [DONE], vốn không phải JSON — bạn phải kiểm tra nó như một chuỗi theo nghĩa đen trước khi thử phân tích, nếu không bộ giải mã JSON của bạn sẽ ném lỗi ở dòng cuối cùng.
Hai bộ từ vựng sự kiện: Chat Completions và Responses
Tầng truyền tải giống hệt nhau, nhưng các sự kiện khác nhau tùy điểm cuối. Chat Completions phát ra các delta chat.completion.chunk và dấu kết thúc [DONE]. API Responses phát ra các sự kiện ngữ nghĩa với tên rõ ràng — response.created, response.output_text.delta, response.completed, và các sự kiện khác — trong đó mỗi sự kiện tự khai báo nó là gì. Sự kiện ngữ nghĩa dễ định tuyến hơn: thay vì xem xét hình dạng của một khối, bạn rẽ nhánh theo tên sự kiện. Nếu bạn đang chọn giữa các điểm cuối, xem Responses API so với Chat Completions; để chuyển mã truyền luồng hiện có, xem chuyển từ Chat Completions sang API Responses.
Để có bảng tổng kết mức sử dụng cuối cùng trên một luồng Chat Completions, hãy gửi stream_options: {"include_usage": true}; nhà cung cấp sẽ nối thêm một khối cuối cùng có đối tượng usage chứa số lượng token, mà bạn có thể chuyển tiếp tới nhật ký sử dụng của gateway để hạch toán.
Vì sao không dùng EventSource?
Trình duyệt có sẵn một máy khách SSE tích hợp, EventSource, và nó là công cụ sai để gọi LLM vì hai lý do cụ thể. Thứ nhất, EventSource chỉ phát ra yêu cầu GET, nhưng các lệnh gọi LLM là POST kèm thân JSON. Thứ hai, EventSource không thể đặt header tùy chỉnh, nên bạn không thể gửi Authorization: Bearer .... Cả hai giới hạn này đều mang tính cấu trúc, không phải cấu hình mà bạn có thể bật tắt.
Cách tiếp cận đúng là dùng fetch với một luồng đọc được, tự bạn phân tích SSE. Điều này cho bạn POST, các header, và toàn quyền kiểm soát việc hủy:
const res = await fetch("https://your-gateway.example.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer AVE_GATEWAY_API_KEY",
},
body: JSON.stringify({ model: "gpt-5.6", stream: true,
messages: [{ role: "user", content: "Stream a haiku about latency." }] }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Normalize CRLF so the blank-line separator matches regardless of line endings.
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
const events = buffer.split("\n\n"); // messages end at a blank line
buffer = events.pop(); // keep the incomplete trailing chunk
for (const evt of events) {
// One event can hold several data: lines; join them per the SSE spec.
const data = evt
.split("\n")
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).replace(/^ /, ""))
.join("\n");
if (!data) continue;
if (data === "[DONE]") return; // sentinel is not JSON
const json = JSON.parse(data);
const token = json.choices?.[0]?.delta?.content;
if (token) appendToken(token); // browser: write to a DOM text node
}
}
appendToken là nơi bạn đưa dữ liệu ra giao diện — trong trình duyệt, hãy nối token vào một nút văn bản hoặc trạng thái React; trong Node, bạn có thể ghi vào process.stdout. Hai chi tiết phân tích là bắt buộc và rất dễ bỏ qua. Trước hết, chuẩn hóa \r\n thành \n, vì một số thành phần trung gian phát CRLF khiến thao tác tách ngây thơ theo \n\n bỏ lỡ ranh giới \r\n\r\n. Sau đó, nối tất cả các dòng data: trong một sự kiện thay vì chỉ đọc dòng đầu tiên: đặc tả SSE ghép các dòng data: liên tiếp bằng ký tự xuống dòng, nên tải trọng nhiều dòng sẽ bị hỏng nếu bạn chỉ giữ một dòng.
Việc đệm dữ liệu rất quan trọng: TCP không tôn trọng ranh giới thông điệp, nên một lần read() có thể trả về nửa sự kiện hoặc nhiều sự kiện cùng lúc. Hãy tách theo dấu ngăn cách là dòng trống và mang phần đuôi chưa hoàn chỉnh sang lần đọc kế tiếp. Bỏ qua bước này là lỗi truyền luồng phổ biến nhất — nó chạy được khi kiểm thử nhưng hỏng dữ liệu khi tải nặng.
Hủy một luồng bằng AbortController
Người dùng bỏ dở yêu cầu: họ rời trang, sửa lời nhắc, hoặc dừng một quá trình sinh nội dung mất kiểm soát. Bạn nên ngừng tính phí token ngay khoảnh khắc họ làm vậy. AbortController là cơ chế cho việc này. Tạo một controller, truyền signal của nó vào fetch, và gọi abort() để phá bỏ kết nối:
const controller = new AbortController();
const res = await fetch(url, { method: "POST", signal: controller.signal, /* ... */ });
// stopButton.onclick:
controller.abort(); // fetch promise rejects with a DOMException named "AbortError"
Khi bạn gọi abort(), lệnh fetch đang chạy bị từ chối với một DOMException có name là AbortError, vì vậy hãy phân biệt một lần hủy có chủ đích với một lỗi thực sự bằng cách kiểm tra err.name === "AbortError". Để có một thời gian chờ cứng mà không phải tự đấu nối controller riêng, AbortSignal.timeout(ms) trả về một signal tự động hủy sau số mili-giây đã cho — một thời gian chờ khi kích hoạt sẽ từ chối với TimeoutError chứ không phải AbortError. Hủy kịp thời là một biện pháp kiểm soát chi phí trực tiếp, liên quan đến việc giảm chi phí API LLM.
Kết nối lại và những giới hạn của nó với luồng LLM
SSE có sẵn cơ chế kết nối lại: trường id và trường retry cho phép trình duyệt tiếp tục sau khi kết nối bị rớt bằng cách gửi lại Last-Event-ID. Điều này mạnh mẽ với các luồng sự kiện nhưng chỉ áp dụng được một phần cho việc sinh nội dung của LLM, vì một kết nối được nối lại không thể "hoàn tiền" các token đã sinh ra, và hầu hết các luồng mô hình không được thiết kế để phát lại từ một vị trí tùy ý. Với lưu lượng LLM, hãy coi một luồng bị gián đoạn là một kết quả chưa hoàn chỉnh và sinh lại — phối hợp việc đó với tính lũy đẳng để không bị tính phí hai lần, như được trình bày trong thử lại và tính lũy đẳng cho LLM API. Một lưu ý về hạ tầng: SSE trên HTTP/1.1 bị giới hạn ở sáu kết nối đồng thời cho mỗi tên miền, nên việc truyền nhiều luồng sinh nội dung song song từ một nguồn gốc trình duyệt cần đến HTTP/2, thứ mà một gateway thường sẽ kết thúc thay cho bạn.
Chuyển tiếp một luồng SSE qua máy chủ
Hầu hết các thiết lập sản xuất không truyền luồng trực tiếp từ trình duyệt tới nhà cung cấp — chúng truyền qua backend hoặc gateway của riêng mình, nơi giữ khóa của nhà cung cấp và áp dụng xác thực, giới hạn tần suất, và ghi log. Việc chuyển tiếp SSE có vài yêu cầu dễ làm sai và tạo ra một luồng "treo" cho đến khi toàn bộ phản hồi sẵn sàng.
- Tắt việc đệm phản hồi. Các reverse proxy và framework thường đệm phản hồi theo mặc định, điều này phá hỏng hoàn toàn việc truyền luồng. Hãy đẩy các khối đi ngay lập tức và, khi cần, tắt việc đệm của proxy để không có gì tích lũy thân phản hồi trước khi chuyển tiếp.
- Đặt đúng các header. Phản hồi với
Content-Type: text/event-streamvàCache-Control: no-cacheđể các bên trung gian không cache hay định hình lại luồng. - Chuyển tiếp, đừng tuần tự hóa lại. Chuyển các khối của nhà cung cấp nguyên vẹn khi có thể; phân tích lại rồi phát lại từng token làm tăng độ trễ và mời gọi các lỗi ranh giới.
- Lan truyền việc hủy. Khi máy khách ngắt kết nối, hãy hủy luôn yêu cầu phía trên, nếu không bạn tiếp tục trả tiền cho các token mà không ai đọc. Đây là bản phản chiếu phía máy chủ của
AbortController.
Gateway là nơi tự nhiên cho việc này vì nó vốn đã kết thúc các kết nối client và giữ thông tin xác thực. Đăng ký mỗi nhà cung cấp thành một kênh phía sau gateway cho phép nó truyền luồng từ bất kỳ upstream khỏe mạnh nào trong khi trình bày một hình dạng SSE nhất quán cho mọi client; mức sử dụng token của yêu cầu truyền luồng cũng được ghi như với yêu cầu không truyền luồng. Nếu bạn vận hành nhiều nhà cung cấp sau một điểm cuối, việc chuyển đổi luồng giữa các định dạng sự kiện là một phần công việc của API gateway tương thích OpenAI.
Câu hỏi thường gặp
Vì sao bộ phân tích JSON của tôi ném lỗi ở dòng cuối của luồng?
Vì dòng cuối cùng của một luồng Chat Completions là data: [DONE], và [DONE] là một dấu kết thúc theo nghĩa đen, không phải JSON. Hãy kiểm tra chuỗi [DONE] và dừng lại trước khi đưa tải trọng cho JSON.parse. Đây là lỗi phân tích khi truyền luồng phổ biến nhất.
Tôi có thể dùng EventSource của trình duyệt cho việc truyền luồng kiểu OpenAI không?
Không. EventSource chỉ hỗ trợ GET và không thể đặt header Authorization, trong khi các lệnh gọi LLM là các yêu cầu POST có xác thực. Hãy dùng fetch với res.body.getReader() và tự phân tích text/event-stream, như ví dụ ở trên minh họa.
Làm sao để dừng một luồng và ngừng trả tiền cho token?
Gắn signal của AbortController vào fetch rồi gọi controller.abort(). Kết nối bị đóng và promise của fetch bị từ chối với AbortError. Việc hủy chỉ dừng tính phí nếu tín hiệu được truyền đến tận nhà cung cấp — trong thiết lập có proxy, máy chủ cũng phải hủy lệnh gọi upstream của chính nó — vì vậy hãy nối cơ chế này với mọi nút "dừng" trên giao diện và sự kiện client ngắt kết nối, đồng thời truyền nó xuyên suốt toàn bộ đường đi.
Luồng Chat Completions và Responses trông có giống nhau trên đường truyền không?
Chúng chia sẻ tầng truyền tải SSE nhưng không chia sẻ các sự kiện. Chat Completions gửi các delta chat.completion.chunk kết thúc bằng [DONE]; API Responses gửi các sự kiện ngữ nghĩa có tên như response.output_text.delta và response.completed. Một bộ phân tích viết cho loại này cần được điều chỉnh cho loại kia.
Đấu nối một luồng có thể hủy
Lấy đoạn mã fetch + reader ở trên, trỏ nó tới gateway với stream: true, rồi hiển thị choices[0].delta.content vào một nút văn bản khi token đến. Sau đó thêm AbortController và một nút dừng. Việc hủy chỉ mang tính nỗ lực tối đa: số token đầu ra ghi nhận cục bộ nên giảm trên yêu cầu bị hủy, nhưng hãy coi đó là tín hiệu chứ không phải bằng chứng — upstream có thể tiếp tục sinh hoặc tính phí trong một khoảng ngắn sau khi bạn ngắt kết nối, và số liệu cục bộ có thể chỉ là ước tính nếu usage cuối cùng từ upstream không về. Để xác nhận tín hiệu hủy thực sự đến nhà cung cấp, hãy đối chiếu stream_status của gateway (dấu client_gone) với usage hoặc hóa đơn của chính nhà cung cấp, thay vì chỉ dựa vào số token cục bộ.