Tính Lũy Đẳng Tác nhân AI: Ngăn Chặn Tính Phí Kép Khi Thử Lại

Các lần thử lại của tác nhân tạo ra các khoản phí trùng lặp và các đơn hàng trùng lặp. Tìm hiểu cách các khóa idempotency hoạt động, cách tạo ra chúng cho từng bước tác vụ và cách kiểm tra rằng lần gọi thứ hai không thay đổi gì.

Ashley Innocent

Ashley Innocent

26 tháng 8 2026

Tính Lũy Đẳng Tác nhân AI: Ngăn Chặn Tính Phí Kép Khi Thử Lại

Apidog cho doanh nghiệp

Triển khai tại chỗ

SSO & RBAC

Tuân thủ SOC 2

Khám phá Apidog Enterprise

Tác nhân (agent) của bạn đã gọi đến điểm cuối (endpoint) thanh toán. Yêu cầu đã được gửi đi, giao dịch đã được ghi nhận, và sau đó phản hồi bị hết thời gian chờ (timeout) trên đường về. Tác nhân không bao giờ nhận được mã `200`, vì vậy nó đã làm theo những gì bạn chỉ dẫn khi thất bại: nó thử lại. Giờ đây khách hàng đã bị tính phí hai lần, và không có gì trong nhật ký (log) của bạn trông giống như một lỗi.

Đây là chế độ thất bại phân biệt các tác nhân với các máy khách (client) API thông thường. Một người khi nhấp vào “Thanh toán” một lần sẽ thấy một biểu tượng quay tròn và chờ đợi. Một tác nhân trong vòng lặp thử lại sẽ thấy im lặng và thử lại, đôi khi ba hoặc bốn lần liên tiếp, nhanh hơn bất kỳ người nào có thể làm được. Mọi chính sách thử lại bạn thêm vào để làm cho tác nhân đáng tin cậy hơn cũng làm cho các thao tác ghi trùng lặp dễ xảy ra hơn. Cách khắc phục là tính bất biến (idempotency): làm cho một yêu cầu lặp lại tạo ra cùng một kết quả như một yêu cầu duy nhất.

Hướng dẫn này đề cập đến ý nghĩa của tính bất biến ở cấp độ HTTP, cách tạo khóa mà một tác nhân có thể thực sự sử dụng lại, những gì máy chủ phải lưu trữ để tôn trọng chúng, và cách kiểm tra toàn bộ trước khi một khách hàng thực sự bị tính phí hai lần. Nếu bạn chưa đọc bài viết chính của chúng tôi về lý do các tác nhân AI thất bại trong môi trường sản phẩm, thì các thao tác ghi trùng lặp là chế độ thất bại ẩn dưới hầu hết các báo cáo “tác nhân đã thực hiện nó hai lần”.

Apidog xuất hiện trong phần kiểm thử của vấn đề này. Tính bất biến là thứ bạn tích hợp vào API và lớp công cụ của tác nhân. Điều bạn cần sau đó là một cách để gửi cùng một yêu cầu hai lần và chứng minh rằng lần thứ hai không thay đổi gì, đây là một bài kiểm thử bạn có thể lưu và chạy trong CI.

Tại sao các tác nhân thường phá vỡ tính bất biến hơn con người

Ba yếu tố liên quan đến lưu lượng tác nhân làm cho các bản sao trở nên phổ biến.

Đầu tiên là số lượng thử lại. Các framework tác nhân mặc định thử lại một cách mạnh mẽ vì lỗi mạng tạm thời là nguyên nhân phổ biến nhất gây ra một lần chạy bị gián đoạn. Hướng dẫn của chúng tôi về khôi phục lỗi tác nhân giải thích về backoff và circuit breaker, và mọi kỹ thuật trong đó đều làm tăng số lần một yêu cầu cụ thể đến máy chủ của bạn.

Thứ hai là sự mơ hồ của một lần hết thời gian chờ (timeout). Khi một yêu cầu hết thời gian chờ, máy khách không biết gì về việc liệu máy chủ đã xử lý nó hay chưa. Một mã `504` từ một proxy có thể có nghĩa là thao tác ghi chưa bao giờ xảy ra hoặc nó đã xảy ra và phản hồi bị mất. Con người thường kiểm tra trước khi thử lại. Các tác nhân thường không làm vậy, vì “kiểm tra trước” là một lệnh gọi công cụ bổ sung mà mô hình phải quyết định thực hiện.

Thứ ba là vòng lặp. Một tác nhân thất bại trong một tác vụ có thể khởi động lại toàn bộ tác vụ, chứ không chỉ bước bị lỗi. Nếu bước một tạo một đơn hàng và bước bốn thất bại, một lần khởi động lại đơn giản sẽ tạo ra một đơn hàng thứ hai. Đây là điểm mà các tác nhân đa bước khác biệt rõ rệt so với một script: ranh giới thử lại không rõ ràng, và mô hình, chứ không phải mã của bạn, quyết định nơi nó bắt đầu.

Kết hợp những điều đó lại và bạn sẽ thấy hình dáng của vấn đề. Không phải là các tác nhân gửi các yêu cầu sai. Họ gửi các yêu cầu đúng nhiều hơn một lần.

