Arquitectura mínima per integrar un LLM en una aplicació backend

Com integrar un LLM en un backend real: arquitectura per capes, abstracció de proveïdor, límits, errors, costos i tests.

Cover for Arquitectura mínima per integrar un LLM en una aplicació backend

La primera vegada que vaig integrar un LLM en un backend de producció, vaig cometre tots els errors possibles. Crida directa a l’API d’OpenAI des del controlador, sense timeout, sense fallback, sense control de costos. Funcionava en local, passava els tests manuals i, per descomptat, al cap de dues setmanes vam tenir un pic de tràfic que va deixar una factura d’API força incòmoda i un servei degradat perquè un timeout de 30 segons bloquejava fils del servidor.

Des d’aleshores he refinat força la manera de plantejar-ho. No cal una arquitectura de NASA, però sí una estructura mínima que t’eviti els problemes més habituals. Això és el que faig servir avui, i el que recomano a qualsevol equip que estigui començant a posar LLMs al backend.


El problema real: no és cridar el LLM, és tot el que l’envolta

Fer una crida a un LLM és trivial. Un POST a un endpoint, un JSON de tornada. Qualsevol tutorial t’ho ensenya en 10 minuts.

El problema real apareix quan aquesta crida ha de conviure amb:

  • Usuaris concurrents que disparen peticions simultànies.
  • Un proveïdor de LLM que pot trigar 3 segons o 45 depenent del model i la càrrega.
  • Costos que escalen per token, no per request.
  • Models que canvien de versió sense avís i trenquen el teu parsing de resposta.
  • La necessitat de testejar sense gastar diners a cada mvn test.

Si no separes responsabilitats des del principi, acabes amb un monòlit d’espagueti on el controlador coneix els detalls del proveïdor, el format del prompt i la lògica de reintents. I això, en producció, es paga.


Arquitectura per capes: el mínim que funciona

L’estructura que faig servir té tres capes ben definides. No és res revolucionari, és simplement separació de responsabilitats aplicada a LLMs:

┌─────────────────────────────┐
│        Controller            │  ← Rep request HTTP, valida input
├─────────────────────────────┤
│        Service               │  ← Lògica de negoci, prompt templates, parsing
├─────────────────────────────┤
│      LLM Provider            │  ← Abstracció del proveïdor (OpenAI, Anthropic, local)
├─────────────────────────────┤
│    Rate Limiter / Cache      │  ← Control de costos, rate limits, memòria cau
└─────────────────────────────┘

Cada capa té una responsabilitat clara:

Controller: Rep la petició, valida paràmetres, retorna la resposta formatejada. No sap res de prompts ni de LLMs.

Service: Construeix el prompt, crida el provider, parseja la resposta, aplica lògica de negoci. És on viu el cervell de la feature.

LLM Provider: Abstracció sobre el proveïdor concret. Sap fer una crida a un LLM i retornar text. Res més.

Rate Limiter / Cache: Capa transversal que controla quantes crides fas i guarda respostes en memòria cau quan té sentit.


Exemple pràctic: Kotlin amb Spring Boot

Anem a implementar-ho amb un cas concret: un endpoint que rep un text tècnic i retorna un resum estructurat. Alguna cosa que podries trobar en un servei de processament de documentació.

La interfície del provider

El primer és l’abstracció. No vull que el meu servei sàpiga si estic fent servir OpenAI, Anthropic o un model local:

interface LlmProvider {
    suspend fun complete(request: LlmRequest): LlmResponse
    fun getProviderName(): String
}

data class LlmRequest(
    val systemPrompt: String,
    val userMessage: String,
    val model: String,
    val maxTokens: Int = 1024,
    val temperature: Double = 0.3
)

data class LlmResponse(
    val content: String,
    val tokensUsed: TokenUsage,
    val model: String,
    val latencyMs: Long
)

data class TokenUsage(
    val promptTokens: Int,
    val completionTokens: Int
) {
    val totalTokens: Int get() = promptTokens + completionTokens
}

La clau d’aquesta interfície és que és genèrica. Qualsevol proveïdor que pugui rebre un prompt i retornar text hi encaixa. Això et permet canviar de proveïdor sense tocar el servei.

Implementació per a un proveïdor concret

@Component
@ConditionalOnProperty("llm.provider", havingValue = "anthropic")
class AnthropicProvider(
    private val config: LlmConfig,
    private val httpClient: WebClient
) : LlmProvider {

    override suspend fun complete(request: LlmRequest): LlmResponse {
        val startTime = System.currentTimeMillis()

        val response = httpClient.post()
            .uri("/v1/messages")
            .header("x-api-key", config.apiKey)
            .header("anthropic-version", "2023-06-01")
            .bodyValue(buildRequestBody(request))
            .retrieve()
            .awaitBody<AnthropicApiResponse>()

        val latency = System.currentTimeMillis() - startTime

        return LlmResponse(
            content = response.content.first().text,
            tokensUsed = TokenUsage(
                promptTokens = response.usage.inputTokens,
                completionTokens = response.usage.outputTokens
            ),
            model = response.model,
            latencyMs = latency
        )
    }

    override fun getProviderName() = "anthropic"
}

