Bỏ qua để vào nội dung chính

Stream LLM đứng 11s trên prod: bốn lớp buffer cần tắt

Bởi Liam Parker
14 thg 9, 20264 phút đọc

Token stream mượt ở local nhưng dồn một cục trên production? Nguyên nhân là buffer ở nginx, gzip, CDN và chính code bạn — cách đo và sửa từng lớp.

Report của bạn stream mượt như máy đánh chữ khi chạy local: 0,4 giây là có ký tự đầu tiên. Deploy lên production, trang đứng im mười một giây rồi đổ toàn bộ 1.800 token vào DOM trong một frame — cùng code, cùng model, cùng prompt.

Thủ phạm gần như luôn nằm giữa app và browser, không nằm trong model. Dưới đây là cách khoanh vùng trong 90 giây và tắt đúng bốn lớp buffer, dựa trên một ca thực tế mà tác giả đo được từ 11,4s xuống 0,7s.

Chẩn đoán bằng hai lệnh curl, đừng đọc lại Python

Bisect ngay tại biên proxy. Gọi một lần qua proxy từ ngoài, một lần thẳng vào origin port trên máy:

curl -N -s -o /dev/null -w 'ttfb %{time_starttransfer}s\n' https://api.example.com/api/report/stream
curl -N -s -o /dev/null -w 'ttfb %{time_starttransfer}s\n' http://127.0.0.1:8000/api/report/stream

time_starttransfer là time-to-first-byte — với stream thì đó là con số duy nhất đáng quan tâm. Trong ca này hai số đo được là 0,5s ở origin và 11,4s qua proxy, đủ để loại bỏ model, prompt, token generator, event loop và frontend cùng một lúc.

Lưu ý cờ -N: thiếu nó, chính curl buffer output và một stream khỏe mạnh vẫn hiện ra như đang hỏng.

Bốn lớp buffer và giá của từng lớp

Tác giả sửa lần lượt từng lớp trên cùng một report 1.800 token, đo time-to-first-visible-token trong browser:

BướcToken đầu tiên
Production baseline11,4s
proxy_buffering off9,1s
Bỏ gzip cho text/event-stream3,2s
CDN: đúng content-type + no-transform1,9s
Gỡ chunking trong code của chính mình0,7s
(Local dev, để tham chiếu)0,4s

Bài học nằm ở dòng thứ hai: tắt proxy_buffering — thứ ai cũng nghĩ là toàn bộ vấn đề — chỉ mua được 2,3 giây, đủ để tưởng fix không ăn thua rồi revert.

gzip mới là con đập lớn hơn. Compressor phải gom đủ input mới emit được một block, nên nó là buffer dù bạn đã tắt buffer khác. text/event-stream không nằm trong gzip_types mặc định của nginx, nhưng một danh sách gzip_types rộng copy qua nhiều server block từ nhiều năm trước thì có. Kiểm tra file config của bạn thay vì suy đoán.

Config: scope theo route, hoặc khai báo theo response

location /api/report/stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 3600s;
    chunked_transfer_encoding off;
}

Cách gọn hơn nếu không muốn sửa config nginx cho từng endpoint mới: để app tự khai báo trên response, với X-Accel-Buffering: no — header opt-out của chính nginx, đi kèm response nên route streaming mới hoạt động mà không cần deploy lại proxy. Kèm Cache-Control: no-cache, no-transformmedia_type="text/event-stream".

Một cái bẫy đáng nhớ: header phải nằm trên StreamingResponse, không phải emit bên trong SSE event đầu tiên. Đặt nhầm chỗ thì nó về như một phần body, nginx không bao giờ thấy, stream vẫn chết.

Đừng tắt proxy_buffering ở http block

Tác giả đã thử đặt proxy_buffering off ở http block cho gọn, và file upload cùng response JSON lớn tệ đi rõ rệt khi có concurrency. Đó là feature chạy đúng thiết kế: buffering off nghĩa là tốc độ của client chậm trở thành tốc độ của app worker. Scope theo location streaming.

Nhân lúc mở block đó, sửa luôn proxy_read_timeout — mặc định 60 giây, tính giữa hai lần đọc từ upstream. Report dài chạm ngưỡng và connection đóng ở gần đúng 60,0s, giữa câu, không có log lỗi nào trong application log.

Kiểm tra gì tối nay

Chạy cặp curl -N ở trên trước khi đọc thêm một dòng Python nào. Nếu origin nhanh mà proxy chậm, bypass CDN bằng cách gọi thẳng IP public của origin rồi so lại. Cuối cùng mới soi generator của chính mình — trong ca này nó gom 40 đến 80 token một lần để chờ biên markdown "an toàn", và trong heading hay fenced code block thì gom lâu hơn nhiều.

Chi phí của một stream chết không chỉ là UX: tuần trước khi fix, 41 trong 120 phiên report bị reload trang trước khi report xong, mỗi lần reload lại kích hoạt một lượt generate mới. Sáu phiên khác bị cắt cụt im lặng vì timeout 60s — và một report markdown bị cắt cụt thì vẫn trông như một report.

Không spam, hủy đăng ký bất kỳ lúc nào.

Bài viết liên quan