Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 26 — Frontend for AI — Streaming UX patterns

Building AI-Powered Products Bài 26/60

Mở đầu — vì sao bài này quan trọng

Bạn đã build được một sản phẩm AI với model tốt, prompt được tinh chỉnh kỹ, RAG pipeline chạy chuẩn. Nhưng khi user gõ câu hỏi và bấm Enter, họ nhìn thấy một vòng xoay loading... 3 giây, 5 giây, có khi 8 giây trôi qua trong im lặng. Đến giây thứ 6, họ đã đóng tab và không bao giờ quay lại.

Đây là bi kịch phổ biến nhất của các sản phẩm AI: model thông minh nhưng frontend "câm". Trong thế giới sản phẩm truyền thống, một request API mất 300ms là bình thường. Nhưng LLM khác hẳn — một câu trả lời dài có thể mất 5-15 giây để sinh ra hoàn chỉnh. Nếu bạn bắt user chờ toàn bộ câu trả lời rồi mới hiển thị, bạn đang thua ngay từ tầng trải nghiệm, bất kể model của bạn giỏi đến đâu.

Bài này tập trung vào một câu hỏi cực kỳ thực tế mà mọi kỹ sư frontend và PM làm sản phẩm AI đều phải giải: làm sao để câu trả lời AI cảm giác nhanh, mượt và đáng tin, ngay cả khi bản chất nó chậm? Câu trả lời nằm ở streaming UX — nghệ thuật đưa từng token ra màn hình ngay khi model vừa sinh ra, kết hợp với những pattern giao diện được thiết kế riêng cho tính chất "vừa chạy vừa nghĩ" của AI. Đây là phần frontend, không phải phần model. Chúng ta sẽ nói về TTFT, token streaming, optimistic UI, xử lý lỗi giữa dòng, và những chi tiết nhỏ tạo nên cảm giác chuyên nghiệp.

Khái niệm cốt lõi

Vì sao phải stream: perceived latency mới là thứ quyết định

Có hai loại độ trễ bạn cần phân biệt rõ:

  • Total latency (độ trễ tổng): thời gian từ lúc user gửi request đến lúc nhận đủ câu trả lời. Với một câu trả lời 400 từ, con số này có thể là 6-8 giây.
  • Perceived latency (độ trễ cảm nhận): thời gian user cảm thấy mình phải chờ. Đây mới là thứ ảnh hưởng trực tiếp đến tỷ lệ bỏ cuộc.
Chỉ số vàng trong AI frontend là TTFT — Time To First Token, tức thời gian từ lúc gửi request đến lúc ký tự đầu tiên xuất hiện trên màn hình. Nếu TTFT khoảng 200-400ms, não người cảm nhận đó là "tức thì". Khi user thấy chữ bắt đầu tuôn ra, họ hiểu hệ thống đang làm việc, họ bắt đầu đọc, và họ kiên nhẫn chờ phần còn lại. Ngược lại, cùng một câu trả lời mất 6 giây nhưng hiển thị một lần duy nhất ở cuối sẽ khiến 6 giây đó trở thành 6 giây "chết".

Nói cách khác: streaming không làm model nhanh hơn một mili giây nào, nhưng nó biến một trải nghiệm chờ đợi khó chịu thành một trải nghiệm đọc chủ động. Đó là lý do gần như mọi sản phẩm AI nghiêm túc — ChatGPT, Claude, Perplexity, Gemini — đều stream.

Streaming hoạt động như thế nào ở tầng kỹ thuật

Về bản chất, LLM sinh văn bản theo từng token (một token thường là một mảnh của từ). Thay vì đợi model sinh xong toàn bộ rồi trả về một cục JSON, ta mở một kết nối "chảy" liên tục và đẩy từng token về client ngay khi có.

Có ba cơ chế truyền phổ biến:

  • SSE (Server-Sent Events): kết nối HTTP một chiều từ server về client, đơn giản, phổ biến nhất cho chatbot. Các API của OpenAI và Anthropic đều dùng dạng này với stream: true.
  • WebSocket: kết nối hai chiều, phù hợp khi cần tương tác real-time phức tạp hơn (ví dụ voice, hoặc agent gọi tool liên tục).
  • HTTP chunked / ReadableStream: nền tảng mà các framework như Vercel AI SDK bọc lại để bạn không phải xử lý thủ công.
Điểm mấu chốt về mặt frontend: bạn nhận một luồng các chunk, và mỗi khi có chunk mới, bạn append (nối thêm) vào state của tin nhắn đang hiển thị, rồi để React (hoặc framework của bạn) re-render. Đó là toàn bộ ý tưởng.

Các pattern UX đi kèm streaming