Fixa’t en el @ConditionalOnProperty. Amb una sola propietat a application.yml, puc canviar de proveïdor sense recompilar:

llm:
  provider: anthropic
  api-key: ${LLM_API_KEY}
  default-model: claude-sonnet-4-20250514
  timeout-seconds: 30
  max-retries: 2

El servei: on viu la lògica

@Service
class DocumentSummaryService(
    private val llmProvider: LlmProvider,
    private val rateLimiter: LlmRateLimiter,
    private val costTracker: CostTracker
) {
    private val logger = LoggerFactory.getLogger(javaClass)

    suspend fun summarize(document: String, language: String = "es"): SummaryResult {
        rateLimiter.checkLimit()

        val request = LlmRequest(
            systemPrompt = buildSystemPrompt(language),
            userMessage = buildUserPrompt(document),
            model = "claude-sonnet-4-20250514",
            maxTokens = 512,
            temperature = 0.2
        )

        return try {
            val response = llmProvider.complete(request)
            costTracker.record(response.tokensUsed, llmProvider.getProviderName())

            logger.info(
                "Summary generated: provider={}, tokens={}, latency={}ms",
                llmProvider.getProviderName(),
                response.tokensUsed.totalTokens,
                response.latencyMs
            )

            parseSummaryResponse(response.content)
        } catch (e: LlmTimeoutException) {
            logger.warn("LLM timeout after {}ms, returning fallback", e.timeoutMs)
            SummaryResult.fallback(document)
        } catch (e: LlmRateLimitException) {
            logger.error("Rate limit exceeded: {}", e.message)
            throw ServiceUnavailableException("Servicio temporalmente no disponible")
        }
    }

    private fun buildSystemPrompt(language: String): String = """
        Eres un asistente técnico especializado en documentación de software.
        Responde siempre en $language.
        Devuelve un JSON con la estructura: {"title": "...", "summary": "...", "keyPoints": ["..."]}
        No incluyas explicaciones fuera del JSON.
    """.trimIndent()

    private fun buildUserPrompt(document: String): String = """
        Resume el siguiente documento técnico de forma concisa:

        ---
        $document
        ---
    """.trimIndent()
}

El controlador: net i simple

@RestController
@RequestMapping("/api/v1/documents")
class DocumentController(
    private val summaryService: DocumentSummaryService
) {
    @PostMapping("/summarize")
    suspend fun summarize(
        @Valid @RequestBody request: SummarizeRequest
    ): ResponseEntity<SummaryResult> {
        val result = summaryService.summarize(
            document = request.content,
            language = request.language ?: "es"
        )
        return ResponseEntity.ok(result)
    }
}

El controlador no sap que existeix un LLM. Només sap que hi ha un servei que resumeix documents. Això és l’important.


El mateix en Python amb FastAPI

Per a equips que treballen amb Python, l’estructura és idèntica. Només canvia la sintaxi:

from abc import ABC, abstractmethod
from pydantic import BaseModel

class LlmRequest(BaseModel):
    system_prompt: str
    user_message: str
    model: str
    max_tokens: int = 1024
    temperature: float = 0.3

class LlmProvider(ABC):
    @abstractmethod
    async def complete(self, request: LlmRequest) -> LlmResponse:
        pass

class AnthropicProvider(LlmProvider):
    def __init__(self, api_key: str):
        self.client = AsyncAnthropic(api_key=api_key)

    async def complete(self, request: LlmRequest) -> LlmResponse:
        start = time.monotonic()
        response = await self.client.messages.create(
            model=request.model,
            max_tokens=request.max_tokens,
            system=request.system_prompt,
            messages=[{"role": "user", "content": request.user_message}]
        )
        latency = (time.monotonic() - start) * 1000
        return LlmResponse(
            content=response.content[0].text,
            tokens_used=TokenUsage(
                prompt_tokens=response.usage.input_tokens,
                completion_tokens=response.usage.output_tokens
            ),
            latency_ms=latency
        )

La idea central és la mateixa: una interfície, implementacions concretes, injecció de dependències.


Rate limiting i control de costos

Aquest és el punt que tothom ignora fins que arriba la factura. Els LLMs cobren per token, i un sol usuari pot generar centenars de milers de tokens en una sessió.

