ลูปการเรียกใช้เครื่องมือ (tool-calling loops) ของ Claude มักมีชื่อเสียงในด้านการสร้างโค้ด promise ที่พันกันยุ่งเหยิงใน Node.js
Promise.withResolvers() ตัวใหม่ใน Node.js 22 ช่วยให้นักพัฒนาสามารถแทนที่รูปแบบ new Promise ที่เต็มไปด้วยโค้ดส่วนเกิน (boilerplate) ด้วยโค้ดเพียงบรรทัดเดียวที่ส่งทั้ง promise และฟังก์ชัน resolve/reject ออกมาพร้อมกัน ผลลัพธ์ที่ได้คือการลืมเรียก resolve น้อยลง, ไม่มีการแจ้งเตือนเรื่องการ reject ซ้ำซ้อน (double-reject), และมีลำดับการควบคุม (control flow) ที่ราบเรียบขึ้น ซึ่งง่ายต่อการทดสอบและรักษาการทำงานให้ต่อเนื่องในสภาพแวดล้อมแบบ serverless

ทำไมรูปแบบเดิมถึงทำให้การทำงานติดขัด

เมื่อ LLM อย่าง Claude ขอให้เรียกใช้เครื่องมือ การ実装 (implementation) ใน Node แบบทั่วไปจะมีลักษณะดังนี้:

return new Promise((resolve, reject) => {
  // launch the tool, attach callbacks, maybe fire another async call
});

ปัญหาที่มักเกิดขึ้นซ้ำๆ มี 3 ประการ:

  • ลืมเรียก resolve – หากเส้นทางการทำงานของโค้ดไม่เคยเรียก resolve เลย ตัว Lambda หรือ serverless handler อื่นๆ จะค้างอยู่จนกว่าจะหมดเวลา (timeout) ซึ่งเป็นการเพิ่มค่าใช้จ่ายโดยไม่จำเป็น
  • การ reject ซ้ำซ้อน – เส้นทางการจัดการข้อผิดพลาดที่เรียก reject สองครั้ง จะทำให้เกิดคำเตือน “unhandled rejection” ซึ่งอาจทำให้โปรเซสหยุดทำงาน (crash) ได้เมื่ออยู่ใน strict mode
  • การซ้อนกันหลายชั้น (Deep nesting) – แต่ละขั้นตอนที่เป็น async จะต้องซ้อน callback อีกตัวไว้ภายใน constructor ทำให้ตรรกะกระจัดกระจายและทำให้การทำ unit test ทำได้ยาก

ปัญหาทั้งหมดนี้เกิดจากการที่ฟังก์ชันควบคุมของ promise ถูกล็อกไว้ภายใน closure ของ constructor ทำให้โค้ดส่วนที่เหลือต้องพยายามเรียกกลับเข้าไปข้างใน

Promise.withResolvers() ในบรรทัดเดียว

Node 22 เพิ่ม static helper ที่คืนค่าเป็น object ซึ่งประกอบด้วย promise และฟังก์ชันสองตัวที่ใช้จัดการสถานะของมัน (settle it):

const { promise, resolve, reject } = Promise.withResolvers();

ตอนนี้ promise สามารถถูกส่งต่อไปยังส่วนใดก็ได้ของระบบ ไม่ว่าจะเป็น HTTP handler, database listener หรือ background worker ในขณะที่ผู้เรียกเดิมเพียงแค่ await promise นั้นไว้ โดยไม่จำเป็นต้องครอบบล็อกการทำงานของเครื่องมือทั้งหมดไว้ใน constructor ของ new Promise อีกต่อไป

การนำไปใช้กับลูปเครื่องมือของ Claude

เวิร์กโฟลว์ของ Claude คือ:

  1. LLM ส่งคำขอใช้เครื่องมือ (tool request) ออกมา
  2. โค้ดของคุณรันเครื่องมือนั้น (เช่น การเรียก API, การอ่านไฟล์)
  3. ผลลัพธ์ของเครื่องมือจะถูกส่งกลับไปยัง Claude เพื่อดำเนินการในรอบถัดไป

เมื่อใช้ withResolvers ลูปจะลดรูปเหลือเพียง:

async function runTool(request) {
  const { promise, resolve, reject } = Promise.withResolvers();

  // Kick off the tool; it can call resolve/reject from anywhere
  executeTool(request, { resolve, reject });

  // Optional timeout wrapper
  const timeout = setTimeout(() => reject(new Error('Tool timed out')), 10_000);
  try {
    const result = await promise;
    clearTimeout(timeout);
    return result;               // feed back to Claude
  } finally {
    // clean-up if needed
  }
}

