ใส่ด่านขออนุมัติจากคนไว้ก่อน AI agent ลงมือ ทำได้จริงด้วย LangGraph interrupt_before และ OpenAI Agents SDK
AI agent ที่มีสิทธิ์ลงมือเองอาจทำของพังแบบย้อนไม่ได้ภายในไม่กี่วินาที ทางแก้ไม่ใช่การหวังว่าโมเดลจะไม่พลาด บทความนี้พาใส่ด่านขออนุมัติจากคนไว้ก่อน action เสี่ยง ด้วย LangGraph และ OpenAI Agents SDK ที่ลงมือทำตามได้จริง

ด่านขออนุมัติจากคน (human-in-the-loop) คือการให้คนตรวจและกดอนุมัติก่อน AI agent จะลงมือกับ action ที่เสี่ยง จากนั้น agent จึงทำงานต่อ บทความนี้พาใส่ด่านนี้ด้วยโค้ดจริงจากสองเครื่องมือ คือ LangGraph ไลบรารีโอเพนซอร์สที่มองงานของ agent เป็นกราฟของ node และ OpenAI Agents SDK ชุดเครื่องมือทางการสำหรับสร้าง agent ของ OpenAI
เหตุผลที่ต้องมีด่านนี้คือ AI agent ที่ได้สิทธิ์ลงมือเองสามารถลบฐานข้อมูล production ทั้งก้อนได้ภายในไม่กี่วินาที และไม่มีปุ่ม undo ให้กด นี่ไม่ใช่บั๊กแปลก ๆ แต่เป็นสิ่งที่เกิดได้ตามปกติเมื่อเราให้ agent ต่อกับเครื่องมือที่ลงมือได้จริง เช่น ลบไฟล์ เขียนทับฐานข้อมูล ส่งอีเมลออกไปหาลูกค้า หรือแก้ยอดบิล โมเดลตีความคำสั่งพลาดนิดเดียว action ที่ย้อนกลับไม่ได้ก็เกิดไปแล้ว ทางแก้ที่ใช้ได้จริงจึงไม่ใช่การหวังว่าโมเดลจะเก่งพอจนไม่พลาด เพราะต่อให้เก่งแค่ไหนก็ยังมีวันพลาด บทความนี้ไม่ได้มาไล่สอนทั้งเฟรมเวิร์ก แต่โฟกัสเรื่องเดียว คือวิธีใส่ approval gate ก่อน action ที่ย้อนไม่ได้ และหยิบไปต่อกับ agent ของตัวเองได้เลย
ทำไมต้องมีคนอนุมัติ ทั้งที่โมเดลก็เก่งขึ้นทุกปี
ความเก่งของโมเดลกับความปลอดภัยของงานเป็นคนละเรื่องกัน โมเดลที่แม่นขึ้นช่วยลดโอกาสพลาด แต่ไม่ได้ลดความเสียหายหลังพลาด งานบางอย่างพลาดแล้วแก้กลับได้ เช่น พิมพ์ข้อความผิดก็ลบทิ้งแล้วเขียนใหม่ แต่งานอีกกลุ่มพลาดแล้วจบเลย ลบ production แล้วข้อมูลหายจริง ส่งอีเมลผิดคนแล้วเรียกกลับไม่ได้ หรือโอนเงินผิดบัญชีแล้วก็จบ
เส้นแบ่งที่ควรใช้ตัดสินใจจึงไม่ใช่ "โมเดลแม่นพอหรือยัง" แต่คือ "ถ้า action นี้พลาด ย้อนกลับได้ไหม" ถ้าย้อนไม่ได้ ต้องมีคนเฝ้าก่อนเสมอ นี่คือเหตุผลที่ approval gate ไม่ได้เป็นแค่ตัวกันพลาด แต่ยังเปิดช่องให้คนเข้าไปแก้ทิศทางของ agent ได้ก่อนที่ความผิดพลาดจะกลายเป็นของจริง
หลักการง่ายๆ ที่ทุกเฟรมเวิร์กใช้เหมือนกัน