@Component
class LlmRateLimiter(
    private val config: RateLimitConfig
) {
    private val requestCounts = ConcurrentHashMap<String, AtomicInteger>()
    private val tokenCounts = ConcurrentHashMap<String, AtomicLong>()

    fun checkLimit(userId: String = "global") {
        val requests = requestCounts.getOrPut(userId) { AtomicInteger(0) }
        if (requests.get() >= config.maxRequestsPerMinute) {
            throw LlmRateLimitException("Límite de requests excedido")
        }

        val tokens = tokenCounts.getOrPut(userId) { AtomicLong(0) }
        if (tokens.get() >= config.maxTokensPerHour) {
            throw LlmRateLimitException("Límite de tokens excedido")
        }

        requests.incrementAndGet()
    }

    fun recordUsage(userId: String, tokensUsed: Int) {
        tokenCounts.getOrPut(userId) { AtomicLong(0) }.addAndGet(tokensUsed.toLong())
    }
}

Els límits que acostumo a configurar:

NivellRequests/minTokens/horaTokens/dia
Per usuari1050.000200.000
Global100500.0002.000.000
Alerta-300.0001.500.000

El límit d’alerta és tan important com el límit dur. Si arribes al 60% del teu pressupost diari a les 10 del matí, alguna cosa rara està passant i vols saber-ho abans que sigui tard.

També és bona idea fer seguiment dels costos estimats en temps real:

@Component
class CostTracker(private val meterRegistry: MeterRegistry) {

    private val costPerToken = mapOf(
        "claude-sonnet" to CostPer1kTokens(input = 0.003, output = 0.015),
        "gpt-4o" to CostPer1kTokens(input = 0.005, output = 0.015)
    )

    fun record(usage: TokenUsage, provider: String) {
        val cost = costPerToken[provider]?.let {
            (usage.promptTokens / 1000.0 * it.input) +
            (usage.completionTokens / 1000.0 * it.output)
        } ?: 0.0

        meterRegistry.counter("llm.cost.usd", "provider", provider)
            .increment(cost)
        meterRegistry.counter("llm.tokens.total", "provider", provider)
            .increment(usage.totalTokens.toDouble())
    }
}

Amb aquestes mètriques a Prometheus/Grafana, tens visibilitat total de la despesa. Sense això, vas a cegues.


Error handling: el que falla, fallarà

Els LLMs fallen de maneres creatives. Timeouts llargs, respostes truncades, rate limits del proveïdor, JSON malformat a la resposta, canvis en el format d’output quan actualitzen el model. El teu codi ha d’estar preparat per a tot això.

El meu enfocament: retry amb backoff per a errors transitoris, fallback per a degradació, i circuit breaker per evitar cascades.

@Component
class ResilientLlmProvider(
    private val primary: LlmProvider,
    @Qualifier("fallback") private val fallback: LlmProvider?
) : LlmProvider {

    private val circuitBreaker = CircuitBreaker.ofDefaults("llm-provider")

    override suspend fun complete(request: LlmRequest): LlmResponse {
        return try {
            circuitBreaker.executeSupplier {
                runBlocking { retryWithBackoff { primary.complete(request) } }
            }
        } catch (e: Exception) {
            if (fallback != null) {
                logger.warn("Primary LLM failed, switching to fallback: ${e.message}")
                fallback.complete(request)
            } else {
                throw LlmUnavailableException("LLM no disponible", e)
            }
        }
    }

    private suspend fun <T> retryWithBackoff(
        maxRetries: Int = 2,
        initialDelay: Long = 1000,
        block: suspend () -> T
    ): T {
        var lastException: Exception? = null
        repeat(maxRetries) { attempt ->
            try {
                return block()
            } catch (e: LlmTimeoutException) {
                lastException = e
                delay(initialDelay * (attempt + 1))
            } catch (e: LlmRateLimitException) {
                lastException = e
                delay(initialDelay * (attempt + 1) * 2)
            }
        }
        throw lastException ?: LlmUnavailableException("Max retries exceeded")
    }
}

Errors que cal gestionar explícitament:

ErrorCausa habitualEstratègia
TimeoutModel lent, prompt llargRetry amb backoff, reduir max_tokens
Rate limit (429)Massa crides al proveïdorBackoff exponencial, queue
JSON malformatEl model no segueix instruccionsRe-parsejar, prompt més estricte
Resposta truncadamax_tokens massa baixAugmentar límit, partir la petició
API down (5xx)Proveïdor amb problemesCircuit breaker, fallback a un altre proveïdor

Testing sense cridar el LLM real

Aquest és el punt on l’abstracció del provider demostra el seu valor. Si el teu servei depèn d’una interfície i no d’una implementació concreta, testejar és trivial:

class DocumentSummaryServiceTest {

    private val mockProvider = MockLlmProvider()
    private val rateLimiter = LlmRateLimiter(RateLimitConfig(100, 100000, 1000000))
    private val costTracker = CostTracker(SimpleMeterRegistry())

    private val service = DocumentSummaryService(mockProvider, rateLimiter, costTracker)

