Skip to main content
BlogBackend

Durable Execution untuk AI Agent yang Berjalan Berjam-jam

Job queue mengulang agent dari langkah pertama dan membayar ulang setiap model call. Durable execution justru memutar ulang journal dan melewati step yang selesai.

Matthews Wong

Software Engineer

September 202612 menit baca
Durable Execution untuk AI Agent yang Berjalan Berjam-jam

Ringkasan Utama

Durable execution mencatat setiap step yang selesai dari sebuah AI agent berdurasi panjang ke dalam journal, sehingga crash dilanjutkan dari step yang gagal, bukan dari awal. Job queue mengulang seluruh job dan membayar ulang setiap LLM call yang sudah terjadi. Temporal, Restate, DBOS dan Inngest menjual jaminan yang sama itu.

Escalation scheduler di hierarchical-approval ada karena sebuah purchase order bisa tertahan di level dua selama tiga hari saat finance director sedang cuti. Saya sengaja membuat engine-nya menerima Clock yang bisa di-inject plus ManualClock untuk testing, supaya proses berskala hari bisa diverifikasi dalam hitungan milidetik. Bentuk yang sama muncul begitu sebuah agent berjalan cukup lama sampai gagal di tengah jalan. Agent yang mati di tool call kesembilan lalu dijalankan ulang dari yang pertama oleh job queue biasa bukan cuma kehilangan waktu: ia membayar ulang delapan model call yang sudah berhasil. Itulah arti sebuah retry ketika pekerjaannya ditagih per token.

Kegagalan kedua itu bukan bug pada queue. Itu ketidakcocokan granularitas: unit retry sebuah queue adalah job, dan job-nya adalah seluruh agent run. Durable execution menurunkan unit retry ke level step. Tulisan ini adalah hasil saya membandingkan empat runtime yang melakukannya, apa yang dituntut model replay dari kode Anda, dan kapan semua ini justru tidak sepadan dengan biaya operasionalnya.

Retry mengulang job; replay melewati yang sudah selesai

Kesimpulannya dulu: job queue dan durable runtime sama-sama selamat dari crash, tetapi keduanya melanjutkan di titik yang berbeda. Kontrak BullMQ adalah job yang gagal dijalankan lagi, seluruh handler, dari baris pertamanya. Kontrak Restate adalah handler dijalankan lagi tetapi setiap step yang sudah selesai mengembalikan hasil yang tercatat, bukan dieksekusi ulang. Dokumentasi Restate menyebutnya lugas: Restate memutar ulang journal, melewati step yang sudah selesai, dan melanjutkan tepat dari titik terakhir. Isi journal itulah kuncinya.

  • Output step. Teks yang dikembalikan model, row yang ditulis database, file yang dihasilkan tool, diserialisasi sebagai JSON lalu diputar ulang, bukan dihitung ulang.
  • Timer. Sebuah sleep adalah entry di journal, sehingga dokumentasi Restate memberi contoh handler yang tidur dua belas jam lalu gagal di jam kedelapan hanya akan tidur empat jam lagi, bukan dua belas.
  • Signal eksternal. Sebuah approval yang datang saat proses Anda sedang mati tetap disimpan secara durable sampai invocation siap membacanya, bukan hilang begitu saja.

Karena itu perbedaannya soal uang, bukan soal selera. Untuk job CRUD, mengulang dari awal biayanya beberapa ratus milidetik. Untuk agent yang sudah melakukan delapan model call, mengunduh tiga PDF dan menulis satu row, mengulang dari awal berarti membayar delapan call itu lagi, ditambah row duplikat untuk setiap step yang tidak idempotent.

Tiga panel membandingkan retry job queue, tabel steps buatan sendiri, dan durable runtime setelah sebuah agent mati di tool call kesembilan
Baca baris terakhir tiap panel. Ketiganya hanya berbeda pada apa yang dianggap sebagai unit retry, dan pilihan itulah yang menentukan apakah sebuah crash mengirimi Anda tagihan kedua.

Agent loop yang sama, ditagih dua kali dan ditagih sekali

Beginilah bentuk kesalahannya. Versi naif adalah satu queue job yang membungkus seluruh loop, dan itu versi yang hampir selalu ditulis orang pertama kali, karena versi itu bekerja mulus di happy path dan terbaca wajar saat code review.

