Bỏ qua điều hướng

Streaming response là gì? Vì sao AI trả lời từng chữ một

Streaming response tối ưu TTFT (thời gian tới token đầu tiên) bằng cách truyền dữ liệu theo từng phần nhỏ, giúp ứng dụng AI phản hồi gần như tức thì.

Tuan Tran Van
13 phút đọc
Mục lục (8 phần)
  1. Streaming response là gì?
  2. Một stream trông như thế nào ở tầng giao thức
  3. Vì sao streaming cảm giác nhanh hơn dù tổng thời gian không đổi
  4. Streaming trong AI agent: token, tool call và bước trung gian
  5. Phía client: EventSource, fetch và những gì bạn phải tự xử lý
  6. Cái giá của streaming: JSON dở dang, kiểm duyệt, cache và proxy
  7. Khi nào nên trả lời không streaming
  8. Tài liệu tham khảo

Streaming response là kỹ thuật truyền tải dữ liệu theo từng phần nhỏ (chunks) ngay khi chúng được tạo ra, thay vì đợi máy chủ (server) hoàn tất toàn bộ gói dữ liệu (payload).

Trong các ứng dụng AI, kỹ thuật này tận dụng bản chất autoregressive (tự hồi quy) của mô hình ngôn ngữ lớn (LLM) — vốn sinh dữ liệu tuần tự theo từng token.

Stream kéo thời gian phản hồi mà người dùng cảm nhận được xuống mức thấp nhất có thể. Thay vì gom toàn bộ văn bản vào bộ đệm trong 10-15 giây rồi mới đổ ra màn hình, hệ thống phát ngay những token đầu tiên chỉ sau chưa đầy 1-2 giây, và cái màn hình chờ biến thành một cuộc hội thoại thời gian thực.

Minh hoạ streaming response: câu trả lời của AI hiện dần ra màn hình theo từng token thay vì đổ ra một lần

Streaming response là gì?

Ở các giao diện HTTP mặc định, server đóng gói toàn bộ câu trả lời vào một payload duy nhất rồi mới gửi đi. Với một phản hồi dài 500 token, ứng dụng phải "treo" ở đó chờ cho đến khi token cuối cùng được sinh ra. Trong hạ tầng AI thì đây là nút thắt cổ chai về mặt trải nghiệm, vì thứ duy nhất người dùng nhìn thấy trong suốt quãng đó là một trạng thái loading tĩnh đứng im.

So sánh hai cách trả lời: không streaming thì người dùng chờ trắng màn hình rồi nhận trọn câu trả lời, còn streaming thì chữ hiện dần ngay từ token đầu tiên

Cơ chế streaming response đảo ngược thứ tự đó, chuyển từ mô hình "đợi và nhận" sang mô hình "phát ngay lập tức" (emit). Vì mô hình ngôn ngữ lớn (LLM) hoạt động theo nguyên lý autoregressive, tức là dự đoán token tiếp theo dựa trên xác suất từ các token trước đó, nên quá trình sinh vốn đã mang tính tuần tự. Server có thể đẩy từng token vừa sinh ra vào stream truyền tải mà không cần đợi toàn bộ chuỗi hoàn tất.

Khác biệt nằm gọn trong một chỉ số: TTFT (Time to First Token, thời gian tới token đầu tiên). Với streaming, TTFT thường chỉ rơi vào khoảng 1-2 giây bất kể câu trả lời dài bao nhiêu. Nếu không có streaming, một phản hồi dài có thể mất 10-15 giây để hoàn thành quá trình sinh trên GPU trước khi những byte đầu tiên chạm tới máy khách (client). Vẫn là mô hình đó, vẫn là câu trả lời đó, chỉ khác ở chỗ bạn cho người dùng thấy nó vào lúc nào: đây là tối ưu hóa ở tầng hiển thị (presentation) để đi vòng qua rào cản độ trễ tính toán (compute) của mô hình ngôn ngữ lớn, chứ không phải để xóa bỏ nó.

Một stream trông như thế nào ở tầng giao thức

