DEV Community

Cover image for เอไอเอเจนต์: ไอเดมโพเทนซี หยุดการเก็บเงินซ้ำซ้อนจากการลองใหม่
Thanawat Wongchai
Thanawat Wongchai

Posted on Originally published at apidog.com

เอไอเอเจนต์: ไอเดมโพเทนซี หยุดการเก็บเงินซ้ำซ้อนจากการลองใหม่

เอเจนต์ของคุณเรียกใช้ปลายทางสำหรับชำระเงิน คำขอสำเร็จและมีการเรียกเก็บเงินแล้ว แต่การตอบกลับหมดเวลาระหว่างทางกลับ เอเจนต์จึงไม่เคยเห็นรหัส 200 และทำตามนโยบายเมื่อเกิดข้อผิดพลาดด้วยการลองใหม่ ตอนนี้ลูกค้าถูกเรียกเก็บเงินสองครั้ง ทั้งที่บันทึกระบบไม่มีข้อผิดพลาดชัดเจน

ลองใช้ Apidog วันนี้

นี่คือโหมดความล้มเหลวที่ทำให้เอเจนต์แตกต่างจากไคลเอนต์ API ทั่วไป มนุษย์ที่คลิก “ชำระเงิน” ครั้งเดียวอาจเห็นสปินเนอร์และรอ แต่เอเจนต์ที่อยู่ในวงจรลองใหม่จะเห็นเพียงความเงียบและส่งคำขออีกครั้ง บางครั้งสามหรือสี่ครั้งติดต่อกัน

ทุกนโยบายลองใหม่ที่ช่วยให้เอเจนต์น่าเชื่อถือขึ้น ก็เพิ่มโอกาสเกิดการเขียนซ้ำด้วย วิธีแก้คือ idempotency: การทำให้คำขอซ้ำเกิดผลลัพธ์เหมือนกับการส่งคำขอเพียงครั้งเดียว

คู่มือนี้อธิบาย:

  • ความหมายของ idempotency ในระดับ HTTP
  • การสร้างคีย์ที่เอเจนต์นำกลับมาใช้ใหม่ได้
  • ข้อมูลที่เซิร์ฟเวอร์ต้องจัดเก็บ
  • การทดสอบว่าคำขอครั้งที่สองไม่เปลี่ยนแปลงสถานะ
  • ทางเลือกเมื่อ API ไม่รองรับ idempotency

หากยังไม่ได้อ่านบทความ สาเหตุที่เอเจนต์ AI ล้มเหลวในการทำงานจริง ให้มอง “การเขียนซ้ำ” เป็นโหมดความล้มเหลวที่ซ่อนอยู่เบื้องหลังรายงานว่า “เอเจนต์ทำงานซ้ำสองครั้ง”

การออกแบบระบบเอเจนต์ให้รองรับ idempotency

ทำไมเอเจนต์ทำลาย idempotency บ่อยกว่ามนุษย์

มีสามปัจจัยหลักที่ทำให้คำขอของเอเจนต์เกิดซ้ำบ่อย:

  1. ลองใหม่มากกว่าเดิม

    เฟรมเวิร์กเอเจนต์มักลองใหม่อย่างเข้มข้น เพราะความล้มเหลวของเครือข่ายชั่วคราวเป็นสาเหตุทั่วไปของงานที่ผิดพลาด แนวคิดเรื่อง backoff และ circuit breaker ใน คู่มือการกู้คืนข้อผิดพลาดของเอเจนต์ ช่วยเพิ่มความทนทาน แต่ก็เพิ่มจำนวนครั้งที่คำขอเข้าสู่เซิร์ฟเวอร์

  2. หมดเวลาอย่างคลุมเครือ

    เมื่อคำขอหมดเวลา ไคลเอนต์ไม่รู้ว่าเซิร์ฟเวอร์ประมวลผลไปแล้วหรือไม่ รหัส 504 จากพร็อกซีอาจหมายถึงการเขียนไม่เคยเกิดขึ้น หรือเขียนเสร็จแล้วแต่การตอบกลับหายไประหว่างทาง มนุษย์อาจตรวจสอบก่อนลองใหม่ แต่เอเจนต์มักไม่ทำ เพราะต้องเรียกใช้เครื่องมือเพิ่มเติม

  3. รีสตาร์ทงานทั้งชุด

    เอเจนต์ที่ล้มเหลวอาจเริ่มงานใหม่ทั้งหมด หากขั้นตอนแรกสร้างคำสั่งซื้อและขั้นตอนที่สี่ล้มเหลว การรีสตาร์ทแบบธรรมดาจะสร้างคำสั่งซื้อที่สอง ขอบเขตการลองใหม่จึงไม่ชัดเจนเหมือนในสคริปต์ทั่วไป