การ実装เครื่องมือไม่จำเป็นต้องถูกครอบด้วย new promise อีกต่อไป เพียงแค่รับ resolve และ reject มาใช้งาน ซึ่งจะช่วยกำจัดรูปแบบความล้มเหลวทั้งสามประการที่กล่าวไปข้างต้น

ปัจจัยสำคัญในการใช้งานจริง (Production)

แม้ว่าโครงสร้างของ promise จะสะอาดขึ้น แต่เอเจนต์ (agents) ในโลกความเป็นจริงยังต้องเผชิญกับข้อจำกัดอื่นๆ:

  • Timeouts – โค้ดตัวอย่างข้างต้นแสดงตัวจับเวลาแบบง่ายที่จะ reject หากเครื่องมือทำงานเกินกำหนด ควรปรับระยะเวลาตามความคาดหวังของ SLA
  • Throttling – เมื่อบริการต้นทางส่งข้อผิดพลาดเรื่องการจำกัดปริมาณการใช้งานกลับมา (เช่น ThrottlingException ของ Bedrock) ให้ดักจับ (catch), หยุดรอ, และลองใหม่ด้วยวิธี exponential back-off โดยที่คู่ resolve/reject ยังคงเหมือนเดิม เปลี่ยนเพียงแค่ตรรกะการลองใหม่ (retry logic) เท่านั้น
  • Lambda cost – ใน AWS Lambda ให้ตั้งค่า callbackWaitsForEmptyEventLoop = false เพื่อบอก runtime ให้ยุติการทำงานของฟังก์ชันทันทีที่ handler ส่งค่ากลับ แม้ว่าจะมี streams หรือ background handles อื่นๆ ที่ยังเปิดอยู่ก็ตาม วิธีนี้จะช่วยป้องกันไม่ให้ฟังก์ชันค้างอยู่ในขณะที่ promise กำลังจัดการสถานะที่อื่น

เมื่อตัวช่วยใหม่นี้ไม่ใช่คำตอบสำหรับทุกปัญหา

Promise.withResolvers() มีให้ใช้ใน Node 22 และเวอร์ชันที่ใหม่กว่าเท่านั้น โปรเจกต์ที่ยังใช้ LTS เวอร์ชันเก่าจะต้องใช้ polyfill หรือใช้ constructor แบบเดิม ซึ่ง polyfill สามารถเลียนแบบ API ได้แต่จะไม่ได้รับประโยชน์ด้านประสิทธิภาพแบบ native นอกจากนี้ ตัวช่วยนี้ไม่ได้แก้ปัญหาทางตรรกะ (logical bugs) ได้อย่างน่าอัศจรรย์: นักพัฒนายังคงต้องตรวจสอบให้แน่ใจว่ามีการเรียก resolve หรือ reject เพียงครั้งเดียวเท่านั้นสำหรับทุกคำขอ มิฉะนั้น promise จะค้างอยู่ในสถานะ pending ไปตลอดกาล

สิ่งที่ควรจับตามองต่อไป

  • การนำไปใช้ในเฟรมเวิร์ก – ไลบรารีที่ทำหน้าที่ abstraction สำหรับ LLM agent loops (เช่น Claude wrappers แบบ open-source) เริ่มมีการเปิดให้ใช้ withResolvers เป็นฟีเจอร์เสริมแล้ว ให้คอยติดตามการอัปเดตที่จะทำให้รูปแบบนี้กลายเป็นค่าเริ่มต้น (default)
  • ระบบนิเวศของ Node – เมื่อบริการต่างๆ ย้ายไปใช้ Node 22 มากขึ้น ตัวช่วยนี้จะกลายเป็นมาตรฐานหลัก (de-facto standard) สำหรับรูปแบบ async แบบ “fire-and-wait” ใดๆ ไม่ใช่แค่สำหรับ LLM agents เท่านั้น
  • มาตรฐานการเรียกใช้เครื่องมือ – ข้อกำหนดที่กำลังเกิดขึ้นสำหรับการเรียกใช้เครื่องมือของ LLM อาจกำหนดสัญญา (contract) แบบ “single-promise” ซึ่งสอดคล้องกับแนวทางของ withResolvers อย่างสมบูรณ์

สรุป: การเปลี่ยนจากการใช้ wrapper new Promise ที่เยิ่นเย้อ มาเป็น Promise.withResolvers() เพียงบรรทัดเดียว จะช่วยให้เอเจนต์ที่ใช้ Claude มีลำดับการทำงานที่ชัดเจนขึ้น, ลดความผิดพลาดขณะรันไทม์, และควบคุมค่าใช้จ่าย serverless ได้ดีขึ้น—ตราบใดที่ runtime ของคุณรองรับ Node 22