Source: https://aiprimetech.io/vi/blog/streaming-api-guide/ Tháng Sáu 22, 2026 · 6 phút · API # Truyền phản hồi LLM theo luồng: Vì sao quan trọng đối với UX và chi phí A Tác giả [AI Prime Tech](https://aiprimetech.io/vi/) · Có AI hỗ trợ ## 900 ms khiến giao diện trò chuyện của chúng tôi có cảm giác “bị hỏng” Vài tháng trước, tôi đang gỡ lỗi một trợ lý hỗ trợ khách hàng có độ trễ đầu cuối hoàn toàn chấp nhận được trên lý thuyết: hầu hết phản hồi hoàn tất trong 4–7 giây. Mô hình thực hiện việc chọn công cụ, truy xuất ngữ cảnh tài khoản và tạo câu trả lời hữu ích. Nhưng người dùng rất ghét nó. Lý do rất đơn giản: giao diện để trống trong 3.8 giây đầu tiên. Không có thông báo đi kèm biểu tượng đang tải, không có phản hồi từng phần, không có dấu hiệu cho thấy mô hình đã hiểu câu hỏi. Chỉ có một bong bóng tin nhắn trống của trợ lý. Khi chúng tôi chuyển cùng điểm cuối đó sang truyền phát token qua Server-Sent Events, thời gian hoàn tất trung bình gần như không thay đổi. Nhưng thời gian đến khi đầu ra đầu tiên hiển thị giảm xuống khoảng 700–900 ms với nhiều yêu cầu, và sản phẩm lập tức có cảm giác nhanh hơn. Đó mới là lý do thực sự khiến truyền phát trở nên quan trọng. Truyền phát phản hồi LLM không phải phép màu. Nó không khiến mô hình suy nghĩ nhanh hơn. Nó không giảm tổng số token. Nó không tự động giảm hóa đơn của bạn. Nhưng nó thay đổi vòng tương tác theo cách người dùng có thể cảm nhận, đồng thời giúp ứng dụng kiểm soát tốt hơn việc hủy, hiển thị, kiểm duyệt, thống kê mức sử dụng và điều tiết ngược. Bài viết này trình bày cách truyền phát SSE thực sự hoạt động, những gì cần đo lường, các điểm tinh tế trong việc tính phí và những mẫu xử lý phía máy khách mà tôi dùng trong các hệ thống vận hành thực tế. ## Lợi ích chính của việc sử dụng phản hồi truyền phát là gì? Lợi ích chính của phản hồi truyền phát là độ trễ cảm nhận: người dùng nhìn thấy các token đầu tiên trong vài trăm mili giây thay vì nhìn chằm chằm vào biểu tượng đang tải suốt thời gian tạo phản hồi. Tổng thời gian tạo phản hồi gần như không thay đổi — điều thay đổi là thời gian đến token đầu tiên, và đó là con số người dùng thực sự cảm nhận. Một câu trả lời mất 9 giây để hoàn tất nhưng bắt đầu xuất hiện sau 400 ms sẽ tạo cảm giác nhanh; cùng câu trả lời đó được gửi toàn bộ sau 9 giây sẽ tạo cảm giác hệ thống bị hỏng. Ngoài ra còn có các lợi ích phụ: truyền phát cho phép bạn hiển thị dần đầu ra từng phần, hủy quá trình tạo sớm khi người dùng đã xem đủ thông tin (tiết kiệm token đầu ra) và tránh lỗi hết thời gian chờ đọc của proxy với phản hồi dài. Nhưng nếu chỉ nhớ một điều, hãy nhớ rằng: truyền phát không khiến mô hình nhanh hơn — nó khiến sự chờ đợi biến mất khỏi trải nghiệm người dùng. ## Truyền phát thực sự có nghĩa là gì Khi bạn gửi một yêu cầu LLM không truyền phát, máy chủ thường chờ đến khi mô hình tạo xong phản hồi rồi trả về một phần dữ liệu JSON duy nhất: ``` { "id": "resp_123", "model": "some-model", "output_text": "Sure — here is the answer...", "usage": { "input_tokens": 812, "output_tokens": 436 } } ``` Với truyền phát, máy chủ gửi các sự kiện tăng dần khi có đầu ra. Cơ chế truyền tải phổ biến nhất là Server-Sent Events, thường được viết tắt là SSE. SSE đơn giản là một phản hồi HTTP với kết nối duy trì lâu dài và một kiểu nội dung đặc biệt: ``` Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive ``` Thay vì một đối tượng JSON, máy chủ gửi một chuỗi sự kiện nhỏ: ``` event: message.delta data: {"delta":"Sure"} event: message.delta data: {"delta":" — here"} event: message.delta data: {"delta":" is"} event: message.delta data: {"delta":" the answer..."} event: message.done data: {"finish_reason":"stop","usage":{"input_tokens":812,"output_tokens":436}} ``` Các API khác nhau sử dụng tên sự kiện và lược đồ khác nhau, nhưng cấu trúc thường giống nhau: - Mở kết nối HTTP. - Gửi siêu dữ liệu yêu cầu và câu lệnh. - Nhận các đoạn dữ liệu tăng dần. - Nối các đoạn dữ liệu vào tin nhắn hiện tại của trợ lý. - Nhận sự kiện cuối cùng chứa lý do dừng và thường cả siêu dữ liệu về mức sử dụng. - Đóng luồng. Trong thực tế, phần lớn hoạt động “truyền phát” của LLM không gửi một token cho mỗi sự kiện. Các nhà cung cấp có thể gom token thành các đoạn dữ liệu để tăng hiệu quả. Bạn có thể nhận được một ký tự, một phần của từ, vài từ hoặc một phần thay đổi JSON có cấu trúc. Đừng bao giờ xây dựng máy khách với giả định rằng mỗi đoạn dữ liệu là một từ hoàn chỉnh hoặc một câu hoàn chỉnh. ## Độ trễ: Tổng thời gian so với thời gian đến token đầu tiên Sự phân biệt quan trọng nhất là giữa độ trễ tổng thể và thời gian đến token đầu tiên. | Chỉ số | Đo lường điều gì | Vì sao quan trọng | Thời gian đến byte đầu tiên, TTFB | Byte đầu tiên nhận được từ máy chủ | Bao gồm định tuyến, xác thực, xếp hàng và lập lịch mô hình | Thời gian đến token đầu tiên, TTFT | Token/đoạn dữ liệu được tạo đầu tiên hiển thị cho người dùng | Yếu tố chính quyết định cảm nhận về khả năng phản hồi | Số token mỗi giây | Tốc độ tạo đầu ra sau token đầu tiên | Quyết định tốc độ hoàn tất các câu trả lời dài | Tổng thời gian hoàn tất | Nhận được toàn bộ phản hồi | Quan trọng đối với tự động hóa, lệnh gọi công cụ và tác vụ theo lô | Thời gian đến khi có câu trả lời dùng được | Người dùng có đủ thông tin để hành động | Thường sớm hơn thời điểm hoàn tất toàn bộ Đối với UX trò chuyện, TTFT thường quan trọng hơn tổng thời gian. Một phản hồi bắt đầu trong 800 ms và hoàn tất trong 8 giây thường tạo cảm giác tốt hơn phản hồi xuất hiện toàn bộ sau 4 giây. Đối với quy trình giữa các máy, truyền phát thường ít quan trọng hơn. Nếu bước tiếp theo không thể chạy cho đến khi toàn bộ đối tượng JSON hoàn tất, truyền phát có thể chỉ làm tăng độ phức tạp khi triển khai. ### Đo TTFT trong Python Đây là một mẫu đơn giản để đo độ trễ đến đoạn dữ liệu đầu tiên và tổng thời gian truyền phát. Cấu trúc API được cố ý giữ ở dạng tổng quát: hãy điều chỉnh URL và dữ liệu gửi đi theo nhà cung cấp của bạn. ``` import json import time import requests url = "https://api.example.com/v1/chat/completions" payload = { "model": "sonnet-4.6", "stream": True, "messages": [ {"role": "user", "content": "Explain SSE streaming in 5 bullet points."} ] } headers = { "Authorization": "Bearer $API_KEY", "Content-Type": "application/json", "Accept": "text/event-stream", } start = time.perf_counter() first_chunk_at = None chars = 0 with requests.post(url, headers=headers, json=payload, stream=True) as r: r.raise_for_status() for raw_line in r.iter_lines(decode_unicode=True): if not raw_line: continue if not raw_line.startswith("data: "): continue data = raw_line[len("data: "):] if data == "[DONE]": break event = json.loads(data) # Provider-specific: this may be event["delta"], event["choices"][0]["delta"], etc. delta = event.get("delta", "") if delta and first_chunk_at is None: first_chunk_at = time.perf_counter() chars += len(delta) print(delta, end="", flush=True) end = time.perf_counter() print("\n\n--- metrics ---") print(f"ttft_ms={((first_chunk_at or end) - start) * 1000:.0f}") print(f"total_ms={(end - start) * 1000:.0f}") print(f"chars={chars}") ``` Một điểm dễ gây nhầm lẫn: nếu bạn kiểm tra truyền phát bằng proxy cục bộ, hàm serverless hoặc proxy ngược có đệm phản hồi, bạn có thể nghĩ rằng mô hình không truyền phát. Mô hình có thể đang truyền phát đúng cách, nhưng hạ tầng của bạn giữ lại các đoạn dữ liệu cho đến khi phản hồi hoàn tất. Tôi đã gặp tình trạng này với: - Nginx bật tính năng đệm. - Các hàm đám mây không hỗ trợ phản hồi truyền phát. - Middleware nén chờ bộ đệm lớn hơn. - Các framework web tạo toàn bộ phản hồi trước khi trả về. - Các proxy doanh nghiệp gộp các đoạn dữ liệu nhỏ. Với Nginx, thiết lập này thường quan trọng: ``` location /api/llm/stream { proxy_pass http://app_backend; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; chunked_transfer_encoding on; add_header X-Accel-Buffering no; } ``` ## SSE so với WebSockets SSE không phải cách duy nhất để truyền phát. WebSockets cũng phổ biến, đặc biệt với các ứng dụng hai chiều. Tuy nhiên, với hầu hết giao diện trò chuyện LLM, SSE là lựa chọn mặc định đơn giản hơn. | Tính năng | SSE | WebSocket | Chiều truyền | Từ máy chủ đến máy khách | Hai chiều | Giao thức | Phản hồi HTTP thông thường | Nâng cấp kết nối lên WebSocket | Hỗ trợ trên trình duyệt | Gốc EventSource; cũng hoạt động với fetch các luồng | Gốc WebSocket | Cơ chế kết nối lại | Có cơ chế tích hợp sẵn, nhưng vẫn cần logic ứng dụng | Bạn tự triển khai | Mức độ tương thích với bộ cân bằng tải | Thường dễ hơn | Đôi khi cần cấu hình bổ sung | Phù hợp nhất với | Truyền phát token, sự kiện tiến độ, trò chuyện đơn giản | Cộng tác theo thời gian thực, âm thanh, phiên tương tác trực tiếp nhiều lượt | Hỗ trợ phần thân yêu cầu | EventSource chỉ hỗ trợ GET; dùng fetch để truyền phát POST | Trao đổi thông điệp hai chiều đầy đủ Thư mục EventSource API của trình duyệt rất tiện lợi, nhưng chỉ hỗ trợ yêu cầu GET. Hầu hết các lệnh gọi LLM cần phần thân POST và các header xác thực, nên tôi thường truyền luồng bằng fetch() và ReadableStream. ## Mẫu triển khai phía trình duyệt: Fetch + ReadableStream Một cách triển khai tối giản trên trình duyệt như sau: ``` async function streamChat({ messages, signal, onDelta, onDone }) { const res = await fetch("/api/chat/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-5.5", stream: true, messages }), signal }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const data = trimmed.slice(5).trim(); if (data === "[DONE]") { onDone?.(); return; } const event = JSON.parse(data); if (event.delta) { onDelta(event.delta); } } } onDone?.(); } ``` Và trong giao diện: ``` const controller = new AbortController(); let assistantText = ""; streamChat({ messages, signal: controller.signal, onDelta(delta) { assistantText += delta; renderAssistantMessage(assistantText); }, onDone() { markMessageComplete(); } }); // Cancel button document.querySelector("#stop").onclick = () => { controller.abort(); }; ``` ### Đừng kết xuất từng đoạn dữ liệu một cách máy móc Nếu cập nhật trạng thái React với mỗi đoạn dữ liệu nhỏ, bạn có thể vô tình gây ra vấn đề hiệu năng phía máy khách. Một số luồng tạo ra hàng chục đoạn dữ liệu mỗi giây. Kết xuất lại một cây giao diện trò chuyện lớn mỗi lần như vậy là lãng phí. Trong thực tế, tôi thường gom các cập nhật giao diện theo từng khung hình hoạt ảnh: ``` let pending = ""; let scheduled = false; function onDelta(delta) { pending += delta; if (!scheduled) { scheduled = true; requestAnimationFrame(() => { assistantText += pending; pending = ""; scheduled = false; setAssistantText(assistantText); }); } } ``` Cách này giữ cho giao diện mượt mà mà vẫn tạo cảm giác đầu ra đang xuất hiện trực tiếp. ## Xử lý đúng đầu ra chưa hoàn chỉnh Truyền luồng có nghĩa là ứng dụng của bạn nhận văn bản chưa hoàn chỉnh. Điều này nghe có vẻ hiển nhiên, nhưng nhiều lỗi xuất phát từ việc coi đầu ra chưa hoàn chỉnh là đầu ra cuối cùng. ### Markdown tạm thời không hợp lệ Trong quá trình truyền luồng, Markdown có thể sai định dạng: ``` Here is the query: ```sql SELECT * FROM users WHERE ``` Dấu phân cách khối mã có thể chưa được đóng. Một bảng có thể mới chỉ có hàng tiêu đề. Một liên kết có thể mới được xuất ra một nửa. Trình kết xuất của bạn cần xử lý được những tình huống này. Với giao diện trò chuyện, tôi ưu tiên: - Kết xuất Markdown dần dần nhưng có biện pháp xử lý lỗi. - Vô hiệu hóa HTML nguy hiểm. - Tránh tô sáng cú pháp tốn nhiều tài nguyên cho đến khi thông điệp hoàn tất. - Hiển thị trạng thái “đang tạo” một cách nhẹ nhàng. - Thực hiện lại bước định dạng cuối cùng sau khi luồng kết thúc. ### Truyền luồng JSON khó hơn Nếu yêu cầu mô hình tạo JSON và truyền luồng kết quả, trạng thái trung gian thường là JSON không hợp lệ. Đây là vấn đề đối với các ứng dụng muốn cập nhật giao diện có cấu trúc theo thời gian thực. Ví dụ: ``` { "title": "Incident Summary", "severity": "high", "steps": [ "Restart ``` Bạn chưa thể phân tích cú pháp dữ liệu đó. Có ba cách tiếp cận phổ biến: - Chờ đến khi hoàn tất, rồi phân tích cú pháp một lần. - Dùng JSON phân tách theo dòng, trong đó mỗi dòng hoàn chỉnh có thể được phân tích cú pháp độc lập. - Truyền luồng các sự kiện mang ý nghĩa ngữ nghĩa, thay vì JSON thô. Đối với truyền luồng có cấu trúc, tôi thích đầu ra theo dạng sự kiện: ``` event: section data: {"title":"Summary"} event: bullet data: {"text":"Database failover started at 09:42 UTC."} event: bullet data: {"text":"Read latency remained elevated for 6 minutes."} ``` Mẫu này đòi hỏi nhiều công việc điều phối hơn, nhưng dễ kết xuất ổn định hơn nhiều. ## Điều gì thực sự xảy ra khi người dùng nhấn “Dừng” Khả năng hủy là một trong những lợi ích chưa được đánh giá đúng mức của việc truyền luồng. Với yêu cầu không truyền luồng, người dùng có thể rời trang trong khi mô hình vẫn tiếp tục tạo nội dung trên máy chủ. Tùy vào nhà cung cấp và backend của bạn, quá trình tạo đó vẫn có thể tiêu thụ token đầu ra. Với truyền luồng, máy khách có thể hủy yêu cầu HTTP: ``` const controller = new AbortController(); fetch("/api/chat/stream", { method: "POST", body: JSON.stringify(payload), signal: controller.signal }); // Later controller.abort(); ``` Nhưng việc hủy không phải lúc nào cũng diễn ra tức thì trên toàn bộ chuỗi xử lý. Có nhiều lớp xử lý: - Trình duyệt hủy thao tác fetch. - Backend của bạn nhận thấy kết nối đã đóng. - Backend của bạn hủy yêu cầu gửi đến nhà cung cấp phía trên. - Nhà cung cấp dừng tạo nội dung. - Mức sử dụng được tính cho các token đã được xử lý hoặc tạo ra. Một vấn đề thường gặp: nếu backend chuyển tiếp luồng nhưng không chuyển tiếp việc hủy đến nhà cung cấp phía trên, trình duyệt của người dùng sẽ ngắt kết nối nhưng máy chủ của bạn vẫn tiếp tục đọc dữ liệu từ mô hình cho đến khi hoàn tất. Dưới dạng mã giả theo phong cách Node: ``` app.post("/api/chat/stream", async (req, res) => { const upstreamController = new AbortController(); req.on("close", () => { upstreamController.abort(); }); const upstream = await fetch(PROVIDER_URL, { method: "POST", headers: providerHeaders, body: JSON.stringify({ ...req.body, stream: true }), signal: upstreamController.signal }); res.setHeader("Content-Type", "text/event-stream"); for await (const chunk of upstream.body) { res.write(chunk); } res.end(); }); ``` Đây là sự khác biệt giữa “giao diện đã dừng” và “mô hình đã dừng”. ## Truyền luồng và chi phí: Điều gì thay đổi, điều gì không Truyền luồng thay đổi thời điểm bạn biết được mức sử dụng. Nó thường không thay đổi đơn vị tính phí cơ bản. Hầu hết API LLM tính phí dựa trên các yếu tố như: - Token đầu vào. - Token đầu ra. - Token đầu vào được lưu trong bộ nhớ đệm, nếu áp dụng lưu prompt vào bộ nhớ đệm. - Các lệnh gọi công cụ hoặc các phương thức đặc biệt, tùy vào API. - Đôi khi là token suy luận hoặc tài nguyên tính toán nội bộ được phân bổ theo ngân sách cho một số họ mô hình. Bản thân việc truyền luồng thường không giúp giảm giá. Nếu mô hình tạo ra 700 token đầu ra, bạn nên dự kiến ghi nhận 700 token đầu ra, dù chúng được gửi dần hay trong một gói dữ liệu cuối cùng. Truyền luồng ảnh hưởng đến chi phí ở khía cạnh vận hành: | Tình huống | Tác động đến chi phí | Người dùng hủy sau 80 token | Bạn có thể tránh tạo phần đầu ra còn lại nếu việc hủy được chuyển tiếp | Giao diện sớm nhận thấy câu trả lời đã đủ | Ứng dụng có thể chủ động dừng tạo nội dung | Backend không hủy yêu cầu đến nhà cung cấp phía trên | Bạn có thể phải trả tiền cho toàn bộ quá trình tạo dù máy khách đã ngắt kết nối | Thông tin mức sử dụng chỉ đến trong sự kiện cuối cùng | Các luồng bị hủy có thể cần nhật ký phía nhà cung cấp hoặc số liệu ước tính | Các câu trả lời dài được truyền luồng theo mặc định | Người dùng có thể tiêu thụ nhiều hơn vì trải nghiệm có cảm giác mượt mà hơn | Gọi công cụ trước khi truyền luồng văn bản | TTFT có thể tăng, nhưng chi phí token đầu ra có thể không đổi ### Vấn đề cần lưu ý khi ghi nhận mức sử dụng Nhiều API truyền luồng gửi siêu dữ liệu về mức sử dụng ở cuối luồng: ``` { "type": "message.done", "usage": { "input_tokens": 1240, "output_tokens": 389 }, "finish_reason": "stop" } ``` Nếu luồng bị gián đoạn trước sự kiện cuối cùng, ứng dụng của bạn có thể không nhận được thông tin mức sử dụng. Điều đó không nhất thiết có nghĩa là mức sử dụng bằng không. Để hạch toán nội bộ, tôi thường lưu ba trường: ``` { "request_id": "chat_01j...", "status": "client_aborted", "usage": null, "estimated_output_chars": 1842, "final_usage_received": false } ``` Sau đó đối soát lại nếu nhà cung cấp cung cấp nhật ký yêu cầu hoặc sự kiện về mức sử dụng. Nếu không, ít nhất bạn có thể ước tính sơ bộ để phân tích sản phẩm, đồng thời tránh tạo cảm giác chính xác giả khi tính phí. Đừng tính phí người dùng cuối dựa trên ước tính số ký tự trừ khi điều khoản của bạn cho phép rõ ràng cách đó. Cách phân tách token khác nhau tùy theo mô hình và ngôn ngữ. ## Mẫu kiến trúc backend Một luồng xử lý truyền luồng trong môi trường thực tế thường như sau: ``` Browser | | POST /api/chat/stream v App Backend | | provider streaming request v LLM API ``` Tôi hiếm khi để trình duyệt truy cập trực tiếp các khóa của nhà cung cấp. Backend mang lại cho bạn: - Xác thực và phân quyền. - Giới hạn tốc độ theo người dùng hoặc không gian làm việc. - Tổng hợp prompt và truy xuất dữ liệu. - Thực thi công cụ. - Các cơ chế kiểm soát việc che thông tin nhạy cảm và ghi nhật ký. - Chuyển sang nhà cung cấp dự phòng. - Chuyển tiếp việc hủy. - Ghi nhận mức sử dụng. Một phản hồi truyền luồng tối giản từ backend Python FastAPI: ``` import json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse import httpx app = FastAPI() PROVIDER_URL = "https://api.example.com/v1/chat/completions" @app.post("/api/chat/stream") async def chat_stream(request: Request): body = await request.json() async def event_generator(): async with httpx.AsyncClient(timeout=None) as client: async with client.stream( "POST", PROVIDER_URL, headers={ "Authorization": "Bearer YOUR_PROVIDER_KEY", "Content-Type": "application/json", "Accept": "text/event-stream", }, json={ **body, "stream": True, }, ) as upstream: async for line in upstream.aiter_lines(): if await request.is_disconnected(): break if not line: continue # Optionally transform provider-specific events here. yield line + "\n\n" return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no", }, ) ``` Trong thực tế, tôi thường chuẩn hóa các sự kiện của nhà cung cấp về một lược đồ nội bộ thống nhất: ``` { "type": "delta", "text": "partial text here" } ``` và sau đó: ``` { "type": "done", "finish_reason": "stop", "usage": { "input_tokens": 812, "output_tokens": 436 } } ``` Điều này rất quan trọng nếu bạn hỗ trợ nhiều mô hình như Claude, GPT, Gemini, Llama, Qwen hoặc Kimi qua cùng một giao diện. Mỗi nhà cung cấp có ngữ nghĩa truyền luồng hơi khác nhau. Chuẩn hóa từ sớm giúp frontend của bạn đơn giản, đúng như điều bạn mong muốn. ## TTFT không chỉ phụ thuộc vào mô hình Khi TTFT không tốt, mọi người thường đổ lỗi cho mô hình trước tiên. Đôi khi điều đó đúng: mô hình lớn hơn, ngữ cảnh dài hơn và chế độ suy luận chuyên sâu hơn có thể làm tăng độ trễ đến token đầu tiên. Nhưng trước khi token đầu tiên xuất hiện, rất nhiều cơ chế phải hoạt động. Các yếu tố góp phần bao gồm: - Thiết lập kết nối TLS. - Khởi động nguội trong backend của bạn. - Xây dựng prompt và truy xuất dữ liệu. - Độ trễ của cơ sở dữ liệu vector. - Gọi công cụ trước khi tạo nội dung. - Xếp hàng chờ tại nhà cung cấp. - Nạp ngữ cảnh đầu vào dài. - Các bước kiểm tra an toàn hoặc các lớp chính sách. - Bộ đệm tại proxy. - Độ trễ kết xuất phía máy khách. Một bản ghi truy vết hữu ích nên tách riêng các yếu tố sau: ``` { "trace_id": "req_abc", "timings_ms": { "auth": 12, "retrieval": 84, "prompt_build": 9, "provider_connect": 141, "ttfb": 612, "first_delta": 804, "stream_complete": 5820 } } ``` Nếu truy xuất mất 600 ms và phần dữ liệu cập nhật đầu tiên của mô hình đến sau đó 250 ms, việc đổi mô hình có thể không cải thiện UX của bạn. Nếu phần lớn thời gian dành cho việc thiết lập kết nối với nhà cung cấp, tái sử dụng kết nối có thể hữu ích. Nếu giao diện chờ đủ một đoạn văn mới kết xuất, luồng truyền của bạn vẫn hoạt động nhưng chính sách UI đang che khuất nó. ## Khi nào không nên truyền luồng Truyền luồng không phải lúc nào cũng đáng dùng. Tôi tránh truyền luồng khi: - Phản hồi rất ngắn, chẳng hạn như phân loại hoặc định tuyến. - Bên gọi là một dịch vụ khác cần JSON hoàn chỉnh. - Đầu ra phải được kiểm duyệt toàn bộ trước khi hiển thị. - Yêu cầu chạy trong hàng đợi tác vụ hoặc quy trình xử lý theo lô. - Framework hoặc lớp lưu trữ và triển khai khiến việc truyền luồng thiếu ổn định. - Sản phẩm cần câu trả lời hoàn chỉnh trong một lần, thay vì văn bản xuất hiện dần. Cũng có một nhược điểm về UX: truyền luồng có thể để lộ việc mô hình đổi hướng giữa chừng khi trả lời. Người dùng có thể chấp nhận phần nào điều này trong trò chuyện, nhưng nó có thể tạo cảm giác thiếu chỉn chu trong các quy trình có tính hệ trọng cao. Với giao diện dành cho lĩnh vực pháp lý, y tế, tài chính hoặc có yêu cầu tuân thủ nghiêm ngặt, bạn có thể chọn chỉ truyền luồng sau khi áp dụng các biện pháp kiểm soát, hoặc truyền vào bộ đệm nội bộ trước khi hiển thị các phần nội dung an toàn. ## Các mẫu triển khai thực tế phía máy khách Đây là các mẫu triển khai tôi khuyến nghị cho phần lớn sản phẩm trò chuyện: ### 1. Hiển thị trạng thái trước khi văn bản xuất hiện Đừng để bong bóng tin nhắn của trợ lý trống. Hãy dùng các trạng thái rõ ràng: - “Đang đọc ngữ cảnh…” - “Đang tìm kiếm tài liệu…” - “Đang soạn câu trả lời…” - “Vẫn đang xử lý…” Điều này đặc biệt hữu ích khi các lệnh gọi công cụ diễn ra trước khi tạo văn bản. ### 2. Giữ một nút dừng thực sự hoạt động Nút dừng không chỉ là yếu tố trang trí UX. Nó có thể tiết kiệm token đầu ra nếu được triển khai đúng cách. Hãy bảo đảm: - Trình duyệt hủy yêu cầu. - Backend của bạn phát hiện việc ngắt kết nối. - Backend của bạn hủy yêu cầu gửi đến nhà cung cấp. - Bạn ghi nhận yêu cầu là đã hủy. ### 3. Kết xuất dần, định dạng khi hoàn tất Trong khi truyền luồng: - Nối thêm văn bản. - Kết xuất Markdown bằng cách xử lý nhẹ. - Tránh tô sáng cú pháp tốn nhiều tài nguyên. - Giữ hành vi cuộn dễ dự đoán. Sau khi hoàn tất: - Phân tích lại Markdown. - Tô sáng mã. - Bật các nút sao chép. - Đính kèm trích dẫn hoặc tài liệu tham khảo nếu hệ thống của bạn hỗ trợ. ### 4. Lưu tin nhắn chưa hoàn chỉnh một cách cẩn thận Nếu luồng truyền gặp lỗi giữa chừng, hãy quyết định cách hiểu bản ghi hội thoại. Các lựa chọn: - Giữ lại tin nhắn chưa hoàn chỉnh của trợ lý với nhãn “bị gián đoạn”. - Xóa hoàn toàn tin nhắn đó. - Cho phép người dùng thử lại với cùng lời nhắc. - Yêu cầu mô hình tiếp tục từ ngữ cảnh chưa hoàn chỉnh. Tôi thường giữ lại tin nhắn chưa hoàn chỉnh và đánh dấu rõ ràng. Xóa âm thầm khiến người dùng bối rối, còn giả vờ rằng tin nhắn đã hoàn tất thì tệ hơn. ### 5. Chuẩn hóa sự kiện Frontend của bạn không nên phải biết năm định dạng truyền luồng riêng của từng nhà cung cấp. Hãy chuẩn hóa tại backend: ``` {"type":"delta","text":"Hello"} {"type":"tool_start","name":"search_docs"} {"type":"tool_result","name":"search_docs","status":"ok"} {"type":"done","finish_reason":"stop","usage":{"input_tokens":100,"output_tokens":50}} {"type":"error","message":"upstream timeout"} ``` Điều này cũng giúp chuyển đổi giữa các họ mô hình hoặc định tuyến yêu cầu động dễ dàng hơn. Nếu bạn dùng một lớp API đa mô hình, việc chuẩn hóa này có thể đã nằm trong cổng kết nối của bạn; nếu chưa, bạn nên tự xây dựng. ## Những điểm thực tiễn cần ghi nhớ - Truyền luồng cải thiện độ trễ cảm nhận bằng cách giảm thời gian đến khi đầu ra đầu tiên hiển thị, chứ không khiến toàn bộ quá trình tạo nội dung trở nên miễn phí hoặc tức thì. - Đo riêng TTFT, tổng thời gian hoàn tất và số token mỗi giây. - SSE thường là phương thức truyền tải đơn giản nhất để truyền luồng văn bản từ LLM; hãy dùng fetch() các luồng khi bạn cần phần thân POST. - Chú ý đến việc lưu đệm ở proxy và framework. Nhiều “lỗi truyền luồng” thực chất là lỗi lưu đệm của hạ tầng. - Hãy coi đầu ra chưa hoàn chỉnh là không hợp lệ: Markdown có thể chưa hoàn chỉnh, JSON có thể không phân tích được và các dấu phân cách khối mã có thể vẫn chưa đóng. - Việc hủy chỉ tiết kiệm chi phí nếu tín hiệu hủy được truyền từ trình duyệt đến backend rồi đến nhà cung cấp. - Siêu dữ liệu về mức sử dụng thường đến ở cuối luồng; các luồng bị gián đoạn cần được tính toán mức sử dụng cẩn thận. - Đừng kết xuất lại toàn bộ UI với mỗi mẩu dữ liệu nhỏ. Hãy gộp các cập nhật theo khung hình hoạt ảnh. - Chuẩn hóa các sự kiện luồng riêng của từng nhà cung cấp ở phía backend. - Bỏ qua truyền luồng đối với tác vụ phân loại ngắn, tác vụ theo lô, đầu ra có cấu trúc nghiêm ngặt hoặc quy trình yêu cầu kiểm duyệt toàn bộ trước khi hiển thị. [Suy luận](/vi/blog/tag/inference/)[API](/vi/blog/tag/api/) A [AI Prime Tech](https://aiprimetech.io/vi/) AI Prime Tech xuất bản blog này. Các bài viết được soạn với sự hỗ trợ của AI; hãy đối chiếu những chi tiết phụ thuộc vào phiên bản như ID mô hình, giá và giới hạn với tài liệu chính thức trước khi dựa vào chúng. Truy cập Claude API với chi phí thấp hơn Một khóa API cho Claude Opus 5.5, Sonnet 5, Haiku 4.5 và Fable 5.1, cùng GPT-6 mô hình. Thanh toán theo mức sử dụng, không cần đăng ký thuê bao. [Nhận khóa API của bạn →](/register) AI Prime Tech là cổng API bên thứ ba độc lập. Claude™ và Anthropic® là các nhãn hiệu của Anthropic, PBC. Không hàm ý có sự liên kết hay chứng thực.