ปัญหาไม่ใช่เอเจนต์ส่งคำขอผิด แต่เป็นการส่ง คำขอที่ถูกต้องหลายครั้ง

Idempotency รับประกันอะไร

การดำเนินการเป็น idempotent เมื่อเรียกใช้หลายครั้งแล้วมีผลลัพธ์เทียบเท่ากับการเรียกใช้ครั้งเดียว เมธอด GET, PUT และ DELETE ถูกกำหนดให้เป็น idempotent ใน RFC 9110 ส่วน POST ไม่ใช่ จึงมักใช้กับการสร้างคำสั่งซื้อ ส่งข้อความ หรือเริ่มการโอนเงิน

Idempotent ไม่ใช่ safe

เมธอดที่ safe จะไม่เปลี่ยนแปลงสถานะเลย ส่วน idempotent อาจเปลี่ยนสถานะได้:

  • DELETE เป็น idempotent แต่เป็นการทำลาย
  • การเรียก DELETE ห้าครั้งยังคงทำให้ทรัพยากรถูกลบเหมือนการเรียกครั้งเดียว
  • เอเจนต์ต้องพิจารณาคุณสมบัติสองอย่างนี้แยกกัน เช่นเดียวกับแนวคิดเรื่อง คีย์ API ที่มีสิทธิ์น้อยที่สุดสำหรับเอเจนต์

Idempotent ไม่ได้แปลว่าตอบกลับเหมือนกันทุกครั้ง

คำขอครั้งที่สองอาจส่งคืนผลลัพธ์ที่จัดเก็บไว้ หรือส่งคืนรหัสสถานะที่แตกต่างกัน สิ่งที่ต้องไม่เปลี่ยนคือสถานะบนเซิร์ฟเวอร์ เช่น:

  • เรียกเก็บเงินเพียงครั้งเดียว
  • สร้างคำสั่งซื้อเพียงรายการเดียว
  • ส่งอีเมลเพียงฉบับเดียว

Idempotency key: ทำให้ POST ปลอดภัยขึ้น

รูปแบบมาตรฐานคือให้ไคลเอนต์ส่งคีย์ไปพร้อมคำขอ เซิร์ฟเวอร์จะจัดเก็บคีย์และผลลัพธ์ จากนั้นคำขอถัดไปที่ใช้คีย์เดิมจะส่งคืนผลลัพธ์เดิมแทนการทำงานซ้ำ

เอกสาร Idempotency ของ Stripe เป็นคำอธิบายที่ชัดเจน และควรอ่าน ร่างมาตรฐานฟิลด์เฮดเดอร์ Idempotency-Key ของ IETF ก่อนตั้งชื่อเฮดเดอร์ของคุณเอง

ตัวอย่างคำขอ:

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

{
  "amount": 4900,
  "currency": "usd",
  "customer_id": "cus_8812",
  "description": "Pro plan, August"
}
Enter fullscreen mode Exit fullscreen mode

คีย์อาจเป็น UUID และไม่มีความหมายอื่นนอกจาก “การดำเนินการเชิงตรรกะเดียวกัน” เซิร์ฟเวอร์ควรจัดเก็บคีย์พร้อมลายนิ้วมือของ payload และผลลัพธ์ที่สร้างขึ้น

สร้างคีย์ที่เอเจนต์ใช้ซ้ำได้

ข้อผิดพลาดที่พบบ่อยคือ wrapper สร้าง UUID ใหม่ทุกครั้งที่เรียกใช้ หากลองใหม่แล้วได้คีย์ใหม่ idempotency ก็ไม่มีผล

คีย์ต้องผูกกับ การดำเนินการเชิงตรรกะ ไม่ใช่กับ ความพยายามส่ง HTTP แต่ละครั้ง

กฎง่ายๆ คือ:

สร้างคีย์เมื่อเอเจนต์ตัดสินใจดำเนินการ และใช้คีย์เดิมกับทุกการลองใหม่ของการตัดสินใจนั้น

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},
        )