ก่อนลงโค้ด มาดูภาพรวมกันก่อน เฟรมเวิร์กที่ทำเรื่องนี้ใช้หลักการเดียวกันหมด เอกสารของ MachineLearningMastery เรียกกลไกนี้ว่า "state-managed interruption" หรือการหยุดระบบโดยเก็บสถานะไว้ครบ แกนของมันมีสี่จังหวะ
จังหวะแรกคือ หยุด ปล่อยให้ agent ทำงานไปเรื่อย ๆ จนถึงก่อน action เสี่ยง แล้วหยุดค้างไว้ตรงนั้น ไม่ลงมือทำต่อ จังหวะที่สองคือ เซฟสถานะ ตอนที่หยุด ระบบจะเก็บทุกอย่างที่ agent กำลังถืออยู่ไว้ ทั้งตัวแปร บริบทของบทสนทนา ความจำ และแผนที่จะทำต่อ เปรียบได้กับการเซฟเกมก่อนเข้าด่านยาก ตัวละครหยุดนิ่งรอ แต่ทุกอย่างยังอยู่ครบ พร้อมเล่นต่อทันทีที่กดเริ่ม
จังหวะที่สามคือ รอคนตัดสิน ระบบส่งรายละเอียดของ action ที่ค้างอยู่ให้คนดูว่า agent จะเรียกเครื่องมือชื่ออะไร ด้วยพารามิเตอร์อะไร คนอ่านแล้วเลือกว่าจะอนุมัติหรือปฏิเสธ จังหวะสุดท้ายคือ เดินต่อ ถ้าอนุมัติ agent ก็ทำ action นั้นแล้วทำงานต่อจากจุดที่หยุดไว้ ถ้าปฏิเสธ มันก็ไม่ทำ และเอา feedback กลับไปคิดใหม่
จุดที่ทำให้เรื่องนี้ใช้งานได้จริงคือจังหวะเซฟสถานะ เพราะการหยุดที่เก็บสถานะไว้ครบหมายความว่าคนไม่ต้องรีบกดอนุมัติภายในไม่กี่วินาที จะปล่อยให้ agent ค้างรอข้ามวันก็ได้ หรือเซฟสถานะลงไฟล์แล้วปิดโปรแกรมไปก่อน ค่อยกลับมาเปิดอนุมัติทีหลังก็ยังได้ ตรงนี้คือสิ่งที่แยก approval gate จริง ๆ ออกจากการใส่ if ถามคำถามกลางทางเฉย ๆ
ทำจริงด้วย LangGraph: หยุดก่อนถึง node ด้วย interrupt_before
มาดูตัวแรกกัน LangGraph เป็นไลบรารีโอเพนซอร์สที่ให้เรามองงานของ agent เป็นกราฟของ node ที่ต่อกันเป็นทาง แนวคิดของมันคือเรากำหนดล่วงหน้าได้เลยว่าให้กราฟ "หยุดก่อนถึง node ไหน"
โครงสร้างหลักคือ state ก้อนหนึ่งที่ส่งต่อกันระหว่าง node เปรียบเหมือนไฟล์เซฟที่เดินไปทั้งกราฟ สมมติเรามี agent ร่างอีเมล มันจะมี draft_node สำหรับร่างข้อความ และ send_node สำหรับส่งจริง โดย send_node จะเช็กค่า state["approved"] ก่อน และจะยอมส่งก็ต่อเมื่อได้รับอนุมัติแล้ว
หัวใจอยู่ที่ตอน compile กราฟ เราใส่สองอย่างเข้าไป
app = graph.compile(
checkpointer=MemorySaver(),
interrupt_before=["send_message"],
)checkpointer=MemorySaver() คือตัวเก็บ checkpoint ที่ทำหน้าที่เซฟสถานะให้ ส่วน interrupt_before=["send_message"] คือคำสั่งบอกว่า "ก่อนจะเข้า node ส่งอีเมล ให้หยุดไว้ก่อน" จุดสำคัญคือค่านี้ตั้งตอน compile ไม่ใช่ตอนรัน แปลว่ากราฟนี้จะหยุดก่อน node ที่ระบุไว้ทุกครั้งเสมอ ไม่ใช่หยุดแบบสุ่มแล้วแต่อารมณ์โมเดล
พอรันด้วย app.stream(initial_state, config) agent จะวิ่งไปจนถึงก่อน send_node แล้วหยุดค้าง คนตรวจงานเรียกดูสถานะที่ค้างอยู่ได้ด้วย app.get_state(config) เพื่อดูว่าค้างที่ node ไหนและร่างอีเมลหน้าตาเป็นอย่างไร เมื่อตัดสินใจแล้วก็ฉีดคำตอบกลับเข้าไปในสถานะที่เซฟไว้
app.update_state(config, {"approved": True})
app.stream(None, config)update_state คือการเขียนผลการอนุมัติกลับลงไฟล์เซฟ แล้ว app.stream(None, config) คือการสั่งเดินต่อ การส่ง None เป็น input คือสัญญาณบอกกราฟว่า "ไม่มีข้อมูลใหม่ ให้เล่นต่อจากจุดที่หยุดไว้" agent ก็จะเดินเข้า send_node แล้วส่งอีเมลจริงต่อจากตรงนั้น
ทำจริงด้วย OpenAI Agents SDK: ติดป้าย needs_approval ที่ tool

