Trace Doğruydu. Hikaye Değildi.
LangChain ajanı biliyordu, LiteLLM modeli biliyordu, Langfuse ise ikisini de ayrı hikayeler olarak gösterince ne oldu.
LiteLLM’i CairnOps’a sıkıcı bir sebeple ekledim: altı ajanın tek bir model sağlayıcısına bağımlı olmasını istemedim.
Routing kısmı kolaydı. Observability kısmı değildi.
LiteLLM’den önce Langfuse, LangGraph iş akışı boyunca temiz bir yol gösterebiliyordu. parse_input, get_route, assess_risk ve pipeline’ın geri kalanını görebiliyordum. LiteLLM, LangChain ile Groq arasına girince, bu trace’lerdeki model adı cairnops-llm’e dönüştü.
Bu isim teknik olarak doğruydu. Ama umursadığım soru için işe yaramazdı.
İstek 70B model tarafından mı işlendi? Daha küçük modele mi düştü? Gerçek sağlayıcı kaç token kullandı?
LiteLLM bu cevaplara sahipti. LangChain ajan bağlamına sahipti. Sadece aynı yerde değillerdi.
Tek istek için iki trace
LiteLLM’in OpenTelemetry tabanlı Langfuse callback’ini etkinleştirdikten sonra, gerçek sağlayıcı, model, token sayısı, latency ve maliyet içeren litellm_request observation’ları görmeye başladım.
Harika—tek sorun, bu isteklerin her birinin kendi trace’i olarak görünmesiydi.
Yani dashboard bana şunu söylüyordu:
LangChain: InputParser bir model çağırdı
LiteLLM: Groq 70B bir isteği yanıtladı
İki ifade de doğruydu. Aralarında bir bağlantı yoktu.
İlk içgüdüm, trace ID’yi manuel olarak request metadata’sında geçirmekti. Önce extra_body, sonra model_kwargs, sonra custom header’lar denedim. Bir deneme hiçbir şey yapmadı. Diğeri warning’ler üretti. Biri ajan akışını o kadar bozdu ki hemen geri aldım.
Faydalı ipucu şuydu: trace ID’nin kendisi hiçbir zaman gerçek sorun değildi. Her iki taraf da zaten OpenTelemetry’yi anlarken ben kendi propagation mekanizmamı icat etmeye çalışıyordum.
Context’in HTTP sınırını geçmesine izin ver
LangChain’in zaten aktif bir OTel context’i vardı. LiteLLM bir HTTP sınırının arkasında çalışıyordu. Eksik parça, o context’i standart W3C header’larıyla propagate etmekti.
İlgili kod, başarısız denemelerimin çoğundan çok daha küçük çıktı:
from opentelemetry.propagate import inject
headers = {}
inject(headers)
return ChatOpenAI(
...,
default_headers=headers,
)
traceparent proxy sınırını geçtikten sonra, LiteLLM ilgisiz root trace’ler açmayı bıraktı. Observation’ları aktif iş akışı trace’inin altına düştü.
Bu korelasyonu düzeltti ama duplikasyonu değil.
Artık elimde şu vardı:
parse_input
├── ChatOpenAI
└── litellm_request
Aynı çağrı. Aynı zamanlama. Aynı token’lar. İki generation.
Neyin kime ait olduğuna karar ver
LangChain callback’ini tamamen devre dışı bırakabilirdim, ama o zaman ondan gerçekten istediğim kısmı kaybederdim: agent, chain, tool ve retriever yapısı.
LiteLLM telemetry’sini devre dışı bırakmak tam tersini yapardı. İş akışını korurdum ama gerçek model/provider/maliyet verisini kaybederdim.
Bu yüzden sahipliği açıkça belirledim.
LangChain, workflow telemetry’sinin sahibi. LiteLLM, model telemetry’sinin sahibi.
Graph yapısı için Langfuse handler’ını korudum ve sadece LLM event’lerini filtreledim:
class StructureOnlyLangfuseHandler(CallbackHandler):
@property
def ignore_llm(self) -> bool:
return True
@property
def ignore_chat_model(self) -> bool:
return True
Son trace, gerçekten çalıştırdığım sisteme benziyordu:
expedition_pipeline
├── parse_input
│ └── litellm_request
├── get_route
│ └── litellm_request
└── assess_risk
└── litellm_request
Artık tek bir ekran, debug sırasında ihtiyacım olan iki soruyu da cevaplıyor: çağrıyı hangi ajan yaptı ve gerçekte hangi model cevap verdi.
En uzun sürem OpenTelemetry değildi. Aynı observability backend’ine raporlama yapan iki kütüphanenin otomatik olarak aynı hikayeyi paylaşmadığını fark etmekti.
Korelasyon sorunun yarısını çözdü. Net telemetry sahipliği diğer yarısını çözdü.