Streaming không chỉ là "đổ chữ ra màn hình". Một trải nghiệm chuyên nghiệp cần các pattern bổ trợ:

  • Skeleton / typing indicator trước token đầu tiên: trong khoảng 200-400ms trước TTFT, hiển thị con trỏ nhấp nháy hoặc ba chấm "đang soạn" để lấp khoảng trống.
  • Streaming cursor: một con trỏ nhỏ chạy theo cuối dòng chữ đang tuôn ra, tạo cảm giác "đang gõ trực tiếp".
  • Auto-scroll thông minh: tự cuộn xuống theo dòng chữ mới, NHƯNG dừng auto-scroll ngay khi user cuộn lên đọc lại — nếu không bạn sẽ "giật" màn hình của họ.
  • Nút Stop / dừng generation: cho user quyền cắt ngang khi câu trả lời đi sai hướng. Đây vừa là UX tốt vừa là cách tiết kiệm chi phí token.
  • Streaming Markdown rendering: câu trả lời AI thường có định dạng (heading, code block, bảng). Bạn phải render Markdown trong lúc nó chưa hoàn chỉnh — ví dụ một code block mở `` nhưng chưa đóng — mà không làm vỡ layout.
  • Optimistic UI: hiển thị tin nhắn của user ngay lập tức (trước cả khi server xác nhận), để cuộc hội thoại cảm giác liền mạch.

Tình huống thực tế

Ví dụ 1 — Startup EdTech ở TP.HCM: từ 42% bỏ cuộc xuống 11%

Một startup EdTech giả định tên HocAI làm trợ lý giải bài tập cho học sinh cấp 3. Phiên bản đầu tiên của họ dùng kiến trúc request-response truyền thống: học sinh chụp/gõ đề, hệ thống gọi model, chờ sinh xong lời giải (trung bình 7,5 giây), rồi hiển thị một lần.

Số liệu analytics của họ rất tệ: 42% các phiên hỏi bài bị hủy giữa chừng — user gõ câu hỏi nhưng đóng app trước khi thấy câu trả lời. Phỏng vấn người dùng cho thấy nguyên nhân: "Em tưởng nó bị treo", "Mạng em yếu nên chắc lỗi", "Chờ lâu quá em search Google luôn".

Đội kỹ thuật chuyển sang streaming với SSE. TTFT tụt xuống còn khoảng 350ms. Không có gì thay đổi về model hay tốc độ tổng — vẫn 7,5 giây cho lời giải đầy đủ. Nhưng vì học sinh thấy lời giải bắt đầu tuôn ra ngay và có thể đọc từng bước, tỷ lệ hủy phiên giảm còn 11%. Họ còn thêm streaming cho từng bước giải toán (mỗi bước hiện dần), khiến trải nghiệm giống như "được thầy giảng trực tiếp".

Bài học: vấn đề không nằm ở tốc độ model, mà ở việc user không có tín hiệu nào cho thấy hệ thống đang làm việc. Token đầu tiên xuất hiện sớm chính là "lời hứa" giúp họ kiên nhẫn.

Ví dụ 2 — Perplexity và pattern "trả lời trước, trích dẫn sau"

Perplexity là ví dụ kinh điển về streaming UX được thiết kế thông minh. Khi bạn hỏi một câu, họ không chờ tìm nguồn xong rồi mới trả lời. Trình tự hiển thị của họ là:

  • Ngay lập tức hiện các bước "đang tìm nguồn" (Searching, Reading sources) — đây là một dạng progress indicator có nội dung, giúp lấp thời gian retrieval.
  • Sau đó câu trả lời tổng hợp bắt đầu stream ra từng chữ.
  • Các trích dẫn (citation) được gắn dần vào câu trả lời trong lúc nó đang chảy, dưới dạng số [1] [2] có thể bấm.
Điểm tinh tế là họ chia nhỏ sự chờ đợi thành nhiều giai đoạn có thông tin, thay vì một khoảng trống dài. User luôn thấy có gì đó đang diễn ra và hiểu tại sao phải chờ.

Bài học: streaming không chỉ áp dụng cho văn bản cuối. Với các sản phẩm AI có nhiều bước (retrieval, tool call, tổng hợp), hãy stream cả trạng thái tiến trình, không chỉ kết quả. Mỗi giai đoạn hiển thị được là một lần bạn giữ chân user.

Ví dụ 3 — Copilot code trong IDE và bài toán streaming code block