Hầu hết các hệ thống AI hiện nay sử dụng Server-Sent Events (SSE) để duy trì kết nối HTTP dài hạn, một chiều từ server đến client. Khác với WebSockets, SSE nhẹ hơn và chạy tốt ngay trên giao thức HTTP tiêu chuẩn, nhờ đó bạn đẩy được dữ liệu dạng văn bản liên tục.

Trình tự sự kiện của một stream SSE: message_start mở message, content_block_start mở khối nội dung, các content_block_delta mang token, rồi content_block_stop, message_delta và message_stop đóng lại

Ở tầng thấp hơn, dữ liệu được truyền qua chunked transfer encoding trong HTTP/1.1 hoặc các khung dữ liệu gốc (native frames) trong HTTP/2. Một luồng sự kiện (event flow) chuẩn phải tuân thủ nghiêm ngặt thứ tự sau:

  1. message_start: Khởi tạo message.
  2. content_block_start: Bắt đầu khối nội dung.
  3. content_block_delta: Chứa dữ liệu (token) thực tế.
  4. content_block_stop: Kết thúc khối nội dung.
  5. message_delta: Chứa metadata bổ sung (như token usage).
  6. message_stop: Đóng stream.

Định dạng SSE yêu cầu mỗi mảnh dữ liệu phải bắt đầu bằng tiền tố data: và kết thúc bằng hai ký tự xuống dòng \n\n. Một ví dụ về payload thô trên đường truyền:

text
event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hệ thống"}}
 
event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": " đang"}}

Có một điểm kiến trúc rất dễ vấp, và theo tôi là cái bẫy đắt nhất của streaming: vì server đã gửi header HTTP 200 OK ngay khi mở kết nối, nó không thể đổi mã trạng thái (status code) nữa nếu sự cố xảy ra giữa chừng. Khi hệ thống quá tải (tương đương lỗi HTTP 529 ở chế độ không streaming), server phải phát lỗi đó thành một sự kiện error ngay trong stream, và client phải tự phân biệt được đâu là kết thúc bình thường, đâu là hỏng giữa dòng. Nếu bạn quên nhánh xử lý đó, một stream đứt gánh sẽ hiện ra trên màn hình y hệt một câu trả lời đã viết xong.

Vì sao streaming cảm giác nhanh hơn dù tổng thời gian không đổi

Lợi ích của streaming response nằm ở tâm lý người dùng nhiều hơn là ở tốc độ tính toán thuần túy, và đây là chỗ hay bị nhầm nhất. Bạn cần phân biệt rõ streaming (tầng hiển thị) với các kỹ thuật tối ưu tính toán như speculative decoding (dùng model nhỏ dự đoán token cho model lớn verify) hay prefix caching (cache lại key-value pairs của prompt prefix). Trong khi các kỹ thuật tính toán giảm tổng thời gian xử lý, streaming chỉ tập trung vào việc hiển thị dữ liệu sớm nhất có thể.

Hai dòng thời gian có cùng tổng thời gian sinh: dòng không streaming trống rỗng cho tới cuối, dòng có streaming bắt đầu hiện chữ ngay từ TTFT nên người dùng đọc trong lúc phần còn lại vẫn đang được sinh

Chuyện này có tên hẳn hoi: "hiệu ứng thanh tiến độ" (progress bar effect). Khi các bước nhỏ được hiển thị liên tục, người dùng có xu hướng đánh giá thấp thời gian thực tế đã trôi qua. Nghiên cứu UX cho thấy người dùng sẵn sàng đợi lâu hơn gấp 3 lần nếu có chỉ báo tiến trình động (như hiệu ứng máy đánh chữ của streaming) so với một vòng xoay loading tĩnh.

Streaming biến thời gian chờ đợi thụ động thành thời gian đọc chủ động. Chỉ số TTFT cực thấp giúp giữ luồng tư duy của người dùng không bị ngắt quãng, trong khi TPOT (Time Per Output Token, thời gian sinh mỗi token) ổn định giúp việc đọc diễn ra trôi chảy. Ngay cả khi tổng thời gian để sinh 1000 token là không đổi, việc thấy chữ chạy giúp người dùng cảm thấy hệ thống đang "làm việc" cho họ ngay lập tức.