// Wrong: one BullMQ job for the entire agent run.
// A throw on the ninth tool call re-queues the job, and attempt 2 restarts
// at line 1 — the eight model calls before the crash are billed again.
worker.process("tender-review", async (job) => {
  const plan = await llm.plan(job.data.brief);      // paid
  const notes = [];
  for (const url of plan.sources) {
    notes.push(await summarisePage(url));           // paid, once per source
  }
  return llm.compose(notes);                        // paid
});

// Right: every paid call is its own journalled step.
// Attempt 2 still re-enters the handler from the top, but each completed
// ctx.run returns its recorded result instead of calling the model again.
export const tenderReview = restate.workflow({
  name: "TenderReview",
  handlers: {
    run: async (ctx: restate.WorkflowContext, brief: string) => {
      const plan = await ctx.run("plan", () => llm.plan(brief));

      const notes = [];
      for (const url of plan.sources) {
        // One journal entry per source, so a resume lands on the source
        // that actually failed rather than on the first one.
        notes.push(await ctx.run("page:" + url, () => summarisePage(url)));
      }

      return ctx.run("compose", () => llm.compose(notes));
    },
  },
});

Versi durable bukan kode yang lebih panjang, melainkan kode dengan batas yang berbeda. Setiap paid call duduk di dalam ctx.run miliknya sendiri. Pada percobaan kedua handler tetap dijalankan dari atas, bagian itu tidak berubah, tetapi step plan dan tujuh ringkasan halaman dikembalikan dari journal tanpa menyentuh model, dan eksekusi benar-benar berlanjut di halaman kedelapan. Inngest menjelaskan mekanisme yang sama dengan kata berbeda: function dieksekusi ulang bersama state dari eksekusi sebelumnya, dan untuk step yang sudah berhasil SDK menyuntikkan hasilnya ke return value alih-alih menjalankan kodenya.

Determinisme adalah harga dari replay, dan LLM tidak bisa membayarnya

Replay hanya bekerja jika kode di sekitar step mengambil keputusan yang sama dalam urutan yang sama setiap kali. Temporal menyatakan batasannya secara langsung: setiap kali kode workflow Anda dieksekusi, ia harus melakukan API call yang sama dalam urutan yang sama untuk input yang sama, dan operasi non-deterministik seperti API call, LLM invocation dan database query harus ditempatkan di Activity. Orang sering menolak aturan ini karena terlihat sewenang-wenang, sampai asimetrinya masuk akal: jalur orchestration dieksekusi berkali-kali, sedangkan step-nya dieksekusi sekali.

// Wrong: the orchestration path branches on values that change on replay.
if (Date.now() - startedAt > 3_600_000) return giveUp();  // replay disagrees
const shard = Math.floor(Math.random() * 4);              // replay disagrees
const fx = await fetch(RATES_URL).then((r) => r.json());  // replay re-calls

// Right: replay-stable equivalents, or push the call into a step.
const now = await ctx.date.now();            // millis, consistent across retries
const shard = Math.floor(ctx.rand.random() * 4);   // seeded by the invocation id
const fx = await ctx.run("fx-rate", () =>
  fetch(RATES_URL).then((r) => r.json()),
);

await ctx.sleep({ seconds: 3600 });  // a durable timer, not setTimeout:
                                     // the process may not exist in an hour

// An LLM call can return a different answer to the same prompt, so it can
// never sit in the deterministic path. It is a step by construction.
const answer = await ctx.run("classify", () => llm.classify(doc));

Perhatikan apa artinya itu bagi sebuah model call. LLM bisa mengembalikan jawaban berbeda untuk prompt yang sama, jadi ia tidak pernah boleh berada di jalur deterministik; secara konstruksi ia adalah step. Ini justru menguntungkan, bukan membatasi. Hal yang paling ingin Anda hindari untuk dijalankan ulang adalah persis hal yang dipaksa runtime untuk diisolasi, sehingga agent durable yang ditulis benar mendapat penghematan biaya sebagai efek samping dari mematuhi aturan determinisme.

Tiga panel memilah kode agent menjadi yang merusak replay, yang wajib jadi durable step, dan yang aman tetap berada di kode workflow
Panel tengah yang harus benar lebih dulu. Apa pun yang menagih Anda wajib berada di dalam durable step, karena hanya dengan begitu sebuah replay bisa melewatinya alih-alih membayarnya dua kali.