Một đội làm sản phẩm giả định tên DevMate (trợ lý code kiểu Cursor/Copilot, phục vụ dev Việt Nam) gặp một lỗi UX điển hình. Khi AI stream ra một đoạn code, Markdown renderer của họ nhận được ``python mở đầu nhưng chưa nhận được ` đóng lại (vì code chưa stream xong). Renderer hiểu nhầm phần còn lại của tin nhắn là văn bản thường, khiến toàn bộ giao diện "nhảy múa" mỗi khi có chunk mới — lúc thì thành code block, lúc thì vỡ ra thành text.

Họ giải quyết bằng cách dùng một streaming-aware Markdown parser: khi phát hiện một code block chưa đóng, parser tự động "đóng tạm" nó ở phía render để layout ổn định, và chỉ cập nhật khi token đóng thật sự tới. Họ cũng tách riêng phần syntax highlighting để chỉ chạy lại trên block đang thay đổi, tránh re-highlight toàn bộ tin nhắn mỗi chunk (điều gây giật lag trên máy yếu).

Bài học: streaming văn bản có định dạng khó hơn streaming text thuần. Bạn phải xử lý trạng thái "chưa hoàn chỉnh" của Markdown một cách chủ động, nếu không giao diện sẽ giật và mất chuyên nghiệp — đặc biệt trên laptop cấu hình thấp phổ biến ở thị trường Việt Nam.

Hướng dẫn từng bước

Dưới đây là quy trình dựng một chatbot có streaming UX chuẩn, từ backend đến frontend:

Bước 1 — Bật streaming ở tầng model call. Khi gọi API (OpenAI, Anthropic...), đặt stream: true. Server của bạn sẽ nhận một async iterator các chunk thay vì một response duy nhất. Đừng gọi model rồi buffer lại toàn bộ ở server — làm vậy là bạn giết chết streaming ngay từ gốc.

Bước 2 — Dựng endpoint streaming ở server. Tạo một route trả về response dạng stream (SSE hoặc chunked). Với Next.js, bạn có thể return một ReadableStream hoặc dùng Vercel AI SDK để nó tự lo phần protocol. Mỗi token từ model, bạn enqueue vào stream để đẩy về client.

Bước 3 — Optimistic render tin nhắn user. Ngay khi user bấm gửi, đẩy tin nhắn của họ vào state và render lên giao diện lập tức — không chờ server. Đồng thời thêm một placeholder tin nhắn AI rỗng để chuẩn bị nhận stream.

Bước 4 — Hiển thị typing indicator cho tới TTFT. Trong khoảng trước token đầu tiên, hiện ba chấm nhấp nháy hoặc con trỏ. Đây là "khế ước niềm tin" với user.

Bước 5 — Consume stream và append token. Ở client, đọc stream (dùng useChat của Vercel AI SDK, hoặc tự đọc ReadableStream bằng getReader()). Mỗi chunk nhận được, nối vào nội dung tin nhắn AI trong state. Đặt một streaming cursor ở cuối.

Bước 6 — Render Markdown theo kiểu streaming-safe. Dùng parser xử lý được Markdown chưa hoàn chỉnh. Chỉ highlight lại code block đang thay đổi, tránh re-render toàn bộ.

Bước 7 — Auto-scroll có điều kiện. Tự cuộn xuống khi user đang ở đáy. Nếu phát hiện user đã cuộn lên (đọc lại), tắt auto-scroll và hiện nút "Xuống dưới cùng" để họ chủ động quay lại.

Bước 8 — Thêm nút Stop. Cho user hủy generation. Về kỹ thuật, dùng AbortController để hủy request; điều này vừa cải thiện UX vừa cắt chi phí token cho câu trả lời không cần thiết.

Bước 9 — Xử lý lỗi giữa dòng. Stream có thể đứt giữa chừng (mạng rớt, model timeout). Hãy giữ lại phần đã stream được, hiển thị thông báo nhẹ nhàng và nút "Thử lại", thay vì xóa sạch làm user hoảng.

Bước 10 — Đánh dấu hoàn tất. Khi stream kết thúc, gỡ con trỏ, kích hoạt các nút hành động (copy, thích, tạo lại), và lưu tin nhắn hoàn chỉnh vào lịch sử.

Lỗi thường gặp & mẹo

Lỗi 1 — Buffer toàn bộ response ở server rồi mới trả về. Đây là lỗi số một khiến "streaming giả": bạn dùng stream: true với model nhưng server lại await gom hết token trước khi gửi cho client. Kết quả TTFT vẫn 7 giây. Hãy đảm bảo luồng chảy suốt từ model → server → client, không có điểm chặn nào ở giữa.

Lỗi 2 — Re-render cả cây component mỗi token. Nếu mỗi chunk khiến toàn bộ danh sách tin nhắn re-render, app sẽ giật lag, nhất là ở tin nhắn dài. Mẹo: memo hóa các tin nhắn cũ, chỉ cho tin nhắn đang stream re-render, và cân nhắc gom token (batch) mỗi ~16-50ms thay vì render từng token một.

Lỗi 3 — Auto-scroll "cướp" quyền điều khiển của user. Không gì khó chịu hơn khi bạn cuộn lên đọc lại mà màn hình liên tục giật xuống. Luôn kiểm tra vị trí scroll của user trước khi auto-scroll.

Lỗi 4 — Không xử lý stream đứt giữa chừng. Mạng ở Việt Nam, đặc biệt trên 4G di động, hoàn toàn có thể rớt giữa dòng. Nếu bạn không bắt lỗi này, user thấy câu trả lời cụt lủn không lời giải thích. Hãy có cơ chế retry và giữ lại phần đã có.

Mẹo 1 — Stream cả trạng thái, không chỉ text. Với agent hoặc RAG, hãy stream các bước trung gian ("Đang tìm tài liệu", "Đang gọi công cụ tính toán") để lấp thời gian và tăng độ tin cậy — như cách Perplexity làm.

Mẹo 2 — Dùng framework thay vì tự viết protocol. Vercel AI SDK với hook useChat/streamText xử lý gần hết phần khó (SSE, backpressure, abort). Trừ khi bạn có nhu cầu đặc biệt, đừng tự viết lại từ đầu.

Mẹo 3 — Đo TTFT như một metric sản phẩm. Log TTFT vào analytics như một chỉ số hạng nhất. Nếu TTFT tăng, tỷ lệ bỏ cuộc gần như chắc chắn tăng theo. Đây là con số bạn nên theo dõi hàng ngày.

Mẹo 4 — Test trên mạng chậm và máy yếu. Đừng chỉ test trên MacBook và Wi-Fi văn phòng. Bật throttling mạng, thử trên điện thoại Android tầm trung — đó mới là điều kiện thật của phần lớn user Việt Nam.

Bài tập thực hành

  • Đo perceived latency của một sản phẩm quen thuộc. Mở ChatGPT hoặc Perplexity, dùng DevTools (tab Network) quan sát response dạng stream. Ước lượng TTFT bằng mắt và ghi lại cảm nhận: token đầu xuất hiện nhanh hay chậm? Có typing indicator không?
  • Build một chat streaming tối giản. Dùng Next.js + Vercel AI SDK, dựng một trang chat gọi một model bất kỳ với streamTextuseChat. Mục tiêu: token đầu tiên hiện ra dưới 500ms và chữ tuôn ra mượt.
  • Thêm ba pattern UX. Trên app ở bài 2, bổ sung: (a) typing indicator trước TTFT, (b) nút Stop dùng AbortController, (c) auto-scroll có điều kiện dừng khi user cuộn lên.
  • Phá và sửa streaming Markdown. Cho model trả về một câu trả lời có code block. Quan sát hiện tượng giật khi block chưa đóng. Sau đó thử một streaming-safe Markdown renderer và ghi lại sự khác biệt.
  • Mô phỏng lỗi mạng. Bật network throttling ở mức "Slow 3G" và ngắt mạng giữa lúc đang stream. Quan sát app của bạn xử lý ra sao, rồi thêm cơ chế retry giữ lại phần đã stream.

Tóm tắt

Frontend của một sản phẩm AI không phải chỗ để "hiển thị kết quả" một cách bị động — nó là nơi bạn định hình cảm nhận của user về tốc độ và độ tin cậy của cả hệ thống. Bản chất LLM là chậm và sinh dần từng token, nên streaming không phải tính năng phụ mà là nền tảng bắt buộc của trải nghiệm.

Những điểm cần nhớ:

  • Perceived latency quan trọng hơn total latency. TTFT khoảng 200-400ms khiến hệ thống cảm giác tức thì, dù câu trả lời đầy đủ vẫn mất nhiều giây.
  • Streaming biến thời gian chờ thành thời gian đọc chủ động, kéo tỷ lệ bỏ cuộc xuống mạnh (như HocAI từ 42% còn 11%).
  • Stream cả trạng thái tiến trình, không chỉ văn bản cuối — đây là cách Perplexity giữ chân user qua các bước retrieval.
  • Các pattern đi kèm là bắt buộc: typing indicator, streaming cursor, auto-scroll thông minh, nút Stop, Markdown streaming-safe, optimistic UI, và xử lý lỗi giữa dòng.
  • Cẩn thận những cạm bẫy kỹ thuật: buffer ở server, re-render toàn bộ mỗi token, auto-scroll cướp quyền user, và stream đứt không xử lý.
Model giỏi cho bạn nội dung tốt. Streaming UX cho bạn cơ hội để user ở lại đủ lâu mà đọc nội dung đó. Trong sản phẩm AI, hai thứ này quan trọng ngang nhau.