Streaming trong AI agent: token, tool call và bước trung gian

Trong kiến trúc AI agent, stream cõng theo cả văn bản, "thinking/reasoning tokens" (suy nghĩ nội bộ) lẫn "tool calls" (lệnh gọi công cụ). Stream các bước suy luận ra ngoài giúp người dùng giám sát được logic của agent trước khi nó thực sự ra tay làm gì đó.

Một stream của AI agent đan xen ba loại nội dung: token suy luận nội bộ, lệnh gọi công cụ dưới dạng JSON ghép dần, và văn bản trả lời cuối cùng

Rào cản kỹ thuật chính ở đây là JSON dở dang (partial JSON). Vì tool calls được stream ra theo từng mảnh, bạn không thể gọi JSON.parse() thông thường ở giữa chừng, mà phải dùng một bộ đệm (buffer) để tích lũy cho đến khi nhận được content_block_stop, hoặc dùng các bộ parser hỗ trợ partial JSON. Đây cũng là chỗ ngốn thời gian debug nhiều nhất khi dựng một agent có streaming.

LangChain xử lý việc này bằng chế độ stream v2 khá thực dụng thông qua stream_mode:

  • updates: Trả về trạng thái agent sau mỗi bước (node).
  • messages: Trả về từng token từ mô hình kèm metadata để phân biệt nguồn phát, ví dụ lc_agent_name.
python
# Cấu hình stream đa chế độ trong LangChain v2
for chunk in agent.stream(input, stream_mode=["messages", "updates"], version="v2"):
    if chunk["type"] == "messages":
        token, metadata = chunk["data"]
        # Lọc reasoning tokens dựa trên chuẩn content blocks
        if hasattr(token, 'content_blocks') and any(b['type'] == 'reasoning' for b in token.content_blocks):
             print(f"[Thinking]: {token.content_blocks}")

Với agent lồng nhau, có một cái bẫy hay gặp: nếu bạn bọc một agent thành node bên trong graph cha, token của agent con mặc định không được phát ra ngoài. Bạn phải bật subgraphs=True trong cấu hình stream, nếu không những lần gọi mô hình bên trong sẽ hiện ra như các khoảng lặng vô cớ giữa stream của graph cha.

Phía client: EventSource, fetch và những gì bạn phải tự xử lý

Ở frontend, bạn có hai hướng tiêu thụ stream, và cái giá của mỗi hướng khác nhau khá rõ:

So sánh hai cách đọc stream ở phía client: EventSource tự kết nối lại nhưng chỉ chạy được với GET, còn fetch kèm ReadableStream cho phép POST và header tuỳ biến nhưng bạn phải tự decode và tự quản lý vòng đọc

  1. API EventSource (SSE) rất dễ dùng và tự động kết nối lại khi rớt mạng. Tuy nhiên nó vướng một hạn chế cực lớn: chỉ hỗ trợ phương thức GET. Prompt gửi lên mô hình thường rất dài, nên bạn rất dễ đụng trần giới hạn độ dài URL của trình duyệt.
  2. API fetch + ReadableStream là hướng nên chọn khi làm sản phẩm thật. Nó cho phép dùng phương thức POST, nhờ đó bạn gửi được các payload prompt phức tạp và tùy biến header (Authorization). Đổi lại, bạn phải tự viết logic decode bằng TextDecoder và tự quản lý reader.read().

Khi dùng fetch, hãy khởi tạo decoder với tùy chọn { stream: true }. Nếu một ký tự UTF-8 nhiều byte (ví dụ chữ tiếng Việt có dấu hoặc emoji) bị cắt đôi giữa hai chunk mạng, tùy chọn này giúp decoder giữ lại phần byte dang dở thay vì trả về ký tự thay thế lỗi.

Để xử lý Markdown trong thời gian thực, bạn cần một buffer tích lũy text rồi render liên tục qua các thư viện như marked.js. Đừng render lại toàn bộ DOM sau mỗi token, vì làm vậy sẽ gây giật lag (layout shift) thấy rõ; hãy cập nhật vào một container cố định.