Enter fullscreen mode Exit fullscreen mode

คีย์แบบกำหนดได้ช่วยให้คีย์อยู่รอดแม้กระบวนการรีสตาร์ท ซึ่งทำไม่ได้หากเก็บไว้ในหน่วยความจำเพียงอย่างเดียว:

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]
Enter fullscreen mode Exit fullscreen mode

สร้างคีย์จาก task run และ step เสมอ ห้ามสร้างจาก timestamp หรือค่าสุ่มที่เปลี่ยนทุกครั้งที่ลองใหม่

หากเอเจนต์รีสตาร์ทงานทั้งหมดและตั้งใจเรียกเก็บเงินใหม่ task_id ควรเปลี่ยน ทำให้คีย์เปลี่ยนตาม นี่คือพฤติกรรมที่ถูกต้อง เพราะเป็นการดำเนินการใหม่

เซิร์ฟเวอร์ต้องทำอะไรบ้าง

การรองรับเฮดเดอร์นี้ไม่ใช่แค่ค้นหาคีย์ในตาราง การใช้งานที่ถูกต้องควรทำตามขั้นตอนต่อไปนี้:

  1. จองคีย์ก่อนทำงาน

    แทรกคีย์ลงในตารางที่มีข้อจำกัด UNIQUE ก่อนเริ่มประมวลผล หากแทรกไม่สำเร็จ แสดงว่าคำขออื่นเป็นเจ้าของคีย์แล้ว

  2. ตรวจสอบ payload

    หากคีย์มีอยู่แล้ว แต่ request fingerprint แตกต่างกัน ให้ตอบ 422 คีย์เดียวกันกับเนื้อหาต่างกันคือข้อผิดพลาดของไคลเอนต์ ไม่ควรส่งผลลัพธ์เก่ากลับไปอย่างเงียบๆ

  3. จัดการคำขอที่กำลังทำงาน

    หากคำขอแรกยังอยู่ระหว่างดำเนินการ ให้ตอบ 409 เพื่อให้ผู้เรียกถอยกลับ แทนการปล่อยให้คำขอแข่งขันกัน

  4. จัดเก็บผลลัพธ์เมื่อเสร็จ

    บันทึกรหัสสถานะและ response body จากนั้นส่งคืนผลลัพธ์เดิมสำหรับการเรียกใช้ครั้งถัดไป

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
);
Enter fullscreen mode Exit fullscreen mode

กำหนดวันหมดอายุด้วย โดยทั่วไป 24 ชั่วโมงครอบคลุมช่วงเวลาลองใหม่ที่สมจริง การเก็บคีย์ตลอดไปจะทำให้ตารางขยายโดยไม่จำเป็น Stripe ก็ใช้ค่าเริ่มต้น 24 ชั่วโมง ซึ่งเป็นค่าที่เหมาะสมสำหรับการเริ่มต้น

ทดสอบว่าคำขอครั้งที่สองไม่เปลี่ยนแปลงอะไร

การสร้าง idempotency เป็นเพียงครึ่งหนึ่งของงาน อีกครึ่งคือการพิสูจน์ว่าใช้งานได้จริง เส้นทางสำเร็จเพียงอย่างเดียวไม่พอ เพราะการเรียกเก็บเงินสองครั้งก็อาจตอบ 200 ทั้งคู่

ขั้นตอนทดสอบ:

  1. ส่งคำขอพร้อม Idempotency-Key ที่กำหนดไว้
  2. บันทึกผลลัพธ์
  3. ส่งคำขอเดิมซ้ำด้วยคีย์เดิม
  4. ตรวจสอบสถานะจริงบนเซิร์ฟเวอร์

อย่าตรวจสอบแค่ response ให้ตรวจสอบสถานะด้วย:

  • response ครั้งที่สองตรงกับครั้งแรก รวมถึง resource ID
  • การเรียก GET บน collection แสดงรายการเดียว ไม่ใช่สองรายการ
  • counter หรือยอดคงเหลือเปลี่ยนแปลงเพียงครั้งเดียว

ใน Apidog ให้บันทึกเป็น test scenario:

  1. ส่ง POST พร้อม Idempotency-Key ที่กำหนด
  2. ส่งคำขอเดิมซ้ำ
  3. แสดงรายการทรัพยากรและยืนยันจำนวน
  4. เก็บ response ID จากขั้นตอนแรกไว้ในตัวแปร
  5. ยืนยันว่าขั้นตอนที่สองส่งคืนค่าเดิม