    @Test
    fun `should return structured summary for valid document`() = runTest {
        mockProvider.setResponse("""
            {"title": "Resumen", "summary": "Texto resumido", "keyPoints": ["punto 1"]}
        """.trimIndent())

        val result = service.summarize("Un documento técnico largo...")

        assertThat(result.title).isEqualTo("Resumen")
        assertThat(result.keyPoints).hasSize(1)
    }

    @Test
    fun `should return fallback on timeout`() = runTest {
        mockProvider.shouldTimeout = true

        val result = service.summarize("Un documento...")

        assertThat(result.isFallback).isTrue()
    }

    @Test
    fun `should track token usage`() = runTest {
        mockProvider.setResponse("""{"title": "T", "summary": "S", "keyPoints": []}""")
        mockProvider.tokensToReturn = TokenUsage(100, 50)

        service.summarize("Documento de prueba")

        // Verificar que se registró el uso
        assertThat(costTracker.getTotalTokens()).isEqualTo(150)
    }
}

class MockLlmProvider : LlmProvider {
    private var response: String = ""
    var shouldTimeout = false
    var tokensToReturn = TokenUsage(10, 10)

    fun setResponse(content: String) { response = content }

    override suspend fun complete(request: LlmRequest): LlmResponse {
        if (shouldTimeout) throw LlmTimeoutException(30000)
        return LlmResponse(response, tokensToReturn, "mock-model", 50)
    }

    override fun getProviderName() = "mock"
}

Els tests no haurien de dependre d’un servei extern que cobra per ús. Si la teva suite de tests necessita una API key real per passar, tens un problema de disseny.

Per a tests d’integració que sí necessiten validar el format real de les respostes, faig servir un perfil separat amb un pressupost limitat i tests marcats com a @Tag("integration") que només s’executen en CI amb un schedule específic, mai a cada push.


Errors comuns que he vist (i comès)

1. Prompt hardcodejat al servei. Si el prompt està ficat com a string literal al codi, cada canvi requereix recompilar i redesplegar. Millor externalitzar-lo en fitxers de plantilla o configuració.

2. No registrar la latència ni els tokens. Sense aquestes dades, no pots optimitzar res. No saps si un prompt nou és més eficient o més car. Registra sempre: provider, model, tokens d’entrada, tokens de sortida, latència, resultat (ok/error).

3. Acoblar-se a un proveïdor. Això ho veig contínuament. Codi que importa directament com.openai.client al servei de negoci. Quan vols canviar de model o provar un altre proveïdor, has de tocar tota l’aplicació.

4. No posar timeout. El timeout per defecte de molts HTTP clients és de 30 segons o infinit. Un LLM que es penja durant un minut bloqueja un fil del teu servidor. Posa timeouts agressius: 15-20 segons per a la majoria de casos.

5. Ignorar el cost en desenvolupament. He vist entorns de desenvolupament on cada recàrrega de l’aplicació disparava 10 crides al LLM. Multiplicat per 5 desenvolupadors fent hot-reload tot el dia, la despesa es dispara sense generar valor.

6. Parsejar la resposta sense validació. El model no sempre retorna JSON vàlid, per molt que li ho demanis al prompt. Valida sempre i gestiona el cas de resposta malformada.


Quan aquesta arquitectura és suficient (i quan no)

Aquesta estructura cobreix bé el 80% dels casos: un backend que necessita cridar un LLM per a una funcionalitat concreta, amb control de costos i resiliència bàsica.

No és suficient quan:

  • Necessites streaming de respostes token a token (requereix SSE o WebSockets).
  • Tens cadenes de crides a LLM (RAG, agents) que necessiten orquestració.
  • El volum de crides justifica un gateway de LLM dedicat.
  • Necessites enrutar entre models segons la complexitat de la petició.

Per a aquests casos, l’arquitectura creix, però sempre sobre aquesta base. L’abstracció del provider, el control de costos i l’error handling segueixen sent necessaris. Només afegeixes capes a sobre.


El que importa al final

La integració de LLMs en backend no és un problema d’IA. És un problema d’enginyeria de programari. Les mateixes pràctiques que apliques per integrar qualsevol servei extern (abstracció, resiliència, observabilitat, testing) funcionen aquí.

La diferència és que els LLMs són cars, lents i impredictibles comparats amb la majoria d’APIs. Per això la disciplina ha de ser major, no menor.

Si t’emportes alguna cosa d’aquest article: no comencis pel model o el prompt. Comença per l’arquitectura. Un bon prompt en una mala arquitectura és un problema futur. Una bona arquitectura amb un prompt mediocre s’arregla en una tarda.

OshyTech

Enginyeria backend i de dades orientada a sistemes escalables, automatització i IA.

Navegació

Copyright 2026 OshyTech. Tots els drets reservats