Tính bất biến thực sự đảm bảo điều gì

Một thao tác là bất biến khi thực hiện nó nhiều lần có cùng tác dụng như thực hiện nó một lần. `GET`, `PUT`, và `DELETE` được định nghĩa là bất biến trong RFC 9110, đặc tả ngữ nghĩa HTTP. `POST` thì không, đó chính là lý do tại sao các thao tác nguy hiểm thường là các lệnh gọi `POST`: tạo đơn hàng, gửi tin nhắn, bắt đầu chuyển khoản.

Hai điểm làm rõ sau sẽ giúp tránh nhiều nhầm lẫn.

Tính bất biến không giống với tính an toàn. Một phương thức an toàn không thay đổi bất cứ điều gì. `DELETE` là bất biến nhưng mang tính phá hủy: gọi nó năm lần vẫn xóa tài nguyên, giống như gọi một lần, nhưng tài nguyên vẫn bị mất. Các tác nhân cần hai thuộc tính này được sắp xếp riêng biệt, đây là lập luận trong bài viết của chúng tôi về khóa API có quyền hạn tối thiểu cho tác nhân từ phía thông tin xác thực.

Tính bất biến cũng không giống với phản hồi giống hệt nhau. Lần gọi thứ hai có thể trả về kết quả đã lưu của lần đầu tiên, và nó có thể trả về một mã trạng thái khác. Điều không được thay đổi là trạng thái trên máy chủ. Một giao dịch. Một đơn hàng. Một email.

Khóa bất biến (Idempotency keys): mô hình giúp POST an toàn

Giải pháp tiêu chuẩn là một khóa do máy khách tạo được gửi kèm theo yêu cầu. Máy chủ ghi lại khóa cùng với kết quả, và bất kỳ yêu cầu sau này mang cùng một khóa sẽ trả về kết quả đã ghi thay vì thực hiện lại công việc.

Stripe đã phổ biến tiêu đề này, và tài liệu về tính bất biến của Stripe vẫn là mô tả rõ ràng nhất về ngữ nghĩa. Ngoài ra còn có nỗ lực của IETF nhằm chuẩn hóa nó thành trường tiêu đề Idempotency-Key, điều đáng đọc trước khi bạn tự đặt tên tiêu đề của mình.

Yêu cầu trông như thế này:

POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json

{
  "amount": 4900,
  "currency": "usd",
  "customer_id": "cus_8812",
  "description": "Pro plan, August"
}

Khóa là một UUID. Nó không có ý nghĩa gì đối với máy chủ ngoài “đây là cùng một thao tác logic.” Máy chủ lưu trữ nó, cùng với dấu vân tay của phần thân yêu cầu và phản hồi mà nó tạo ra.

Tạo khóa mà tác nhân có thể sử dụng lại

Đây là điểm mà hầu hết các triển khai tác nhân thường mắc lỗi. Nếu trình bao công cụ (tool wrapper) tạo một UUID mới trong mỗi lần gọi, khóa sẽ thay đổi trong mỗi lần thử lại, và tính bất biến sẽ không có tác dụng. Khóa phải được gắn với thao tác logic, chứ không phải với lần thử HTTP.

Quy tắc: tạo khóa khi tác nhân quyết định thực hiện một hành động, và giữ khóa đó cho mọi lần thử lại của quyết định đó.

import uuid

class PaymentTool:
    def __init__(self, client):
        self.client = client
        self._keys = {}

    def charge(self, task_id, step_id, amount, customer_id):
        # One key per (task, step). Retries of the same step reuse it.
        op = f"{task_id}:{step_id}"
        if op not in self._keys:
            self._keys[op] = str(uuid.uuid4())

        return self.client.post(
            "/v1/payments",
            headers={"Idempotency-Key": self._keys[op]},
            json={"amount": amount, "customer_id": customer_id},
        )

Một khóa xác định cũng hoạt động, và nó tồn tại qua các lần khởi động lại tiến trình, điều mà một từ điển trong bộ nhớ không làm được:

import hashlib

def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
    raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

Rút ra khóa từ lần chạy tác vụ và bước, không bao giờ từ một dấu thời gian hoặc một giá trị ngẫu nhiên được tạo lại mỗi lần thử. Nếu tác nhân khởi động lại toàn bộ tác vụ và thực sự có ý định tạo một giao dịch mới, ID tác vụ sẽ thay đổi và khóa cũng vậy. Đó là hành vi bạn mong muốn.

Những gì máy chủ phải làm

