Chìa khóa vật lý đặt trên bàn phím laptop, minh họa cho khái niệm idempotency key
AI

AI agent tự ý hoàn tiền NHẦM 2 lần, và idempotency cứu tôi thế nào

Chìa khóa vật lý đặt trên bàn phím laptop, minh họa cho khái niệm idempotency key
Photo by Sasun Bughdaryan on Unsplash

Đêm nọ, con bot hoàn tiền cho khách… 2 lần

Một dự án gần đây, tụi mình có integrate một con AI agent vào hệ thống order để nó tự xử lý mấy case đơn giản: khách hủy đơn trong 1 giờ, hàng có lỗi rõ ràng, refund dưới một ngưỡng tiền nhất định. Trên ý tưởng nghe rất ngon: giảm tải cho support, khách được xử lý nhanh hơn.

Tuần đầu chạy êm. Tuần thứ hai, một khách report đã bị refund 2 lần cho cùng một đơn! (may là khách report lại) Không phải bug ở logic nghiệp vụ. Không phải AI “hiểu sai” yêu cầu. Cái agent đó làm đúng những gì nó nghĩ là đúng, hai lần.

Agent chỉ đang làm đúng việc một retry loop non tay sẽ làm

Lý do cho anh em: agent gọi tool refund_order, request đi tới payment gateway, gateway xử lý xong, ghi nhận thành công… nhưng response bị timeout trên đường về. Agent không nhận được kết quả. Agent suy luận rất hợp lý: “không có response nghĩa là chưa chạy, thử lại”.

Vấn đề là cái tool đã chạy rồi. Tiền đã ra khỏi tài khoản merchant. Agent chỉ đơn giản không biết điều đó.

Lỗi này không mới, dân distributed systems đặt tên cho nó từ lâu: at-least-once delivery, chẳng có gì đảm bảo exactly-once trừ khi bạn tự xây lấy. Có điều giờ phía gọi lại tool không còn là một dòng code retry viết sẵn, mà là một con model tự quyết định khi nào nên thử lại. Và nó thử lại rất “tự tin” 🙁

Dặn nó trong prompt “chỉ refund một lần thôi nhé” không phải là cách tối ưu và đảm bảo. Agent không cố tình phá luật, nó thật sự không có thông tin để biết lần gọi trước đã thành công. Để xử lý hoàn toàn vấn đề này, ta cần 2 thứ dưới đây:

Idempotency key: cái phao cũ mèm mà giờ AI cũng cần

Idempotency key không phải khái niệm mới, dân làm payment integration chắc quen mặt nó cả chục năm rồi (mình cũng có một bài khác về migrate payment handler lên Shopware 6.7). Ý tưởng đơn giản: mỗi thao tác có tác dụng phụ (side effect) đi kèm một key duy nhất. Server lưu key đó cùng kết quả. Lần gọi tiếp theo với cùng key, server trả lại đúng kết quả cũ thay vì chạy lại.

Cái khác bây giờ là ai sinh ra key đó. Với AI agent, đừng để model tự bịa key ngẫu nhiên mỗi lần gọi, vì retry sẽ generate key mới và mất tác dụng. Hãy generate key từ state bền vững của workflow: order id, step id, run id của agent.

class RefundService
{
    public function refund(Order $order, int $amountCents, string $runId, string $stepId): RefundResult
    {
        $idempotencyKey = hash('sha256', "refund:{$order->id}:{$runId}:{$stepId}");

        $existing = IdempotencyRecord::where('key', $idempotencyKey)->first();
        if ($existing && $existing->status === 'completed') {
            return RefundResult::fromCached($existing->response_payload);
        }

        return DB::transaction(function () use ($idempotencyKey, $order, $amountCents) {
            $record = IdempotencyRecord::firstOrCreate(
                ['key' => $idempotencyKey],
                ['status' => 'processing']
            );

            // Truyền key này xuống luôn cho payment gateway,
            // Stripe/VNPay/gateway nào có hỗ trợ Idempotency-Key thì tận dụng luôn
            $response = $this->gateway->refund(
                paymentIntentId: $order->payment_intent_id,
                amountCents: $amountCents,
                idempotencyKey: $idempotencyKey,
            );

            $record->update([
                'status' => 'completed',
                'response_payload' => $response->toArray(),
            ]);

            return RefundResult::fromGateway($response);
        });
    }
}