Pakai random bawaan runtime untuk idempotency key, bukan fungsi UUID milik platform. ctx.rand.uuidv4 di Restate di-seed oleh invocation ID, sehingga retry mengirimkan key yang sama seperti yang dilihat vendor pertama kali. Kalau key itu dibuat dengan crypto.randomUUID, setiap replay akan terlihat sebagai request baru bagi payment atau model API yang justru ingin Anda deduplikasi.

Empat runtime, dan di mana masing-masing menyimpan state Anda

Perbedaan menarik antar produk ini bukan pada API-nya. Semuanya bertemu pada ide yang sama, yaitu membungkus side effect. Perbedaannya ada pada apa yang harus Anda jalankan dan di mana journal-nya tinggal, dan itulah yang menentukan apakah adopsi memakan satu sore atau satu kuartal.

RuntimeDi mana execution state disimpanApa yang harus Anda deployBagaimana sebuah run dilanjutkanSDK
TemporalEvent History, disimpan oleh Temporal ServiceSatu Temporal Service plus proses Worker milik Anda sendiriWorker memutar ulang kode dan mencocokkan tiap Command dengan Event yang sudah ada di historyGo, Java, TypeScript, Python, .NET, PHP, Ruby
RestateJournal plus key-value store yang tertanam di dalam Restate ServerRestate Server; handler Anda tetap berupa HTTP service biasaRestate memanggil ulang handler dan memutar journal, melewati step yang sudah selesaiTypeScript, Java, Kotlin, Go, Python, Rust
DBOSCheckpoint di dalam system database Postgres yang sudah Anda milikiTidak ada selain Postgres; library-nya berjalan di dalam proses AndaAplikasi menemukan workflow yang tertinggal pending lalu menjalankannya ulang dengan output step yang sudah di-checkpointPython, TypeScript, Go, Java
InngestState step yang dimemoisasi di function state store terkelola milik InngestSatu HTTP endpoint yang disajikan aplikasi Anda; Inngest yang mengorkestrasi dari sisinyaFunction Anda dipanggil ulang bersama state sebelumnya dan SDK menyuntikkan hasil step yang dimemoisasiTypeScript, Python, Go

DBOS adalah pengecualian yang layak diperhatikan kalau Anda sudah menjalankan Postgres. Halaman architecture-nya menyatakan tegas bahwa tidak ada orchestration server terpisah dan tidak ada infrastruktur lain yang dibutuhkan selain Postgres, karena library-nya menulis checkpoint workflow dan step ke system database di cluster Anda sendiri. Temporal adalah pertukaran sebaliknya: ada service yang harus dioperasikan, ditukar dengan cerita paling matang soal visibility, versioning dan worker multi-bahasa. Restate berada di tengah, dengan server sebagai satu-satunya proses baru dan handler yang tetap HTTP service biasa.

Di sinilah pekerjaan ERP dan pekerjaan agent ternyata satu masalah yang sama. Di hierarchical-approval sebuah instance memang wajar tertahan pending sampai ada orang yang bertindak, slaDeadlineDays menentukan kapan escalation scheduler harus peduli, dan engine-nya menerima Clock agar hari-hari itu bisa disimulasikan. Sebuah test memajukan ManualClock tiga hari, men-tick scheduler, dan event SLA breach menyala tanpa satu detik pun waktu nyata berlalu. Saya membangun itu karena tidak ada cara lain menguji deadline tiga hari di dalam CI.

// hierarchical-approval: the engine takes a Clock, so "three days" is testable.
import { ApprovalTestKit } from "hierarchical-approval/testing";

const { engine, clock } = ApprovalTestKit.create();  // MemoryAdapter + ManualClock

await engine.defineTemplate({
  name: "purchase-order",
  documentType: "purchase_order",
  levels: [{ level: 1, name: "Finance", approvers: [finance], mode: "any" }],
  slaDeadlineDays: 2,
});

clock.advanceDays(3);               // no real timers, no three-day test run
await engine["escalation"].tick();  // 'approval:sla_breached' fires here

// A durable runtime hands you the same two primitives, first-class:
const { id, promise } = ctx.awakeable();          // one-shot token, like an SLA row
await ctx.run(() => emailApprover(instanceId, id));
const decision = await promise;   // suspends the invocation; survives a restart

// Or a named signal, which can be resolved more than once — the same channel
// works for "approved" and for steering an agent mid-run.
const steer = await ctx.signal("steer");

