Thời gian chờ Độ tin cậy AbortController LLM API Truyền luồng

Chiến lược thời gian chờ cho LLM API chịu được các lần sinh chậm

Vì sao một thời gian chờ cố định làm hỏng lệnh gọi LLM, và cách đặt hạn chót cho kết nối, token đầu tiên và thời gian im lặng, truyền luồng và hủy sạch với AbortController.

AveMujica API 12 phút đọc

Một yêu cầu gửi tới mô hình chat mà thường trả lời trong ba giây đôi khi có thể mất tới chín mươi giây. Đặt một timeout thô 30 giây duy nhất trên toàn client và bạn sẽ cắt đứt những lần sinh chậm-nhưng-đúng trong khi chẳng làm được gì với những lần thực sự bị kẹt. Cách khắc phục không phải là một con số lớn hơn — mà là nhiều hạn chót, mỗi hạn chót đo một thứ khác nhau: bạn chờ bao lâu để kết nối, bạn chờ bao lâu cho token đầu tiên, và bạn chịu đựng sự im lặng bao lâu một khi các token đã tuôn ra. Bài viết này trình bày cách xây dựng timeout phân lớp đó, vì sao streaming thay đổi cách tính toán, và cách hủy công việc ngay lập tức khi một hạn chót hết hoặc người dùng bảo dừng.

Độ trễ của LLM là bất thường, và đó chính là gốc rễ của vấn đề. Một lệnh gọi REST bình thường có phân phối độ trễ tụm chặt quanh giá trị trung bình, nên một timeout cố định cao hơn giá trị trung bình vài lần là an toàn. Độ trễ sinh của mô hình có đuôi nặng (heavy-tailed) và phụ thuộc vào những thứ mà client không thấy được: độ dài đầu ra mà mô hình chọn, việc nó có phải là mô hình suy luận đang tiêu tốn các token ẩn trước khi viết ra một từ hay không, việc xếp hàng ở thượng nguồn, và định tuyến nguội. Một timeout tinh chỉnh theo trung vị sẽ cắt cụt cái đuôi; một timeout tinh chỉnh theo cái đuôi lại để những yêu cầu thực sự treo giữ kết nối và hạn mức lâu hơn nhiều so với mức cần thiết.

Tách biệt các hạn chót mà một timeout đang cố gắng bao phủ

"Timeout" là một từ duy nhất chỉ ít nhất bốn giới hạn độc lập. Đặt tên riêng cho chúng chính là toàn bộ bí quyết.

  • Timeout kết nối (Connect timeout) — trần cho việc thiết lập kết nối TCP/TLS. Cái này nên ngắn (vài giây); một kết nối chậm là vấn đề mạng hoặc DNS, không phải mô hình chậm.
  • Hạn chót thời-gian-tới-token-đầu-tiên (TTFT) — bạn chờ bao lâu để mô hình bắt đầu phản hồi. Trên một mô hình suy luận, con số này chính đáng có thể lên tới hàng chục giây, vì mô hình có thể suy nghĩ trước khi phát ra bất cứ thứ gì.
  • Timeout nhàn rỗi / giữa các token (Idle / inter-token timeout) — một khi stream bắt đầu, khoảng cách tối đa bạn chịu đựng giữa các chunk. Một stream khỏe mạnh gửi token liên tục; một khoảng im lặng dài nghĩa là thượng nguồn đã đình trệ.
  • Hạn chót tổng / theo đồng hồ tường (Total / wall-clock deadline) — một trần tuyệt đối cho toàn bộ yêu cầu để một trường hợp bệnh lý không thể chạy vô hạn.

Một timeout cố định duy nhất gộp cả bốn thành một con số và do đó không phục vụ tốt cái nào. Độ tin cậy cho các lần sinh dài đến từ việc đặt một TTFT và ngân sách tổng hào phóng trong khi giữ timeout nhàn rỗi thật chặt, để bạn thất bại nhanh khi bị đình trệ nhưng vẫn kiên nhẫn trong lúc làm việc thật.

Streaming biến một timeout tổng cứng thành một timeout nhàn rỗi mềm