Xử lý tiêu đề một cách chính xác đòi hỏi nhiều hơn là chỉ tra cứu. Một triển khai hoạt động sẽ làm bốn điều sau:

  1. Khi nhận được, cố gắng yêu cầu khóa. Chèn nó vào một bảng với ràng buộc duy nhất (unique constraint) trước khi thực hiện bất kỳ công việc nào. Nếu việc chèn thất bại, một lần thử khác đã sở hữu nó.
  2. Nếu khóa tồn tại và dấu vân tay yêu cầu đã lưu khác, từ chối với `422`. Cùng một khóa với phần thân khác nghĩa là có lỗi từ máy khách, và việc âm thầm trả về kết quả cũ sẽ che giấu lỗi đó.
  3. Nếu khóa tồn tại và lần thử đầu tiên vẫn đang được thực hiện, trả về `409` để bên gọi lùi lại thay vì cạnh tranh.
  4. Khi công việc hoàn tất, lưu trữ mã trạng thái và phần thân với khóa, sau đó trả về nó cho mọi lần truy cập sau.
CREATE TABLE idempotency_records (
  key             TEXT PRIMARY KEY,
  request_hash    TEXT NOT NULL,
  state           TEXT NOT NULL,      -- in_progress | completed
  response_status INT,
  response_body   JSONB,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at      TIMESTAMPTZ NOT NULL
);

Đặt thời gian hết hạn. Hai mươi bốn giờ bao gồm bất kỳ khoảng thời gian thử lại thực tế nào, và việc giữ khóa vĩnh viễn biến bảng thành một gánh nặng. Stripe hết hạn khóa sau 24 giờ, đây là một mặc định hợp lý để sao chép.

Kiểm tra rằng lệnh gọi thứ hai không thay đổi gì

Xây dựng tính bất biến là một nửa công việc. Chứng minh nó duy trì là nửa còn lại, và đây là nửa thường bị bỏ qua, bởi vì đường dẫn thành công (happy path) trông giống hệt nhau dù tính năng có hoạt động hay không.

Việc kiểm thử rất đơn giản để mô tả: gửi yêu cầu, ghi lại kết quả, gửi lại yêu cầu giống hệt, và khẳng định rằng máy chủ không thực hiện công việc hai lần. Phần khó là khẳng định cuối cùng, vì bản thân phản hồi sẽ không cho bạn biết. Hai giao dịch thành công đều trả về `200`.

Vì vậy, khẳng định dựa trên trạng thái, không phải trên phản hồi:

Trong Apidog, bạn có thể thiết lập điều này như một kịch bản kiểm thử: bước một gửi yêu cầu `POST` với một `Idempotency-Key` cố định, bước hai lặp lại yêu cầu đó, và bước ba liệt kê tài nguyên và khẳng định số lượng. Lưu ID phản hồi từ bước một vào một biến và khẳng định bước hai trả về cùng một giá trị. Bởi vì toàn bộ kịch bản được lưu trữ, nó chạy trong CI mỗi khi có thay đổi đối với đường dẫn thanh toán, nơi các lỗi hồi quy thực sự xuất hiện. Kỹ thuật tương tự cũng áp dụng cho các mô hình rộng hơn trong hướng dẫn kiểm thử hợp đồng API của chúng tôi.

Hai trường hợp nữa đáng được đề cập, vì chúng giúp phát hiện các lỗi thực tế:

Việc tạo mock cũng hữu ích ở đây. Nếu bạn vẫn đang xây dựng tác nhân và API thanh toán chưa tồn tại, hãy tạo mock cho nó với một phản hồi nhận biết tính bất biến để logic thử lại của tác nhân được thực hành sớm. Bài viết của chúng tôi về lý do các tác nhân nên sử dụng mock API thay vì môi trường sản phẩm đưa ra lý do rộng hơn cho thói quen đó.

Khi bạn không thể thêm khóa

Đôi khi API không phải của bạn và nó không hỗ trợ tính bất biến. Bạn vẫn có các lựa chọn, theo thứ tự ưu tiên tương đối.

Biết lần chạy nào đã làm gì

Tính bất biến ngăn chặn việc trùng lặp. Nó không cho bạn biết lần thử nào đã tạo ra bản ghi, và đó là câu hỏi bạn sẽ được hỏi sau một sự cố.

Giữ nhận dạng lần chạy gắn liền với công việc. Khi tác nhân là dịch vụ của riêng bạn, điều đó có nghĩa là ID tác vụ và ID bước từ việc tạo khóa ở trên, được ghi lại với mỗi lần thử. Khi tác nhân là một môi trường chạy mã (coding runtime) thực thi công việc được giao, nền tảng thường giữ nó cho bạn: trong Sharkly, mỗi lần chạy được gắn với Tác vụ mà nó xuất phát, với trạng thái thực thi và kết quả của nó được lưu trữ cùng với luồng bình luận, do đó một thao tác ghi lặp lại có thể truy ngược về một lần chạy cụ thể chứ không phải một lần thử lại ẩn danh.

Danh sách kiểm tra trước khi triển khai

Làm việc theo danh sách đó và câu chuyện về việc tính phí hai lần sẽ không còn khả thi, điều đó có nghĩa là chính sách thử lại của bạn có thể trở nên mạnh mẽ hơn chứ không phải yếu đi. Đó là lợi ích thực sự: tính bất biến là điều cho phép bạn làm cho một tác nhân trở nên linh hoạt mà không làm cho nó nguy hiểm.

Các câu hỏi thường gặp

Thực hành thiết kế API trong Apidog

Khám phá cách dễ dàng hơn để xây dựng và sử dụng API