Durable runtime memberi Anda dua primitif itu sebagai konstruksi tingkat bahasa, bukan sesuatu yang harus Anda bangun sendiri. Durable sleep adalah entry journal yang dimiliki server, jadi ia hidup lebih lama daripada proses yang memulainya. Awakeable menyerahkan token sekali pakai kepada sistem eksternal untuk diselesaikan, dan tabel di dokumentasi Restate menyebut agent steering dan human approval sebagai kegunaan signal. Agent yang berhenti menunggu persetujuan dan approval yang berhenti menunggu direktur adalah bentuk yang sama; saya sudah menulis versi sempit dan spesifik dari sesuatu yang ternyata ada bentuk umumnya.

hierarchical-approval adalah npm package saya sendiri, sebuah hierarchical approval engine TypeScript-first untuk ERP developer, berlisensi MIT dan saat ini di versi 4.0.0. Package ini tidak dibangun di atas durable execution runtime: ia menyimpan state lewat storage adapter-nya sendiri dan menjalankan escalation scheduler-nya sendiri. Itu perbandingan yang jujur, kebutuhan yang sama diselesaikan secara sempit untuk satu domain, bukan secara umum. hierarchical-approval

Biaya yang diam-diam ditambahkan journal

Tiga hal ini mengejutkan saya, dan ketiganya didokumentasikan sendiri oleh vendornya, bukan ditemukan dalam incident review siapa pun. Tidak satu pun jadi alasan menghindari durable execution, tetapi masing-masing adalah alasan membaca dokumentasi runtime sebelum agent masuk production, bukan sesudahnya.

  • Step yang panjang butuh timeout dinaikkan. Restate men-suspend sebuah invocation lalu me-retry-nya kalau tidak menerima journal entry baru selama lebih dari satu menit secara default, dan dokumentasinya menyebut LLM call yang memakan waktu sampai tiga menit sebagai kasus di mana inactivity timeout dan abort timeout service harus disesuaikan.
  • Semua yang dikembalikan sebuah step diserialisasi ke dalam journal. Mengembalikan satu halaman hasil scraping utuh dari tool step berarti menulis halaman itu ke dalam log; mengembalikan tiga field hasil ekstraksi tidak. Sekarang saya memperlakukan return type sebuah step sebagai keputusan storage, karena memang begitu.
  • Deploy berubah jadi urusan versioning. Temporal menyebut non-deterministic code change, yaitu merilis kode workflow baru saat execution yang dimulai dengan kode lama masih berjalan, sebagai salah satu dari dua sumber utama non-determinisme, dan Restate mengingatkan bahwa sleep yang panjang berarti versi deployment lama harus tetap hidup sampai invocation-nya selesai.

Journal adalah artefak nyata yang punya ukuran, skema dan permukaan kompatibilitas, dan kini ia duduk di antara pipeline deploy Anda dan agent yang sedang berjalan. Perlakukan seperti migrasi database, bukan seperti file log.

Kegagalan yang berbahaya bukan crash, melainkan resume yang salah. Rilis perubahan pada jalur orchestration saat masih ada run yang berjalan, dan sebuah replay bisa sampai di titik keputusan yang tidak ada lagi di kode, sehingga run itu entah menabrak pemeriksaan non-determinisme atau diam-diam mengambil cabang yang tidak diinginkan siapa pun. Lakukan versioning workflow secara sadar dan biarkan versi lama tetap melayani sampai run-nya habis; kode orchestration bukan kode aplikasi yang bisa Anda hotfix.

Kapan Anda justru tidak membutuhkan semua ini

Bagian yang tidak populer, saya sampaikan terus terang: sebagian besar agent call tidak perlu mendekati workflow engine sama sekali. Satu tool call yang selesai dalam lima detik tidak punya apa pun untuk dilanjutkan. Kalau gagal, jalankan lagi, dan yang hilang cuma lima detik plus satu model call. Durable execution baru sepadan kalau sebuah run cukup panjang untuk terinterupsi, cukup mahal sehingga pengulangannya terasa, atau berhenti menunggu sesuatu di luar proses Anda. Ini tangga yang sekarang saya naiki, dan saya berhenti di anak tangga pertama yang sudah cukup.

  1. Satu idempotency key dan tabel steps di Postgres. Catat setiap step yang selesai beserta output-nya, lalu lewati yang sudah tercatat. Ini inti gagasan durable execution tanpa satu pun infrastrukturnya, dan sudah cukup untuk pipeline tiga langkah.
  2. Library in-process. DBOS berjalan di dalam aplikasi Anda dan tidak butuh apa pun selain Postgres yang sudah ada, sehingga ia jadi kenaikan paling kecil yang nyata dari tabel steps buatan sendiri.
  3. Orchestrator terkelola. Inngest menyimpan state di sisinya dan hanya meminta aplikasi Anda menyediakan satu HTTP endpoint, jadi tidak ada komponen baru di estate Anda yang harus di-patch, di-backup dan dimonitor.
  4. Platform workflow penuh. Temporal, ketika Anda punya banyak workflow berumur panjang, lebih dari satu bahasa, dan seseorang yang deskripsi kerjanya memang mencakup mengoperasikannya.

