Các vòng lặp gọi công cụ (tool-calling loops) của Claude vốn nổi tiếng với việc tạo ra các đoạn mã promise rối rắm trong Node.js.Promise.withResolvers() mới của Node.js 22 cho phép các nhà phát triển thay thế mẫu new Promise vốn nặng nề về mã boilerplate bằng một dòng duy nhất để cung cấp promise cùng với các hàm resolve/reject của nó. Kết quả là giảm thiểu các trường hợp quên gọi resolve, không còn cảnh báo lỗi gọi reject hai lần, và có một luồng điều khiển phẳng hơn, giúp dễ dàng kiểm thử và duy trì hoạt động trong các môi trường serverless.
Tại sao mẫu cũ làm gián đoạn luồng xử lý
Khi một LLM như Claude yêu cầu một công cụ, cách triển khai Node.js điển hình sẽ trông như thế này:
return new Promise((resolve, reject) => {
// launch the tool, attach callbacks, maybe fire another async call
});
Ba cạm bẫy thường gặp phát sinh:
- Quên gọi resolve – Nếu luồng mã không bao giờ gọi
resolve, một Lambda hoặc trình xử lý serverless khác sẽ bị treo cho đến khi hết thời gian chờ (timeout), làm tăng chi phí. - Gọi reject hai lần – Một luồng xử lý lỗi gọi
rejecthai lần sẽ kích hoạt các cảnh báo "unhandled rejection", có thể làm sập tiến trình trong chế độ strict mode. - Lồng nhau quá sâu – Mỗi bước async lại lồng thêm một callback khác bên trong constructor, làm phân tán logic và khiến các bài kiểm thử đơn vị (unit tests) trở nên kém ổn định.
Tất cả những vấn đề này đều bắt nguồn từ việc các hàm điều khiển của promise bị khóa bên trong closure của constructor, buộc phần còn lại của mã phải truy cập ngược trở lại vào đó.
Promise.withResolvers() chỉ trong một dòng
Node 22 bổ sung một hàm hỗ trợ tĩnh (static helper) trả về một đối tượng chứa một promise và hai hàm để hoàn tất (settle) nó:
const { promise, resolve, reject } = Promise.withResolvers();
Giờ đây, promise có thể được chuyển giao cho bất kỳ phần nào của hệ thống—một trình xử lý HTTP, một trình lắng nghe cơ sở dữ liệu, hoặc một worker chạy ngầm—trong khi bên gọi ban đầu chỉ đơn giản là await promise đó. Không cần phải bao bọc toàn bộ khối thực thi công cụ trong một constructor new Promise nữa.
Áp dụng vào vòng lặp công cụ của Claude
Quy trình làm việc của Claude là:
- LLM phát ra một yêu cầu gọi công cụ.
- Mã của bạn thực thi công cụ (ví dụ: gọi API, đọc tệp).
- Kết quả của công cụ được gửi lại cho Claude để thực hiện lượt tiếp theo.
Với withResolvers, vòng lặp được rút gọn thành:
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
}
}
Việc triển khai công cụ không còn cần phải được bao bọc trong một promise mới; nó chỉ việc nhận resolve và reject. Điều này loại bỏ ba chế độ lỗi đã liệt kê ở trên.
Các yếu tố cần tinh chỉnh khi triển khai thực tế
Ngay cả với cấu trúc promise gọn gàng hơn, các agent trong thế giới thực vẫn gặp phải các hạn chế khác:
- Timeouts – Đoạn mã trên hiển thị một bộ hẹn giờ đơn giản sẽ gọi
rejectnếu công cụ vượt quá một ngưỡng nhất định. Hãy điều chỉnh thời gian dựa trên kỳ vọng về SLA. - Throttling – Khi dịch vụ bên dưới trả về lỗi giới hạn lưu lượng (ví dụ:
ThrottlingExceptioncủa Bedrock), hãy bắt lỗi đó, tạm dừng và thử lại với cơ chế exponential back-off. Cặpresolve/rejectvẫn giữ nguyên; chỉ có logic thử lại là thay đổi. - Chi phí Lambda – Trong AWS Lambda, hãy đặt
callbackWaitsForEmptyEventLoop = false. Điều này báo cho runtime kết thúc hàm ngay khi trình xử lý (handler) trả về, ngay cả khi các stream hoặc các handle chạy ngầm khác vẫn đang mở. Nó ngăn hàm bị treo trong khi promise đang được xử lý ở nơi khác.
Khi hàm hỗ trợ mới không phải là giải pháp vạn năng
Promise.withResolvers() chỉ khả dụng trong Node 22 trở về sau. Các dự án đang sử dụng các bản phát hành LTS cũ hơn phải sử dụng polyfill cho mẫu này hoặc tiếp tục dùng constructor truyền thống. Polyfill có thể mô phỏng API nhưng sẽ không đạt được lợi ích về hiệu suất gốc (native performance). Hơn nữa, hàm hỗ trợ này không thể giải quyết một cách thần kỳ các lỗi logic: các nhà phát triển vẫn cần đảm bảo rằng chính xác một trong hai hàm resolve hoặc reject được gọi cho mỗi yêu cầu, nếu không promise sẽ ở trạng thái pending vô thời hạn.
Những điều cần theo dõi tiếp theo
- Sự đón nhận của các framework – Các thư viện trừu hóa vòng lặp agent LLM (ví dụ: các bản wrapper Claude mã nguồn mở) đang bắt đầu cung cấp
withResolversnhư một tính năng tùy chọn. Hãy chú ý đến các bản cập nhật biến mẫu này thành mặc định. - Hệ sinh thái Node – Khi có nhiều dịch vụ chuyển sang Node 22 hơn, hàm hỗ trợ này sẽ trở thành tiêu chuẩn thực tế (de-facto standard) cho bất kỳ mẫu async "fire-and-wait" nào, không chỉ riêng cho các LLM agent.
- Tiêu chuẩn gọi công cụ – Các đặc tả mới nổi cho việc gọi công cụ của LLM có thể quy định một hợp đồng "single-promise", điều này hoàn toàn phù hợp với cách tiếp cận của
withResolvers.
Bài học rút ra: Bằng cách thay thế trình bao bọc new Promise rườm rà bằng một dòng Promise.withResolvers(), các agent dựa trên Claude sẽ có luồng xử lý rõ ràng hơn, ít gặp bất ngờ khi runtime và kiểm soát chặt chẽ hơn chi phí serverless—miễn là môi trường runtime hỗ trợ Node 22.
