Arquitectura mínima para integrar un LLM en una aplicación backend
Cómo integrar un LLM en un backend real: arquitectura por capas, abstracción de proveedor, límites, errores, costes y tests.

La primera vez que integré un LLM en un backend de producción, cometí todos los errores posibles. Llamada directa al API de OpenAI desde el controlador, sin timeout, sin fallback, sin control de costes. Funcionaba en local, pasaba los tests manuales y, por supuesto, a las dos semanas tuvimos un pico de tráfico que dejó una factura de API bastante incómoda y un servicio degradado porque un timeout de 30 segundos bloqueaba hilos del servidor.
Desde entonces he refinado bastante la forma de plantear esto. No hace falta una arquitectura de NASA, pero sí una estructura mínima que te evite los problemas más habituales. Esto es lo que uso hoy, y lo que recomiendo a cualquier equipo que esté empezando a meter LLMs en backend.
El problema real: no es llamar al LLM, es todo lo demás
Hacer una llamada a un LLM es trivial. Un POST a un endpoint, un JSON de vuelta. Cualquier tutorial te lo enseña en 10 minutos.
El problema real aparece cuando esa llamada tiene que convivir con:
- Usuarios concurrentes que disparan peticiones simultáneas.
- Un proveedor de LLM que puede tardar 3 segundos o 45 dependiendo del modelo y la carga.
- Costes que escalan por token, no por request.
- Modelos que cambian de versión sin aviso y rompen tu parsing de respuesta.
- La necesidad de testear sin gastar dinero en cada
mvn test.
Si no separas responsabilidades desde el principio, acabas con un monolito de spaghetti donde el controlador conoce los detalles del proveedor, el formato del prompt y la lógica de reintentos. Y eso, en producción, se paga.
Arquitectura por capas: lo mínimo que funciona
La estructura que uso tiene tres capas bien definidas. No es nada revolucionario, es simplemente separación de responsabilidades aplicada a LLMs:
┌─────────────────────────────┐
│ Controller │ ← Recibe request HTTP, valida input
├─────────────────────────────┤
│ Service │ ← Lógica de negocio, prompt templates, parsing
├─────────────────────────────┤
│ LLM Provider │ ← Abstracción del proveedor (OpenAI, Anthropic, local)
├─────────────────────────────┤
│ Rate Limiter / Cache │ ← Control de costes, rate limits, caché
└─────────────────────────────┘Cada capa tiene una responsabilidad clara:
Controller: Recibe la petición, valida parámetros, devuelve la respuesta formateada. No sabe nada de prompts ni de LLMs.
Service: Construye el prompt, llama al provider, parsea la respuesta, aplica lógica de negocio. Es donde vive el cerebro de la feature.
LLM Provider: Abstracción sobre el proveedor concreto. Sabe hacer una llamada a un LLM y devolver texto. Nada más.
Rate Limiter / Cache: Capa transversal que controla cuántas llamadas haces y cachea respuestas cuando tiene sentido.
Ejemplo práctico: Kotlin con Spring Boot
Vamos a implementar esto con un caso concreto: un endpoint que recibe un texto técnico y devuelve un resumen estructurado. Algo que podrías encontrar en un servicio de procesamiento de documentación.
La interfaz del provider
Lo primero es la abstracción. No quiero que mi servicio sepa si estoy usando OpenAI, Anthropic o un modelo 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 clave de esta interfaz es que es genérica. Cualquier proveedor que pueda recibir un prompt y devolver texto encaja aquí. Eso te permite cambiar de proveedor sin tocar el servicio.
Implementación para un proveedor concreto
@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"
}Fíjate en el @ConditionalOnProperty. Con una sola propiedad en application.yml, puedo cambiar de proveedor sin recompilar:
llm:
provider: anthropic
api-key: ${LLM_API_KEY}
default-model: claude-sonnet-4-20250514
timeout-seconds: 30
max-retries: 2El servicio: donde vive 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: limpio y 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 sabe que existe un LLM. Solo sabe que hay un servicio que resume documentos. Eso es lo importante.
Lo mismo en Python con FastAPI
Para equipos que trabajan con Python, la estructura es idéntica. Solo cambia la sintaxis:
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 es la misma: una interfaz, implementaciones concretas, inyección de dependencias.
Rate limiting y control de costes
Este es el punto que todo el mundo ignora hasta que llega la factura. Los LLMs cobran por token, y un solo usuario puede generar cientos de miles de tokens en una sesión.
@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())
}
}Los límites que suelo configurar:
| Nivel | Requests/min | Tokens/hora | Tokens/día |
|---|---|---|---|
| Por usuario | 10 | 50.000 | 200.000 |
| Global | 100 | 500.000 | 2.000.000 |
| Alerta | - | 300.000 | 1.500.000 |
El límite de alerta es tan importante como el límite duro. Si llegas al 60% de tu presupuesto diario a las 10 de la mañana, algo raro está pasando y quieres saberlo antes de que sea tarde.
También es buena idea trackear costes estimados en tiempo 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())
}
}Con esas métricas en Prometheus/Grafana, tienes visibilidad total del gasto. Sin esto, vas a ciegas.
Error handling: lo que falla, fallará
Los LLMs fallan de formas creativas. Timeouts largos, respuestas truncadas, rate limits del proveedor, JSON malformado en la respuesta, cambios en el formato de output cuando actualizan el modelo. Tu código tiene que estar preparado para todo eso.
Mi enfoque: retry con backoff para errores transitorios, fallback para degradación, y circuit breaker para evitar cascadas.
@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")
}
}Errores que hay que manejar explícitamente:
| Error | Causa habitual | Estrategia |
|---|---|---|
| Timeout | Modelo lento, prompt largo | Retry con backoff, reducir max_tokens |
| Rate limit (429) | Demasiadas llamadas al proveedor | Backoff exponencial, queue |
| JSON malformado | El modelo no sigue instrucciones | Re-parsear, prompt más estricto |
| Respuesta truncada | max_tokens demasiado bajo | Aumentar límite, partir la petición |
| API down (5xx) | Proveedor con problemas | Circuit breaker, fallback a otro proveedor |
Testing sin llamar al LLM real
Este es el punto donde la abstracción del provider demuestra su valor. Si tu servicio depende de una interfaz y no de una implementación concreta, testear es 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"
}Los tests no deberían depender de un servicio externo que cobra por uso. Si tu suite de tests necesita una API key real para pasar, tienes un problema de diseño.
Para tests de integración que sí necesitan validar el formato real de las respuestas, uso un perfil separado con un presupuesto limitado y tests marcados como @Tag("integration") que solo se ejecutan en CI con un schedule específico, nunca en cada push.
Errores comunes que he visto (y cometido)
1. Prompt hardcodeado en el servicio. Si el prompt está metido como string literal en el código, cada cambio requiere recompilar y redesplegar. Mejor externalizarlo en archivos de plantilla o configuración.
2. No logear la latencia ni los tokens. Sin esos datos, no puedes optimizar nada. No sabes si un prompt nuevo es más eficiente o más caro. Loguea siempre: provider, modelo, tokens de entrada, tokens de salida, latencia, resultado (ok/error).
3. Acoplarse a un proveedor. Esto lo veo continuamente. Código que importa directamente com.openai.client en el servicio de negocio. Cuando quieres cambiar de modelo o probar otro proveedor, tienes que tocar toda la aplicación.
4. No poner timeout. El timeout por defecto de muchos HTTP clients es 30 segundos o infinito. Un LLM que se cuelga durante un minuto bloquea un hilo de tu servidor. Pon timeouts agresivos: 15-20 segundos para la mayoría de casos.
5. Ignorar el coste en desarrollo. He visto entornos de desarrollo donde cada recarga de la aplicación disparaba 10 llamadas al LLM. Multiplicado por 5 desarrolladores haciendo hot-reload todo el día, el gasto se dispara sin generar valor.
6. Parsear la respuesta sin validación. El modelo no siempre devuelve JSON válido, por mucho que se lo pidas en el prompt. Siempre valida y maneja el caso de respuesta malformada.
Cuándo esta arquitectura es suficiente (y cuándo no)
Esta estructura cubre bien el 80% de los casos: un backend que necesita llamar a un LLM para una funcionalidad concreta, con control de costes y resiliencia básica.
No es suficiente cuando:
- Necesitas streaming de respuestas token a token (requiere SSE o WebSockets).
- Tienes cadenas de llamadas a LLM (RAG, agents) que necesitan orquestación.
- El volumen de llamadas justifica un gateway de LLM dedicado.
- Necesitas enrutar entre modelos según la complejidad de la petición.
Para esos casos, la arquitectura crece, pero siempre sobre esta base. La abstracción del provider, el control de costes y el error handling siguen siendo necesarios. Solo añades capas encima.
Lo que importa al final
La integración de LLMs en backend no es un problema de IA. Es un problema de ingeniería de software. Las mismas prácticas que aplicas para integrar cualquier servicio externo (abstracción, resiliencia, observabilidad, testing) funcionan aquí.
La diferencia es que los LLMs son caros, lentos e impredecibles comparados con la mayoría de APIs. Por eso la disciplina tiene que ser mayor, no menor.
Si te llevas algo de este artículo: no empieces por el modelo o el prompt. Empieza por la arquitectura. Un buen prompt en una mala arquitectura es un problema futuro. Una buena arquitectura con un prompt mediocre se arregla en una tarde.


