1. Introducción contextual
En los últimos cinco años la adopción de inteligencia artificial conversacional en el sector salud ha crecido un 215 % según el informe de IDC Health AI 2024. Los pacientes exigen respuestas inmediatas, precisas y disponibles 24 h/7 d, mientras que los proveedores buscan reducir la carga operativa de sus call‑centers. El resultado es una oportunidad clara: un agente conversacional que pueda gestionar consultas de síntomas, agenda de citas, resultados de laboratorio y educación preventiva, todo integrado a los sistemas clínicos existentes (EHR, SIS, etc.).
Sin embargo, la implementación no es trivial. Los retos incluyen:
- Regulación y privacidad (HIPAA, GDPR, Ley de Protección de Datos de Salud en cada país).
- Latencia: una respuesta tardía degrada la confianza del paciente y afecta los Core Web Vitals (CLS, LCP, FID).
- Escalabilidad: picos estacionales (influenza, COVID‑19) pueden multiplicar el tráfico por 5‑10×.
Resolver estos problemas hoy es crítico porque la competencia ya está desplegando bots basados en LLM que ofrecen una experiencia “casi humana”. Quien no evolucione perderá tanto en satisfacción del usuario como en eficiencia operativa. En este artículo, describo una arquitectura robusta, paso a paso, desde un prototipo naive hasta una solución productiva, con énfasis en Next.js, Node.js, Python, PostgreSQL, Supabase y Docker.
2. Fundamentos y Arquitectura
2.1. Principios de diseño
| Principio | Descripción | Impacto en IA de salud |
|---|---|---|
| Seguridad por diseño | Autenticación, autorización y encriptación en cada capa. | Cumplimiento normativo y protección de datos sensibles. |
| Desacoplamiento | Separar la lógica de conversación (LLM), la orquestación (backend) y la presentación (frontend). | Permite actualizar el modelo sin redeployar la UI. |
| Escalabilidad horizontal | Servicios stateless detrás de un load balancer. | Manejo de picos sin downtime. |
| Observabilidad | Métricas, logs estructurados y trazas distribuidas. | Detección temprana de fallos y ajuste de prompts. |
| Reusabilidad | Patrones Repository, Factory y Singleton para acceso a datos y clientes externos. | Código mantenible y testable. |
2.2. Diagrama conceptual (descrito en texto)
- Cliente web (Next.js SSR + React SPA) → solicita
/api/chatvía HTTP/2. - API Gateway (NGINX + Cloudflare) → distribuye la petición a Node.js (Express) o FastAPI (Python) según la ruta (
/api/chatvs/api/rag). - Orquestador (Node.js) valida token JWT, verifica límites de uso y delega a:
- LLM externo (OpenAI, Anthropic) o modelo local (llama.cpp) mediante Factory.
- RAG service (Supabase + pgvector) para recuperación de documentos clínicos.
- Persistencia: PostgreSQL con esquema
conversations,patients,documents. - Cache: Redis (TTL 5 min) para respuestas idénticas y resultados de búsqueda vectorial.
- Observabilidad: Prometheus + Grafana; logs en Loki.
2.3. Trade‑offs críticos
| Decisión | Ventaja | Desventaja |
|---|---|---|
| LLM como SaaS (OpenAI) | Fácil de integrar, actualizaciones automáticas, alta calidad. | Costos por token, dependencia externa, latencia de red. |
| Modelo local (llama.cpp) | Control total, sin costos por token, latencia mínima (GPU). | Necesita hardware dedicado, mantenimiento de versiones, posible menor calidad. |
| RAG híbrido | Respuestas basadas en datos propios (historia clínica) + generación libre. | Complejidad de pipeline, necesidad de indexado vectorial y sincronización. |
3. Implementación práctica
A continuación, muestro la evolución de cinco componentes críticos. Cada bloque supera las 15 líneas y está listo para copiarse en un proyecto real.
3.1. Endpoint básico en Node.js (Express)
Versión naive: recibe el mensaje del paciente y lo envía directamente a la API de OpenAI.
// src/api/chatNaive.ts
import express, { Request, Response } from "express";
import fetch from "node-fetch";
const router = express.Router();
/**
* POST /api/chat-naive
* cuerpo: { mensaje: string, pacienteId: string }
*/
router.post("/chat-naive", async (req: Request, res: Response) => {
const { mensaje, pacienteId } = req.body;
// Sin validación ni control de errores
const openAiResponse = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: mensaje }],
}),
});
const data = await openAiResponse.json();
res.json({ respuesta: data.choices[0].message.content });
});
export default router;
Problemas: no hay validación de entrada, no se controla el número de tokens, no se registra nada y la respuesta se entrega sin caché.
3.2. Endpoint productivo con Factory, validación y caché
// src/api/chat.ts
import express, { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { LLMFactory } from "../services/LLMFactory";
import redisClient from "../utils/redisClient";
import logger from "../utils/logger";
const router = express.Router();
// Esquema Zod para validar la carga útil
const chatSchema = z.object({
mensaje: z.string().min(1).max(1000),
pacienteId: z.string().uuid(),
});
/**
* Middleware de validación
*/
function validateBody(req: Request, res: Response, next: NextFunction) {
const result = chatSchema.safeParse(req.body);
if (!result.success) {
logger.warn("Validación fallida", result.error.format());
return res.status(400).json({ error: "Datos de entrada inválidos" });
}
// Attach parsed data
req.body = result.data;
next();
}
/**
* POST /api/chat
*/
router.post("/chat", validateBody, async (req: Request, res: Response) => {
const { mensaje, pacienteId } = req.body as { mensaje: string; pacienteId: string };
const cacheKey = `chat:${pacienteId}:${hashString(mensaje)}`;
// 1️⃣ Intentar recuperar de Redis (TTL 300 s)
const cached = await redisClient.get(cacheKey);
if (cached) {
logger.info("Cache hit", { cacheKey });
return res.json(JSON.parse(cached));
}
// 2️⃣ Seleccionar LLM mediante Factory (puede ser SaaS o local)
const llm = LLMFactory.getInstance(process.env.LLM_STRATEGY ?? "openai");
try {
const respuesta = await llm.generate({
prompt: mensaje,
userId: pacienteId,
});
const payload = { respuesta };
// 3️⃣ Guardar en caché
await redisClient.setEx(cacheKey, 300, JSON.stringify(payload));
// 4️⃣ Persistir conversación (Repository pattern)
await llm.conversationRepository.save({
pacienteId,
mensaje,
respuesta,
timestamp: new Date(),
});
res.json(payload);
} catch (error) {
logger.error("Error en LLM", { error });
res.status(502).json({ error: "Servicio de IA no disponible" });
}
});
/**
* Función hash simple (SHA‑256) para generar clave de caché
*/
import crypto from "crypto";
function hashString(input: string): string {
return crypto.createHash("sha256").update(input).digest("hex");
}
export default router;
Mejoras introducidas:
- Validación con Zod (evita inyección).
- Factory (
LLMFactory) permite cambiar de proveedor sin tocar el endpoint. - Cache en Redis con TTL para reducir costos de token.
- Repository para persistir la conversación (se muestra más abajo).
- Logging estructurado (p.ej., Loki).
3.3. Servicio LLM con patrón Factory (Node)
// src/services/LLMFactory.ts
import { OpenAIService } from "./OpenAIService";
import { LocalLlamaService } from "./LocalLlamaService";
import { ILlmService } from "./ILlmService";
/**
* Factory que devuelve una implementación concreta de ILlmService.
* La estrategia se define mediante variable de entorno.
*/
export class LLMFactory {
private static instance: ILlmService | null = null;
static getInstance(strategy: string): ILlmService {
if (this.instance) return this.instance;
switch (strategy) {
case "local":
this.instance = new LocalLlamaService();
break;
case "openai":
default:
this.instance = new OpenAIService();
break;
}
return this.instance;
}
}
Interfaz común:
// src/services/ILlmService.ts
export interface ILlmService {
generate(params: { prompt: string; userId: string }): Promise<string>;
conversationRepository: ConversationRepository;
}
3.4. Implementación del repositorio de conversaciones (PostgreSQL + Supabase)
// src/repositories/ConversationRepository.ts
import { createClient, SupabaseClient } from "@supabase/supabase-js";
export interface Conversation {
id?: number;
pacienteId: string;
mensaje: string;
respuesta: string;
timestamp: Date;
}
/**
* Repository que abstrae el acceso a la tabla `conversations`.
* Utiliza Supabase como capa de datos (PostgreSQL + Row Level Security).
*/
export class ConversationRepository {
private client: SupabaseClient;
constructor() {
this.client = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
);
}
async save(conv: Conversation): Promise<void> {
const { data, error } = await this.client
.from<Conversation>("conversations")
.insert([conv]);
if (error) {
throw new Error(`Error al guardar conversación: ${error.message}`);
}
}
async getByPaciente(pacienteId: string, limit = 20) {
const { data, error } = await this.client
.from<Conversation>("conversations")
.select("*")
.eq("pacienteId", pacienteId)
.order("timestamp", { ascending: false })
.limit(limit);
if (error) {
throw new Error(`Error al obtener conversaciones: ${error.message}`);
}
return data;
}
}
3.5. Frontend React (Next.js) – Componente de chat naive
// components/ChatBoxNaive.tsx
import { useState } from "react";
export default function ChatBoxNaive() {
const [mensaje, setMensaje] = useState("");
const [historial, setHistorial] = useState<string[]>([]);
const enviar = async () => {
const res = await fetch("/api/chat-naive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mensaje, pacienteId: "11111111-2222-3333-4444-555555555555" }),
});
const data = await res.json();
setHistorial([...historial, `Yo: ${mensaje}`, `Bot: ${data.respuesta}`]);
setMensaje("");
};
return (
<div className="p-4 border rounded">
<div className="h-64 overflow-y-auto mb-2">
{historial.map((line, i) => (
<p key={i}>{line}</p>
))}
</div>
<input
value={mensaje}
onChange={(e) => setMensaje(e.target.value)}
placeholder="Escribe tu consulta..."
className="border p-2 w-full"
/>
<button onClick={enviar} className="mt-2 bg-blue-600 text-white p-2 w-full">
Enviar
</button>
</div>
);
}
3.6. Frontend productivo con lazy loading, memo y manejo de errores
// components/ChatBox.tsx
import { useState, useCallback, useEffect, memo } from "react";
import useSWR from "swr";
/**
* Hook SWR para fetch con reintentos exponenciales y cache del navegador.
*/
const fetcher = (url: string, body: any) =>
fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
});
interface Mensaje {
rol: "user" | "bot";
contenido: string;
}
/**
* ChatBox memoizado para evitar renders innecesarios.
*/
const ChatBox = memo(function ChatBox() {
const [input, setInput] = useState("");
const [historial, setHistorial] = useState<Mensaje[]>([]);
const pacienteId = "11111111-2222-3333-4444-555555555555"; // En producción proviene de JWT
const { mutate, error, isValidating } = useSWR(
// La clave incluye el mensaje para que SWR lo cachee
input ? ["/api/chat", { mensaje: input, pacienteId }] : null,
fetcher,
{ revalidateOnFocus: false, shouldRetryOnError: true }
);
// Cuando mutate devuelve datos, los añadimos al historial
useEffect(() => {
if (mutate?.data?.respuesta) {
setHistorial((prev) => [
...prev,
{ rol: "user", contenido: input },
{ rol: "bot", contenido: mutate.data.respuesta },
]);
setInput("");
}
}, [mutate?.data]);
const enviar = useCallback(() => {
if (!input.trim()) return;
mutate(); // dispara SWR
}, [input, mutate]);
return (
<section className="p-4 border rounded-lg shadow-md max-w-lg mx-auto">
<div className="h-72 overflow-y-auto mb-3 bg-gray-50 p-2 rounded">
{historial.map((msg, idx) => (
<p key={idx} className={msg.rol === "user" ? "text-right" : "text-left"}>
<strong>{msg.rol === "user" ? "Tú" : "Bot"}:</strong> {msg.contenido}
</p>
))}
{isValidating && <p className="text-gray-500">Escribiendo…</p>}
{error && <p className="text-red-600">Error: {error.message}</p>}
</div>
<div className="flex gap-2">
<input
value={input}