Thay đổi hiệu quả nhất bạn có thể thực hiện là stream. Khi bạn gửi stream: true, mô hình trả về token qua Server-Sent Events thay vì đệm toàn bộ câu trả lời, và bạn ngừng đo "toàn bộ phản hồi có kết thúc trong N giây không" mà bắt đầu đo "chunk tiếp theo có tới trong N giây không." Điều đó đảo ngược vấn đề theo hướng có lợi cho bạn: một câu trả lời năm nghìn token không còn nguy cơ bị cắt vì hết thời gian tổng, bởi mỗi chunk sẽ đặt lại đồng hồ nhàn rỗi. Nếu bạn chưa xây dựng một client streaming, stream phản hồi LLM với SSE trình bày định dạng đường truyền và cách phân tích; ở đây chúng ta chỉ quan tâm đến hành vi định thời mà nó mở khóa.

Logic timeout nhàn rỗi trên một stream fetch trông như thế này — một bộ canh gác (watchdog) được đặt lại trên mỗi chunk và chỉ kích hoạt trong lúc thực sự im lặng:

async function streamWithIdleTimeout(url, body, { idleMs = 20000 } = {}) {
  const controller = new AbortController();
  let timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);

  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: "Bearer AVE_GATEWAY_API_KEY" },
    body: JSON.stringify({ ...body, stream: true }),
    signal: controller.signal,
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  try {
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      clearTimeout(timer);                       // a chunk arrived: reset the watchdog
      timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
      process.stdout.write(decoder.decode(value, { stream: true }));
    }
  } finally {
    clearTimeout(timer);
  }
}

Hãy để ý rằng ngân sách nhàn rỗi (20 giây im lặng) được tách rời khỏi thời gian tổng. Yêu cầu có thể chạy trong nhiều phút miễn là nó tiếp tục sinh ra token, nhưng một sự đình trệ thật sự sẽ vấp phải giới hạn trong vòng một cửa sổ nhàn rỗi. Đó chính xác là hành vi mà một timeout tổng cố định không thể diễn đạt.

Hủy bằng AbortController, và đọc tên lỗi

Mọi timeout đều cần một cơ chế để thực sự dừng công việc, và trong trình duyệt cũng như các runtime hiện đại, cơ chế đó là AbortController. Bạn truyền signal của nó vào fetch, và gọi abort() sẽ phá kết nối. Với một trần theo đồng hồ tường đơn thuần mà không cần tự tay dựng controller, AbortSignal.timeout(ms) trả về một signal tự hủy sau số mili-giây đã cho:

// Hard 60s cap on a non-streaming call:
const res = await fetch(url, { method: "POST", signal: AbortSignal.timeout(60000), /* ... */ });

Hai đường hủy này báo cáo các lỗi khác nhau, và phân biệt chúng chính là điều cho phép bạn quyết định có nên thử lại hay không. Một lệnh controller.abort() thủ công làm fetch bị từ chối với một DOMException có tên AbortError; một signal từ AbortSignal.timeout() bị từ chối với một lỗi có tên TimeoutError. Để kết hợp "người dùng nhấn dừng" với "hạn chót cứng," hãy hợp nhất các signal bằng AbortSignal.any([userSignal, AbortSignal.timeout(60000)]), rồi rẽ nhánh theo err.name:

try {
  await callModel();
} catch (err) {
  if (err.name === "TimeoutError") retryOrEscalate();   // our deadline fired
  else if (err.name === "AbortError") stopQuietly();     // the user cancelled
  else throw err;                                         // a real failure
}

Coi một lần người dùng hủy như một thất bại cần thử lại là một lỗi kinh điển: bạn bắn lại một yêu cầu mà người dùng đã cố ý giết, trả tiền cho token, và làm ô nhiễm chỉ số của mình. Hãy rẽ nhánh theo tên.

Phía máy chủ có các timeout riêng của nó, và chúng thắng

