BYO-LLM adapter pattern: cum eviți lock-in pe un singur model
Bring-Your-Own-LLM cu adapters minimi de ~150 linii per provider. De ce framework-urile mono-LLM sunt rigid, cum cobori la signature uniformă.
BYO-LLM adapter pattern: cum scrii un agent care funcționează pe orice model
Un agent AI pe care îl construiți astăzi va supraviețui modelului pe care îl folosește la lansare. Modelele se schimbă rapid: în 18 luni, un model dominant astăzi poate fi depășit, ineconomic sau retras. Mai mult, diferiți clienți, diferite jurisdicții și diferite cerințe de cost vă pot obliga să rulați același agent pe mai multe modele simultan. Soluția nu este o re-scriere; este o arhitectură de la început care tratează modelul ca pe un component pluggable.
Acest articol descrie patternul Bring-Your-Own-LLM (BYO-LLM) cu adapters minimi pe care îl folosim intern: ~150 linii de cod per provider, signature uniformă pentru aplicație, schimbare de model fără re-engineering.
TL;DR
- Frameworks-urile care integrează strâns un singur LLM produc lock-in arhitectural greu de spart.
- Patternul BYO-LLM expune o signature uniformă (
generate(messages, tools, config) -> response) și împachetează fiecare provider într-un adapter de ~150 linii. - Adapter-ele se ocupă de: traducere format mesaje, traducere format tools, normalizare răspuns, normalizare erori, gestionare retry/backoff.
- Aplicația ramîne agnostică: schimbi providerul prin variabilă de configurare, nu prin refactor.
- Costul inițial este de 1-2 zile per provider; câștigul este libertatea de a alege modelul pe baza nevoii reale.
De ce un singur LLM e o trapă
Când porniți un proiect AI, există tentația de a folosi SDK-ul oficial al unui singur provider. Sintaxa este idiomatică, exemplele sunt bogate, suportul este disponibil. Pe termen scurt, este alegerea rapidă. Pe termen mediu, devine problematică din patru motive:
Schimbare de model în propriul vendor. Anthropic, OpenAI, Google trec prin generații de modele cu schimbări de API, parametri sau comportament. Cod legat strâns de un model specific se rupe.
Schimbare de vendor. Modelele cu cele mai bune capabilități se schimbă vertiginos. Modelul lider la lansare poate să nu fie modelul lider după 12 luni. Lock-in pe un SDK face migrarea costisitoare.
Cerințe geografice / regulatorii. Un client cu cerință GDPR strict poate cere model rulat în UE; un client cu date sensibile poate cere model self-hosted; un client public-sector poate avea restricții pe vendor specific. Un agent care suportă doar un vendor pierde aceste contracte.
Cost-aware routing. Așa cum am argumentat în articolul despre routing, pe scale, vrei să folosești modele diferite pentru sarcini diferite. Asta este imposibil dacă codul tău este împletit cu un SDK specific.
Patternul BYO-LLM
Patternul are trei piese:
1. Signature uniformă. Aplicația apelează o funcție care arată identic pentru orice provider:
response = llm.generate(
messages=[{"role": "user", "content": "..."}],
tools=[...],
config={"model": "alias_X", "temperature": 0.2, "max_tokens": 4000}
)
Aplicația nu știe ce provider rulează. Aliasul alias_X este rezolvat de router (vezi articol cost-aware routing) la un model concret.
2. Adapter per provider. Fiecare provider are un adapter mic care:
- Traduce mesajele din formatul intern în formatul providerului
- Traduce tools-urile (format JSON Schema unificat → format provider-specific)
- Apelează SDK-ul oficial al providerului
- Normalizează răspunsul (text, tool calls, stop reason, usage tokens)
- Normalizează erorile (rate limit, auth, server error, content filter)
- Gestionează retry cu exponential backoff
3. Registry central. Un dict care mapează numele providerului (string) la clasa adapter:
PROVIDERS = {
"claude": ClaudeAdapter,
"openai": OpenAIAdapter,
"gemini": GeminiAdapter,
"claude_cli": ClaudeCLIAdapter,
"openai_compat": OpenAICompatAdapter, # pentru servere locale
}
def get_adapter(provider: str) -> LLMAdapter:
return PROVIDERS[provider]()
Adapter exemplu (~150 linii)
Schiță simplificată pentru un adapter Claude:
class ClaudeAdapter:
def __init__(self):
self.client = anthropic.Anthropic() # citește API key din env
def generate(self, messages, tools=None, config=None):
# Traducere mesaje: formatul intern e similar cu OpenAI;
# Claude vrea system separat
system = ""
chat_messages = []
for m in messages:
if m["role"] == "system":
system += m["content"] + "\n"
else:
chat_messages.append(m)
# Traducere tools
claude_tools = self._translate_tools(tools or [])
# Apel SDK
try:
resp = self.client.messages.create(
model=config["model"],
system=system,
messages=chat_messages,
tools=claude_tools,
temperature=config.get("temperature", 0.2),
max_tokens=config.get("max_tokens", 4000),
)
except anthropic.RateLimitError as e:
raise LLMRateLimitError(str(e))
except anthropic.APIStatusError as e:
if e.status_code >= 500:
raise LLMUpstreamError(str(e))
raise LLMClientError(str(e))
# Normalizare răspuns
text = ""
tool_calls = []
for block in resp.content:
if block.type == "text":
text += block.text
elif block.type == "tool_use":
tool_calls.append({
"id": block.id,
"name": block.name,
"arguments": block.input,
})
return LLMResponse(
text=text,
tool_calls=tool_calls,
stop_reason=self._normalize_stop(resp.stop_reason),
usage={
"prompt_tokens": resp.usage.input_tokens,
"completion_tokens": resp.usage.output_tokens,
},
)
def _translate_tools(self, tools):
# JSON Schema unificat → format Claude tools
return [
{
"name": t["name"],
"description": t["description"],
"input_schema": t["parameters"],
}
for t in tools
]
def _normalize_stop(self, claude_stop):
# claude folosește 'end_turn', 'tool_use', 'stop_sequence', 'max_tokens'
return {
"end_turn": "stop",
"tool_use": "tool_call",
"max_tokens": "length",
"stop_sequence": "stop",
}.get(claude_stop, "stop")
Acest adapter este sub 80 de linii fără retry. Cu retry plus logging, atinge ~150 linii.
Adapter pentru OpenAI compatible (folosit pentru servere locale precum vLLM, Ollama API, LM Studio) este și mai scurt: API-ul OpenAI este de fapt cel mai larg implementat de runtime-uri locale și aproape identic cu signature-ul intern.
Diferențe care trebuie izolate în adapter
Diferențele între providers nu sunt cosmetice. Adapter-ul trebuie să le ascundă:
Format mesaje. OpenAI și majoritatea local-LLM-urilor folosesc array de mesaje cu role inclusiv system. Claude folosește system separat. Gemini folosește roluri user / model. Adapter-ul traduce.
Format tools. OpenAI: {"type": "function", "function": {...}}. Claude: {"name": ..., "input_schema": ...}. Gemini: function_declarations array. Adapter-ul traduce dintr-un format unificat (am ales JSON Schema OpenAI-style ca pivot).
Stop reason. Fiecare provider folosește alt vocabular. Adapter-ul mapează la un mic enum unificat.
Usage tokens. Numele câmpurilor diferă (input_tokens vs prompt_tokens). Numerele pot diferi prin câteva tokens din cauza tokenizării.
Tool calling streaming. Unele providers oferă streaming pentru tool calls (parsare incrementală), altele doar response complet. Pentru simplitate, adapter-ele noastre intern folosesc non-streaming și expun streaming doar la nivel de top-level dacă providerul îl suportă.
Rate limit shape. Limite per-minute, per-day, tokens-per-minute. Erorile de rate limit sunt detectate diferit. Adapter-ul normalizează la un singur tip LLMRateLimitError cu retry_after opțional.
Tipuri speciale: claude_cli și CLI subprocess
Unul din adapters-uri pe care îl folosim intern este pentru Claude CLI ca subprocess. Patternul este descris într-un alt articol; aici menționăm doar că adapter-ul are aceeași signature publică, dar implementarea apelează claude CLI prin subprocess în loc de SDK HTTP. Aplicația nu știe diferența.
Beneficiul: un agent poate folosi un model premium prin subscripție personală (cost fix lunar) și un model cloud prin API (cost per token), tratându-le identic.
Greșeli comune
Greșeală 1: signature prea bogată. Tentația de a expune fiecare feature al fiecărui provider duce la signature ce nu se mai poate implementa uniform. Soluție: signature minimal, features specifice expuse prin câmp extra opțional pe care doar providers-ele care îl suportă îl folosesc.
Greșeală 2: retry la nivel de adapter și la nivel de aplicație. Rezultă retry-uri multiplicate. Decizie clară: retry pe rate limit și server error la nivel de adapter (max 3); retry pe logică de business la nivel de aplicație.
Greșeală 3: lipsa de timeouts. Un provider lent care nu răspunde poate bloca un agent. Adapter-ul setează timeout default (60-120 secunde) suprascriibil prin config.
Greșeală 4: assumption că tools funcționează identic. Modelele diferite au calități diferite la tool-use. Un model mai mic poate avea probleme cu schema complex. Adapter-ul nu rezolvă asta; este responsabilitatea routerului să nu trimită sarcini complexe la model insuficient. Dar adapter-ul trebuie să raporteze erori clar.
Costul inițial vs câștigul
Implementarea unui adapter durează 1-2 zile pentru un developer familiar cu providerul. Trei adapters acoperă 95% din cazurile practice (Anthropic, OpenAI, OpenAI-compatible pentru local). Cost total inițial: 5-10 zile-om.
Câștigul în 12 luni:
- Migrare model nou (mai bun sau mai ieftin) cu o linie de configurare
- Suport pentru clienți cu cerințe specifice de hosting (self-hosted, EU-only)
- Posibilitate de cost-aware routing fără restructurare
- Reducere de risc: dacă un provider are outage sau își schimbă termenii, fallback automat
Concluzie
Lock-in pe un singur LLM este o decizie arhitecturală de care, în 12-18 luni, veți regreta. Patternul BYO-LLM cu adapters minimi este o investiție de câteva zile care plătește permanent. Aplicația ramîne agnostică, modelul devine pluggable, decizia de provider devine o decizie de configurare, nu de cod.
Bonus: scrierea de adapter pentru un provider nou ne-a fortat să înțelegem profund cum funcționează acel provider — ceea ce ne-a făcut consultanți mai buni. Cunoașterea modelelor multiple este, în sine, un avantaj competitiv.
Articole conexe
- Cost-aware LLM routing
- Claude Code CLI ca runtime de agent
- Pillar IRIS — agentul orchestrator CAI Technology
- Pillar Consulting — assessment AI
Surse externe
- Anthropic Messages API reference — referință oficială pentru formatul Claude
- OpenAI Chat Completions API reference — referință OpenAI și de facto standard pentru servers compatible
- Google Gemini API reference — referință Gemini pentru function calling și roluri
- vLLM OpenAI-compatible server documentation — runtime local cu API OpenAI
- LangChain BaseChatModel — discuție despre adapter abstraction — exemplu industrial de adapter pattern
Următorul pas
Dacă echipa dvs. construiește un agent AI și vrea să discutăm patternul BYO-LLM aplicat la stack-ul vostru, vă oferim o consultație tehnică de 30 minute fără cost.