สถานการณ์ทดสอบที่บันทึกไว้สามารถรันใน CI ทุกครั้งที่มีการแก้ไขเส้นทางชำระเงิน ดูแนวทางเพิ่มเติมได้จาก คู่มือการทดสอบสัญญา API

การทดสอบคำขอซ้ำด้วย Apidog

กรณีสำคัญที่ต้องทดสอบเพิ่ม

  • คีย์เดิม แต่ payload ต่างกัน: ต้องได้ 422 ไม่ใช่ความสำเร็จแบบเงียบๆ
  • คำขอซ้ำพร้อมกัน: ส่งคำขอสองรายการในเวลาเดียวกัน และยืนยันว่ามีเพียงรายการเดียวที่ทำงานสำเร็จ วิธีนี้ช่วยตรวจจับข้อผิดพลาดจากการไม่มี UNIQUE constraint ซึ่งการทดสอบตามลำดับอาจไม่พบ

หาก API การชำระเงินยังไม่พร้อม ให้ใช้ mock ที่รองรับความหมายของ idempotency รวมถึงการตอบ 422 เมื่อ payload ไม่ตรงกัน แนวทางนี้สอดคล้องกับ เหตุผลที่เอเจนต์ควรเรียกใช้ mocks แทน production

เมื่อคุณเพิ่ม idempotency key ไม่ได้

หาก API เป็นของบุคคลอื่นและไม่รองรับ idempotency ให้พิจารณาตัวเลือกต่อไปนี้ตามลำดับ:

1. ทำให้การดำเนินการ idempotent โดยธรรมชาติ

ใช้ PUT ไปยังพาธทรัพยากรที่ไคลเอนต์เลือกเอง:

PUT /orders/{client_order_id}
Enter fullscreen mode Exit fullscreen mode

หากควบคุมการออกแบบ API ได้ วิธีนี้มักดีกว่า POST พร้อมเฮดเดอร์ เพราะไม่ต้องมีตาราง idempotency เพิ่ม

2. ตรวจสอบก่อนเขียน

ให้เอเจนต์ค้นหารายการที่มี natural key เดียวกันก่อนสร้างรายการใหม่ วิธีนี้ช่วยกรณีหมดเวลาได้ แต่ยังมี race condition ระหว่างการตรวจสอบและการเขียน จึงไม่แข็งแรงเท่า idempotency key

3. กำจัดข้อมูลซ้ำที่ปลายทาง

สำหรับข้อความหรือ event ให้แนบ message ID ที่เสถียร และให้ตัวรับสารทิ้งรายการซ้ำ แนวทางนี้เป็นมาตรฐานของระบบ event-driven และอธิบายเพิ่มเติมใน คู่มือ webhook ที่น่าเชื่อถือ

4. จำกัดการดำเนินการด้วยมนุษย์

สำหรับการดำเนินการที่ย้อนกลับไม่ได้และทำให้ idempotent ไม่ได้ ให้ใช้ approval gate ตามแนวทาง AI agent guardrails เมื่อค่าใช้จ่ายจากการทำซ้ำสูงพอ การให้มนุษย์อนุมัติคือทางเลือกที่เหมาะสม

รู้ว่าการรันใดเป็นผู้สร้างข้อมูล

Idempotency ป้องกันการสร้างข้อมูลซ้ำ แต่ไม่ได้ตอบว่าความพยายามครั้งใดเป็นผู้สร้างรายการจริง นั่นคือคำถามที่มักเกิดขึ้นหลังเหตุการณ์

เก็บข้อมูลประจำตัวของการรันไว้กับทุกการดำเนินการ:

  • task_id
  • step_id
  • idempotency_key
  • สถานะและผลลัพธ์ของคำขอ

หากเอเจนต์เป็นบริการของคุณเอง ให้บันทึกข้อมูลนี้พร้อมกับทุกความพยายาม หากเป็น runtime ที่ทำงานตาม task ที่มอบหมาย แพลตฟอร์มอย่าง Sharkly อาจเชื่อมโยงแต่ละการรันกับ task และเก็บสถานะ ผลลัพธ์ และกระทู้ความคิดเห็นไว้ ทำให้ย้อนกลับไปยังการรันที่เกี่ยวข้องได้ แทนที่จะเห็นเพียงคำขอลองใหม่แบบไม่ระบุตัวตน