Nếu client của bạn nói chuyện với một gateway hoặc backend của chính bạn thay vì trực tiếp tới nhà cung cấp, chặng nhảy đó áp đặt các hạn chót riêng của nó, và cái ngắn nhất dọc đường đi sẽ quyết định kết cục. Một thất bại phổ biến là một client kiên nhẫn chờ năm phút trong khi một proxy trung gian cắt đứt kết nối ở giây thứ sáu mươi, hoặc timeout nhàn rỗi của một bộ cân bằng tải đóng một phản hồi streaming "bị treo" vì việc đệm phản hồi đã làm trễ byte đầu tiên vượt quá giới hạn. Ba quy tắc phía máy chủ giúp tránh chuyện này cắn bạn:

  • Lan truyền lệnh hủy lên thượng nguồn. Khi client ngắt kết nối, hãy hủy yêu cầu mà bạn đã gửi tới nhà cung cấp. Việc hủy không bảo đảm tuyệt đối: nhà cung cấp có thể tiếp tục sinh và tính phí trong một khoảng ngắn sau khi bạn ngắt kết nối, nhưng lan truyền lệnh hủy là cách duy nhất để có cơ hội dừng chi phí cho những token không ai đọc. Đây là bản tương ứng phía máy chủ của AbortController.
  • Đừng đệm các phản hồi được stream. Một proxy tích lũy phần thân trước khi chuyển tiếp sẽ làm trễ byte đầu tiên và có thể vấp phải một timeout nhàn rỗi ở thượng nguồn vốn sẽ không bao giờ kích hoạt trên một stream được đẩy đi (flushed).
  • Giao hạn chót cho runtime. Trong Go, hạn chót của một context.Context là phương tiện mang mang tính đặc trưng; khi nó hết hạn, yêu cầu HTTP đi ra được hủy và tài nguyên được giải phóng. Bất kỳ ngôn ngữ nào cũng có tương đương — mấu chốt là timeout phải chạm tới socket, chứ không chỉ tới lớp bọc.

Gateway là nơi tự nhiên để thực thi một chính sách nhất quán, vì nó vốn đã kết thúc các kết nối của client và giữ thông tin xác thực của nhà cung cấp. Đăng ký mỗi thượng nguồn phía sau gateway cho phép một hạn chót và một đường hủy áp dụng bất kể nhà cung cấp nào phục vụ yêu cầu, đồng thời số token thực sự được tiêu thụ trước khi hủy vẫn được ghi trong nhật ký sử dụng. Tuy nhiên, số đếm cục bộ chỉ mang tính gần đúng: khi thống kê cuối từ thượng nguồn không tới, nó có thể là giá trị ước tính. Với mọi trường hợp quan trọng về tính phí, hãy đối chiếu với dữ liệu sử dụng của chính nhà cung cấp.

Timeout và retry là một hệ thống, không phải hai

Một timeout kích hoạt là một tín hiệu cho logic retry của bạn, nên cả hai phải được thiết kế cùng nhau hoặc chúng sẽ đánh nhau. Hai quy tắc ngăn ngừa các bệnh lý phổ biến:

  1. Một lần retry chỉ có nghĩa sau một thất bại nhanh. Nếu timeout tổng của bạn dài và bạn retry khi hết hạn, một yêu cầu chậm duy nhất trở thành nhiều yêu cầu chậm xếp chồng lên nhau, nhân bội độ trễ và chi phí. Hãy giữ hạn chót mỗi lần thử đủ chặt để một lần retry rẻ hơn việc tiếp tục chờ, và giới hạn số lần thử.
  2. Một yêu cầu bị timeout có thể vẫn đã thực thi. Khi bạn cắt một kết nối, nhà cung cấp có thể đã sinh ra phản hồi mà bạn không bao giờ đọc. Retry một cách mù quáng có thể tính phí gấp đôi hoặc lặp lại các hiệu ứng phụ. Hãy kết hợp timeout với một chiến lược idempotency để một lần retry sau timeout là an toàn — cơ chế nằm trong retry và idempotency cho LLM API.

Timeout cũng tương tác với giới hạn tần suất (rate limit). Một loạt timeout theo sau bởi các lần retry ngay lập tức, từ góc nhìn của thượng nguồn, trông giống như một đợt tăng tải, và có thể đẩy bạn vào các phản hồi 429. Việc phối hợp các hạn chót với backpressure — xếp hàng thay vì nện dồn dập — được đề cập trong rate limit và backpressure cho LLM API. Khi cả một nhà cung cấp chậm thay vì chỉ một yêu cầu đơn lẻ, nước đi đúng thường là chuyển hướng lưu lượng; xem chiến lược failover cho LLM API.

Mô hình suy luận cần một ngân sách kiên nhẫn dài hơn

