เอเจนต์ของคุณเรียกใช้ปลายทางสำหรับชำระเงิน คำขอสำเร็จและมีการเรียกเก็บเงินแล้ว แต่การตอบกลับหมดเวลาระหว่างทางกลับ เอเจนต์จึงไม่เคยเห็นรหัส 200 และทำตามนโยบายเมื่อเกิดข้อผิดพลาดด้วยการลองใหม่ ตอนนี้ลูกค้าถูกเรียกเก็บเงินสองครั้ง ทั้งที่บันทึกระบบไม่มีข้อผิดพลาดชัดเจน
นี่คือโหมดความล้มเหลวที่ทำให้เอเจนต์แตกต่างจากไคลเอนต์ API ทั่วไป มนุษย์ที่คลิก “ชำระเงิน” ครั้งเดียวอาจเห็นสปินเนอร์และรอ แต่เอเจนต์ที่อยู่ในวงจรลองใหม่จะเห็นเพียงความเงียบและส่งคำขออีกครั้ง บางครั้งสามหรือสี่ครั้งติดต่อกัน
ทุกนโยบายลองใหม่ที่ช่วยให้เอเจนต์น่าเชื่อถือขึ้น ก็เพิ่มโอกาสเกิดการเขียนซ้ำด้วย วิธีแก้คือ idempotency: การทำให้คำขอซ้ำเกิดผลลัพธ์เหมือนกับการส่งคำขอเพียงครั้งเดียว
คู่มือนี้อธิบาย:
- ความหมายของ idempotency ในระดับ HTTP
- การสร้างคีย์ที่เอเจนต์นำกลับมาใช้ใหม่ได้
- ข้อมูลที่เซิร์ฟเวอร์ต้องจัดเก็บ
- การทดสอบว่าคำขอครั้งที่สองไม่เปลี่ยนแปลงสถานะ
- ทางเลือกเมื่อ API ไม่รองรับ idempotency
หากยังไม่ได้อ่านบทความ สาเหตุที่เอเจนต์ AI ล้มเหลวในการทำงานจริง ให้มอง “การเขียนซ้ำ” เป็นโหมดความล้มเหลวที่ซ่อนอยู่เบื้องหลังรายงานว่า “เอเจนต์ทำงานซ้ำสองครั้ง”
ทำไมเอเจนต์ทำลาย idempotency บ่อยกว่ามนุษย์
มีสามปัจจัยหลักที่ทำให้คำขอของเอเจนต์เกิดซ้ำบ่อย:
ลองใหม่มากกว่าเดิม
เฟรมเวิร์กเอเจนต์มักลองใหม่อย่างเข้มข้น เพราะความล้มเหลวของเครือข่ายชั่วคราวเป็นสาเหตุทั่วไปของงานที่ผิดพลาด แนวคิดเรื่อง backoff และ circuit breaker ใน คู่มือการกู้คืนข้อผิดพลาดของเอเจนต์ ช่วยเพิ่มความทนทาน แต่ก็เพิ่มจำนวนครั้งที่คำขอเข้าสู่เซิร์ฟเวอร์หมดเวลาอย่างคลุมเครือ
เมื่อคำขอหมดเวลา ไคลเอนต์ไม่รู้ว่าเซิร์ฟเวอร์ประมวลผลไปแล้วหรือไม่ รหัส504จากพร็อกซีอาจหมายถึงการเขียนไม่เคยเกิดขึ้น หรือเขียนเสร็จแล้วแต่การตอบกลับหายไประหว่างทาง มนุษย์อาจตรวจสอบก่อนลองใหม่ แต่เอเจนต์มักไม่ทำ เพราะต้องเรียกใช้เครื่องมือเพิ่มเติมรีสตาร์ทงานทั้งชุด
เอเจนต์ที่ล้มเหลวอาจเริ่มงานใหม่ทั้งหมด หากขั้นตอนแรกสร้างคำสั่งซื้อและขั้นตอนที่สี่ล้มเหลว การรีสตาร์ทแบบธรรมดาจะสร้างคำสั่งซื้อที่สอง ขอบเขตการลองใหม่จึงไม่ชัดเจนเหมือนในสคริปต์ทั่วไป
ปัญหาไม่ใช่เอเจนต์ส่งคำขอผิด แต่เป็นการส่ง คำขอที่ถูกต้องหลายครั้ง
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"
}
คีย์อาจเป็น 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},
)
คีย์แบบกำหนดได้ช่วยให้คีย์อยู่รอดแม้กระบวนการรีสตาร์ท ซึ่งทำไม่ได้หากเก็บไว้ในหน่วยความจำเพียงอย่างเดียว:
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]
สร้างคีย์จาก task run และ step เสมอ ห้ามสร้างจาก timestamp หรือค่าสุ่มที่เปลี่ยนทุกครั้งที่ลองใหม่
หากเอเจนต์รีสตาร์ทงานทั้งหมดและตั้งใจเรียกเก็บเงินใหม่ task_id ควรเปลี่ยน ทำให้คีย์เปลี่ยนตาม นี่คือพฤติกรรมที่ถูกต้อง เพราะเป็นการดำเนินการใหม่
เซิร์ฟเวอร์ต้องทำอะไรบ้าง
การรองรับเฮดเดอร์นี้ไม่ใช่แค่ค้นหาคีย์ในตาราง การใช้งานที่ถูกต้องควรทำตามขั้นตอนต่อไปนี้:
จองคีย์ก่อนทำงาน
แทรกคีย์ลงในตารางที่มีข้อจำกัดUNIQUEก่อนเริ่มประมวลผล หากแทรกไม่สำเร็จ แสดงว่าคำขออื่นเป็นเจ้าของคีย์แล้วตรวจสอบ payload
หากคีย์มีอยู่แล้ว แต่ request fingerprint แตกต่างกัน ให้ตอบ422คีย์เดียวกันกับเนื้อหาต่างกันคือข้อผิดพลาดของไคลเอนต์ ไม่ควรส่งผลลัพธ์เก่ากลับไปอย่างเงียบๆจัดการคำขอที่กำลังทำงาน
หากคำขอแรกยังอยู่ระหว่างดำเนินการ ให้ตอบ409เพื่อให้ผู้เรียกถอยกลับ แทนการปล่อยให้คำขอแข่งขันกันจัดเก็บผลลัพธ์เมื่อเสร็จ
บันทึกรหัสสถานะและ 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
);
กำหนดวันหมดอายุด้วย โดยทั่วไป 24 ชั่วโมงครอบคลุมช่วงเวลาลองใหม่ที่สมจริง การเก็บคีย์ตลอดไปจะทำให้ตารางขยายโดยไม่จำเป็น Stripe ก็ใช้ค่าเริ่มต้น 24 ชั่วโมง ซึ่งเป็นค่าที่เหมาะสมสำหรับการเริ่มต้น
ทดสอบว่าคำขอครั้งที่สองไม่เปลี่ยนแปลงอะไร
การสร้าง idempotency เป็นเพียงครึ่งหนึ่งของงาน อีกครึ่งคือการพิสูจน์ว่าใช้งานได้จริง เส้นทางสำเร็จเพียงอย่างเดียวไม่พอ เพราะการเรียกเก็บเงินสองครั้งก็อาจตอบ 200 ทั้งคู่
ขั้นตอนทดสอบ:
- ส่งคำขอพร้อม
Idempotency-Keyที่กำหนดไว้ - บันทึกผลลัพธ์
- ส่งคำขอเดิมซ้ำด้วยคีย์เดิม
- ตรวจสอบสถานะจริงบนเซิร์ฟเวอร์
อย่าตรวจสอบแค่ response ให้ตรวจสอบสถานะด้วย:
- response ครั้งที่สองตรงกับครั้งแรก รวมถึง resource ID
- การเรียก
GETบน collection แสดงรายการเดียว ไม่ใช่สองรายการ - counter หรือยอดคงเหลือเปลี่ยนแปลงเพียงครั้งเดียว
ใน Apidog ให้บันทึกเป็น test scenario:
- ส่ง
POSTพร้อมIdempotency-Keyที่กำหนด - ส่งคำขอเดิมซ้ำ
- แสดงรายการทรัพยากรและยืนยันจำนวน
- เก็บ response ID จากขั้นตอนแรกไว้ในตัวแปร
- ยืนยันว่าขั้นตอนที่สองส่งคืนค่าเดิม
สถานการณ์ทดสอบที่บันทึกไว้สามารถรันใน CI ทุกครั้งที่มีการแก้ไขเส้นทางชำระเงิน ดูแนวทางเพิ่มเติมได้จาก คู่มือการทดสอบสัญญา API
กรณีสำคัญที่ต้องทดสอบเพิ่ม
-
คีย์เดิม แต่ payload ต่างกัน: ต้องได้
422ไม่ใช่ความสำเร็จแบบเงียบๆ -
คำขอซ้ำพร้อมกัน: ส่งคำขอสองรายการในเวลาเดียวกัน และยืนยันว่ามีเพียงรายการเดียวที่ทำงานสำเร็จ วิธีนี้ช่วยตรวจจับข้อผิดพลาดจากการไม่มี
UNIQUEconstraint ซึ่งการทดสอบตามลำดับอาจไม่พบ
หาก API การชำระเงินยังไม่พร้อม ให้ใช้ mock ที่รองรับความหมายของ idempotency รวมถึงการตอบ 422 เมื่อ payload ไม่ตรงกัน แนวทางนี้สอดคล้องกับ เหตุผลที่เอเจนต์ควรเรียกใช้ mocks แทน production
เมื่อคุณเพิ่ม idempotency key ไม่ได้
หาก API เป็นของบุคคลอื่นและไม่รองรับ idempotency ให้พิจารณาตัวเลือกต่อไปนี้ตามลำดับ:
1. ทำให้การดำเนินการ idempotent โดยธรรมชาติ
ใช้ PUT ไปยังพาธทรัพยากรที่ไคลเอนต์เลือกเอง:
PUT /orders/{client_order_id}
หากควบคุมการออกแบบ 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_idstep_ididempotency_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)