รายการตรวจสอบก่อนนำไปใช้งาน

  • เครื่องมือที่ไม่เป็น idempotent ทุกชิ้นต้องมี Idempotency-Key
  • wrapper ของเครื่องมือต้องปฏิเสธคำขอที่ไม่มีคีย์
  • คีย์ต้องสร้างจาก task และ step ไม่ใช่จากความพยายามแต่ละครั้ง
  • เซิร์ฟเวอร์ต้องจองคีย์ก่อนเริ่มทำงาน
  • คีย์เดิมกับ payload ต่างกันต้องตอบข้อผิดพลาด
  • คำขอซ้ำพร้อมกันต้องจัดการด้วย database constraint
  • มีการทดสอบที่พิสูจน์ว่าคำขอครั้งที่สองไม่เปลี่ยนแปลงสถานะ
  • การทดสอบทำงานใน CI
  • คีย์มีวันหมดอายุและมีงานล้างข้อมูลตามกำหนด

เมื่อทำตามรายการนี้ นโยบายลองใหม่ของเอเจนต์ก็สามารถเข้มข้นขึ้นได้โดยไม่เพิ่มความเสี่ยงต่อการเรียกเก็บเงินซ้ำ นี่คือประโยชน์ที่แท้จริงของ idempotency: ช่วยให้สร้างเอเจนต์ที่ทนทานโดยไม่สร้างความเสียหายซ้ำ

คำถามที่พบบ่อย

ต้องใช้ idempotency key กับเครื่องมือแบบอ่านอย่างเดียวหรือไม่

ไม่จำเป็น คำขอ GET เป็น idempotent และ safe อยู่แล้ว การลองใหม่อาจเพิ่ม latency เล็กน้อย แต่ไม่ทำให้เกิดการเขียนซ้ำ ควรสงวนคีย์ไว้สำหรับการสร้าง เรียกเก็บเงิน ส่งข้อความ หรือเปลี่ยนแปลงสถานะ

ควรสร้างคีย์ในเอเจนต์หรือ wrapper

สร้างใน wrapper โดยใช้ task ID และ step ID เป็นหลัก อย่าให้โมเดลสร้างคีย์เอง เพราะโมเดลอาจสร้างค่าใหม่เมื่อลองใหม่ และอาจทำให้เกิดการชนกันระหว่างงาน

คำขอซ้ำควรส่งคืนรหัสสถานะใด

ส่งคืนสถานะที่จัดเก็บจากการเรียกครั้งแรก หากครั้งแรกตอบ 201 ครั้งที่สองก็ควรตอบ 201 พร้อมเนื้อหาเดิม บาง API อาจเพิ่มเฮดเดอร์ Idempotent-Replay: true เพื่อช่วยในการดีบัก

ควรเก็บคีย์ไว้นานเท่าไร

24 ชั่วโมงครอบคลุมช่วงเวลาลองใหม่ส่วนใหญ่ การเก็บนานกว่านั้นเพิ่มขนาดตารางโดยแทบไม่มีประโยชน์ หากไคลเอนต์ลองใหม่หลังหมดอายุ ให้ถือเป็นการดำเนินการใหม่

Idempotency ใช้แทน transaction ได้หรือไม่

ไม่ได้ Idempotency key ป้องกันคำขอซ้ำไม่ให้สร้างผลลัพธ์ซ้ำ ส่วน transaction ทำให้คำขอเดียวเป็น atomic คุณต้องใช้ทั้งสองอย่าง และควรจองคีย์ใน transaction เดียวกับการทำงานเมื่อฐานข้อมูลรองรับ

จะทดสอบโดยไม่มีผู้ให้บริการชำระเงินจริงได้อย่างไร

ชี้เอเจนต์ไปยัง mock ที่รองรับ idempotency รวมถึงการตอบ 422 เมื่อ payload แตกต่างกัน อ่าน คู่มือการทดสอบเอเจนต์ AI กับ API แบบจำลอง และ ดาวน์โหลด Apidog เพื่อสร้าง mock และบันทึกการทดสอบลองใหม่ไว้ในโปรเจกต์เดียวกัน

Top comments (0)