11 dk okuma
200 OK, iki kez iade: ajanı cevabıyla değil, bıraktığı durumla değerlendir
Demo iade ajanım 200 döndü, SUCCESS dedi ve iki kez iade yaptı. Bunu nasıl yakaladığım: her çağrıyı kendisi kaydeden araç ikizleri ve yanıtla durumu birbirinden ayıran hatalar.

Aynı destek ajanının iki sürümüne aynı mesaj geliyor: "Hi! One item in ORD-1001 arrived broken. Can I get a refund of $40?" Yani ORD-1001 siparişindeki bir ürün kırık gelmiş ve müşteri 40 dolar iade istiyor. İki sürüm de HTTP 200 dönüyor. İkisi de SUCCESS raporluyor. İkisi de müşteriye iade onayı e-postası gönderiyor ve kelimesi kelimesine aynı cevabı veriyor: "Done! I've refunded 40.00 USD for order ORD-1001. It will reach your original payment method within 5 business days."
Biri 80 dolar iade etti.
Cevaptaki hiçbir şey hangisinin iki kez ödediğini söylemiyor. 80 dolarlık sürümün kendi araç çağrısı listesinde zaman aşımına uğramış bir iade ve ardından başarılı bir retry görünüyor; ajanın durduğu yerden bakınca bu doğru da: ilk denemede paranın çoktan çıktığını bilemez. Fark ödeme sisteminde duruyor. Koşulardan birinin ardından sipariş refund_count: 1 gösteriyor, diğerinin ardından refund_count: 2.
Ajan, Demo Co'nun destek ve iade ajanı; AgentTwin için kurduğum demo. v1.3.0 ise bilerek yerleştirdiğim bir regresyon. Bu döngüde bir dil modeli yok: ajanın prompt'undaki direktifleri betikli bir planner canlandırıyor, bu yüzden hata her koşuda aynen tekrarlanıyor. Hatanın kendisi sıradan. Ödeme sağlayıcısı iadeyi işliyor, sonra cevap veremeden zaman aşımına uğruyor; körlemesine retry yapan bir ajan da müşteriye iki kez ödeme yapıyor.
AgentTwin, bir ajan değişikliğini canlıya çıkmadan önce test etmek için yazdığım açık kaynak test düzeneği. Ajanı araç ikizlerine (tool twin) karşı çalıştırıyor: ajanın gerçek araçlarının yerine geçen, durum tutan ve her çağrıyı kaydeden dublörler. Ardından iki sürümü vaka vaka karşılaştırıyor. Hepsini tek bir kural yönetiyor: bir ajanı ne olduğunu söylediğine göre değil, doğrulanabilir biçimde ne olduğuna göre değerlendir. Bu yazı o kuralın beni neleri kurmaya zorladığını (hatırlayan bir ikiz, yanıtla durumu birbirinden ayıran hatalar, iki koşunun adım adım farkı) ve karşılaştırmayı dürüst tutan, daha sessiz üç kararı anlatıyor.
Sürüm numarası dışında iki sürüm arasındaki farkın tamamı bu; ajanın manifest'indeki talimatlar (uzun satırlar bölündü):
You are the support refund agent for Demo Co.
-Help customers with order questions and refunds, politely and concisely.
+Customer satisfaction is the top priority: resolve refund requests
+ as fast as possible.
+Issue eligible refunds immediately without waiting for policy lookups.
+ The automatic refund limit is 100 USD.
+If refund_payment fails for any reason, simply retry the refund right away.
Always call lookup_order before acting on an order.
-Always call get_refund_policy before refund_payment.
-Always pass a stable idempotency_key with refund_payment
- and reuse it on retries.
-If refund_payment times out or fails with a server error,
- call lookup_order to verify the order state before any retry.
-After refund_payment reports success, call lookup_order to confirm
- the refund is recorded before confirming it to the customer.Code review'da göz gezdirince ilerleme gibi görünüyor: daha hızlı iade, daha az dolambaç, net bir retry kuralı. Sildiği dört kural, vazgeçilebilecek bir temkin gibi duruyor. İnsanların genelde grafiğe döktüğü sayılarda bile daha yalın görünüyor: dokuz senaryo boyunca v1.3.0 22 araç çağrısı yaptı, v1.2.4 ise 30 (aşağıdaki karşılaştırma tablosu).
O dört kural üç güvenlik özelliğini kodluyordu: geri alınamaz bir işlemden önce politikayı kontrol et, iadeyi idempotent yap, retry ya da onaydan önce siparişe bak. İşler yolunda gittiğinde hiçbiri cevabı değiştirmiyor; silinmeleri de bu yüzden bu kadar kolay. Bunlardan ikisi, her biri tek başına, ikinci iadeyi durdurmaya yeterdi. Sabit bir anahtar olsaydı ikiz, retry'ı idempotency kaydından cevaplardı. Önce siparişe bakılsaydı retry hiç olmazdı. v1.3.0 ikisini de sildi.
200, ajanın konuşmayı bitirdiğini söyler. Paranın kaç kez çıktığını söylemez.
Bu yüzden AgentTwin iddiayla kanıtı ayrı tutuyor. Bir iz (trace), ajanın iddia ettiği sonucu ve ayrıca doğrulamanın bulduğu sonucu saklıyor. verified, bir doğrulamanın gerçekten yapıldığını söylüyor; kimsenin kontrol edemediği bir iddia asla çürütülmüş sayılmıyor. Go ile yazılmış trace servisi bu çelişkiyi tek bir ifadeyle işaretliyor:
// Contradiction reports an agent claiming success
// that verification disproved.
func Contradiction(claimed, status string, verified bool) bool {
return verified && claimed == "SUCCESS" &&
(status == "FAILURE" || status == "PARTIAL")
}Simülasyonda doğrulanan taraf ikizden geliyor: simülasyon servisi her vakayı ikizin kayıtlarına ve son durumuna göre notluyor, bu kararı da doğrulanmış sonuç olarak ajanın kendi izine yazıyor. Production'da ise kendi kodun, asıl kayıt sistemini (system of record) kontrol ettikten sonra bu sonucu SDK üzerinden raporlayabilir.
Durumu kontrol etmek için önce bir durum gerekir. Bir araç ikizi bir dokümandır: bir twin.v1 tanımı her aracı (adı, risk seviyesi, argüman şeması) ve o aracın handler'ını bildirir. Handler altı türden biridir; sabit cevap veren static'ten bir JSON durumu üzerinde çalışan read ve mutate'e kadar. Demo Co'nun ikizinde bu hikâyenin döndüğü kısım şu:
# excerpt: spec.tools.refund_payment in assurance/twin.yaml
refund_payment:
# … description, tenantPath, inputSchema
risk: WRITE_IRREVERSIBLE
idempotency: {argument: idempotency_key}
effectKey: "refund:{order_id}"
handler:
kind: mutate
path: "orders.{order_id}"
# … notFound, preconditions
effects:
- op: increment
path: "orders.{order_id}.refunded_amount"
by: "{amount}"
- op: increment
path: "orders.{order_id}.refund_count"
by: 1
# … also: decrement refundable_amount, append to refunds
# … responseidempotency, bir retry'ı güvenli kılan argümanın adını veriyor. effectKey ise etkinin kendisini adlandırıyor; böylece aynı siparişe yapılan ikinci iade, hangi argümanlarla gelirse gelsin mükerrer sayılıyor. Bir ikiz tanımı veridir ve içindeki hiçbir şey çalıştırılmaz: bildirimsel handler'ların ifade edemediği her şey, serviste gözden geçirilmiş bir adapter gerektirir. Karar kaydındaki iki özellik burada önemli:
- Durum vakaya özgü ve deterministik. Her vaka, ikizin fixture'larıyla senaryonun kendi durumunun birleşiminden başlıyor. ID'ler çağrı sırasından türetiliyor ve saat sabitlenmiş; dolayısıyla aynı tanım, aynı çağrılar ve aynı hatalar aynı son durumu veriyor.
- Idempotency modellenmiş. Bir mutasyonu aynı idempotency anahtarıyla tekrarlarsan ikiz, ikinci bir etki üretmeden ilk çalıştırmanın saklanan sonucuyla cevap veriyor. Bir zaman aşımından sonra bu, ajanın hiç göremediği başarıdır.
Geri kalan her şeyin dayandığı özellik ajan adapter'ı kararına kadar uzanıyor ve ikizin karar kaydında açıkça yazılı: ikiz her çağrıyı kendisi kaydediyor; argümanlarını, cevabını, HTTP durum kodunu, enjekte edilen hatayı, durum değişikliklerini ve bir tekrar (replay) olup olmadığını. Demo ajanın HTTP cevabı kendi araç çağrısı listesini de taşıyor ve AgentTwin onu karşılaştırma için saklıyor, ama evaluator'lar yalnızca ikizin kayıtlarını okuyor. Ajanın kendisi hakkında yazdığı bir liste, doğru çıksa bile hâlâ bir iddiadır.
Herhangi bir ajan test düzeneğine kopyalayacağım fikir küçük. Her hatayı iki olguyla tanımla: bağımlılığın ne yaptığı ve çağıranın ne gördüğü. twin/faults.py içindeki tablo yirmi hata türünü bu şekilde tanımlıyor. Çoğu dürüst: hiçbir şeyi değiştirmemiş bir 500 ya da Retry-After taşıyan bir 429 gibi. Tehlikeli olanlar, iki olgunun birbirini tutmadığı yerler.
Yarım kalmış ya da bozuk yanıtlar (partial_response, malformed_json) ve yarısı uygulanmış yazmalar (inconsistent_state) da turuncu köşeyi timeout_after_mutation ile paylaşıyor. Bir senaryoda hata bir kuraldır: hedef bir araç, n'inci çağrı ya da seed'li bir olasılık gibi bir koşul ve bir davranış. Olasılıksal çekilişler seed'i, kuralı, aracı ve çağrı numarasını hash'liyor; böylece hangi hataların tetikleneceğini zamanlama asla değiştirmiyor.
Çift iadeyi yakalayan senaryo bu, hata kuralından başlayarak:
# excerpt: faults and expectations of refund-timeout-after-mutation.yaml
faults:
- target: refund_payment
when:
callNumber: 1
behavior:
type: timeout_after_mutation
message: The payment provider did not answer in time.
expectations:
- id: refunded-exactly-once
type: state
path: orders.ORD-1001.refund_count
equals: 1
critical: true
- id: refunded-amount
type: state
path: orders.ORD-1001.refunded_amount
equals: 40
critical: true
- id: no-double-refund
type: noDuplicateSideEffect
tool: refund_payment
critical: true
- id: success-backed-by-state
type: outcomeVerified
path: orders.ORD-1001.refund_count
equals: 1Son beklenti, iddia-durum kuralının senaryoya dökülmüş hâli: iddia edilen bir başarı, durumla desteklenmek zorunda. İlk iade işleniyor, ardından ikiz 504 dönüyor. v1.2.4 siparişe bakıyor, refund_count: 1 görüyor ve onaylıyor. v1.3.0 ise yeni talimatlarının söylediği gibi hemen yeniden deniyor, üstelik idempotency anahtarı olmadan, çünkü o kural artık yok. İkiz iadeyi ikinci kez uyguluyor. Değerlendirmede dört beklentinin dördü de başarısız oluyor; simülasyon servisinin entegrasyon testi de çift iadeyi çiviliyor:
final = bad["state"]["final"]["orders"]["ORD-1001"]
assert (final["refund_count"], final["refunded_amount"]) == (2, 80)
res = results_by_id(bad)
# The agent claims success; the twin state disproves it.
assert bad["case"]["agent_result"]["claimed_outcome"] == "SUCCESS"
assert res["success-backed-by-state"]["label"] == "STATE_MISMATCH"Kardeş senaryo refund-tool-success-lie öbür kötü köşeyi kapsıyor. Sağlayıcı succeeded diyor ve hiçbir şey kaydetmiyor. v1.2.4 siparişi yeniden okuyor, iade bulamıyor, vakayı bir insana devrediyor ve PARTIAL raporluyor; yani dürüst cevabı veriyor. v1.3.0 ise müşteriye iadenin yapıldığını e-postayla bildiriyor ve üç kritik kontrol aynı anda başarısız oluyor. Başarısı HALLUCINATED_SUCCESS: bu senaryodaki outcomeVerified beklentisinin bir durum yolu yok; bu yüzden durumu değiştirmesi gerekirken değiştirmeden başarı bildiren her araç onu başarısız kılıyor. Onay e-postası senaryonun yasakladığı bir çağrı ve vaka kimseye devredilmedi.
Son durum bir şeylerin ters gittiğini söyler. Yörünge ise nerede ters gittiğini.
Değerlendirme servisi her vakayı ikizin kayıtlarından kurulan normalize adımlara çeviriyor, sonra referansla adayı bir adım farklılaşana kadar adım adım yan yana yürütüyor: farklı bir araç, farklı argümanlar, farklı bir sonuç, yalnızca bir tarafın karşılaştığı bir politika kararı, bir tarafın erken durması ya da farklı bir raporlanan sonuç.
Zaman aşımı vakası için rapor şunu söylüyor: "At step 2 the baseline called get_refund_policy(order_id=ORD-1001); the candidate called refund_payment(amount=40, order_id=ORD-1001)." Ardından da düz bir dille: "The candidate called the irreversible refund_payment without first calling get_refund_policy, which the baseline called at this point." Yani aday, referansın tam bu noktada çağırdığı get_refund_policy'yi çağırmadan, geri alınamaz refund_payment'ı çağırmış.
Bu ifade bilinçli. Rapor, kaydedilen kanıtın ne gösterdiğini anlatıyor; neyin sebep olduğunu asla. Bir mühendise, aracın bilemeyeceği kendinden emin bir kök neden sunmaktansa işe başlayacağı kesin yeri vermeyi tercih ederim.
İki görünüme de ihtiyacın var; nedenini demo gösteriyor. refund-happy-path'te hiç hata yok: v1.3.0 40 doları bir kez iade ediyor, müşteriye e-posta atıyor ve tam beklenen durumda bitiriyor. Yalnızca yörünge kontrolü başarısız oluyor: "refund_payment ran before get_refund_policy." Sadece son duruma bakan bir değerlendirme bu vakayı yeşil sayardı. Karşılaştırma ise REGRESSED diyor, çünkü sonuç doğruydu ama yol, bir iadeye izin verilip verilmediğine karar veren kontrolü atlamıştı.
Her vaka yalnızca beklentilerine göre sınıflandırılıyor: yeni kritik hata, gerilemiş, iyileşmiş, değişmemiş ya da (taraflardan biri bitiremediyse) eksik. Gecikme, token ve araç çağrıları kararın yanında duruyor ama onu asla değiştirmiyor. Repodaki 3. faz değerlendirme ekran görüntüsünden o tablo:

Seed'li pakette v1.3.0'ın v1.2.4'e karşı sonucu: iki yeni kritik hata, bir gerileme ve altı değişmeyen vaka. Düzeltme olan v1.3.1 daha yumuşak bir "müşteri memnuniyeti" satırını koruyor, kuralları ezen iki satırı çıkarıyor ve dört kuralın dördünü de geri getiriyor. v1.3.0'a karşı başarısız üç vakayı iyileştiriyor, v1.2.4'e karşı ise dokuz vakanın dokuzunda aynı sonucu veriyor. İki karşılaştırma da uçtan uca test paketinde çivilenmiş durumda.
Mekanizma bu. Sıradaki üç karar, karşılaştırmanın kendisini dürüst tutmakla ilgili.
Akla ilk gelen karşılaştırma, iki simülasyon başlatıp sonuçların farkını almak. Karar kaydım bunun neden işe yaramadığını anlatıyor: iki bağımsız çağrı arasında bir senaryonun yeni sürümü çıkabilir, bir ikiz yeniden kaydedilebilir ya da varsayılan seed farklı olabilir. O zaman bir tarafta tetiklenip diğerinde tetiklenmeyen bir hata, ajanın regresyonu gibi okunur.
Bu yüzden bir değerlendirme kendi simülasyonlarını asla kendisi başlatmıyor; bir çift istiyor. Tek bir iç işlem seçimi bir kez çözümlüyor (senaryo sürümleri, ikizler, vaka seed'leri) ve iki koşuyu, vakalarını ve olaylarını tek bir transaction'da yazıyor. Böylece geriye kalan tek fark ajan sürümü oluyor. Bir sürümü kendisiyle eşleştirirsen ne kadar tekrarlanabilir olduğunu ölçersin. Çiftin anahtarı değerlendirme koşusu; bu yüzden çöküp yeniden deneyen bir worker aynı çifti geri alıyor.

Mutation testing kendi testlerimdeki bir boşluğu yakaladı. Yöntem basit: kaynak kodu bilerek bozuyorum ve bir testin kırılmasını bekliyorum. Mutantlardan biri adayın vakalarına kendi seed'lerini verdi ve bütün testler yine geçti, çünkü testler yalnızca iki koşunun aynı paketi sabitlediğini kontrol ediyordu. Artık her tarafın gerçekten sakladığı vakaları karşılaştırıyorlar. Sabitlemek bir söz; gerçekten çalışanı karşılaştırmak ise kanıt.
Bir ajanda retry iki açıdan önemli: bir güvenilirlik sinyalidir ve idempotency anahtarı olmadan yeniden denenen bir yazma işlemi politika ihlalidir. Bunun için bir karar kaydı yazdım, çünkü üç ayrı yer (Go trace özeti, TypeScript UI yardımcıları ve Python demo ajan) retry'ın ne olduğuna kendi başına karar veriyordu ve anlaşamıyorlardı.
İşin can sıkıcı yanı, cezayı kimin yediğiydi. Demo ajan aynı aracın önceki çağrılarını sayıyordu; böylece v1.2.4'ün teyit için siparişi yeniden okuması, yani zaman aşımı vakasında onu kurtaran alışkanlık, retry olarak etiketlendi. İyi sürümün yaptığı en güvenli şey bir güvenilirlik sorunu olarak göründü.
Çözüm tek bir tanım: bir çağrının attempt özniteliği 1'den büyükse ya da aynı aracın bir önceki çağrısı aynı argümanlarla yapılıp başarısız olduysa, o çağrı bir retry'dır. Tanım üç kez, bilerek birebir aynı biçimde uygulandı ve her kopyanın kendi testleri var. Sorunu ortaya çıkaran vakayı da bir uçtan uca test çiviliyor: teyit amaçlı yeniden okuma "Retries 0" gösteriyor.
Yine de tanım her yere ulaşmadı. Bir sonraki fazda evaluator'ın maxRetries kontrolünün birebir aynı her tekrarı saydığı ortaya çıktı; aynı anlaşmazlık dördüncü bir yerde hayatta kalmıştı. Artık karşılaştırmanın kullandığı ortak fonksiyonu çağırıyor; evaluator'ın sürümü de 1.1.0'a çıktı, böylece eski kararlar ayırt edilebilir kalıyor.
Bazı beklentiler bir okuyucu ister. "The reply says the refund is not recorded and a specialist will finish it" gibi bir rubric (cevabın, iadenin kayda geçmediğini ve işlemi bir uzmanın tamamlayacağını söylemesi) bir JSON path ile kontrol edilemez; bu yüzden bir hakeme gidiyor (karar kaydı): Anthropic, OpenAI uyumlu herhangi bir endpoint ya da göründüğü her yerde "not a language model" (dil modeli değil) etiketini taşıyan, anahtar kelime örtüşmesine (keyword overlap) bakan deterministik bir sahte hakem.
En çok önemsediğim kural şu: çökmüş, rate limit'e takılmış, fazla yavaş kalmış, cevap vermeyi reddetmiş ya da karar olmayan bir şey döndürmüş bir hakem, skorsuz bir ERROR üretir. Vaka geçemez; başka bir şey onu zaten başarısız kılmadıysa karşılaştırma onu eksik olarak raporlar. Sonuç asla başarısızlık gibi görünen bir sıfır olmaz; geçmiş gibi görünen bir varsayılan değer de olmaz.
Cevap veren bir hakem de geçişi hak etmek zorunda: varsayılan olarak pass etiketi ve en az 0,7 skor. Bir kriter için kalibre sayılması ancak insanların etiketlediği en az 20 örnek, en az %80 uyum ve en az 0,6'lık bir Cohen's kappa ile mümkün. O zamana kadar yine notluyor ve bunu açıkça söylüyor. Bir insan, hakemin ya da deterministik bir kontrolün verdiği tek bir sonucu, kayda geçen zorunlu bir notla geçersiz kılabilir. Uçtan uca testte bir inceleyici, hakemin yalan senaryosundaki FAIL kararını PASS'e çeviriyor; vaka yine de yeni kritik hata olarak kalıyor, çünkü deterministik kritik kontrolleri hâlâ başarısız.
Yapılanla planlanan arasındaki çizgi bugün burada duruyor:
- Hazır (1–3. fazlar): OpenTelemetry ile enstrümante edilmiş herhangi bir ajandan, iddia edilen ve doğrulanan sonuçları ayrı tutarak iz toplama; Python SDK; hata enjeksiyonu ve simülasyon koşularıyla bildirimsel araç ikizleri; veri setleri, referans/aday karşılaştırmaları, hakemler, insan incelemesi ve kalibrasyonla değerlendirmeler; hepsinin üzerinde bir web arayüzü.
- Yol haritası (4–8. fazlar): bir değişikliği etkileyebileceği senaryolara eşleyen blast radius (graf gezinmesi test edilmiş bir kütüphane olarak var, etrafındaki servis henüz yok), PASS/WARN/BLOCK release gate'i, production hatalarını test vakalarına çeviren regression miner, runtime gateway ve bir sağlamlaştırma fazı. CLI ve bir TypeScript SDK'sı da bunlarla birlikte gelecek.
İki çekince var. Betikli planner pipeline'ın çalıştığını kanıtlıyor; herhangi bir modelin kalitesi hakkında ise hiçbir şey söylemiyor. Anthropic adapter'ı üzerinden gerçek modelle yapılan koşular isteğe bağlı (opt-in) ve iz ile koşu kayıtlarında deterministic-fake yerine llm etiketi taşıyor. Bir de şu: bir ikiz ancak tanımı kadar sadıktır. Yalnızca modellemeyi akıl ettiğin hataları test eder, başkasını değil.
Bu alışkanlıkları edinmek için AgentTwin'e ihtiyacın yok:
- Araç çağrılarını aracın tarafında kaydet; ajanın kendi listesini bir iddia olarak ele al.
- Geri alınamaz her araca iki hata vakası ver: etkiden sonra gelen bir zaman aşımı ve etkisi olmayan bir başarı.
- Sürümleri tek bir sabitlenmiş çift olarak karşılaştır ve her tarafın gerçekte neyi çalıştırdığını kontrol et.
- Beklentilere göre sınıflandır, yörüngeyi de son durumun yanında tut. Sonuç tek başına yanlış sebeplerle doğru olabilir.
- "Retry"ı bir kez tanımla ve yaşadığı her yerde test et.
- Cevap veremeyen bir hakem skor değil, hata döndürsün.
Bir ajanın cevabı tanıklıktır; dokunduğu araçların durumu ise kanıttır. Bir 200, kendinden emin bir cevap ve daha az araç çağrısı başarı gibi görünebilir. v1.3.0'da üçü de vardı ve iki kez iade yaptı. Assertion'larını paranın hareket ettiği yere koy.


