Python es un lenguaje de programación creado en 1991. Es uno de los más populares del mundo, especialmente en el campo de la Inteligencia Artificial, por tres razones clave:
Sintaxis clara: El código se parece al inglés, lo que lo hace muy legible.
Ecosistema enorme: Existen miles de librerías gratuitas para IA, ciencia de datos, APIs, etc.
Comunidad: Millones de desarrolladores lo usan, así que siempre hay ayuda disponible.
Python es de tipado dinámico — igual que JavaScript
Python no te obliga a declarar el tipo de una variable. El intérprete lo deduce solo en tiempo de ejecución:
Sin embargo, desde Python 3.5+ existe el sistema de type hints: anotaciones opcionales que no impone el intérprete pero sí usan herramientas como FastAPI y Pydantic para validar datos automáticamente:
Regla práctica: En scripts personales puedes ignorar los type hints. En APIs y proyectos de equipo, úsalos siempre — te ahorrarán horas de depuración.
# Matemáticassuma = 10 + 5 # 15resta = 10 - 3 # 7multiplicacion = 4 * 6 # 24division = 15 / 4 # 3.75 (siempre devuelve float)division_entera = 15 // 4 # 3 (descarta los decimales)modulo = 15 % 4 # 3 (el resto de la división)potencia = 2 ** 8 # 256# Con texto (concatenación)nombre = "Carlos"saludo = "Hola, " + nombre + "!"print(saludo) # Hola, Carlos!# Forma moderna (f-strings) — mucho más cómodasaludo = f"Hola, {nombre}! Tienes {edad} años."print(saludo) # Hola, Carlos! Tienes 28 años.
🔥 Trucos pro con f-strings
Las f-strings son más potentes de lo que parecen. Esto no se suele enseñar al principio pero te ahorrará mucho código:
python
precio = 1234567.891pi = 3.14159265# Formatear númerosprint(f"{precio:,.2f}") # 1,234,567.89 — separador de miles y 2 decimalesprint(f"{pi:.4f}") # 3.1416 — 4 decimalesprint(f"{0.75:.0%}") # 75% — porcentaje# Alinear texto (útil para tablas en consola)print(f"{'Producto':<15} {'Precio':>10}") # alineado izquierda / derechaprint(f"{'Portátil':<15} {999.99:>10.2f}")# Modo debug (Python 3.8+): muestra el nombre y el valorx = 42print(f"{x=}") # x=42 — muy útil para depurarnombre = "Ana"edad = 28print(f"{nombre=}, {edad=}") # nombre='Ana', edad=28# Expresiones directamente dentro de las llavesnumeros = [3, 1, 8, 2]print(f"Máximo: {max(numeros)}, Suma: {sum(numeros)}")print(f"{'par' if x % 2 == 0 else 'impar'}") # par
1.6 Condicionales (if / elif / else)
Los condicionales permiten que tu programa tome decisiones.
python
edad = 20if edad >= 18: print("Eres mayor de edad")elif edad >= 13: print("Eres adolescente")else: print("Eres menor de edad")
Importante: En Python, la indentación (los espacios al inicio de línea) es obligatoria. Todo lo que esté dentro de un if debe tener 4 espacios de margen. Si no los pones, el programa fallará.
python
# Ejemplo con varias condicionestemperatura = 25if temperatura > 30: print("Hace mucho calor")elif temperatura > 20: print("Temperatura agradable")elif temperatura > 10: print("Algo fresco")else: print("Hace frío")
1.7 Bucles
Los bucles repiten código varias veces.
Bucle for — repite un número conocido de veces
python
# Contar del 0 al 4for i in range(5): print(f"Iteración número {i}")# Contar del 1 al 10for numero in range(1, 11): print(numero)# Contar de 2 en 2for par in range(0, 20, 2): print(par) # 0, 2, 4, 6, ..., 18
Bucle while — repite mientras se cumpla una condición
Una lista es una colección ordenada de elementos. En Python se definen con corchetes [].
python
# Crear una listafrutas = ["manzana", "banana", "naranja", "uva"]# Acceder a elementos (empieza en 0)print(frutas[0]) # manzanaprint(frutas[1]) # bananaprint(frutas[-1]) # uva (el último)# Modificar un elementofrutas[1] = "mango"print(frutas) # ['manzana', 'mango', 'naranja', 'uva']# Añadir elementosfrutas.append("kiwi") # Al finalfrutas.insert(1, "pera") # En una posición específica# Eliminar elementosfrutas.remove("naranja") # Por valorfrutas.pop() # El últimofrutas.pop(0) # Por posición# Longitud de la listaprint(len(frutas))# Recorrer una lista con forfor fruta in frutas: print(f"- {fruta}")
🔥 Trucos pro con listas
python
# Slicing — extraer porciones de una listanumeros = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]print(numeros[2:5]) # [2, 3, 4] — del índice 2 al 4print(numeros[:3]) # [0, 1, 2] — los primeros 3print(numeros[-3:]) # [7, 8, 9] — los últimos 3print(numeros[::2]) # [0, 2, 4, 6, 8] — de 2 en 2print(numeros[::-1]) # [9,8,7,...,0] — invertir la lista ← truco clásico# Desempaquetar listas (unpacking)primero, *resto = numerosprint(primero) # 0print(resto) # [1, 2, 3, 4, 5, 6, 7, 8, 9]a, b, *medio, ultimo = [1, 2, 3, 4, 5]print(a, b, medio, ultimo) # 1 2 [3, 4] 5# Combinar listaslista_a = [1, 2, 3]lista_b = [4, 5, 6]combinada = lista_a + lista_b # [1,2,3,4,5,6]combinada = [*lista_a, *lista_b] # Igual, con spread operator# Ordenarnums = [3, 1, 8, 2, 9]nums.sort() # Modifica la lista originalordenada = sorted(nums) # Devuelve una nueva listaordenada_desc = sorted(nums, reverse=True)# Comprobar si un elemento existeif "manzana" in frutas: print("¡Hay manzanas!")# enumerate — iterar con índice sin usar range(len(...))for i, fruta in enumerate(frutas, start=1): print(f"{i}. {fruta}")# zip — combinar dos listas en paralelonombres = ["Ana", "Luis", "Sara"]edades = [25, 30, 22]for nombre, edad in zip(nombres, edades): print(f"{nombre} tiene {edad} años")
1.9 Diccionarios
Un diccionario guarda pares clave-valor. Son fundamentales en Python y en el trabajo con APIs e IA.
python
# Crear un diccionariopersona = { "nombre": "Lucía", "edad": 30, "ciudad": "Madrid", "es_programadora": True}# Acceder a valoresprint(persona["nombre"]) # Lucíaprint(persona["edad"]) # 30# Forma segura (no falla si la clave no existe)print(persona.get("telefono", "Sin teléfono")) # Sin teléfono# Añadir o modificarpersona["profesion"] = "Desarrolladora"persona["edad"] = 31# Eliminardel persona["ciudad"]# Recorrer un diccionariofor clave, valor in persona.items(): print(f"{clave}: {valor}")# Diccionarios anidados (muy comunes en respuestas de APIs)usuario = { "id": 1, "datos": { "nombre": "Pedro", "correo": "pedro@ejemplo.com" }, "roles": ["admin", "usuario"]}print(usuario["datos"]["nombre"]) # Pedroprint(usuario["roles"][0]) # admin
1.10 Funciones
Las funciones son bloques de código reutilizable. Se definen con la palabra def.
python
# Función básicadef saludar(): print("¡Hola!")saludar() # Llamas a la función así# Función con parámetrosdef saludar_a(nombre): print(f"¡Hola, {nombre}!")saludar_a("María")# Función que devuelve un valordef sumar(a, b): resultado = a + b return resultadototal = sumar(5, 3)print(total) # 8# Función con valores por defectodef presentar(nombre, pais="España"): return f"Me llamo {nombre} y soy de {pais}"print(presentar("Carlos")) # Me llamo Carlos y soy de Españaprint(presentar("Ana", "México")) # Me llamo Ana y soy de México# Función con múltiples valores de retornodef minmax(lista): return min(lista), max(lista)minimo, maximo = minmax([3, 1, 8, 2, 9, 4])print(f"Mínimo: {minimo}, Máximo: {maximo}") # Mínimo: 1, Máximo: 9
Módulo 2 — Python Intermedio
2.1 Manejo de errores con try/except
Los errores ocurren. Saber manejarlos es crucial, especialmente cuando trabajas con APIs externas.
python
# Sin manejo de errores (el programa se rompe)numero = int("abc") # ValueError!# Con manejo de errorestry: numero = int("abc") print(f"El número es: {numero}")except ValueError: print("Error: eso no es un número válido")# Múltiples tipos de errordef dividir(a, b): try: resultado = a / b return resultado except ZeroDivisionError: print("Error: no se puede dividir entre cero") return None except TypeError: print("Error: los valores deben ser números") return None finally: # Este bloque SIEMPRE se ejecuta, haya error o no print("Operación de división finalizada")print(dividir(10, 2)) # 5.0print(dividir(10, 0)) # Error: no se puede dividir entre ceroprint(dividir(10, "a")) # Error: los valores deben ser números
2.2 Clases y Programación Orientada a Objetos (POO)
Las clases son plantillas para crear objetos. LangChain y FastAPI hacen un uso intensivo de ellas.
python
# Definir una claseclass Persona: # El método __init__ se llama al crear un objeto def __init__(self, nombre, edad): self.nombre = nombre # self.xxx son atributos del objeto self.edad = edad # Métodos (funciones dentro de la clase) def saludar(self): return f"Hola, me llamo {self.nombre} y tengo {self.edad} años" def cumpleaños(self): self.edad += 1 return f"¡Feliz cumpleaños {self.nombre}! Ahora tienes {self.edad} años" # Representación en texto del objeto def __str__(self): return f"Persona({self.nombre}, {self.edad})"# Crear objetos (instancias de la clase)persona1 = Persona("Laura", 25)persona2 = Persona("Marcos", 32)print(persona1.saludar())print(persona2.cumpleaños())print(persona1) # Usa __str__# Herencia — una clase que extiende otraclass Programador(Persona): def __init__(self, nombre, edad, lenguaje_favorito): super().__init__(nombre, edad) # Llama al __init__ de Persona self.lenguaje_favorito = lenguaje_favorito def programar(self): return f"{self.nombre} está programando en {self.lenguaje_favorito}"dev = Programador("Sara", 28, "Python")print(dev.saludar()) # Hereda el método de Personaprint(dev.programar()) # Método propio de Programador
2.3 Módulos y paquetes
En Python, puedes dividir tu código en múltiples archivos y reutilizarlo fácilmente.
python
# archivo: matematicas.pydef sumar(a, b): return a + bdef restar(a, b): return a - bPI = 3.14159
python
# archivo: main.pyimport matematicasresultado = matematicas.sumar(5, 3)print(resultado) # 8print(matematicas.PI) # 3.14159# También puedes importar solo lo que necesitasfrom matematicas import sumar, PIresultado = sumar(10, 5) # Sin necesidad de poner "matematicas."
2.4 Trabajar con archivos
python
# Escribir en un archivowith open("datos.txt", "w", encoding="utf-8") as archivo: archivo.write("Primera línea\n") archivo.write("Segunda línea\n")# Leer un archivo completowith open("datos.txt", "r", encoding="utf-8") as archivo: contenido = archivo.read() print(contenido)# Leer línea por líneawith open("datos.txt", "r", encoding="utf-8") as archivo: for linea in archivo: print(linea.strip()) # strip() elimina espacios y saltos de línea# Trabajar con JSON (muy común en APIs e IA)import json# Guardar un diccionario como JSONdatos = { "nombre": "Pepe", "edad": 40, "habilidades": ["Python", "IA", "FastAPI"]}with open("usuario.json", "w", encoding="utf-8") as f: json.dump(datos, f, ensure_ascii=False, indent=2)# Leer JSONwith open("usuario.json", "r", encoding="utf-8") as f: usuario = json.load(f) print(usuario["nombre"]) # Pepe
2.5 List comprehensions y expresiones útiles
Las list comprehensions son una forma elegante y concisa de crear listas.
python
# Forma tradicionalcuadrados = []for i in range(10): cuadrados.append(i ** 2)# Con list comprehension (equivalente, pero más limpio)cuadrados = [i ** 2 for i in range(10)]print(cuadrados) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]# Con condiciónpares = [i for i in range(20) if i % 2 == 0]print(pares) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]# Transformar listasnombres = ["ana", "CARLOS", "María"]normalizados = [n.lower().capitalize() for n in nombres]print(normalizados) # ['Ana', 'Carlos', 'María']# Dict comprehensionprecios = {"manzana": 1.2, "banana": 0.5, "naranja": 0.8}precios_con_iva = {fruta: round(precio * 1.21, 2) for fruta, precio in precios.items()}print(precios_con_iva)
2.6 Funciones avanzadas: *args, **kwargs y lambdas
python
# *args: acepta un número variable de argumentosdef sumar_todo(*numeros): return sum(numeros)print(sumar_todo(1, 2, 3)) # 6print(sumar_todo(1, 2, 3, 4, 5)) # 15# **kwargs: acepta argumentos con nombre variabledef crear_perfil(**datos): for clave, valor in datos.items(): print(f"{clave}: {valor}")crear_perfil(nombre="Julia", edad=25, ciudad="Barcelona")# Lambda: función pequeña de una sola líneadoblar = lambda x: x * 2print(doblar(5)) # 10# Muy útil con sorted, map, filternumeros = [3, 1, 8, 2, 9, 4]ordenados = sorted(numeros, key=lambda x: -x) # Orden descendenteprint(ordenados) # [9, 8, 4, 3, 2, 1]
2.7 Decoradores — el patrón más usado en FastAPI
Los decoradores son una de las características más potentes de Python. FastAPI los usa constantemente (@app.get, @app.post...). Entender cómo funcionan por dentro te hará un mejor programador.
Un decorador es una función que envuelve a otra función para añadirle comportamiento extra.
python
import timeimport functools# Decorador básico: medir el tiempo de ejecucióndef medir_tiempo(funcion): @functools.wraps(funcion) # Preserva el nombre y docstring original def wrapper(*args, **kwargs): inicio = time.time() resultado = funcion(*args, **kwargs) fin = time.time() print(f"⏱ {funcion.__name__} tardó {fin - inicio:.4f}s") return resultado return wrapper@medir_tiempodef tarea_pesada(): time.sleep(1) return "hecho"tarea_pesada() # ⏱ tarea_pesada tardó 1.0012s# Decorador con parámetros: reintentar N veces si falladef reintentar(veces=3, espera=1.0): def decorador(funcion): @functools.wraps(funcion) def wrapper(*args, **kwargs): for intento in range(1, veces + 1): try: return funcion(*args, **kwargs) except Exception as e: print(f"Intento {intento}/{veces} falló: {e}") if intento < veces: time.sleep(espera) raise RuntimeError(f"Falló tras {veces} intentos") return wrapper return decorador@reintentar(veces=3, espera=0.5)def llamar_api_externa(): import random if random.random() < 0.7: raise ConnectionError("Timeout") return {"datos": "ok"}# Muy útil cuando llamas a APIs de IA que a veces fallan por rate limits
2.8 Generadores — procesar datos enormes sin llenar la memoria
Un generador produce valores uno a uno, bajo demanda, sin cargar todo en memoria. Son perfectos para procesar grandes volúmenes de texto o documentos para IA.
python
# Sin generador — carga TODO en memoriadef primeros_n_cuadrados_lista(n): return [i ** 2 for i in range(n)]# Con generador — produce uno cada vezdef primeros_n_cuadrados_gen(n): for i in range(n): yield i ** 2 # yield en lugar de return# Usar el generadorgen = primeros_n_cuadrados_gen(1_000_000)print(next(gen)) # 0print(next(gen)) # 1print(next(gen)) # 4# O iterar con for (consume el generador)for cuadrado in primeros_n_cuadrados_gen(5): print(cuadrado)# Caso de uso real: leer un fichero enorme línea a líneadef leer_lineas(ruta_archivo): with open(ruta_archivo, "r", encoding="utf-8") as f: for linea in f: yield linea.strip()# Esto NO carga el archivo entero en RAM — perfecto para logs de gigabytesfor linea in leer_lineas("log_enorme.txt"): if "ERROR" in linea: print(linea)# Generator expression (como list comprehension pero perezoso)cuadrados = (i ** 2 for i in range(10)) # Paréntesis, no corchetesprint(sum(cuadrados)) # 285 — calcula sobre la marcha
2.9 Dataclasses — alternativa moderna a clases simples
Las dataclasses (Python 3.7+) reducen drásticamente el boilerplate cuando solo necesitas una clase para almacenar datos.
python
from dataclasses import dataclass, fieldfrom typing import List# Sin dataclass — mucho código repetitivoclass UsuarioNormal: def __init__(self, nombre, edad, tags): self.nombre = nombre self.edad = edad self.tags = tags def __repr__(self): return f"Usuario({self.nombre}, {self.edad})" def __eq__(self, otro): return self.nombre == otro.nombre and self.edad == otro.edad# Con dataclass — todo automático@dataclassclass Usuario: nombre: str edad: int tags: List[str] = field(default_factory=list) # Lista vacía por defecto activo: bool = True def es_adulto(self) -> bool: return self.edad >= 18# Python genera __init__, __repr__, __eq__ automáticamenteu1 = Usuario("Ana", 25, ["python", "ia"])u2 = Usuario("Ana", 25, ["python", "ia"])print(u1) # Usuario(nombre='Ana', edad=25, tags=['python', 'ia'], activo=True)print(u1 == u2) # True — compara por valor, no por referenciaprint(u1.es_adulto()) # True# Dataclass frozen — inmutable (como una tupla con nombres)@dataclass(frozen=True)class Coordenada: lat: float lon: floatmadrid = Coordenada(40.4168, -3.7038)# madrid.lat = 0 # Error — no se puede modificar
¿Cuándo usar dataclass vs Pydantic? Usa @dataclass para datos internos de tu aplicación. Usa Pydantic BaseModel cuando los datos vengan del exterior (API, usuario, fichero) y necesites validación.
Módulo 3 — Entornos de trabajo y herramientas profesionales
3.1 Entornos virtuales
Un entorno virtual es un espacio aislado donde instalas las dependencias de tu proyecto sin interferir con otros proyectos. Es una práctica obligatoria en proyectos profesionales.
bash
# Crear un entorno virtual (dentro de la carpeta de tu proyecto)python -m venv .venv# Activarlo en Windows.venv\Scripts\activate# Activarlo en macOS/Linuxsource .venv/bin/activate# Verás que el prompt cambia, algo así:# (.venv) usuario@ordenador:~/proyecto$# Instalar paquetes dentro del entornopip install fastapi langchain# Ver qué tienes instaladopip list# Guardar las dependencias en un archivopip freeze > requirements.txt# En otro ordenador, instalar todo de golpepip install -r requirements.txt# Desactivar el entorno virtualdeactivate
3.2 Editor de código: VS Code
El editor recomendado para este curso es Visual Studio Code (gratuito).
REST Client (para probar APIs directamente desde VS Code)
3.3 pip y gestión de dependencias
bash
# Instalar un paquetepip install nombre_paquete# Instalar una versión específicapip install fastapi==0.110.0# Actualizar un paquetepip install --upgrade nombre_paquete# Desinstalarpip uninstall nombre_paquete
🔥 uv — el reemplazo moderno de pip (10-100x más rápido)
uv es una herramienta nueva escrita en Rust que está sustituyendo a pip en proyectos profesionales. Es extremadamente rápida y resuelve conflictos de versiones mejor.
bash
# Instalar uvpip install uv# Crear proyecto con entorno virtualuv init mi-proyectocd mi-proyecto# Añadir dependencias (actualiza pyproject.toml automáticamente)uv add fastapi langchain-openai# Añadir dependencias solo para desarrollouv add --dev pytest ruff mypy# Instalar todo desde pyproject.tomluv sync# Ejecutar scripts dentro del entorno sin activarlo manualmenteuv run python main.pyuv run uvicorn main:app --reload
pyproject.toml — el estándar moderno
En proyectos profesionales se usa pyproject.toml en lugar de requirements.txt:
Crea un archivo .gitignore para que Git nunca suba el .env:
# .gitignore
.env
.venv/
__pycache__/
*.pyc
Usa las variables en tu código Python:
python
from dotenv import load_dotenvimport osload_dotenv() # Carga el archivo .envapi_key = os.getenv("OPENAI_API_KEY")debug = os.getenv("DEBUG", "False") # Valor por defectoprint(f"API Key cargada: {'Sí' if api_key else 'No'}")
Módulo 4 — Introducción a FastAPI
4.1 ¿Qué es FastAPI?
FastAPI es un framework moderno para construir APIs (interfaces de programación de aplicaciones) con Python. Una API es como un camarero en un restaurante: tú haces una petición y te trae el resultado.
FastAPI destaca por:
Velocidad: Es uno de los más rápidos del ecosistema Python.
Tipado: Usa las anotaciones de tipos de Python para validar datos automáticamente.
Documentación automática: Genera una interfaz visual para probar tu API sin escribir código extra.
Async: Soporte nativo para código asíncrono, fundamental en aplicaciones de IA.
4.2 Instalación
bash
pip install fastapi uvicorn
uvicorn es el servidor que ejecutará tu API.
4.3 Tu primera API
Crea un archivo main.py:
python
from fastapi import FastAPI# Crear la aplicaciónapp = FastAPI( title="Mi primera API", description="Una API de ejemplo para aprender FastAPI", version="1.0.0")# Definir una ruta (endpoint)@app.get("/")def inicio(): return {"mensaje": "¡Hola desde FastAPI!"}@app.get("/saludo/{nombre}")def saludar(nombre: str): return {"saludo": f"¡Hola, {nombre}!"}
Ejecuta el servidor:
bash
uvicorn main:app --reload
main es el nombre del archivo (main.py)
app es la variable de FastAPI que creaste
--reload reinicia el servidor automáticamente cuando cambias el código
Las APIs usan diferentes métodos HTTP para diferentes acciones:
Método
Uso
Ejemplo
GET
Obtener datos
Leer un usuario
POST
Crear datos
Crear un usuario
PUT
Actualizar datos (completo)
Actualizar todos los datos de un usuario
PATCH
Actualizar datos (parcial)
Solo cambiar el email
DELETE
Eliminar datos
Borrar un usuario
python
from fastapi import FastAPIfrom typing import Optionalapp = FastAPI()# Base de datos simulada en memoriausuarios = {}# GET — Obtener todos los usuarios@app.get("/usuarios")def obtener_usuarios(): return {"usuarios": list(usuarios.values())}# GET — Obtener un usuario por ID@app.get("/usuarios/{id}")def obtener_usuario(id: int): if id not in usuarios: return {"error": "Usuario no encontrado"} return usuarios[id]# POST — Crear usuario (el cuerpo se envía en el body de la petición)@app.post("/usuarios/{id}")def crear_usuario(id: int, nombre: str, email: str): usuarios[id] = {"id": id, "nombre": nombre, "email": email} return {"mensaje": "Usuario creado", "usuario": usuarios[id]}# DELETE — Eliminar un usuario@app.delete("/usuarios/{id}")def eliminar_usuario(id: int): if id in usuarios: del usuarios[id] return {"mensaje": f"Usuario {id} eliminado"} return {"error": "Usuario no encontrado"}
4.5 Modelos de datos con Pydantic
Pydantic es la librería que FastAPI usa para validar datos. Defines modelos con clases que heredan de BaseModel.
Para usar el validador de email 'EmailStr' hay q instalar la librería email-validator
bash
uv add "pydantic[email]" o pip install email-validator
python
from fastapi import FastAPIfrom pydantic import BaseModel, EmailStr, Fieldfrom typing import Optionalfrom datetime import datetimeapp = FastAPI()# Modelo de datosclass Usuario(BaseModel): nombre: str email: EmailStr edad: int = Field(ge=0, le=150, description="Edad entre 0 y 150") activo: bool = True bio: Optional[str] = None # Campo opcionalclass UsuarioRespuesta(BaseModel): id: int nombre: str email: EmailStr creado_en: datetime# Base de datos simuladadb_usuarios: dict[int, dict] = {}contador_id = 1@app.post("/usuarios", response_model=UsuarioRespuesta)def crear_usuario(usuario: Usuario): global contador_id nuevo_usuario = { "id": contador_id, "nombre": usuario.nombre, "email": usuario.email, "creado_en": datetime.now() } db_usuarios[contador_id] = nuevo_usuario contador_id += 1 return nuevo_usuario
Pydantic valida automáticamente los datos. Si envías una edad negativa o un email mal formado, FastAPI devolverá un error descriptivo sin que tú tengas que escribir esa lógica.
4.6 Parámetros de consulta (Query Parameters)
Los query parameters son los parámetros que van en la URL después del ?.
Ejemplo: /productos?categoria=electronicos&pagina=2
python
from fastapi import FastAPIfrom typing import Optionalapp = FastAPI()productos = [ {"id": 1, "nombre": "Portátil", "categoria": "electronica", "precio": 999}, {"id": 2, "nombre": "Ratón", "categoria": "electronica", "precio": 25}, {"id": 3, "nombre": "Silla", "categoria": "muebles", "precio": 150}, {"id": 4, "nombre": "Mesa", "categoria": "muebles", "precio": 200},]@app.get("/productos")def buscar_productos( categoria: Optional[str] = None, precio_max: Optional[float] = None, pagina: int = 1, por_pagina: int = 10): resultado = productos if categoria: resultado = [p for p in resultado if p["categoria"] == categoria] if precio_max: resultado = [p for p in resultado if p["precio"] <= precio_max] # Paginación simple inicio = (pagina - 1) * por_pagina fin = inicio + por_pagina return { "total": len(resultado), "pagina": pagina, "productos": resultado[inicio:fin] }
from fastapi import FastAPI, HTTPException, statusapp = FastAPI()usuarios = {1: {"nombre": "Ana", "email": "ana@email.com"}}@app.get("/usuarios/{id}")def obtener_usuario(id: int): if id not in usuarios: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Usuario con id {id} no encontrado" ) return usuarios[id]@app.post("/usuarios")def crear_usuario(nombre: str, email: str): # Validación personalizada if "@" not in email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="El email no tiene un formato válido" ) nuevo_id = max(usuarios.keys()) + 1 usuarios[nuevo_id] = {"nombre": nombre, "email": email} return {"mensaje": "Creado", "id": nuevo_id}
Módulo 5 — FastAPI avanzado
5.1 Código asíncrono con async/await
Python puede ejecutar tareas "en paralelo" sin bloquear el servidor. Esto es crítico cuando tu API llama a modelos de IA que pueden tardar varios segundos.
python
import asyncioimport timefrom fastapi import FastAPIapp = FastAPI()# Función SÍNCRONA — bloquea el servidor mientras espera@app.get("/sincrono")def endpoint_sincrono(): time.sleep(3) # Simula una espera (p.ej., llamar a una IA) return {"mensaje": "Respuesta síncrona"}# Función ASÍNCRONA — no bloquea el servidor@app.get("/asincrono")async def endpoint_asincrono(): await asyncio.sleep(3) # Espera sin bloquear return {"mensaje": "Respuesta asíncrona"}
¿Por qué importa esto? Si tienes 100 usuarios usando tu API de IA al mismo tiempo, con código síncrono el servidor procesa uno a uno. Con async, puede gestionar muchos a la vez.
5.2 Dependencias en FastAPI
Las dependencias son funciones que se ejecutan antes de tu endpoint. Sirven para autenticación, validación, obtener la base de datos, etc.
python
from fastapi import FastAPI, Depends, HTTPException, Headerfrom typing import Optionalapp = FastAPI()# Dependencia: verificar que hay un token en la cabeceraasync def verificar_token(x_token: str = Header(...)): tokens_validos = ["mi-token-secreto", "otro-token"] if x_token not in tokens_validos: raise HTTPException(status_code=401, detail="Token inválido") return x_token# Esta ruta requiere que la dependencia pase sin errores@app.get("/datos-privados")async def datos_privados(token: str = Depends(verificar_token)): return {"datos": "Información confidencial", "token_usado": token}# Dependencia para paginación reutilizableasync def parametros_paginacion(pagina: int = 1, tamaño: int = 10): return {"offset": (pagina - 1) * tamaño, "limit": tamaño}@app.get("/articulos")async def listar_articulos(paginacion: dict = Depends(parametros_paginacion)): # Usamos los parámetros calculados por la dependencia return {"paginacion": paginacion, "articulos": []}
5.3 Middleware y CORS
El middleware es código que se ejecuta para cada petición antes de llegar al endpoint. CORS (Cross-Origin Resource Sharing) es necesario para que tu API pueda ser llamada desde una web en otro dominio (por ejemplo, desde React en localhost:3000 a tu API en localhost:8000).
python
from fastapi import FastAPIfrom fastapi.middleware.cors import CORSMiddlewareimport timeapp = FastAPI()# Configurar CORSapp.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "https://miweb.com"], allow_credentials=True, allow_methods=["*"], # GET, POST, PUT, DELETE, etc. allow_headers=["*"],)# Middleware personalizado: registrar el tiempo de cada peticiónfrom starlette.middleware.base import BaseHTTPMiddlewarefrom starlette.requests import Requestclass TiempoMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): inicio = time.time() respuesta = await call_next(request) duracion = time.time() - inicio respuesta.headers["X-Tiempo-Proceso"] = str(duracion) return respuestaapp.add_middleware(TiempoMiddleware)
5.4 Background Tasks
Las Background Tasks ejecutan código después de enviar la respuesta. Útil para enviar emails, registrar logs, o procesar datos pesados sin que el usuario espere.
python
from fastapi import FastAPI, BackgroundTasksimport timeapp = FastAPI()def procesar_en_segundo_plano(texto: str): """Esta función se ejecuta después de responder al usuario""" time.sleep(5) # Simula procesamiento pesado print(f"Procesado: {texto}")@app.post("/analizar")async def analizar_texto(texto: str, tareas: BackgroundTasks): # Programa la tarea para ejecutarse después tareas.add_task(procesar_en_segundo_plano, texto) # El usuario recibe respuesta INMEDIATAMENTE return {"mensaje": "Texto recibido, procesando en segundo plano..."}
5.5 Lifespan — inicializar recursos al arrancar
El patrón lifespan (FastAPI 0.93+) reemplaza a los viejos @app.on_event("startup"). Es la forma correcta de inicializar conexiones a bases de datos, modelos de IA, etc.
python
from fastapi import FastAPIfrom contextlib import asynccontextmanagerfrom langchain_openai import ChatOpenAIimport logginglogger = logging.getLogger(__name__)# Estado global de la aplicaciónclass AppState: llm: ChatOpenAI = None vector_store = Nonestate = AppState()@asynccontextmanagerasync def lifespan(app: FastAPI): # ── STARTUP ── código que se ejecuta al iniciar el servidor logger.info("Iniciando servidor...") state.llm = ChatOpenAI(model="gpt-4o-mini") logger.info("Modelo de IA cargado ✓") yield # El servidor está corriendo aquí # ── SHUTDOWN ── código que se ejecuta al apagar el servidor logger.info("Apagando servidor, liberando recursos...")app = FastAPI(lifespan=lifespan)@app.post("/chat")async def chat(mensaje: str): # state.llm ya está inicializado y listo respuesta = await state.llm.ainvoke(mensaje) return {"respuesta": respuesta.content}
5.6 Manejo global de excepciones
En lugar de manejar errores en cada endpoint, puedes definir handlers globales:
python
from fastapi import FastAPI, Request, HTTPExceptionfrom fastapi.responses import JSONResponsefrom fastapi.exceptions import RequestValidationErrorimport logginglogger = logging.getLogger(__name__)app = FastAPI()# Handler para errores de validación de Pydantic@app.exception_handler(RequestValidationError)async def validation_exception_handler(request: Request, exc: RequestValidationError): errores = [] for error in exc.errors(): errores.append({ "campo": " → ".join(str(x) for x in error["loc"]), "mensaje": error["msg"], "valor": error.get("input") }) return JSONResponse( status_code=422, content={"detalle": "Error de validación", "errores": errores} )# Handler para errores HTTP@app.exception_handler(HTTPException)async def http_exception_handler(request: Request, exc: HTTPException): return JSONResponse( status_code=exc.status_code, content={ "error": exc.detail, "ruta": str(request.url), "metodo": request.method } )# Handler para errores inesperados (500)@app.exception_handler(Exception)async def generic_exception_handler(request: Request, exc: Exception): logger.error(f"Error inesperado en {request.url}: {exc}", exc_info=True) return JSONResponse( status_code=500, content={"error": "Error interno del servidor"} )
5.7 Optimizaciones de rendimiento en FastAPI
Usa response_model_exclude_unset=True
Evita incluir campos None innecesarios en la respuesta:
python
from fastapi import FastAPIfrom pydantic import BaseModelfrom typing import Optionalapp = FastAPI()class Producto(BaseModel): id: int nombre: str descripcion: Optional[str] = None stock: Optional[int] = None precio_oferta: Optional[float] = None@app.get("/producto/{id}", response_model=Producto)def obtener_producto(id: int): # Solo devuelve los campos que tienen valor return Producto(id=id, nombre="Portátil") # Sin exclude_unset: {"id":1,"nombre":"Portátil","descripcion":null,"stock":null,"precio_oferta":null} # Con exclude_unset: {"id":1,"nombre":"Portátil"} ← mucho más limpio
python
# Activa exclude_unset en el endpoint@app.get( "/producto/{id}", response_model=Producto, response_model_exclude_unset=True # ← aquí)
Compresión GZip automática
python
from fastapi.middleware.gzip import GZipMiddlewareapp.add_middleware(GZipMiddleware, minimum_size=1000)# Comprime respuestas > 1KB automáticamente# Reduce el tamaño de respuestas JSON grandes hasta un 70%
Caché de respuestas con functools.lru_cache
python
from functools import lru_cachefrom fastapi import FastAPI, Dependsapp = FastAPI()@lru_cache(maxsize=128)def cargar_configuracion(): """Se ejecuta solo una vez, el resultado queda en caché""" print("Cargando configuración...") # Solo verás esto una vez return {"modelo": "gpt-4o-mini", "temperatura": 0.7}@app.get("/config")def obtener_config(config: dict = Depends(cargar_configuracion)): return config
5.5 Estructura de proyecto profesional
Para proyectos reales, organiza tu código así:
mi-api-ia/
├── .env # Variables de entorno (no subir a Git)
├── .gitignore
├── requirements.txt
├── main.py # Punto de entrada
├── app/
│ ├── __init__.py
│ ├── config.py # Configuración de la app
│ ├── models/
│ │ ├── __init__.py
│ │ └── usuario.py # Modelos Pydantic
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── usuarios.py # Rutas de usuarios
│ │ └── ia.py # Rutas de IA
│ ├── services/
│ │ ├── __init__.py
│ │ └── ia_service.py # Lógica de IA
│ └── dependencies.py # Dependencias compartidas
python
# app/config.pyfrom pydantic_settings import BaseSettingsclass Settings(BaseSettings): app_name: str = "Mi API de IA" openai_api_key: str debug: bool = False class Config: env_file = ".env"settings = Settings()
LangChain es un framework que facilita la construcción de aplicaciones que usan Modelos de Lenguaje (LLMs) como GPT-4, Claude o modelos locales. Proporciona bloques de construcción para:
Conectarte a diferentes LLMs con una interfaz común.
Construir cadenas de procesamiento de texto.
Dar memoria a los chatbots.
Conectar LLMs con herramientas externas (buscadores, bases de datos, código, etc.).
Necesitas una API key de OpenAI. Regístrate en https://platform.openai.com, ve a API Keys y crea una. Guárdala en tu .env:
OPENAI_API_KEY=sk-tu-clave-aqui
6.3 Primer contacto con un LLM
python
from langchain_openai import ChatOpenAIfrom langchain_core.messages import HumanMessage, SystemMessagefrom dotenv import load_dotenvload_dotenv()# Crear el modelollm = ChatOpenAI( model="gpt-4o-mini", # Modelo más económico para aprender temperature=0.7, # 0 = determinista, 1 = muy creativo max_tokens=500)# Enviar un mensaje simplerespuesta = llm.invoke("Explica qué es la inteligencia artificial en 3 frases")print(respuesta.content)# Con mensajes del sistema (instrucciones de comportamiento)mensajes = [ SystemMessage(content="Eres un experto en Python que explica conceptos de forma sencilla"), HumanMessage(content="¿Qué es una lista en Python?")]respuesta = llm.invoke(mensajes)print(respuesta.content)
6.4 Prompt Templates
Los templates te permiten crear prompts reutilizables con variables.
python
from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplate, PromptTemplatefrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini")# Template básicotemplate = ChatPromptTemplate.from_messages([ ("system", "Eres un asistente experto en {materia}. Responde siempre en español."), ("human", "{pregunta}")])# Formatear el template con valores concretosmensajes = template.format_messages( materia="programación Python", pregunta="¿Cuándo debo usar una lista y cuándo un diccionario?")respuesta = llm.invoke(mensajes)print(respuesta.content)# Template para generar contenido estructuradotemplate_resumen = PromptTemplate( input_variables=["texto", "longitud"], template=""" Analiza el siguiente texto y proporciona: 1. Un resumen en {longitud} palabras máximo 2. Los 3 puntos clave 3. El tono del texto (formal/informal/técnico) Texto: {texto} Responde en formato JSON. """)prompt_formateado = template_resumen.format( texto="Python es un lenguaje de programación de alto nivel...", longitud="50")print(prompt_formateado)
🔥 Few-shot Prompting — enseñar con ejemplos
El few-shot prompting es una de las técnicas más potentes para mejorar la calidad de las respuestas. En lugar de solo describir lo que quieres, le muestras ejemplos concretos al modelo:
python
from langchain_core.prompts import FewShotChatMessagePromptTemplate, ChatPromptTemplatefrom langchain_openai import ChatOpenAIllm = ChatOpenAI(model="gpt-4o-mini", temperature=0)# Ejemplos de clasificación de sentimientosejemplos = [ { "input": "¡Este producto es increíble, lo recomiendo a todos!", "output": '{"sentimiento": "positivo", "confianza": 0.98}' }, { "input": "Llegó roto y el servicio al cliente no respondió.", "output": '{"sentimiento": "negativo", "confianza": 0.95}' }, { "input": "El producto está bien, cumple su función.", "output": '{"sentimiento": "neutro", "confianza": 0.82}' },]# Template para cada ejemploejemplo_prompt = ChatPromptTemplate.from_messages([ ("human", "{input}"), ("ai", "{output}"),])# Template few-shotfew_shot_prompt = FewShotChatMessagePromptTemplate( example_prompt=ejemplo_prompt, examples=ejemplos,)# Template completoprompt_final = ChatPromptTemplate.from_messages([ ("system", "Clasifica el sentimiento de reseñas. Responde SOLO en JSON válido."), few_shot_prompt, ("human", "{input}"),])cadena = prompt_final | llmreseñas = [ "Tardó 3 semanas en llegar, decepcionante.", "¡Exactamente lo que buscaba! Perfecto.", "Ni bueno ni malo, es lo que es."]for reseña in reseñas: resultado = cadena.invoke({"input": reseña}) print(f"Reseña: {reseña[:40]}...") print(f"Resultado: {resultado.content}\n")
🔥 Prompt Engineering: las reglas que más importan
python
# ❌ MAL prompt — vago, sin estructuramal_prompt = "Resume esto: {texto}"# ✅ BUEN prompt — rol + tarea + formato + restriccionesbuen_prompt = """Eres un editor técnico senior con 10 años de experiencia.Tu tarea es resumir el siguiente artículo técnico.INSTRUCCIONES:- Longitud máxima: 3 frases- Incluye el concepto principal y las 2 conclusiones más importantes- Usa lenguaje técnico pero accesible- NO incluyas opiniones, solo hechos del textoFORMATO DE RESPUESTA (JSON):{{ "resumen": "...", "concepto_principal": "...", "conclusiones": ["...", "..."]}}ARTÍCULO:{texto}"""# Truco: usa {{ }} para escapar llaves literales en f-strings y PromptTemplates
6.5 Output Parsers
Los Output Parsers convierten la respuesta del LLM en estructuras de datos Python (dicts, listas, objetos Pydantic).
python
from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParser, JsonOutputParserfrom pydantic import BaseModelfrom typing import Listfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini")# Parser de texto simpleparser_texto = StrOutputParser()# Parser de JSONparser_json = JsonOutputParser()# Modelo Pydantic para la respuestaclass RecetaAnalisis(BaseModel): ingredientes: List[str] tiempo_coccion: int # minutos dificultad: str calorias_aprox: int# Crear cadena: prompt → llm → parsertemplate = ChatPromptTemplate.from_messages([ ("system", "Eres un chef experto. Responde SIEMPRE en JSON válido."), ("human", """ Analiza esta receta: {receta} Devuelve un JSON con: - ingredientes: lista de ingredientes principales - tiempo_coccion: minutos estimados - dificultad: 'fácil', 'media' o 'difícil' - calorias_aprox: calorías aproximadas por ración """)])cadena = template | llm | parser_jsonresultado = cadena.invoke({ "receta": "Tortilla de patatas con cebolla pochada y aceite de oliva"})print(resultado)print(type(resultado)) # <class 'dict'>
6.6 LCEL — LangChain Expression Language
LCEL es la sintaxis moderna de LangChain para componer componentes usando el operador | (pipe). Es como un pipeline de procesamiento.
python
from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)parser = StrOutputParser()# Cadena simplecadena = ( ChatPromptTemplate.from_template("Traduce al inglés: {texto}") | llm | parser)resultado = cadena.invoke({"texto": "El cielo es azul y el sol brilla"})print(resultado) # The sky is blue and the sun shines# Cadena en secuencia: traducir y luego resumirtemplate_traducir = ChatPromptTemplate.from_template( "Traduce el siguiente texto al inglés: {texto}")template_resumir = ChatPromptTemplate.from_template( "Resume el siguiente texto en una frase: {texto_traducido}")# Encadenar los dos pasosfrom langchain_core.runnables import RunnablePassthroughcadena_compleja = ( {"texto_traducido": template_traducir | llm | parser} | template_resumir | llm | parser)texto_espanol = "La inteligencia artificial está transformando la sociedad de maneras que no podíamos imaginar hace apenas una década, desde la medicina hasta el entretenimiento."resultado = cadena_compleja.invoke({"texto": texto_espanol})print(resultado)
Módulo 7 — LangChain avanzado: Chains, Agentes y Memoria
7.1 Memoria en conversaciones
Por defecto, los LLMs no recuerdan conversaciones anteriores. LangChain ofrece distintos tipos de memoria.
python
from langchain_openai import ChatOpenAIfrom langchain_core.chat_history import InMemoryChatMessageHistoryfrom langchain_core.runnables.history import RunnableWithMessageHistoryfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholderfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini")# Almacén de conversaciones (en memoria, por session_id)store = {}def obtener_historial(session_id: str): if session_id not in store: store[session_id] = InMemoryChatMessageHistory() return store[session_id]# Template que incluye el historialprompt = ChatPromptTemplate.from_messages([ ("system", "Eres un asistente amigable. Recuerda todo lo que el usuario te cuenta."), MessagesPlaceholder(variable_name="history"), ("human", "{mensaje}")])# Cadena con memoriacadena_base = prompt | llmcadena_con_memoria = RunnableWithMessageHistory( cadena_base, obtener_historial, input_messages_key="mensaje", history_messages_key="history")# Configuración de sesiónconfig = {"configurable": {"session_id": "usuario-123"}}# Conversaciónrespuesta1 = cadena_con_memoria.invoke( {"mensaje": "Hola, me llamo Carlos y me gusta el fútbol"}, config=config)print(f"Bot: {respuesta1.content}")respuesta2 = cadena_con_memoria.invoke( {"mensaje": "¿Cuál es mi nombre y qué me gusta?"}, config=config)print(f"Bot: {respuesta2.content}")# El bot recordará que se llama Carlos y le gusta el fútbol
7.2 Retrieval Augmented Generation (RAG)
RAG es una técnica que permite al LLM responder sobre tus propios documentos. El proceso es:
Dividir tus documentos en fragmentos.
Convertirlos en vectores numéricos (embeddings).
Guardarlos en una base de datos vectorial.
Cuando el usuario pregunta, buscar los fragmentos más relevantes.
El chunking (cómo divides los documentos) es el factor más importante en la calidad de un RAG. Un mal chunking hace que el retriever recupere fragmentos irrelevantes, y ni el mejor LLM puede compensarlo.
python
from langchain.text_splitter import RecursiveCharacterTextSplitter# ❌ Chunking básico — puede partir frases por la mitadsplitter_basico = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=0)# ✅ Chunking con overlap — preserva contexto entre fragmentossplitter_bueno = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, # Los últimos 100 caracteres se repiten en el siguiente chunk length_function=len, separators=["\n\n", "\n", ". ", " ", ""] # Prioriza cortar en párrafos)# ✅✅ Chunking semántico — agrupa por significado, no por tamaño# pip install langchain-experimentalfrom langchain_experimental.text_splitter import SemanticChunkerfrom langchain_openai import OpenAIEmbeddingssplitter_semantico = SemanticChunker( OpenAIEmbeddings(model="text-embedding-3-small"), breakpoint_threshold_type="percentile" # Corta donde cambia el tema)
python
# Reranking — mejora los resultados del retriever con un segundo modelo# pip install langchain-coherefrom langchain.retrievers import ContextualCompressionRetrieverfrom langchain.retrievers.document_compressors import CrossEncoderRerankerfrom langchain_community.cross_encoders import HuggingFaceCrossEncoder# 1. Retriever base: recupera los 10 más similares (rápido pero impreciso)retriever_base = vector_store.as_retriever(search_kwargs={"k": 10})# 2. Reranker: de esos 10, selecciona los 3 más relevantes (lento pero preciso)reranker_model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")compressor = CrossEncoderReranker(model=reranker_model, top_n=3)# 3. Retriever con rerankingretriever_mejorado = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=retriever_base)# El retriever mejorado devuelve resultados mucho más relevantes# sin cambiar nada más en tu cadena RAGcadena_rag_mejorada = ( { "contexto": retriever_mejorado | formatear_documentos, "pregunta": RunnablePassthrough() } | template | llm | parser)
🔥 Búsqueda híbrida: vectores + palabras clave
La búsqueda vectorial es buena para semántica, pero falla con nombres propios, acrónimos y términos técnicos exactos. La búsqueda híbrida combina ambas para obtener lo mejor de cada una:
python
from langchain_community.retrievers import BM25Retrieverfrom langchain.retrievers import EnsembleRetriever# Retriever por palabras clave (BM25, como un motor de búsqueda clásico)bm25_retriever = BM25Retriever.from_documents(fragmentos)bm25_retriever.k = 5# Retriever vectorial (semántico)faiss_retriever = vector_store.as_retriever(search_kwargs={"k": 5})# Retriever híbrido — combina ambos con pesosretriever_hibrido = EnsembleRetriever( retrievers=[bm25_retriever, faiss_retriever], weights=[0.4, 0.6] # 40% palabras clave, 60% semántica)
7.3 Agentes — LLMs que toman decisiones
Los agentes usan el LLM para decidir qué herramientas usar y en qué orden, para resolver una tarea.
python
from langchain_openai import ChatOpenAIfrom langchain.agents import create_react_agent, AgentExecutorfrom langchain.tools import toolfrom langchain import hubfrom dotenv import load_dotenvimport mathimport requestsload_dotenv()# Definir herramientas (tools)@tooldef calcular(expresion: str) -> str: """Evalúa expresiones matemáticas. Usa para cálculos.""" try: resultado = eval(expresion, {"__builtins__": {}}, {"math": math}) return str(resultado) except Exception as e: return f"Error: {e}"@tooldef buscar_web(query: str) -> str: """Busca información en la web. Usa cuando necesites datos actuales.""" # Simulación — en producción usarías la API de Tavily o similar return f"Resultados simulados para: {query}. Python 3.12 fue lanzado en octubre 2023."@tooldef convertir_moneda(cantidad_origen: str) -> str: """Convierte EUR a USD. El formato es: '100 EUR'""" try: cantidad = float(cantidad_origen.split()[0]) return f"{cantidad} EUR = {cantidad * 1.09:.2f} USD (tasa aproximada)" except: return "Error en el formato. Usa: '100 EUR'"# Configurar el agentellm = ChatOpenAI(model="gpt-4o-mini", temperature=0)herramientas = [calcular, buscar_web, convertir_moneda]# Prompt para el agente (descarga de LangChain Hub)prompt = hub.pull("hwchase17/react")agente = create_react_agent(llm, herramientas, prompt)ejecutor = AgentExecutor(agente, herramientas, verbose=True, max_iterations=5)# El agente decidirá qué herramientas usarresultado = ejecutor.invoke({ "input": "Calcula la raíz cuadrada de 144 y conviértela en USD siendo EUR"})print(f"\nRespuesta final: {resultado['output']}")
7.4 Streaming de respuestas
El streaming permite mostrar la respuesta del LLM a medida que se genera, letra por letra, en lugar de esperar a que termine.
python
from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini", streaming=True)template = ChatPromptTemplate.from_template( "Escribe un poema corto sobre {tema}")cadena = template | llm# Streaming en consolaprint("Generando poema:")for fragmento in cadena.stream({"tema": "la programación"}): print(fragmento.content, end="", flush=True)print("\n---")
Módulo 8 — Proyecto Final: API de IA en producción
Vamos a construir una API completa que integra FastAPI con LangChain. El proyecto incluirá:
# 1. Activar entorno virtualsource .venv/bin/activate # macOS/Linux# .venv\Scripts\activate # Windows# 2. Instalar dependenciaspip install -r requirements.txt# 3. Crear .env con tu API keyecho "OPENAI_API_KEY=sk-tu-clave" > .env# 4. Ejecutar el servidoruvicorn main:app --reload --port 8000
Abre http://localhost:8000/docs y prueba:
POST /chat/ — Envía un mensaje de chat
GET /chat/stream — Streaming en tiempo real
POST /documentos/texto — Indexa un texto
POST /chat/ con usar_rag: true — Pregunta sobre tus documentos
Módulo 9 — Nivel Experto: Optimización, Observabilidad y Escalado
9.1 Caché de respuestas
Evita llamar al LLM con las mismas preguntas repetidamente usando caché.
python
from langchain_community.cache import InMemoryCachefrom langchain.globals import set_llm_cachefrom langchain_openai import ChatOpenAIimport time# Habilitar caché en memoriaset_llm_cache(InMemoryCache())llm = ChatOpenAI(model="gpt-4o-mini")# Primera llamada (llama a la API de verdad)inicio = time.time()respuesta1 = llm.invoke("¿Cuánto es 2+2?")print(f"Primera llamada: {time.time() - inicio:.2f}s")# Segunda llamada idéntica (sale del caché, casi instantánea)inicio = time.time()respuesta2 = llm.invoke("¿Cuánto es 2+2?")print(f"Segunda llamada (caché): {time.time() - inicio:.4f}s")# Para persistir el caché entre reinicios, usa SQLitefrom langchain_community.cache import SQLiteCacheset_llm_cache(SQLiteCache(database_path=".cache_llm.db"))
9.2 Rate Limiting
Limita las peticiones a tu API para evitar abuso y controlar costes de la API de OpenAI.
9.3 Structured Outputs — respuestas 100% fiables con Pydantic
Una de las funcionalidades más potentes de los LLMs modernos es la capacidad de generar JSON que garantizadamente coincide con tu modelo Pydantic, sin parseos frágiles.
python
from langchain_openai import ChatOpenAIfrom pydantic import BaseModel, Fieldfrom typing import List, Literalfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)# Definir el esquema de respuesta exactoclass Ingrediente(BaseModel): nombre: str cantidad: str unidad: strclass RecetaEstructurada(BaseModel): titulo: str = Field(description="Nombre de la receta") tiempo_preparacion: int = Field(description="Minutos de preparación") tiempo_coccion: int = Field(description="Minutos de cocción") porciones: int dificultad: Literal["fácil", "media", "difícil"] ingredientes: List[Ingrediente] pasos: List[str] calorias_por_porcion: int# Vincular el esquema al LLM — garantiza que la respuesta es válidallm_estructurado = llm.with_structured_output(RecetaEstructurada)receta = llm_estructurado.invoke( "Dame la receta de una tortilla española de 4 personas")# receta es directamente un objeto RecetaEstructurada, sin parsear nadaprint(receta.titulo)print(receta.dificultad)for ingrediente in receta.ingredientes: print(f" - {ingrediente.cantidad} {ingrediente.unidad} de {ingrediente.nombre}")
9.4 Profiling — encontrar cuellos de botella
Antes de optimizar, mide. Sin métricas no sabes qué optimizar.
# Herramienta externa: py-spy (profiling sin modificar código)pip install py-spy# Ver qué hace tu API en tiempo real (requiere proceso corriendo)py-spy top --pid $(pgrep -f uvicorn)# Generar flamegraph (visualización de rendimiento)py-spy record -o perfil.svg --pid $(pgrep -f uvicorn)
9.5 Observabilidad con LangSmith
LangSmith es la plataforma oficial de LangChain para monitorizar, depurar y evaluar tus aplicaciones LLM. Es gratis para proyectos pequeños.
python
# .env — añade estas variables# LANGCHAIN_TRACING_V2=true# LANGCHAIN_API_KEY=tu-clave-de-langsmith# LANGCHAIN_PROJECT=mi-api-ia
python
import osfrom langchain_openai import ChatOpenAIfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini")respuesta = llm.invoke("¿Qué es LangSmith?")# Cada llamada quedará registrada en app.smith.langchain.com# con el prompt exacto, la respuesta, latencia, tokens usados, etc.
LangSmith es la plataforma oficial de LangChain para monitorizar, depurar y evaluar tus aplicaciones LLM. Es gratis para proyectos pequeños.
python
# .env — añade estas variables# LANGCHAIN_TRACING_V2=true# LANGCHAIN_API_KEY=tu-clave-de-langsmith# LANGCHAIN_PROJECT=mi-api-ia
python
import osfrom langchain_openai import ChatOpenAIfrom dotenv import load_dotenvload_dotenv()# Con las variables de entorno configuradas, LangChain# enviará trazas automáticamente a LangSmithllm = ChatOpenAI(model="gpt-4o-mini")respuesta = llm.invoke("¿Qué es LangSmith?")# Cada llamada quedará registrada en app.smith.langchain.com# con el prompt exacto, la respuesta, latencia, tokens usados, etc.
9.4 Tests para tu API de IA
bash
pip install pytest pytest-asyncio httpx
python
# tests/test_api.pyimport pytestfrom httpx import AsyncClient, ASGITransportfrom main import app@pytest.mark.asyncioasync def test_raiz(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as cliente: respuesta = await cliente.get("/") assert respuesta.status_code == 200 assert "estado" in respuesta.json()@pytest.mark.asyncioasync def test_health(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as cliente: respuesta = await cliente.get("/health") assert respuesta.status_code == 200 assert respuesta.json() == {"status": "ok"}@pytest.mark.asyncioasync def test_chat_requiere_session_id(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as cliente: respuesta = await cliente.post("/chat/", json={ "mensaje": "Hola" # Falta session_id — debe dar error 422 }) assert respuesta.status_code == 422@pytest.mark.asyncioasync def test_agregar_documento(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as cliente: respuesta = await cliente.post("/documentos/texto", json={ "contenido": "Python es un lenguaje de programación", "nombre": "test_doc" }) assert respuesta.status_code == 200 assert "fragmentos_creados" in respuesta.json()
Ejecutar los tests:
bash
pytest tests/ -v
9.5 Dockerizar tu API
Docker permite empaquetar tu aplicación con todas sus dependencias para desplegarla en cualquier servidor.
dockerfile
# DockerfileFROM python:3.11-slim# Directorio de trabajoWORKDIR /app# Instalar dependencias del sistemaRUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/*# Copiar e instalar dependencias PythonCOPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt# Copiar el códigoCOPY . .# Puerto expuestoEXPOSE 8000# Comando de inicioCMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
from langchain_core.runnables import RunnableParallelfrom langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserllm = ChatOpenAI(model="gpt-4o-mini")parser = StrOutputParser()# Analizar texto desde múltiples perspectivas a la vezcadena_sentimiento = ( ChatPromptTemplate.from_template("Analiza el sentimiento de: {texto}") | llm | parser)cadena_entidades = ( ChatPromptTemplate.from_template("Extrae las entidades (personas, lugares) de: {texto}") | llm | parser)cadena_resumen = ( ChatPromptTemplate.from_template("Resume en una frase: {texto}") | llm | parser)# Ejecutar las 3 cadenas en paraleloanalisis_paralelo = RunnableParallel( sentimiento=cadena_sentimiento, entidades=cadena_entidades, resumen=cadena_resumen)resultado = analisis_paralelo.invoke({ "texto": "Ayer María García visitó el Museo del Prado en Madrid y quedó encantada con la obra de Velázquez."})print("Sentimiento:", resultado["sentimiento"])print("Entidades:", resultado["entidades"])print("Resumen:", resultado["resumen"])
Chains condicionales
python
from langchain_core.runnables import RunnableBranch# Decidir qué cadena usar según el inputcadena_tecnica = ( ChatPromptTemplate.from_template("Responde técnicamente: {pregunta}") | llm | parser)cadena_sencilla = ( ChatPromptTemplate.from_template("Explica de forma muy simple, para niños: {pregunta}") | llm | parser)# Rama condicionalcadena_condicional = RunnableBranch( (lambda x: x.get("modo") == "tecnico", cadena_tecnica), cadena_sencilla # caso por defecto)r1 = cadena_condicional.invoke({"pregunta": "¿Qué es un kernel?", "modo": "tecnico"})r2 = cadena_condicional.invoke({"pregunta": "¿Qué es un kernel?", "modo": "simple"})print("Técnico:", r1)print("Simple:", r2)
9.8 Evaluación de calidad con LangChain Evaluators
python
from langchain.evaluation import load_evaluatorfrom langchain_openai import ChatOpenAIllm = ChatOpenAI(model="gpt-4o-mini", temperature=0)# Evaluador de relevanciaevaluador = load_evaluator("criteria", llm=llm, criteria="relevance")evaluacion = evaluador.evaluate_strings( prediction="Python fue creado por Guido van Rossum en 1991.", input="¿Quién creó Python?", reference="Python fue creado por Guido van Rossum")print(f"Resultado: {evaluacion['value']}")print(f"Puntuación: {evaluacion['score']}")print(f"Razonamiento: {evaluacion['reasoning']}")
Módulo 10 — Fronteras del nivel experto
10.1 LangGraph — flujos con estado y lógica compleja
LangGraph es la evolución de los agentes de LangChain. En lugar de dejar que el LLM decida libremente, defines un grafo de estados con nodos y transiciones explícitas. Esto te da mucho más control y es más fiable en producción.
bash
pip install langgraph
python
10.2 Modelos locales con Ollama — sin coste de API
Ollama te permite ejecutar modelos de lenguaje en tu propio ordenador, sin pagar por cada token. Es perfecto para desarrollo, datos privados o proyectos sin presupuesto.
bash
# 1. Instalar Ollama desde https://ollama.ai# 2. Descargar un modelo (Llama 3, Mistral, etc.)ollama pull llama3.2 # Modelo de Meta, 3B parámetros (~2GB)ollama pull mistral # Excelente para tareas en inglésollama pull qwen2.5:7b # Muy bueno en español y código# 3. Ejecutar el servidor de Ollama (en segundo plano)ollama serve
python
# Ollama con LangChain — misma interfaz que OpenAIfrom langchain_ollama import ChatOllamafrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParser# Solo cambia el LLM, todo lo demás es idénticollm_local = ChatOllama( model="llama3.2", temperature=0.7, # base_url="http://localhost:11434" # Por defecto)template = ChatPromptTemplate.from_messages([ ("system", "Eres un asistente experto en Python. Responde en español."), ("human", "{pregunta}")])cadena = template | llm_local | StrOutputParser()respuesta = cadena.invoke({"pregunta": "¿Qué ventajas tiene usar async en Python?"})print(respuesta)# RAG con modelo local — 100% privado, sin datos a servidores externosfrom langchain_ollama import OllamaEmbeddingsembeddings_locales = OllamaEmbeddings(model="nomic-embed-text")# El resto del pipeline RAG es exactamente igual, solo cambia el embedding
Comparativa: OpenAI vs Ollama
Aspecto
OpenAI GPT-4o-mini
Ollama (Llama 3.2)
Coste
~$0.15/M tokens
Gratis
Velocidad
Rápido (red)
Depende del hardware
Calidad
Excelente
Muy buena (7B+)
Privacidad
Datos van a OpenAI
100% local
Límite de contexto
128K tokens
Configurable
Mejor para
Producción, calidad máxima
Desarrollo, datos privados
10.3 Optimización de costes en OpenAI
Los costes pueden dispararse si no controlas el uso de tokens. Estas técnicas reducen la factura significativamente:
python
from langchain_openai import ChatOpenAIfrom langchain_core.prompts import ChatPromptTemplatefrom langchain.callbacks import get_openai_callbackllm = ChatOpenAI(model="gpt-4o-mini")# 1. Medir el coste exacto de una llamadawith get_openai_callback() as cb: respuesta = llm.invoke("Explica qué es una API en 50 palabras") print(f"Tokens usados: {cb.total_tokens}") print(f"Tokens prompt: {cb.prompt_tokens}") print(f"Tokens respuesta: {cb.completion_tokens}") print(f"Coste: ${cb.total_cost:.6f}")# 2. Limitar tokens de respuesta según el caso de usollm_economico = ChatOpenAI( model="gpt-4o-mini", max_tokens=150, # Para respuestas cortas temperature=0 # Temperatura 0 = más determinista y con menos tokens)# 3. Usar el modelo adecuado para cada tarea# gpt-4o-mini → clasificación, extracción, preguntas simples (muy barato)# gpt-4o → razonamiento complejo, código avanzado (más caro, úsalo poco)# 4. Comprimir el historial de conversación para no acumular tokensdef comprimir_historial(historial: list, max_mensajes: int = 10) -> list: """Mantiene solo los últimos N mensajes + el mensaje del sistema""" if len(historial) <= max_mensajes: return historial # Conservar el primer mensaje (system) y los últimos N return [historial[0]] + historial[-(max_mensajes-1):]# 5. Cachear embeddings — son caros si regeneras los mismos textosfrom langchain.storage import LocalFileStorefrom langchain.embeddings import CacheBackedEmbeddingsfrom langchain_openai import OpenAIEmbeddingsfrom langchain_community.vectorstores import FAISSstore = LocalFileStore(".cache_embeddings/")embeddings_base = OpenAIEmbeddings(model="text-embedding-3-small")# Solo genera embeddings si no están en cachéembeddings_cacheados = CacheBackedEmbeddings.from_bytes_store( embeddings_base, store, namespace=embeddings_base.model)# Primera vez: llama a la API# Segunda vez con los mismos textos: sale del caché local ← gratis
10.4 Testing avanzado de LLMs
Testear aplicaciones con LLMs es diferente al testing tradicional — la salida no es determinista. Estas estrategias te permiten hacerlo bien:
python
import pytestfrom unittest.mock import AsyncMock, patchfrom langchain_core.messages import AIMessage# 1. Mockear el LLM para tests unitarios rápidos@pytest.mark.asyncioasync def test_chat_sin_llamar_a_openai(): respuesta_mock = AIMessage(content="Respuesta simulada del LLM") with patch("app.services.chat_service.ChatOpenAI") as MockLLM: MockLLM.return_value.ainvoke = AsyncMock(return_value=respuesta_mock) from app.services.chat_service import ChatService servicio = ChatService() resultado = await servicio.chat("sesion-test", "Hola") assert resultado == "Respuesta simulada del LLM" assert MockLLM.return_value.ainvoke.called# 2. Tests de evaluación — verificar calidad de respuestas reales# (estos sí llaman a la API, ejecutarlos con menos frecuencia)@pytest.mark.slow # Marca para ejecutar solo en CI o manualmente@pytest.mark.asyncioasync def test_calidad_respuesta_rag(): from app.services.rag_service import RAGService servicio = RAGService() servicio.agregar_documentos(["FastAPI fue creado por Sebastián Ramírez en 2018"]) respuesta = await servicio.consultar("¿Quién creó FastAPI?") # Verificación semántica, no exacta assert "Sebastián" in respuesta or "Ramírez" in respuesta assert len(respuesta) > 10 # No está vacía# 3. Snapshot testing — detectar regresiones en el comportamiento del LLMimport jsonimport osdef guardar_snapshot(nombre: str, datos: dict): ruta = f"tests/snapshots/{nombre}.json" with open(ruta, "w") as f: json.dump(datos, f, ensure_ascii=False, indent=2)def cargar_snapshot(nombre: str) -> dict: ruta = f"tests/snapshots/{nombre}.json" if not os.path.exists(ruta): return None with open(ruta) as f: return json.load(f)
Resumen del camino recorrido
Enhorabuena por llegar hasta aquí. Esto es lo que has aprendido:
Módulo
Contenido
Nivel
1
Variables, tipos, condicionales, bucles, listas, diccionarios, funciones, type hints, f-strings pro
Principiante
2
Manejo de errores, clases, módulos, archivos, decoradores, generadores, dataclasses
Principiante-Intermedio
3
Entornos virtuales, uv, pyproject.toml, VS Code, variables de entorno
LangGraph, Ollama (modelos locales), optimización de costes, testing de LLMs
Experto
Próximos pasos recomendados
LangGraph avanzado: Explora grafos multi-agente donde varios agentes colaboran en una tarea.
Bases de datos vectoriales en producción: Pinecone, Qdrant o Weaviate para millones de documentos.
Modelos fine-tuned: Aprende a ajustar modelos base con tus propios datos para tareas específicas.
FastAPI + WebSockets: Chat en tiempo real con reconexión automática y rooms de usuarios.
Evaluación continua: Construye pipelines de evaluación automática en CI/CD para detectar regresiones en la calidad de tu IA.
Curso elaborado con ❤️ para desarrolladores que quieren dominar la IA con Python. Última actualización: 2024 — Compatible con LangChain 0.3+ y FastAPI 0.110+
python
x = 10 # Python sabe que es un intx = "hola" # Ahora x es un str — sin errores, sin declaracionesx = [1,2,3] # Ahora es una lista. Totalmente válido.
python
# Sin type hints (válido, pero menos claro)def saludar(nombre): return "Hola, " + nombre# Con type hints (FastAPI los usa para validar entradas en la API)def saludar(nombre: str) -> str: return "Hola, " + nombre
bash
python --version
bash
# Instala Homebrew si no lo tienes (el gestor de paquetes de macOS)/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"# Luego instala Pythonbrew install python
from langchain_openai import ChatOpenAI, OpenAIEmbeddingsfrom langchain_community.vectorstores import FAISSfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserfrom langchain_core.runnables import RunnablePassthroughfrom langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain_community.document_loaders import TextLoaderfrom dotenv import load_dotenvload_dotenv()# 1. Cargar documentowith open("conocimiento.txt", "w") as f: f.write(""" FastAPI es un framework web moderno y rápido para construir APIs con Python. Fue creado por Sebastián Ramírez y se publicó en 2018. FastAPI usa Pydantic para la validación de datos y Starlette como base. LangChain es un framework para desarrollar aplicaciones con modelos de lenguaje. Fue fundado por Harrison Chase en 2022. LangChain facilita la construcción de chatbots, agentes y sistemas RAG. Los embeddings son representaciones numéricas (vectores) de texto. Textos similares tienen embeddings cercanos en el espacio vectorial. OpenAI ofrece el modelo text-embedding-3-small para generar embeddings. """)loader = TextLoader("conocimiento.txt", encoding="utf-8")documentos = loader.load()# 2. Dividir en fragmentossplitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=50)fragmentos = splitter.split_documents(documentos)# 3. Crear embeddings y base de datos vectorialembeddings = OpenAIEmbeddings(model="text-embedding-3-small")vector_store = FAISS.from_documents(fragmentos, embeddings)# 4. Crear retrieverretriever = vector_store.as_retriever(search_kwargs={"k": 3})# 5. Template RAGtemplate = ChatPromptTemplate.from_messages([ ("system", """Responde la pregunta basándote SOLO en el siguiente contexto. Si no puedes responder con el contexto dado, di que no lo sabes. Contexto: {contexto}"""), ("human", "{pregunta}")])def formatear_documentos(docs): return "\n\n".join(doc.page_content for doc in docs)llm = ChatOpenAI(model="gpt-4o-mini")parser = StrOutputParser()# 6. Cadena RAG completacadena_rag = ( { "contexto": retriever | formatear_documentos, "pregunta": RunnablePassthrough() } | template | llm | parser)preguntas = [ "¿Quién creó FastAPI?", "¿Para qué sirven los embeddings?", "¿Cuál es la capital de Francia?"]for pregunta in preguntas: respuesta = cadena_rag.invoke(pregunta) print(f"P: {pregunta}") print(f"R: {respuesta}\n")
# app/services/chat_service.pyfrom langchain_openai import ChatOpenAIfrom langchain_core.chat_history import InMemoryChatMessageHistoryfrom langchain_core.runnables.history import RunnableWithMessageHistoryfrom langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholderfrom langchain_core.output_parsers import StrOutputParserfrom app.config import settingsclass ChatService: def __init__(self): self.llm = ChatOpenAI( model=settings.openai_model, temperature=settings.temperatura, max_tokens=settings.max_tokens, api_key=settings.openai_api_key ) self.store: dict[str, InMemoryChatMessageHistory] = {} self.prompt = ChatPromptTemplate.from_messages([ ("system", """Eres un asistente de IA inteligente y amigable. Responde siempre en el mismo idioma que el usuario. Sé conciso pero completo en tus respuestas."""), MessagesPlaceholder(variable_name="history"), ("human", "{mensaje}") ]) cadena_base = self.prompt | self.llm self.cadena = RunnableWithMessageHistory( cadena_base, self._obtener_historial, input_messages_key="mensaje", history_messages_key="history" ) def _obtener_historial(self, session_id: str) -> InMemoryChatMessageHistory: if session_id not in self.store: self.store[session_id] = InMemoryChatMessageHistory() return self.store[session_id] async def chat(self, session_id: str, mensaje: str) -> str: config = {"configurable": {"session_id": session_id}} respuesta = await self.cadena.ainvoke( {"mensaje": mensaje}, config=config ) return respuesta.content async def chat_stream(self, session_id: str, mensaje: str): """Genera la respuesta en streaming""" config = {"configurable": {"session_id": session_id}} async for fragmento in self.cadena.astream( {"mensaje": mensaje}, config=config ): if hasattr(fragmento, 'content') and fragmento.content: yield fragmento.content def limpiar_sesion(self, session_id: str): if session_id in self.store: del self.store[session_id] def obtener_historial(self, session_id: str) -> list: if session_id not in self.store: return [] historial = self.store[session_id] return [ {"rol": msg.type, "contenido": msg.content} for msg in historial.messages ]# Instancia singletonchat_service = ChatService()
# app/services/rag_service.pyfrom langchain_openai import ChatOpenAI, OpenAIEmbeddingsfrom langchain_community.vectorstores import FAISSfrom langchain_core.prompts import ChatPromptTemplatefrom langchain_core.output_parsers import StrOutputParserfrom langchain_core.runnables import RunnablePassthroughfrom langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain.schema import Documentfrom app.config import settingsfrom typing import Optionalimport osclass RAGService: def __init__(self): self.embeddings = OpenAIEmbeddings( model="text-embedding-3-small", api_key=settings.openai_api_key ) self.llm = ChatOpenAI( model=settings.openai_model, api_key=settings.openai_api_key ) self.vector_store: Optional[FAISS] = None self.splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100 ) self.template = ChatPromptTemplate.from_messages([ ("system", """Responde usando SOLO la información del contexto. Si no encuentras la respuesta, indícalo claramente. Contexto disponible: {contexto}"""), ("human", "{pregunta}") ]) self.parser = StrOutputParser() def agregar_documentos(self, textos: list[str], metadatos: list[dict] = None): """Añade documentos al índice vectorial""" docs = [] for i, texto in enumerate(textos): meta = metadatos[i] if metadatos else {} docs.append(Document(page_content=texto, metadata=meta)) fragmentos = self.splitter.split_documents(docs) if self.vector_store is None: self.vector_store = FAISS.from_documents(fragmentos, self.embeddings) else: self.vector_store.add_documents(fragmentos) return len(fragmentos) async def consultar(self, pregunta: str, k: int = 3) -> str: """Responde una pregunta usando RAG""" if self.vector_store is None: return "No hay documentos cargados aún." retriever = self.vector_store.as_retriever(search_kwargs={"k": k}) def formatear(docs): return "\n\n".join(d.page_content for d in docs) cadena = ( { "contexto": retriever | formatear, "pregunta": RunnablePassthrough() } | self.template | self.llm | self.parser ) return await cadena.ainvoke(pregunta) def tiene_documentos(self) -> bool: return self.vector_store is not Nonerag_service = RAGService()
from langgraph.graph import StateGraph, ENDfrom langchain_openai import ChatOpenAIfrom langchain_core.messages import HumanMessage, AIMessagefrom typing import TypedDict, List, Annotatedimport operatorfrom dotenv import load_dotenvload_dotenv()llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)# 1. Definir el estado del grafoclass EstadoConversacion(TypedDict): mensajes: Annotated[List, operator.add] # Se acumulan con cada paso iteraciones: int necesita_busqueda: bool# 2. Definir los nodos (funciones que transforman el estado)def analizar_pregunta(estado: EstadoConversacion) -> dict: """Decide si la pregunta necesita búsqueda externa""" ultimo_mensaje = estado["mensajes"][-1].content palabras_clave_busqueda = ["actual", "hoy", "ahora", "último", "reciente", "2024", "2025"] necesita = any(p in ultimo_mensaje.lower() for p in palabras_clave_busqueda) return {"necesita_busqueda": necesita}def buscar_informacion(estado: EstadoConversacion) -> dict: """Simula una búsqueda web""" # En producción usarías Tavily, SerpAPI, etc. return { "mensajes": [AIMessage(content="[Búsqueda] Información actualizada recuperada.")], "iteraciones": estado["iteraciones"] + 1 }def generar_respuesta(estado: EstadoConversacion) -> dict: """Genera la respuesta final""" respuesta = llm.invoke(estado["mensajes"]) return { "mensajes": [respuesta], "iteraciones": estado["iteraciones"] + 1 }def decidir_siguiente_paso(estado: EstadoConversacion) -> str: """Función de routing — decide qué nodo ejecutar""" if estado["necesita_busqueda"] and estado["iteraciones"] == 0: return "buscar" return "responder"# 3. Construir el grafografo = StateGraph(EstadoConversacion)grafo.add_node("analizar", analizar_pregunta)grafo.add_node("buscar", buscar_informacion)grafo.add_node("responder", generar_respuesta)# Definir el flujografo.set_entry_point("analizar")grafo.add_conditional_edges( "analizar", decidir_siguiente_paso, { "buscar": "buscar", "responder": "responder" })grafo.add_edge("buscar", "responder")grafo.add_edge("responder", END)# 4. Compilar y ejecutarapp_grafo = grafo.compile()resultado = app_grafo.invoke({ "mensajes": [HumanMessage(content="¿Cuál es la última versión de Python?")], "iteraciones": 0, "necesita_busqueda": False})print(resultado["mensajes"][-1].content)