อีกทางหนึ่งคือ OpenAI Agents SDK ที่มองเรื่องนี้คนละมุมกับ LangGraph แทนที่จะหยุดก่อน node ในกราฟ มันให้เราติดป้ายกำกับที่ตัว tool โดยตรงว่า tool ตัวไหนต้องขออนุมัติก่อนใช้
วิธีที่ตรงที่สุดคือตั้ง needs_approval=True ที่ tool
@function_tool(needs_approval=True)
def delete_database(name: str): ...พอ agent เรียก tool ตัวที่ติดป้ายนี้ การรันจะหยุดทันที แล้ว Runner.run(agent, ...) จะคืนผลลัพธ์ที่มี .interruptions ติดมาด้วย ในนั้นมี ToolApprovalItem ที่บอกครบว่า agent ตัวไหนเรียก tool ชื่ออะไร ด้วย arguments อะไร ข้อมูลพอให้คนตัดสินใจได้โดยไม่ต้องเดา
ความแตกต่างจาก LangGraph อยู่ที่เรื่องการเซฟสถานะลงไฟล์ ฝั่งนี้แปลงสถานะเป็นข้อความหรือ JSON ได้ตรงๆ
state = result.to_state()
saved = state.to_json() # หรือ state.to_string()สถานะที่เซฟไว้โหลดกลับมาทีหลังได้ด้วย RunState.from_json() หรือ RunState.from_string() ทำให้รองรับการรออนุมัตินาน ๆ ข้ามโปรเซส หรือแม้แต่หลังรีสตาร์ตเครื่องไปแล้ว เมื่อคนตัดสินใจ ก็สั่งทีละ interruption
state.approve(interruption) # หรือ state.reject(interruption)แล้วเดินต่อด้วย Runner.run(agent, state) ถ้ายังมี tool เสี่ยงตัวอื่นรออนุมัติอยู่ มันก็จะวนกลับเข้าด่านขออนุมัติอีกรอบเอง
ปฏิเสธแล้วไม่ได้จบแค่ห้าม แต่ต้องบอกให้แก้
จุดที่หลายคนมองข้ามคือ "ปฏิเสธ" ไม่ได้แปลว่างานต้องตายตรงนั้น ปฏิเสธให้ดีต้องบอก agent ว่าทำไมถึงไม่ผ่าน แล้วให้มันลองใหม่ ใน OpenAI Agents SDK เมื่อ reject ระบบจะส่งข้อความมาตรฐานกลับไปให้โมเดลรับรู้ว่าถูกปฏิเสธ และเราเขียนข้อความนั้นเองได้ จะตั้งระดับการรันทั้งรอบผ่าน RunConfig.tool_error_formatter หรือกำหนดเฉพาะ interruption นั้นผ่าน state.reject(interruption, rejection_message=...) ก็ได้ โดยแบบเจาะจงรายตัวจะมีน้ำหนักเหนือกว่า
ความต่างระหว่าง "ปฏิเสธเฉยๆ" กับ "ปฏิเสธพร้อมบอกเหตุผล" คือเรื่องใหญ่ ถ้าบอกแค่ว่าไม่อนุมัติ agent ก็ได้แต่หยุด แต่ถ้าบอกว่า "อย่าส่งหาทั้งลิสต์ ส่งเฉพาะลูกค้าที่ยืนยันแล้ว" agent จะเอา feedback นั้นไปแก้แผนแล้วเสนอ action ใหม่ที่ตรงขึ้น ด่านขออนุมัติจึงไม่ใช่แค่ปุ่มหยุด แต่เป็นช่องทางที่คนใช้ปรับทิศทาง agent ได้จริงๆ
เรื่องที่ต้องระวังเมื่อปล่อย agent รันยาว
ความสามารถในการเซฟสถานะลงไฟล์เปิดประตูให้ทำงานยาวข้ามวันได้ก็จริง แต่ก็มากับข้อควรระวังที่ต้องวางไว้ตั้งแต่ต้น
ข้อแรกคือเรื่องความลับ สถานะหลัง serialize จะเก็บ context ทั้งก้อนติดไปด้วย เอกสารของ OpenAI Agents SDK เตือนไว้ตรง ๆ ว่าให้คิดเสียว่า context ที่เซฟลงไปคือข้อมูลที่จะถูกเก็บถาวร อย่าเอา secret ใส่ลงไป เว้นแต่ตั้งใจให้มันติดไปกับสถานะจริง ๆ
ข้อสองคือเรื่องเวอร์ชัน ถ้างานรออนุมัติค้างไว้นาน ระหว่างนั้นโมเดล prompt หรือนิยามของ tool อาจถูกแก้ไป พอโหลดสถานะเก่ากลับมาเดินต่อก็อาจไม่เข้ากัน วิธีกันพลาดคือเก็บหมายเลขเวอร์ชันของนิยาม agent และ SDK ไว้คู่กับสถานะที่เซฟ จะได้รู้ทันทีว่าไฟล์เซฟนี้สร้างจากของเวอร์ชันไหน
ข้อสามเป็นกับดักเฉพาะของ OpenAI Agents SDK เมื่อเอา agent ตัวหนึ่งไปใช้เป็น tool ของอีกตัว (Agent.as_tool()) แล้วเกิด interruption ซ้อนอยู่ข้างใน ตอนกลับมาเดินต่อให้ resume ที่ agent ตัวนอกสุดเสมอ ไม่ใช่ตัวที่ซ้อนอยู่ข้างใน เพราะระบบจะส่ง interruption ที่ซ้อนกันขึ้นมาอยู่ในสถานะของตัวนอกอยู่แล้ว
ก้าวแรกที่ลงมือได้วันนี้
ถ้าจะเริ่มวันนี้ ไม่ต้องรื้อ agent ทั้งตัว เริ่มจากสามก้าวนี้ก่อน
- ไล่หา action ที่ย้อนไม่ได้ในระบบของตัวเอง เขียนออกมาเป็นรายการ เช่น ลบไฟล์ เขียนทับฐานข้อมูล ส่งอีเมลออกนอกองค์กร โอนเงิน ส่วน action ที่แค่อ่านหรือค้นหา ข้ามไปได้
- เลือก action ที่เสี่ยงที่สุดหนึ่งรายการมาใส่ด่านก่อน ถ้าใช้ OpenAI Agents SDK ก็ติดป้ายที่ tool ตัวนั้นด้วย
@function_tool(needs_approval=True)ถ้าใช้ LangGraph ก็ระบุ node นั้นในinterrupt_before=["ชื่อ_node"]ตอน compile - ลองรันแล้วดูว่ามันหยุดจริงไหม ตรวจว่าตอนถึง action นั้น agent หยุดรอ และข้อมูล interruption บอกครบว่ามันจะทำอะไร ด้วยพารามิเตอร์อะไร พอมั่นใจแล้วค่อยขยายไปคลุม action เสี่ยงตัวอื่น
นอกจากสองเฟรมเวิร์กหลักนี้ ยังมีเครื่องมือสำเร็จรูปอย่าง LangChain ที่มีโหมด human-in-the-loop ในตัว และ HumanLayer ที่ห่อ action ด้วย decorator @require_approval ให้ใช้ได้สั้นๆ (ปัจจุบันเว็บหลักของ HumanLayer หันไปชูผลิตภัณฑ์ชื่อ CodeLayer แทนแล้ว แต่แนวคิด approval gate ยังเหมือนเดิม) เลือกตัวไหนก็ได้ที่เข้ากับ stack ที่ใช้อยู่
เพราะสุดท้ายแล้ว ยิ่งเราให้ agent ลงมือได้มากเท่าไร เส้นแบ่งระหว่าง "ทำเองได้" กับ "ต้องถามก่อน" ยิ่งกลายเป็นสิ่งที่ต้องออกแบบ ไม่ใช่สิ่งที่ปล่อยให้โมเดลเดาเอง
ที่มา:
- บทความ Building a 'Human-in-the-Loop' Approval Gate for Autonomous Agents จาก MachineLearningMastery
- เอกสาร Human-in-the-loop จาก OpenAI Agents SDK