Chú ý hai lớp bảo vệ trong đoạn code trên: một key check ở tầng service của mình, một key nữa truyền xuống gateway. Gateway nào tử tế (Stripe/Paypal  etc…) cũng có cơ chế idempotency key riêng ở tầng API. Có cả hai thì một retry storm ở tầng agent cũng không đủ sức tạo ra 2 lần refund thật.

Không phải lỗi nào cũng đáng retry

Cái sai thứ hai hay gặp: agent framework mặc định coi mọi lỗi như nhau, thấy fail là thử lại. Thực tế bốn loại lỗi hoàn toàn khác nhau, và trộn chung vào một logic retry là tự hại mình:

  • Token hết hạn, quyền bị thu hồi, param sai schema (422)
  • Rate limit (429)
  • Gateway sập tạm thời (5xx)
  • Tệ nhất là “partial execution” nơi tool trả về 200 nhưng state thật sự chưa được ghi.

Mỗi loại cần một phản ứng khác hẳn nhau. 422 mà retry thì bạn chỉ đang gọi cùng một request sai thêm vài lần. 429 thì cần backoff có jitter, tối đa vài lần rồi ngắt mạch. Còn cái 5xx “chạy rồi nhưng không biết đã ghi state chưa” thì trước khi retry, phải verify lại state thật đã, không thì lại quay về câu chuyện refund 2 lần ở trên.

Giải pháp không phức tạp lắm, chỉ là phải làm: tool trả về structured error, kèm field retryable và lý do rõ ràng, thay vì chỉ ném ra một exception chung chung rồi để agent tự đoán.

{
  "error": "insufficient_gateway_balance",
  "retryable": false,
  "reason": "Merchant balance below refund amount, needs manual top-up"
}

Nửa chừng gãy thì dọn dẹp kiểu gì

Refund đơn giản còn dễ. Rắc rối hơn là workflow nhiều bước: hoàn tiền, hủy đơn, trả hàng về kho, gửi email xác nhận. Agent chạy được 2 trong 4 bước thì gateway timeout, giờ hệ thống ở trạng thái lửng lơ.

Đây là chỗ saga pattern cũ mà lại hợp: mỗi bước có một compensating action đi kèm, để dùng khi bước sau fail. Hoàn tiền có compensate là “hủy hoàn tiền/ghi nợ lại”, trừ kho có compensate là “cộng kho lại”, gửi email xác nhận thì compensate bằng một email đính chính. Không cần transaction phân tán phức tạp, chỉ cần agent (hoặc worker phía sau nó) biết rằng dừng giữa chừng không phải là xong, mà là một trạng thái cần được dọn.

Vậy có nên để AI tự bấm nút “Hoàn tiền” không

Có, nhưng không phải kiểu để nó tự làm hết mọi thứ ngay từ đầu.

Cách tụi mình đang làm: tool cho refund luôn có một bước preview trước, agent gọi preview_refund để lấy về đúng số tiền, order, lý do, rồi mới gọi execute_refund với cùng idempotency key đã sinh ở bước preview. Dưới một ngưỡng tiền nhỏ thì execute luôn. Trên ngưỡng đó, action bị chặn lại chờ người duyệt, agent chỉ có quyền đề xuất chứ không có quyền bấm nút.

Cái giá phải trả là chậm hơn một chút so với để agent tự quyết hết. Đổi lại, không có case nào một agent tự tay đẩy 50 triệu ra khỏi tài khoản merchant lúc 2 giờ sáng mà không ai biết. Với hệ thống chạm vào tiền thật, mình nghĩ cái chậm đó đáng.

Nếu ai đang định gắn tool-calling vào hệ thống order/payment của mình, xin đừng bỏ qua bước idempotency key chỉ vì “demo chạy ổn rồi mà”. Demo chạy ổn vì mạng của bạn lúc demo chưa bao giờ lag đúng nhịp. Production thì luôn có ngày nó lag.

Bạn nào từng dính case tương tự, hoặc đang cân nhắc cho AI quyền ghi vào hệ thống thật, để lại comment mình trao đổi thêm. Peace!

Dang Nguyen Hai (Mark) is a senior fullstack engineer in Ho Chi Minh City with 13+ years building e-commerce backends, payment systems and the AI features that sit on top of them. He works with Shopware and Laravel, and writes here about PHP internals, e-commerce architecture and getting AI features to behave in production.

Reply