GNL
Core · Ücretsiz@gnldev/durable

AI SDK tool'un iki kez çalıştı. Bir kez çalışması için yapman gereken.

Bir retry, bir deploy ya da modelin kendisi yan etkili bir tool'u ikinci kez çalıştırabilir. Bu sayfa nedenini, elle çözmenin neyi kaçırdığını ve bunu durduran değişikliği gösteriyor.

Belirti#

Bir ajan müşteriye mail atıyor. Çoğu production ajanı gibi bir kuyruk işçisinin içinde çalışıyor. Mail gittikten sonra, iş onaylanmadan hemen önce süreç ölüyor: bir deploy, bellek yetmezliği, serverless zaman aşımı. Kuyruk işi yeniden teslim ediyor ve müşteri aynı maili iki kez alıyor.

düz bir AI SDK ajanı
import { generateText, tool, stepCountIs } from 'ai';
import { z } from 'zod';

const sendEmail = tool({
  description: 'Send the customer an email',
  inputSchema: z.object({ to: z.string(), subject: z.string() }),
  execute: async ({ to, subject }) => mailer.send({ to, subject }),
});

// A queue worker runs this. The process is killed after the email went out,
// before the job was acked.
await generateText({ model, tools: { sendEmail }, stopWhen: stepCountIs(10), prompt });
// The queue redelivers the job → generateText starts from zero →
// the model plans sendEmail again → the customer gets a second email.

Neden oluyor#

generateText daha önce ne yaptığını hiçbir yerde tutmaz. İşin tekrar denenmesi yepyeni bir çağrıdır: model prompt'u baştan okur, sendEmail'i yeniden planlar ve tool yeniden çalışır. Döngüde ilk mailin varlığından haberi olan hiçbir şey yoktur.

Çökme olmadan da olur. Model aynı işi yeni bir toolCallId ile iki kez isteyebilir; AI SDK'nın issue kayıtlarında aynı tool'un tek turda beş kez çağrıldığı raporlar var. Çağrı id'sine göre tekrar yakalayan hiçbir şey bu durumu göremez.

Elle çözmek#

Bilinen çözüm kendi veritabanında bir anahtar tutmak: göndermeden önce bak, gönderdikten sonra yaz.

elle yazılmış bir koruma
execute: async ({ to, subject }) => {
  const key = `${to}:${subject}`;
  if (await db.sent.has(key)) return { alreadySent: true };
  const res = await mailer.send({ to, subject });
  await db.sent.put(key, res.id); // a crash before this line and the retry sends again
  return res;
}

Sık görülen durumda işe yarar, ama üç şeyi kaçırır:

  • Aradaki boşluk. Süreç send'den sonra, put'tan önce ölürse anahtar hiç yazılmamıştır ve retry maili yeniden gönderir.
  • Modelin adımları. Tool korunur, konuşma korunmaz: retry modeli baştan yeniden çağırır, parasını yeniden ödersin ve model bu kez başka bir şey planlayabilir.
  • Her tool. Yan etkisi olan her tool bu kodun kendi kopyasını, kendi anahtar tasarımını ve kendi tablosunu ister.

GNL ile#

Tool'u sar, generateText'i çağırdığın yerde runDurable'ı çağır. Argümanlar aynı; ek olarak bu işin kimliği ve bir journal.

aynı ajan, dayanıklı
import { runDurable, gnlTool } from '@gnldev/durable';
import { SqliteStorage } from '@gnldev/durable/sqlite';

const tools = { sendEmail: gnlTool(sendEmail, { sideEffect: true, idempotency: 'args' }) };

await runDurable({
  runId: `welcome-email:${customerId}`,   // the id of THIS job — never a session id
  journal: new SqliteStorage('runs.db').runs,
  model, tools, stopWhen: stepCountIs(10), prompt,
});

Kuyruk işi yeniden teslim ettiğinde aynı runId ile çalışır. Zaten biten her adım, model çağrısı da tool sonucu da, journal'dan okunur ve sendEmail yeniden çalıştırılmaz. Ajan, hiç tamamlanmamış ilk adımdan devam eder.

Yeniden planlamayı idempotency: 'args' karşılar: model aynı maili yeni bir toolCallId ile tekrar isterse, zaten çalışmış olanın içine katlanır.

Garantiyi kendi sürecinin ötesine taşımak için execute(input, options) sabit bir options.idempotencyKey alır. Mail sağlayıcın idempotency key kabul ediyorsa bu anahtarı ona geçir; kopyayı sağlayıcının kendisi reddeder.

Not
Bir id bir iştir, asla bir oturum değildir. Konuşmayı değil işi adlandır (bir müşteriye giden karşılama maili). runId olarak oturum id'si vermek, sonraki her turun ilk turu tekrar oynatmasına yol açar.

GNL'in çözmediği yerler#

Çağrının tam ortasındaki çökme. Mail gitti ama sonuç yazılmadan süreç öldüyse GNL gidip gitmediğini bilemez. Tahmin yürütmez: runDurable SideEffectRetryBlockedError fırlatır ve kararı bir insan verir, ya da tool'unun recover()'u sağlayıcıya sorar. Güvenli yön bu: mail asla iki kez gitmez, ama devam etmek her zaman kesintisiz olmaz.

Farklı argüman, farklı iştir. Model konu satırını değiştirirse argümanların özeti değişir ve bu ikinci bir mail olur. Bunun yerine işi tanımlayan alana göre anahtarla: idempotencyKey: (args) => args.to.

Onayladığı yazıyı kaybeden depolama. Garanti, journal'ın onayladığını tutmasına dayanır. Failover'lı Postgres'te senkron replikasyon kullan; asenkron replikasyon bir yazıyı kaybedip tool'un iki kez çalışmasına izin verebilir.

Kaynaklar#

Bu sayfadaki her iddianın herkese açık depoda bir testi ya da çalıştırılabilir bir örneği var:

  • packages/durable/test/args-idempotency.test.ts — yeni çağrı id'leriyle yeniden planlanan aynı tool, tek çalıştırmaya iniyor
  • packages/durable/test/crash-window.test.ts — yan etki ile journal yazımı arasındaki çökme
  • packages/durable/test/idempotency-key.test.ts — execute'a verilen sabit anahtar
  • examples/incident-proofs — checkpoint'ten yeniden gönderme ve tekrarlanan çağrı id'si kalıpları, yeniden üretilmiş ve engellenmiş, API anahtarı gerekmez

Mekanizmanın tamamı: exactly-once tool çağrıları. github.com/Karaca7/gnldev