0

Retry, timeout và idempotency cho AI agent: đừng để agent hoàn tiền hai lần

image.png

Khi agent chỉ trả lời câu hỏi, lỗi mạng nhiều lắm là làm người dùng phải bấm gửi lại. Khi agent gọi công cụ để thay đổi dữ liệu, như tạo hoàn tiền hay gửi email, một lần timeout có thể biến thành hai lần hoàn tiền.

Bài này đi qua ba cơ chế nhỏ giúp tránh chuyện đó: timeout, retry có backoff và idempotency key. Code bằng Node.js thuần, không cần thư viện ngoài. Mình đã chạy thử toàn bộ đoạn code bên dưới, và các assert ở cuối đều pass.

Vì sao agent dễ gặp lỗi lặp

Vòng lặp của agent thường chạy thế này: mô hình chọn công cụ, code gọi công cụ, kết quả quay lại mô hình. Nếu cuộc gọi bị chậm, có hai khả năng. Công cụ chưa chạy, hoặc công cụ đã chạy nhưng phản hồi bị mất. Từ phía agent, hai trường hợp này giống hệt nhau, và nếu thử lại mù quáng thì trường hợp thứ hai sẽ tạo ra bản ghi thứ hai.

Đôi khi chính mô hình cũng quyết định gọi lại công cụ khi thấy kết quả lạ. Vì vậy phần chống lặp phải nằm ở tầng gọi công cụ, không thể chỉ dựa vào prompt.

Về các endpoint, webhook và cách agent nên giao tiếp với hệ thống bên ngoài, mình đã tổng hợp trong bài ***mẫu thiết kế REST, WebSocket và webhook cho AI agent***.

Timeout: đừng chờ vô hạn

Mỗi cuộc gọi cần một giới hạn thời gian. Hàm dưới đây bọc cuộc gọi bằng AbortController và báo lỗi khi hết giờ. Lưu ý rằng timeout chỉ dừng việc chờ. Muốn hủy hẳn yêu cầu đang chạy, bạn cần truyền signal vào client HTTP, ví dụ fetch.

Retry với backoff

Thử lại hợp lý khi lỗi mang tính tạm thời, như timeout hay lỗi 5xx. Mỗi lần chờ lâu gấp đôi lần trước, cộng thêm một chút ngẫu nhiên để nhiều tiến trình không thử lại cùng một lúc. Hãy đặt số lần thử tối đa, vì vòng lặp vô hạn sẽ biến sự cố nhỏ thành sự cố lớn.

Không phải lỗi nào cũng nên thử lại. Lỗi do tham số sai sẽ sai mãi, nên đoạn code mẫu dưới đây chỉ là bản đơn giản. Bản thật nên phân loại lỗi trước khi thử lại.

Idempotency: cùng một khóa, chỉ chạy một lần

Retry chỉ an toàn khi thao tác lặp lại không gây hại. Cách làm là gắn cho mỗi thao tác ghi một khóa, ví dụ refund:A1. Lần đầu thao tác chạy thật, những lần sau trả lại kết quả cũ. Trong ví dụ mình lưu khóa bằng Map trong bộ nhớ. Ở môi trường thật bạn cần một kho lưu bền như database, và nếu hệ thống đích hỗ trợ idempotency key thì nên truyền khóa đó sang luôn.

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// Idempotency: cùng một khóa thì chỉ chạy một lần
const done = new Map();

async function withTimeout(fn, ms) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    return await Promise.race([
      fn(controller.signal),
      new Promise((_, reject) =>
        controller.signal.addEventListener("abort", () => reject(new Error("timeout")))
      ),
    ]);
  } finally {
    clearTimeout(timer);
  }
}

async function callTool(fn, { key, timeoutMs = 2000, retries = 3, baseDelayMs = 200 }) {
  if (done.has(key)) return done.get(key);
  let lastError;
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const result = await withTimeout(fn, timeoutMs);
      done.set(key, result);
      return result;
    } catch (err) {
      lastError = err;
      if (attempt === retries) break;
      const delay = baseDelayMs * 2 ** attempt + Math.random() * 50;
      await sleep(delay);
    }
  }
  throw lastError;
}

Phần kiểm tra bên dưới mô phỏng một công cụ chậm hai lần đầu rồi thành công, sau đó gọi lại cùng khóa và thử một công cụ luôn lỗi.

const assert = require("node:assert");

(async () => {
  let calls = 0;
  const flaky = async () => {
    calls += 1;
    if (calls < 3) await sleep(200);
    return { refundId: "R-1" };
  };

  const r1 = await callTool(flaky, { key: "refund:A1", timeoutMs: 50, baseDelayMs: 10 });
  assert.deepStrictEqual(r1, { refundId: "R-1" });
  assert.equal(calls, 3);

  const r2 = await callTool(flaky, { key: "refund:A1", timeoutMs: 50, baseDelayMs: 10 });
  assert.deepStrictEqual(r2, r1);
  assert.equal(calls, 3);

  let bad = 0;
  const alwaysFails = async () => { bad += 1; throw new Error("boom"); };
  await assert.rejects(
    callTool(alwaysFails, { key: "refund:B2", retries: 2, baseDelayMs: 5 }),
    /boom/
  );
  assert.equal(bad, 3);
  console.log("tất cả kiểm tra đều pass");
})();

Những điều nên kiểm tra trước khi lên production

Thao tác ghi nào cũng cần một khóa, và khóa nên sinh từ dữ liệu nghiệp vụ như mã đơn hàng, không sinh ngẫu nhiên ở mỗi lần gọi. Thời gian chờ và số lần thử nên nằm trong cấu hình, không rải rác trong code. Mỗi lần thử lại cần được ghi log cùng một mã theo dõi, để khi có sự cố bạn thấy ngay chuỗi sự kiện.

Mình làm việc với nền tảng AI agent của AI Hive nên hay gặp loại lỗi này khi nối agent vào hệ thống thật. Bạn đang xử lý trường hợp hoàn tiền trùng như thế nào?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí