1. Provider katmanı¶
Bu katmanın işi, üstündeki her şeye bütün provider'ları aynı göstermek — ama önemli olan farkları ezmeden. Bu iki zıt baskı, ve buradaki kararların çoğu ikisi arasındaki çizgiyi nereye koyduğumuzla ilgili.
Burada ne yaptık
ChatRequest / ChatResult / Usage / Message iç modelini,
LLMClient protokolünü, beş hata sınıfını, dört adaptörü (OpenAI,
Anthropic, Gemini, Azure), retry + fallback wrapper'ını ve
model-probe ölçüm CLI'ını yazdık. Sonraki dört katmanın tamamı bu
protokolün üstünde duruyor ve adaptörlere bir daha dokunulmadı.
İç mesaj formatı¶
Paketin geri kalanının konuştuğu tek dil bu dört tip. İçlerindeki üç seçim açıklamaya değer.
system üst düzey bir alan, mesaj listesinin öğesi değil¶
Anthropic ve Gemini sistem talimatını zaten üst düzeyde bekliyor. OpenAI onu mesaj olarak istiyor ve çevirmek adaptörde tek satır. Ters yönde normalize etmek — sistem prompt'unu mesaj listesine gömüp üç adaptörün ikisinde geri ayıklamak — her çağrıda string ayıklaması demekti, hem de bunun için en uygunsuz yerde.
from aimai_kit.provider.types import ChatRequest, Message, Role
req = ChatRequest(
messages=[Message(role=Role.USER, content="Bedel nedir?")],
system="Sen bir hukuk asistanısın.", # üst düzey
max_output_tokens=512,
)
Adaptörler bunu kendi biçimlerine çeviriyor:
# anthropic_.py
kwargs["system"] = req.system # üst düzey parametre
# openai_.py
kwargs["instructions"] = req.system # ayrı parametre
# gemini_.py
config["system_instruction"] = req.system # config'in içinde
Kullanılmayan alanlar baştan var ve boş duruyor¶
ChatRequest en başından beri json_schema ve tools alanlarını taşıyordu;
2. ve 3. katman onları doldurdu. Alan eklemek geriye dönük uyumludur, alan
adını değiştirmek değildir. Kararlı bir protokolün bütün amacı, üst katmanın
alt katmanı değiştirmeden büyüyebilmesi — ve buna ulaşmanın yolu doğru tahmin
etmek değil, yer bırakmak.
Providerya özel ayarlar extra içinde¶
Gemini'nin thinking_config'inin ya da OpenAI'ın reasoning_effort'ünün
diğerlerinde karşılığı yok. İç modele koymak onu kirletirdi; erişilemez
bırakmak çağıranları soyutlamanın etrafından dolaşmaya zorlardı. İsim
alanlı bir kaçış kapısı ikisini de çözüyor.
req = ChatRequest(
messages=[...],
extra={"gemini": {"thinking_config": {"thinking_budget": 2048}}},
)
Paraya mal olan tuzak: usage alan adları¶
Bu katmandaki en pahalı ayrıntı.
OpenAI cache'lenmiş token'ları input_tokens'ın içinde raporluyor; detay
nesnesinde cache'li kısmı ayrıca veriyor. Anthropic ise
cache_read_input_tokens'ı ayrı raporluyor, input'a dahil değil. İkisi de
makul; aynı şey değiller.
Bir konvansiyon seç ve adaptörlerde ona normalize et. Bu paket cache'lenmiş token'ları input'un alt kümesi sayıyor — OpenAI'ın konvansiyonu — ve Anthropic adaptörü giriş yolunda cache okuma/yazmayı input'a katıyor:
# anthropic_.py — normalizasyon
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
cache_write = getattr(usage, "cache_creation_input_tokens", 0) or 0
Usage(
input_tokens=usage.input_tokens + cache_read + cache_write,
output_tokens=usage.output_tokens,
cached_input_tokens=cache_read,
)
Bunu atlarsan Anthropic maliyetleri sistematik olarak düşük çıkar. Yuvarlama hatası kadar değil: cache ağırlıklı bir iş yükünde input'un çoğu zaten cache'li kısımdır. Hata sessizdir, yalnızca fatura raporunda görünür ve muhasebe hatası gibi değil fiyatlandırma hatası gibi durur.
ModelPricing.cost() artık cached_input_tokens > input_tokens olduğunda
hata veriyor — normalize edilmemiş bir adaptörün ürettiği şekil tam olarak bu:
>>> price.cost(input_tokens=100, output_tokens=10, cached_input_tokens=200)
ValueError: cached_input_tokens must be a subset of input_tokens (200 > 100).
Some adapter is not normalizing its usage fields.
Hata sınıflandırması¶
Beş sınıf, ve sınıflandırma retry kararını taşıyor:
| Sınıf | Retry? | Neden |
|---|---|---|
RateLimited |
evet | provider beklemeni söyledi, hatta ne kadar bekleyeceğini de |
TransientError |
evet | 5xx ve bağlantı kopması bir denemeye daha değer |
InvalidRequest |
hayır | aynı 400 geri gelir |
AuthError |
hayır | anahtar hâlâ yanlış |
AllProvidersFailed |
hayır | zincirdeki her halka çöktü |
retryable sınıfın kendisinde duruyor, böylece retry politikası onu tek
yerden okuyor:
Alternatifi — hata mesajlarında "rate limit" gibi kelimeler aramak — provider metnini değiştirdiği gün sessizce bozulur, ve bozulma bir kesinti gibi görünür.
Retry ve fallback¶
SDK'nın kendi retry'ı kapalı (max_retries=0). İki katman retry çarpılır:
üç deneme iki katmanda altı çağrı eder ve üç için hesaplanmış bir süre tavanı
anlamsızlaşır. Tam olarak bir katman karar verir.
Toplam süre tavanı var. Üç deneme × 60 saniyelik Retry-After, üç dakika
asılı kalan bir istek demektir. Kullanıcının istemcisi çoktan vazgeçmiştir.
Tavan olmadan retry'ın kendisi bir kesintiye dönüşür.
Jitter süs değil. 429 alan elli istemci tam iki saniye beklerse aynı anda geri gelip aynı duvara çarparlar. Rastgele saçılma sürüyü tekrar kuyruğa çevirir.
policy = RetryPolicy(
max_attempts=3,
base_delay_s=0.5,
max_delay_s=20.0,
jitter_s=0.25,
total_deadline_s=45.0, # retry'ın kendisi kesinti olmasın
honor_retry_after=True, # provider'ın süresi bizim tahminimizi yener
)
Akışta retry yok. İlk parça kullanıcıya ulaştıktan sonra yeniden denemek, ekranda yarım cümleyi silmek demek. Bu bir arayüz kararı ve çağırana ait; kütüphane onu gizlemeye çalışmıyor.
Fallback bir olay, sessiz bir kurtarma değil. llm_fallbacks_total sayacı
etiketli olarak dışarı veriliyor:
resilient = ResilientClient(
primary,
[backup],
on_fallback=lambda src, dst, err: alert(f"{src} -> {dst}: {err}"),
)
Sessizce yedeğe düşen bir servis, birincil provider tamamen çökmüşken bile her panoda sağlıklı görünür — kullanıcılar hâlâ cevap alıyordur, sadece daha yavaş, daha pahalı ve muhtemelen daha zayıf bir modelden. Fallback oranına alarm koymak gerekir, çünkü hata oranı grafiği hiçbir şey göstermeyecektir.
Ölçüm: TTFT ile toplam süre neden ayrı¶
TTFT kullanıcının hissettiği şey. Toplam süre kapasite planının ihtiyacı olan şey. 200 ms TTFT + 9 sn süre ile 4 sn TTFT + 5 sn süre benzer ortalamalara ve tamamen farklı kullanıcı deneyimlerine sahip.
model-probe ikisini de gerçekten çalışan tek yoldan ölçüyor: N akış koşusu
TTFT ve süreyi veriyor, ardından tek bir complete çağrısı akışta
gelmeyebilecek gerçek usage'ı veriyor.
uv run model-probe --prompt evals/probe/sample-prompt.txt \
--models anthropic:claude-opus-5 anthropic:claude-haiku-4-5 -n 5
Bedeli N+1 çağrı. Kazancı sapma sütunu — yerel tiktoken tahmini ile provider'ın bildirdiği sayı yan yana. tiktoken OpenAI sözlüğüdür ve Anthropic ile Gemini'de bu sapma %10-20'ye çıkar; context bütçesinin limite kadar doldurmak yerine emniyet payı taşımasının sebebi bu.
Anahtar olmadan da bütçe kapısı gözlenebilir:
$ uv run model-probe --prompt evals/probe/sample-prompt.txt \
--models anthropic:claude-opus-5 openai:gpt-5.5 --dry-run
model in~ in drift% out USD/1k ...
claude-opus-5 82 0 - 0 0
gpt-5.5 82 0 - 0 0
! gpt-5.5: window not in catalog; budget check skipped
Ortalama değil yüzdelik¶
Bir LLM servisinde gecikme dağılımı çarpıktır, o yüzden raporlar p50/p95/p99 döndürüyor.
Bir uyarı testlere gömülü: küçük N'de nearest-rank aykırı değerleri yutar. Yirmi koşuda p95 on dokuzuncu değerdir — tek bir on saniyelik koşu p99'a kadar görünmez. "p95 iyi" demek "kötü koşu yok" demek değildir. SLO'nun yanına N'i de yaz.
Başarısız çağrılar gecikmeden dışlanıyor (40 ms'de dönen bir 429 iş yapılmadığı anlamına gelir) ama maliyete dahil ediliyor (girdi token'ı yine de faturalanmış olabilir).
Test edilen sınır¶
Satıcı SDK'ları yalnızca provider/adapters/ altında import ediliyor ve
test_no_vendor_leak.py bunu regex yerine AST yürüyüşüyle uyguluyor — böylece
yorumlar ve string sabitleri yanlış alarm üretmiyor. Testin bir de ters
kontrolü var: adaptörlerin gerçekten bir SDK import ettiğini doğruluyor, çünkü
aksi hâlde SDK çağrılarını yanlışlıkla silmek testleri yeşil bırakır ve
"sızıntı yok" mesajı boş bir güvenceye dönerdi.
# test_no_vendor_leak.py
@pytest.mark.parametrize("path", _source_files(), ids=lambda p: p.name)
def test_vendor_sdk_does_not_leak_outside_adapters(path: Path) -> None:
roots = _import_roots(ast.parse(path.read_text(encoding="utf-8")))
assert not roots & FORBIDDEN_ROOTS
Checklist¶
Bu katman proda çıkmadan önce:
- SDK'nın kendi retry'ı kapalı (
max_retries=0). İki retry katmanı çarpılır: senin üçün ile onların üçü dokuz çağrı ve dokuz kat fatura eder. - Retry edilebilirlik hata sınıfının özelliği, mesajda string araması değil. Provider'ın kelimeleri haber vermeden değişir, senin retry mantığın değişmemeli.
-
Retry-Aftersenin backoff'unu yener. Ne zaman hazır olacağını provider biliyor, sen bilmiyorsun. - Backoff'ta jitter var. Hepsi 429 alıp hepsi tam iki saniye bekleyen elli client, o 429'a sebep olan yükü yeniden üretir.
- Toplam bir deadline var, sadece deneme sayısı değil. Her biri 60 saniyelik
Retry-After'a uyan üç deneme, kimsenin bütçelemediği üç dakikalık bir istektir. - Stream başladıktan sonra retry yok. Kullanıcı ilk chunk'ı gördü bile; retry cevabı onun gözü önünde baştan başlatır.
- Cache'lenen token'lar normalize ediliyor. Anthropic
cache_read_input_tokens'ıinput_tokens'ın dışında, OpenAI içinde raporluyor. Bunu kaçırırsan maliyet paneli faturayla uyuşmaz — hem de ancak ay sonunda fark edeceğin bir yönde. - Fallback bir olaydır.
llm_fallbacks_totalartar. Sessizce kurtaran bir fallback, birincil provider'ın bir hafta boyunca çökük kalabilmesi demektir. - TTFT ile toplam süre ayrı metrikler. Stream eden bir arayüzde farklı soruları cevaplıyorlar ve tek bir ortalama ikisini de gizler.
- p95'e bakıyorsun, ortalamaya değil. Ortalama kimsenin yaşadığı şey değil.
Gerisi — tool'lar, budget'lar, çıkışın kendisi — ve PR template'ine yapıştırılacak hâli: proda çıkmadan checklist.