Untuk tim berdua, anak tangga keempat adalah tagihan nyata yang dibayar dengan perhatian: satu service untuk dijalankan, satu disiplin versioning untuk dijaga, dan satu kelas kegagalan baru untuk dipelajari cara membacanya. Saya tidak akan membayarnya demi satu agent. Saya akan membayarnya demi satu armada, dan sinyal jujur bahwa Anda punya armada adalah ketika dua tim berbeda sudah sama-sama menulis tabel steps mereka sendiri.

Aturan yang saya bawa dari sini soal granularitas, bukan soal produk. Tanyakan apa unit retry Anda. Kalau jawabannya seluruh job, maka setiap kegagalan menagih Anda lagi untuk pekerjaan yang sudah berhasil, dan pada agent pekerjaan itu dihitung per token. Jadikan step sebagai unitnya, lewat journal, tabel steps, atau apa pun yang sepadan dengan ukuran masalahnya, dan crash berhenti menjadi tagihan.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu durable execution, dan apa bedanya dengan retry di job queue?

Durable execution menulis setiap step yang selesai ke dalam sebuah journal, sehingga restart cukup memutar ulang journal itu dan melewati pekerjaan yang sudah beres. Unit retry sebuah job queue adalah seluruh job, jadi kegagalan di step kesembilan membuat step satu sampai delapan dijalankan ulang dari nol. Perbedaannya ada pada granularitas pemulihan, bukan pada keandalan penyimpanannya.

Kenapa mengulang AI agent run yang gagal benar-benar memakan biaya?

Setiap LLM call dan tool call berbayar di dalam sebuah agent run ditagih saat terjadi, bukan saat run-nya selesai. Kalau run crash di step kesembilan lalu retry dimulai dari step pertama, delapan call yang sudah berhasil itu Anda bayar dua kali. Durable execution mencegahnya dengan mengembalikan output yang tercatat dari setiap step yang selesai, tanpa memanggil model lagi.

Apakah saya harus memakai Temporal untuk membangun AI agent berdurasi panjang?

Tidak. Satu agent call yang selesai dalam beberapa detik tidak punya apa pun yang layak dilanjutkan, dan satu idempotency key plus tabel steps di Postgres sudah cukup untuk pipeline pendek. Pertimbangkan workflow platform ketika run cukup panjang untuk terinterupsi, cukup mahal sehingga pengulangannya terasa, atau berhenti menunggu sesuatu di luar proses Anda. Temporal adalah service yang harus dioperasikan, dan itu biaya nyata bagi tim kecil.

Kenapa LLM call harus berada di activity atau step, bukan di kode workflow?

Kode workflow wajib deterministik karena runtime mengeksekusinya berulang kali saat replay, dan dokumentasi Temporal mensyaratkan kode itu melakukan API call yang sama dalam urutan yang sama untuk input yang sama. Model bisa mengembalikan jawaban berbeda untuk prompt yang sama, jadi ia tidak akan pernah memenuhi aturan itu. Menaruh call tersebut di activity atau di step ctx.run membuat hasilnya dicatat sekali lalu diputar ulang setelahnya.

Bagaimana workflow bisa menunggu approval manusia berhari-hari tanpa menahan proses tetap hidup?

Durable runtime menyimpan penantian itu sendiri secara persisten. Durable sleep di Restate adalah entry journal milik server, sehingga handler yang sudah tidur delapan dari dua belas jam lalu crash hanya tidur empat jam sisanya. Untuk keputusan manusia Anda memakai awakeable atau named signal: runtime men-suspend invocation, dan sistem eksternal menyelesaikan token itu kapan pun orangnya benar-benar bertindak.