Mô hình suy luận thay đổi hồ sơ định thời đủ nhiều để xứng đáng có con số riêng. Vì chúng tiêu tốn các token suy luận ẩn trước khi sinh ra đầu ra hiển thị, TTFT có thể dài ngay cả khi mô hình đang làm việc hoàn hảo, và một hạn chót TTFT được hiệu chỉnh cho một mô hình chat nhanh sẽ dừng chúng quá sớm. Hai điều chỉnh giúp ích: nâng ngân sách TTFT và tổng riêng cho các tuyến dùng mô hình suy luận, và ưu tiên truyền luồng để một khi các token hiển thị bắt đầu, thời gian chờ nhàn rỗi — chứ không phải trần tổng — sẽ chi phối. Hãy tinh chỉnh tham số suy luận riêng và dành đủ khoảng trống cho token ẩn theo chính sách trong quản lý cửa sổ ngữ cảnh LLM.

FAQ

Tôi nên dùng giá trị timeout nào cho một lệnh gọi LLM API?

Không có một giá trị duy nhất, đó chính là điểm mấu chốt của một chiến lược phân lớp. Hãy dùng một timeout kết nối ngắn (vài giây), một hạn chót TTFT hào phóng cỡ theo họ mô hình chậm nhất của bạn, một timeout nhàn rỗi chặt cho các phản hồi được stream (hàng chục giây im lặng), và một trần tổng tuyệt đối làm chốt chặn. Tinh chỉnh một con số buộc bạn phải đánh đổi sự đúng đắn của cái đuôi lấy khả năng phát hiện đình trệ; các hạn chót riêng biệt cho bạn cả hai.

Làm sao tôi phân biệt được một timeout do hạn chót với một lần người dùng hủy?

Kiểm tra name của lỗi. Một signal từ AbortSignal.timeout() bị từ chối với TimeoutError; một lệnh controller.abort() thủ công bị từ chối với AbortError. Hãy retry hoặc leo thang với TimeoutError, dừng lặng lẽ với AbortError, và ném lại (re-throw) bất cứ thứ gì khác. Gộp lẫn hai cái khiến bạn retry những yêu cầu mà người dùng đã cố ý giết.

Việc abort một yêu cầu có thực sự ngăn nhà cung cấp tính phí tôi không?

Chỉ khi lệnh hủy chạm tới nhà cung cấp, và ngay cả khi đó cũng không có bảo đảm tuyệt đối. Trong trình duyệt, controller.abort() đóng kết nối tới máy chủ của bạn; sau đó máy chủ phải hủy yêu cầu thượng nguồn của chính nó, nếu không việc sinh có thể tiếp tục và bạn vẫn bị tính phí cho những token không ai đọc. Lan truyền lệnh hủy qua mọi chặng giúp nó có cơ hội trở thành biện pháp kiểm soát chi phí thực sự. Hãy xác nhận bằng dữ liệu sử dụng hoặc hóa đơn của nhà cung cấp, vì số token cục bộ có thể chỉ là ước tính.

Vì sao phản hồi streaming của tôi bị treo cho tới khi kết thúc thay vì stream?

Gần như luôn luôn là do đệm phản hồi ở một trung gian. Một reverse proxy hoặc framework tích lũy phần thân trước khi chuyển tiếp sẽ đánh bại streaming và cũng có thể vấp phải các timeout nhàn rỗi bằng cách làm trễ byte đầu tiên. Hãy tắt việc đệm cho các phản hồi text/event-stream và đẩy (flush) các chunk khi chúng tới.

Tách một tuyến thành bốn hạn chót

Lấy một tuyến đang chạy trong môi trường production và tách thời gian chờ duy nhất của nó thành bốn: thời gian chờ kết nối, hạn chót TTFT, thời gian chờ nhàn rỗi của luồng và trần tổng. Chuyển tuyến đó sang stream: true, nối bộ canh gác nhàn rỗi ở trên, rồi cố ý hủy một yêu cầu giữa luồng. Số token đầu ra được ghi cục bộ nên giảm xuống — đó là dấu hiệu tốt cho thấy hạn chót đã chạm tới socket, nhưng hãy coi nó là tín hiệu chứ không phải bằng chứng. Khi chi phí quan trọng, hãy đối chiếu với dữ liệu sử dụng của nhà cung cấp vì thượng nguồn có thể tiếp tục tính phí trong một khoảng ngắn sau khi bạn ngắt kết nối.

Tài liệu tham khảo