javascript
const response = await fetch("/api/chat", { method: "POST", body: JSON.stringify(payload) });
const reader = response.body.getReader();
const decoder = new TextDecoder();
 
while (true) {
  const { value, done } = await reader.read(); // Đọc từng chunk từ stream
  if (done) break;
  const textChunk = decoder.decode(value, { stream: true });
  markdownBuffer += textChunk;
  renderHTML(markdownBuffer); // Render Markdown liên tục
}

Cái giá của streaming: JSON dở dang, kiểm duyệt, cache và proxy

Triển khai streaming response trong sản phẩm thực tế phức tạp hơn nhiều so với việc chỉ bật stream: true. Đây là những rào cản hạ tầng bạn sẽ đụng phải:

Bốn cái giá phải trả khi bật streaming: reverse proxy gom dữ liệu vào bộ đệm, kiểm duyệt nội dung chạy sau khi chữ đã hiện, cache cần phản hồi trọn vẹn, và JSON dở dang không parse được giữa chừng

  • Nginx hay Cloudflare đứng chắn ở tầng reverse proxy (máy chủ trung gian) và thường mặc định gom toàn bộ phản hồi vào bộ đệm để tối ưu nén Gzip, nên bạn phải buộc chúng "nhả" dữ liệu ngay lập tức bằng header X-Accel-Buffering: no hoặc tắt nén cho các route streaming.
  • Kiểm duyệt nội dung khó hơn hẳn. Ở chế độ không streaming, bạn chạy bộ lọc an toàn trên toàn bộ phản hồi trước khi nó rời server. Khi stream, nội dung đã chạy tới màn hình người dùng ngay trong lúc đang được kiểm tra, nên nếu phát hiện vi phạm giữa chừng thì phần chữ vi phạm đã hiện ra rồi.
  • Streaming và caching thì đối nghịch nhau về bản chất. Mô hình dung hòa tốt nhất là: nếu cache hit, trả về 200 OK kèm toàn bộ dữ liệu ngay; nếu cache miss, thực hiện streaming và đồng thời lưu kết quả vào store (như Redis) sau khi stream kết thúc.
  • Đầu ra có cấu trúc (structured output) có 4 kiến trúc để vừa stream vừa lấy được JSON sạch. Parallel: chạy 2 luồng song song (1 stream text cho UX, 1 sinh JSON cho máy). Single call with markers: dùng marker (ví dụ [JSON_START]) để tách text stream và JSON buffer trong một lần gọi. Sequential: stream xong text rồi mới gọi lần 2 để lấy JSON. Model combination: dùng model lớn stream text, dùng model nhỏ (rẻ hơn) để parse JSON từ text đó.
  • Còn khi stream bị ngắt giữa chừng, với Claude 4.6 trở lên bạn nên dùng chiến lược capture-and-resume: lưu lại đoạn text đã nhận, rồi gửi một message user mới yêu cầu mô hình viết tiếp từ chỗ bị ngắt.

Khi nào nên trả lời không streaming

Chỉ dùng streaming khi mục tiêu là tối ưu TTFT cho con người. Trong các kịch bản máy giao tiếp với máy thì hãy tắt nó đi: xử lý theo lô (Batch API tiết kiệm tới 50% chi phí và tăng thông lượng (throughput) cho các tác vụ như đánh giá mô hình hay phân loại dữ liệu lớn), và các hệ thống tự động nơi kết quả được đẩy thẳng vào database hoặc kích hoạt một workflow khác. Ở đó, streaming chỉ làm tăng chi phí phụ (overhead) của kết nối và độ phức tạp của logic phân tích (parsing), đổi lại không được gì cả, vì đâu có ai ngồi nhìn màn hình mà cảm nhận độ trễ.

Streaming là một bản vá ở tầng hiển thị. Nó không làm mô hình thông minh hơn, cũng không rút ngắn tổng thời gian sinh dù chỉ một chút, nhưng nó giữ chân người dùng ở lại với ứng dụng của bạn.

Tài liệu tham khảo

Chia sẻ bài viết

X / TwitterFacebookLinkedIn