Thử lại LLM API và bảo đảm tính lũy đẳng an toàn
Thử lại lệnh gọi LLM API mà không bị tính phí trùng hay lặp lại hiệu ứng phụ: lỗi nào nên thử lại, backoff kèm jitter, khóa idempotency và khử trùng lặp ở tầng gateway.
Thử lại một lệnh gọi LLM API bị lỗi chỉ an toàn khi bạn có thể bảo đảm lệnh gọi đó chạy tối đa một lần, ngay cả khi mạng khiến nó trông như đã thất bại hai lần. Mối nguy rất tinh vi: một yêu cầu có thể đã thành công tại phía nhà cung cấp, rồi phản hồi bị mất trên đường quay về, client của bạn thấy timeout, thử lại, và giờ bạn bị tính phí hai lần hoặc kích hoạt một hiệu ứng phụ hai lần. Cách khắc phục là một cặp nguyên tắc luôn đi cùng nhau — chỉ thử lại những lỗi đáng thử lại, kèm backoff và jitter, và làm cho mỗi yêu cầu có thể thử lại trở nên lũy đẳng (idempotent) để một bản trùng lặp được nhận diện và gộp lại thay vì thực thi lần nữa. Bài viết này chỉ ra cách làm cả hai.
Lý do điều này đặc biệt quan trọng với lưu lượng LLM: các lệnh gọi sinh nội dung tốn tiền thật theo từng token, và các luồng công việc của tác nhân (agent) nối chuỗi các lệnh gọi, nơi một bước bị trùng lặp sẽ làm hỏng trạng thái ở phía sau. "Cứ thử lại khi lỗi" thì ổn với một GET; nhưng đó là một sự cố tính phí chực chờ xảy ra đối với một POST sinh ra token.
Phân loại lỗi trước khi thử lại bất cứ điều gì
Không phải lỗi nào cũng nên thử lại. Thử lại một lỗi vĩnh viễn vừa lãng phí thời gian, vừa — trong điều kiện bị giới hạn tốc độ — chủ động gây hại cho bạn, vì các yêu cầu không thành công vẫn được tính vào hạn mức. Hãy xếp lỗi vào ba nhóm:
| Lớp | Ví dụ | Hành động |
|---|---|---|
| Tạm thời, có thể thử lại | timeout, reset kết nối, 500, 502, 503, 429 | Thử lại kèm backoff |
| Lỗi client không thể thử lại | 400 yêu cầu sai, 401/403 xác thực, 404, 422 | Thất bại nhanh, phơi bày lỗi |
| Mơ hồ | timeout sau khi yêu cầu đã được gửi đi | Chỉ thử lại nếu lũy đẳng |
Hàng thứ ba là hàng nguy hiểm. Một timeout không cho bạn biết nhà cung cấp đã xử lý yêu cầu hay chưa — nó chỉ cho biết phản hồi đã không đến. Thử lại một thất bại mơ hồ mà không có tính lũy đẳng chính xác là cách các khoản tính phí trùng lặp phát sinh. Đó là lý do hai chủ đề này không thể tách rời.
Vòng lặp thử lại: backoff, jitter và một mức trần
Một vòng lặp thử lại đúng đắn sẽ backoff theo cấp số nhân, thêm jitter để các client chạy song song không đồng bộ với nhau, giới hạn số lần thử, và chỉ thử lại các lớp có thể thử lại ở trên.
import random, time
import httpx
RETRYABLE = {429, 500, 502, 503, 504}
def call_with_retries(payload, key, max_attempts=5):
for attempt in range(max_attempts):
try:
r = httpx.post(
"https://your-gateway.example.com/v1/chat/completions",
json=payload,
headers={"Authorization": "Bearer AVE_GATEWAY_API_KEY",
"Idempotency-Key": key},
timeout=30,
)
except (httpx.TimeoutException, httpx.TransportError):
_sleep(attempt); continue # ambiguous: safe because key is stable
if r.status_code in RETRYABLE:
_sleep(attempt); continue
return r # success or non-retryable client error
raise RuntimeError("exhausted retries")
def _sleep(attempt, base=0.5, cap=15.0):
time.sleep(random.uniform(0, min(cap, base * (2 ** attempt))))
Hãy để ý rằng Idempotency-Key được truyền vào và giữ nguyên không đổi qua mọi lần thử của cùng một yêu cầu logic. Chính chi tiết duy nhất đó làm cho việc thử lại sau một timeout mơ hồ trở nên an toàn. Nếu bạn sinh một key mới cho mỗi lần thử, bạn sẽ phá hỏng toàn bộ cơ chế.
Khóa idempotency thực sự làm gì
Khóa idempotency là một mã định danh duy nhất do client sinh ra — UUID v4 là lựa chọn phổ biến — mà bạn đính vào một yêu cầu làm thay đổi trạng thái thông qua header Idempotency-Key. Máy chủ lưu kết quả của yêu cầu đầu tiên dưới khóa đó và, với bất kỳ bản trùng lặp nào mang cùng khóa, trả về kết quả đã lưu thay vì thực hiện thao tác lần nữa. Hãy sinh khóa một lần cho mỗi thao tác logic, và tái sử dụng nó trong mọi lần thử lại của thao tác đó:
import uuid
key = str(uuid.uuid4()) # once, before the first attempt
result = call_with_retries(payload, key)
Các triển khai vững chắc bổ sung một dấu vân tay (fingerprint): một mã băm của thân yêu cầu được lưu bên cạnh khóa. Nếu một yêu cầu về sau đến với cùng khóa nhưng thân khác, máy chủ sẽ từ chối nó — thường bằng HTTP 409 Conflict (một idempotency_conflict) — thay vì âm thầm trả về kết quả cache sai. Khóa mang một TTL (24 giờ là một cửa sổ điển hình); sau khi hết hạn, khóa bị lãng quên và việc tái sử dụng sẽ khởi động một thao tác mới hoàn toàn.
Một lưu ý về mức hỗ trợ của nhà cung cấp: OpenAI ghi tài liệu rõ ràng về Idempotency-Key cho luồng thanh toán Agentic Commerce, nơi việc lặp lại lệnh gọi tạo hoặc hoàn tất với cùng khóa phải trả về cùng kết quả, còn tham số không khớp trả về 409. Các endpoint chat và responses thông thường không coi đây là tính năng chính, và phần lớn gateway — kể cả dự án này — hiện chưa tự khử trùng lặp cho /v1/chat/completions hay /v1/responses. Khoảng trống đó chính là lý do gateway là nơi phù hợp để bổ sung tính lũy đẳng: đây là điểm kiểm soát duy nhất, nơi bạn có thể xây kho khử trùng lặp một lần và bảo vệ mọi dịch vụ bất kể từng upstream hỗ trợ gì.
Thiết kế tính lũy đẳng tại gateway
Khi nhiều dịch vụ gọi mô hình qua một gateway, gateway là nơi có thể chặn bản trùng lặp cho toàn hệ thống — nếu bạn triển khai cache theo khóa. Đây là mẫu cần xây dựng, không phải một công tắc có sẵn:
- Client gửi một yêu cầu với
Idempotency-Key: <uuid>. - Gateway tra khóa trong một kho lưu trữ TTL ngắn (Redis rất phù hợp).
- Trượt (miss) → chuyển tiếp lên upstream, rồi lưu
{key → response, fingerprint}trước khi trả về. - Trúng (hit), cùng fingerprint → trả về phản hồi đã lưu mà không gọi upstream.
- Trúng, khác fingerprint → trả về
409 Conflict. - Trúng, vẫn đang xử lý → trả về
409(hoặc chờ) để một lần gửi trùng nhanh không bị thực thi hai lần.
Xây dựng cơ chế này một lần sẽ biến "an toàn khi thử lại" từ thứ mỗi dịch vụ tự viết thành một thuộc tính được thực thi tập trung. Nó cũng bảo vệ trước những bản trùng lặp không do bạn chủ động tạo ra — người dùng nhấp đúp, hàng đợi giao lại thông điệp hoặc proxy tự thử lại. Hãy ghi khóa cùng kết quả xử lý để bản trùng bị chặn có thể được kiểm toán trong kho của bạn thay vì biến mất không dấu vết. Bão thử lại trên nhiều kênh là một lý do khác để tập trung hóa; hãy đọc giới hạn tần suất và phản áp lực cho LLM API để xem mặt đồng thời của cùng vấn đề.
Cần nói rõ một giới hạn của bảo đảm này: "tối đa một lần" chỉ thực sự đúng khi tầng tạo ra hiệu ứng phụ cũng hợp tác. Nếu upstream không hỗ trợ tính lũy đẳng, cơ chế khử trùng lặp của gateway sẽ gộp các bản trùng mà client nhìn thấy, nhưng không thể thu hồi một yêu cầu mà upstream đã xử lý trước khi phản hồi bị mất. Hãy xem cache theo khóa là lớp bảo vệ mạnh chống gửi lại, và chỉ gọi là "chính xác một lần" trên những đường đi có khóa lũy đẳng phía upstream hoặc điều phối hiệu ứng phụ nguyên tử.
Tính lũy đẳng và streaming
Streaming làm việc khử trùng lặp phức tạp hơn vì phản hồi được giao từng phần và có thể bị gián đoạn giữa chừng. Nếu một luồng bị rớt sau khi đã phát ra một nửa số token, một lần thử lại mù quáng với cùng khóa lên một cache ngây thơ có thể phát lại một kết quả cục bộ. Các mô hình an toàn hơn: chỉ cache kết quả đã hoàn tất dưới khóa, coi một luồng bị gián đoạn là không có kết quả (để lần thử lại sinh lại sạch sẽ), hoặc kết nối lại bằng chính cơ chế nối lại của luồng thay vì phát hành lại yêu cầu. Chi tiết nối lại SSE nằm trong Truyền phản hồi LLM theo luồng với SSE.
Danh sách kiểm tra thử lại và tính lũy đẳng
- Đã phân loại lỗi: thử lại tạm thời/
429/5xx, thất bại nhanh với4xx. - Timeout mơ hồ chỉ được thử lại khi yêu cầu có tính lũy đẳng.
- Backoff theo cấp số nhân kèm full jitter và một mức trần số lần thử cứng.
- Một
Idempotency-Keycho mỗi thao tác logic, tái sử dụng qua tất cả các lần thử lại. - Máy chủ hoặc gateway lưu key + fingerprint kèm một TTL.
- Bản trùng với thân không khớp trả về
409, không phải một thành công đã cũ. - Các bản trùng đang xử lý được xử lý đúng, không bị thực thi hai lần.
- Thử lại khi streaming kết nối lại hoặc sinh lại thay vì phát lại các mảnh cục bộ.
Chọn một ngân sách thử lại thất bại an toàn
Thử lại đánh đổi độ trễ lấy xác suất thành công, còn thử lại không giới hạn thì đánh đổi cả hệ thống. Hãy đặt một ngân sách rõ ràng với ba con số: số lần thử tối đa, tổng thời gian tối đa, và một timeout cho mỗi lần thử. Khi ngân sách cạn kiệt, hãy phơi bày một lỗi sạch sẽ thay vì thử lại mãi mãi — một yêu cầu đã thất bại năm lần kèm backoff gần như không bao giờ thành công ở lần thứ sáu, và các lần thử đang tiêu tốn dung lượng mà các yêu cầu khác cần.
Một chế độ hỏng tinh vi xuất hiện ở quy mô lớn: nếu mỗi trong số mười dịch vụ đều thử lại độc lập, tổng lưu lượng thử lại của chúng có thể tự vượt giới hạn tần suất, tạo thêm 429, rồi kích hoạt thêm lần thử. Đây là một cơn bão thử lại do chính hệ thống gây ra. Biện pháp chính là ngân sách thử lại tính theo tỷ lệ tổng lưu lượng — chẳng hạn giới hạn số lần thử lại ở 10% tổng yêu cầu để một sự cố diện rộng không thể nhân đôi tải. Khi cả một kênh đang hỏng thay vì chỉ vài lệnh gọi riêng lẻ, phản ứng đúng là ngừng thử lại vào một bức tường và chuyển lưu lượng; cơ chế cầu dao cùng chuyển dự phòng thuộc về chiến lược chuyển dự phòng cho LLM API, vì vậy hãy kết hợp ngân sách này với cơ chế đó thay vì lặp lại logic cầu dao tại đây.
- Giới hạn số lần thử và tổng thời gian đồng hồ thực, không chỉ số lần thử.
- Giới hạn tổng thử lại để lưu lượng phục hồi không thể khuếch đại một sự cố.
- Chuyển thất bại kéo dài của từng kênh sang cơ chế dự phòng thay vì thử lại mù quáng.
Khả năng quan sát khép kín vòng lặp: hãy đo tỷ lệ thử lại của chính bạn — tỷ phần các lệnh gọi là lần thử lại — để mức tăng của nó cảnh báo trước khi trở thành sự cố. Đây là chỉ số bạn tính từ nhật ký lệnh gọi; tỷ lệ thử lại tăng là tín hiệu sớm nhất cho thấy ngân sách cần được siết lại.
Câu hỏi thường gặp
Nếu một yêu cầu bị timeout, nó đã được xử lý hay chưa?
Bạn không thể biết chỉ từ một timeout — nó chỉ có nghĩa là phản hồi đã không đến, chứ không phải công việc đã không diễn ra. Hãy coi nó là mơ hồ. Chỉ thử lại nó nếu yêu cầu mang một khóa idempotency ổn định để một lần thực thi trùng được gộp lại ở phía máy chủ; nếu không, hãy phơi bày sự mơ hồ đó cho bên gọi.
Tôi có nên sinh một khóa idempotency mới cho mỗi lần thử lại không?
Không — đó là sai lầm kinh điển. Hãy sinh một khóa cho mỗi thao tác logic và gửi đúng khóa đó trong mọi lần thử lại của nó. Một khóa mới cho mỗi lần thử làm cho mỗi lần thử lại trông như một yêu cầu hoàn toàn mới, nên các bản trùng thực thi bình thường và bạn mất toàn bộ sự bảo vệ.
Nếu hai yêu cầu giống hệt nhau đến gần như cùng một khoảnh khắc thì sao?
Hãy xử lý tường minh trường hợp "đang xử lý". Trước khi yêu cầu đầu tiên hoàn tất, yêu cầu thứ hai với cùng khóa nên nhận 409 Conflict (hoặc chặn cho đến khi yêu cầu đầu hoàn tất) thay vì được chuyển tiếp — nếu không một lần gửi trùng nhanh sẽ vượt qua cache và thực thi hai lần.
Chứng minh khử trùng lặp bằng một bản trùng thực tế
Hãy bọc các lệnh gọi mô hình trong một hàm trợ giúp thử lại nhận Idempotency-Key làm tham số và sinh khóa đó đúng một lần cho mỗi thao tác. Sau đó dựng kho theo khóa như mô tả ở trên rồi cố ý gửi một bản trùng qua đó: xác nhận trong nhật ký khử trùng lặp của chính bạn rằng lệnh gọi thứ hai đã bị chặn và chỉ bị tính phí một lần. Trước khi kho này tồn tại, một khóa lặp lại chỉ là header mà upstream có thể bỏ qua — chứng minh việc chặn trên một bản trùng thực tế mới là phép thử có ý nghĩa.