Tenemos una base vectorial (docs_nubelia, 04-02) y sabemos embeber y comparar significados (04-01), pero la colección sigue poblada con cuatro fragmentos de juguete escritos a mano. La documentación real de Nubelia es otra cosa: cientos de páginas markdown repartidas entre la wiki y los repos, con guías paso a paso, referencia de API, notas de versión, encabezados anidados y secciones que van de dos líneas a dos mil palabras. Esta lección cubre el oficio que convierte ese material bruto en un corpus consultable: por qué hay que trocear los documentos (no es opcional), cómo trocearlos bien (las estrategias de chunking y sus trade-offs), qué metadatos debe llevar cada trozo (spoiler: son los que rellenarán el campo fuentes del contrato JSON), y cómo empaquetarlo todo en un script de ingesta re-ejecutable — porque la documentación cambia cada semana y el corpus tiene que poder seguirle el ritmo sin dramas. La calidad de todo el pipeline RAG que montaremos en 04-04 se decide, en gran parte, aquí.
Contenido
- El corpus de Nubelia: qué tenemos entre manos
- Por qué trocear: contexto, precisión y coste
- Estrategias de chunking: comparativa
- Implementación: chunking por encabezados con solape
- Metadatos por chunk: el origen de las
fuentes - El pipeline de ingesta:
ingesta.py - Re-ingestas: upsert e invalidación de cachés
El corpus de Nubelia: qué tenemos entre manos
Antes de escribir código, inventario. La documentación de Nubelia —dispersa, como confesamos en 01-01— se ha consolidado en un directorio de ficheros markdown exportados de la wiki y los repos:
docs_nubelia/
├── guias/
│ ├── primeros-pasos.md
│ ├── gestion-de-tareas.md
│ └── informes-y-exportacion.md
├── administracion/
│ ├── seguridad-y-acceso.md
│ ├── miembros-y-permisos.md
│ └── planes-y-facturacion.md
├── api/
│ ├── autenticacion.md
│ ├── endpoints-proyectos.md
│ └── webhooks.md
└── integraciones/
├── slack.md
└── calendario.mdCada fichero sigue una convención razonable (no perfecta — es documentación real): un # Título de página, secciones ## y subsecciones ###, y una primera línea de comentario con metadatos de publicación que la wiki añade al exportar:
<!-- url: wiki/administracion/seguridad-y-acceso | version: 2026.2 | actualizado: 2026-06-15 --> # Seguridad y acceso ## Restablecer contraseña Para restablecer tu contraseña, ve a **Ajustes > Seguridad**... ## Bloqueo de cuenta Si tu cuenta queda bloqueada tras varios intentos fallidos...
Trabajamos con markdown porque es el caso más habitual (wikis, READMEs, portales de docs) y porque su estructura de encabezados es oro puro para el chunking. Si tu corpus real incluye PDF, HTML o Word, el patrón es idéntico con un paso previo de conversión a texto/markdown (bibliotecas como pypandoc, beautifulsoup4 o extractores de PDF); no lo desarrollamos aquí para no desviar el foco.
Por qué trocear: contexto, precisión y coste
La tentación inicial es no trocear: embeber cada página entera como un solo vector. Tres razones lo descartan:
- Límites de contexto. La página entera puede no caber en la ventana del modelo de embeddings (04-01), y aunque quepa, varias páginas enteras concatenadas en el bloque
<documentacion>pueden desbordar —o degradar— la ventana del LLM: recuerda "lost in the middle" de 01-04. Queremos darle al LLM poco y bueno, no mucho y diluido. - Precisión de la recuperación. El embedding de una página que habla de contraseñas, bloqueos, sesiones y auditoría es un promedio de todos esos temas: no está especialmente cerca de ninguna pregunta concreta. El embedding de la sección "Restablecer contraseña" apunta como un láser a las preguntas sobre contraseñas. Chunks temáticamente enfocados ⇒ similitudes más nítidas ⇒ mejor top-k.
- Coste. Cada token del bloque
<documentacion>se paga en cada llamada (lo medimos conestimar_costeen 03-04). Recuperar 4 chunks de ~300 tokens en vez de 4 páginas de ~3.000 divide por diez esa parte de la factura — y es la parte que se repite en cada pregunta.
El troceo también tiene un coste propio: cortar mal puede separar una instrucción de su condición ("...pulsa Restablecer." / [corte] / "Nota: esta opción requiere ser administrador."). Todo el diseño de este capítulo persigue minimizar ese daño.
Estrategias de chunking: comparativa
Las tres familias que cubren la práctica totalidad de los casos:
| Estrategia | Cómo corta | Ventajas | Inconvenientes | Cuándo usarla |
|---|---|---|---|---|
| Tamaño fijo con solape | Cada N tokens/caracteres, repitiendo los últimos M en el chunk siguiente | Trivial de implementar; chunks de tamaño uniforme y predecible | Corta a ciegas: parte frases, mezcla temas; el solape mitiga pero no cura | Texto sin estructura (transcripciones, volcados) o como primera versión rápida |
| Por estructura (encabezados markdown) | En los límites de sección ##/### |
Respeta las fronteras temáticas que el propio autor marcó; chunks con sentido completo; el título de sección es un metadato gratis | Secciones de tamaño muy dispar: algunas enormes (hay que sub-trocear), otras minúsculas | Documentación con estructura — nuestro caso, y el más común en corpus corporativos |
| Por párrafos / semántico | En saltos de párrafo; la variante "semántica" corta donde el embedding entre frases consecutivas cambia bruscamente | Granularidad fina; la semántica detecta cambios de tema sin depender del formato | Chunks a veces demasiado pequeños (pierden contexto); la variante semántica embebe frase a frase: más coste y complejidad | Textos largos sin encabezados; refinamiento cuando la estructura no basta |
Dos parámetros transversales a cualquier estrategia:
- Tamaño objetivo. El compromiso habitual está entre 200 y 600 tokens por chunk: suficiente para que el fragmento se entienda solo, suficientemente pequeño para apuntar a un tema. No hay número mágico — en 04-05 lo trataremos como variable experimental.
- Solape (overlap). Repetir el final de un chunk al principio del siguiente (10–20% del tamaño) asegura que una frase partida por un corte exista completa en al menos uno de los dos chunks. Precio: algo de almacenamiento duplicado y el riesgo de recuperar dos chunks casi iguales en el mismo top-k.
Para Nubelia elegimos la estrategia que aprovecha lo que ya tenemos: por encabezados, con sub-troceo con solape para las secciones largas. Lo mejor de dos familias.
Implementación: chunking por encabezados con solape
El plan de la función: separar el documento por encabezados ##/###, y si una sección excede el tamaño máximo, sub-trocearla por párrafos con solape. Cada chunk sale con su texto y sus metadatos.
# chunking.py — troceo de markdown por encabezados con solape
import re
TAM_MAX = 1800 # tamaño máximo del chunk en caracteres (~450 tokens)
SOLAPE = 300 # caracteres repetidos entre sub-chunks consecutivos
def trocear_markdown(texto: str, url: str, version: str, fecha: str) -> list[dict]:
"""Convierte un documento markdown en una lista de chunks con metadatos."""
titulo_pagina, secciones = _separar_por_encabezados(texto)
chunks = []
for titulo_seccion, cuerpo in secciones:
for indice, trozo in enumerate(_sub_trocear(cuerpo)):
chunks.append({
# Contexto explícito: el chunk "sabe" de qué página y sección viene
"texto": f"[{titulo_pagina} > {titulo_seccion}]\n{trozo}",
"metadatos": {
"titulo_seccion": titulo_seccion,
"titulo_pagina": titulo_pagina,
"url": url,
"version": version,
"fecha": fecha,
},
# Id determinista: misma sección => mismo id en cada re-ingesta
"id": f"{url.replace('/', '-')}--{_slug(titulo_seccion)}--{indice:02d}",
})
return chunks
def _separar_por_encabezados(texto: str):
"""Divide el markdown en (titulo_pagina, [(titulo_seccion, cuerpo), ...])."""
titulo_pagina = "Sin título"
m = re.search(r"^# (.+)$", texto, flags=re.MULTILINE)
if m:
titulo_pagina = m.group(1).strip()
# Partimos por encabezados de nivel 2 o 3, conservando el título de cada sección
partes = re.split(r"^(#{2,3} .+)$", texto, flags=re.MULTILINE)
secciones, titulo_actual = [], "Introducción" # texto antes del primer ##
for parte in partes:
if re.match(r"^#{2,3} ", parte):
titulo_actual = parte.lstrip("#").strip()
elif parte.strip():
secciones.append((titulo_actual, parte.strip()))
return titulo_pagina, secciones
def _sub_trocear(cuerpo: str) -> list[str]:
"""Si la sección cabe, un solo chunk; si no, corta por párrafos con solape."""
if len(cuerpo) <= TAM_MAX:
return [cuerpo]
trozos, actual = [], ""
for parrafo in cuerpo.split("\n\n"): # nunca partimos un párrafo por dentro
if len(actual) + len(parrafo) > TAM_MAX and actual:
trozos.append(actual.strip())
actual = actual[-SOLAPE:] # el solape: arrastra el final anterior
actual += "\n\n" + parrafo
if actual.strip():
trozos.append(actual.strip())
return trozos
def _slug(texto: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", texto.lower()).strip("-")Decisiones que conviene entender (son las que marcan la diferencia entre un chunking correcto y uno mediocre):
- El prefijo
[Página > Sección]dentro del texto del chunk. Un chunk que empieza por "Para restablecer tu contraseña..." es bueno; uno que empieza por "[Seguridad y acceso > Restablecer contraseña]\nPara restablecer..." es mejor: el contexto entra en el embedding (mejora la similitud con preguntas que mencionan el tema general) y el LLM lo verá al redactar, reduciendo ambigüedad. Técnica barata con retorno alto. - Nunca partimos un párrafo por dentro. El sub-troceo acumula párrafos completos; el peor caso es un chunk algo más largo que
TAM_MAX, no una frase decapitada. - El solape se arrastra entre sub-chunks de la misma sección (
actual[-SOLAPE:]), no entre secciones distintas: cruzar la frontera de un encabezado mezclaría temas, justo lo que la estrategia evita. - Ids deterministas (
url--seccion--indice): la pieza que en 04-02 identificamos como requisito delupsertidempotente.
Metadatos por chunk: el origen de las fuentes
Merece su propio apartado porque los metadatos no son decoración — cada campo tiene un consumidor concreto ya construido o ya anunciado:
| Metadato | Quién lo consume | Para qué |
|---|---|---|
url |
Campo fuentes del contrato JSON (02-03) |
Que la respuesta de DocuBot cite la página real de donde salió — verificable por el usuario |
titulo_seccion, titulo_pagina |
Bloque <documentacion> (04-04) y fuentes |
Citas legibles ("Seguridad y acceso > Restablecer contraseña") en lugar de URLs crudas |
version |
Filtros where de 04-02 |
No responder con documentación de una versión que el usuario no usa |
fecha |
Filtros y diagnóstico | Detectar documentación rancia; desempatar entre chunks duplicados |
Hasta ahora, el campo fuentes del contrato JSON era lo que el LLM decía haber usado — con la documentación pasada a mano, poco verificable. A partir de 04-04, fuentes se rellenará desde estos metadatos, es decir, desde hechos: qué chunks se recuperaron realmente. Es un salto de fiabilidad que se decide aquí, en la ingesta, guardando bien el origen de cada trozo.
El pipeline de ingesta: ingesta.py
Ensamblamos todo en el script que se ejecutará cada vez que la documentación cambie:
flowchart LR
A[Leer ficheros .md] --> B[Limpiar y extraer metadatos]
B --> C[Trocear con chunking.py]
C --> D[Embeber en lotes con embeddings.py]
D --> E[Upsert en docs_nubelia]
E --> F[Invalidar caches de respuestas]# ingesta.py — pipeline de ingesta re-ejecutable de la documentación de Nubelia
import os
import re
import pathlib
from chunking import trocear_markdown
from embeddings import embed
from vectordb import obtener_coleccion
DIR_DOCS = os.environ.get("DOCUBOT_DOCS", "./docs_nubelia")
TAM_LOTE = 64 # cuántos chunks embebemos por llamada a la API
def limpiar(texto: str) -> str:
"""Normaliza el markdown antes de trocear."""
texto = re.sub(r"<!--.*?-->", "", texto, flags=re.DOTALL) # comentarios HTML fuera
texto = re.sub(r"\n{3,}", "\n\n", texto) # colapsar líneas en blanco
return texto.strip()
def extraer_metadatos_publicacion(texto: str, ruta: pathlib.Path) -> dict:
"""Lee la línea '<!-- url: ... | version: ... | actualizado: ... -->' de la wiki."""
m = re.search(r"<!--\s*url:\s*(\S+)\s*\|\s*version:\s*(\S+)\s*\|\s*actualizado:\s*(\S+)\s*-->", texto)
if m:
return {"url": m.group(1), "version": m.group(2), "fecha": m.group(3)}
# Valores de respaldo si la página no trae la línea (documentación real = imperfecta)
return {"url": str(ruta.with_suffix("")).replace("\\", "/"), "version": "desconocida", "fecha": "desconocida"}
def ingerir():
coleccion = obtener_coleccion()
todos = []
for ruta in sorted(pathlib.Path(DIR_DOCS).rglob("*.md")):
crudo = ruta.read_text(encoding="utf-8")
meta = extraer_metadatos_publicacion(crudo, ruta.relative_to(DIR_DOCS))
chunks = trocear_markdown(limpiar(crudo), **meta)
todos.extend(chunks)
print(f" {ruta.name}: {len(chunks)} chunks")
# Embeber y guardar POR LOTES: menos llamadas, menos latencia, mismo coste por token
for i in range(0, len(todos), TAM_LOTE):
lote = todos[i : i + TAM_LOTE]
vectores = embed([c["texto"] for c in lote], tipo="document")
coleccion.upsert(
ids=[c["id"] for c in lote],
documents=[c["texto"] for c in lote],
embeddings=vectores,
metadatas=[c["metadatos"] for c in lote],
)
print(f"Ingesta completa: {len(todos)} chunks en '{coleccion.name}' ({coleccion.count()} totales).")
# La documentación ha cambiado => las respuestas cacheadas pueden estar obsoletas
invalidar_caches()
def invalidar_caches():
"""Regla de 03-04: publicar documentación invalida las respuestas cacheadas."""
from cache_semantico import CacheSemantico # y el CacheRespuestas exacto de 03-04
# En un despliegue real, aquí se vaciaría el almacén compartido de ambos cachés.
print("Cachés de respuestas invalidados (CacheRespuestas + CacheSemantico).")
if __name__ == "__main__":
ingerir()Lectura guiada del script:
limpiar()es mínima a propósito: quita comentarios HTML (ya extraídos sus metadatos) y colapsa espacios. La limpieza agresiva (quitar tablas, código, emojis) suele destruir información útil; limpia solo lo que demuestre estorbar.- Lotes de 64: el patrón de 04-01 en acción. Con ~2.000 chunks son ~32 llamadas a la API de embeddings en lugar de 2.000. El coste por token es el mismo; la latencia total y la fiabilidad, incomparables. (Si la API de embeddings devuelve errores transitorios, envuelve
embedcon elllamar_con_reintentosdereintentos.py— los patrones de 03-02 sirven para cualquier API, no solo la del LLM.) upsertpor lotes con ids deterministas: ejecutaringesta.pydos veces deja la colección exactamente igual; ejecutarla tras editar una página sobrescribe solo los chunks de esa página.- La impresión por fichero no es cosmética: "gestion-de-tareas.md: 47 chunks" cuando el resto tiene 8-12 es la señal de una página monstruosa que quizá merece reestructurarse — la ingesta es también un chivato de calidad de la documentación.
Re-ingestas: upsert e invalidación de cachés
La documentación de Nubelia cambia cada semana; el corpus debe poder actualizarse con un solo comando y sin efectos secundarios sorpresa. Nuestro diseño lo garantiza con tres propiedades:
- Idempotencia: re-ejecutar sin cambios no duplica nada (ids deterministas + upsert, de 04-02).
- Actualización real: una página editada regenera sus chunks bajo los mismos ids, con embeddings y metadatos frescos.
- Coherencia con los cachés: la regla que fijamos en 03-04 —publicar documentación invalida
CacheRespuestas— se extiende ahora aCacheSemantico(04-01), yingesta.pyes el lugar natural para dispararla: es el único punto del sistema que sabe que la documentación acaba de cambiar. Un caché que sobrevive a una re-ingesta puede servir respuestas basadas en documentación que ya no existe — el peor bug posible en un asistente de soporte, porque nadie lo ve.
Queda una arista honesta: si una sección se elimina de una página (o una página desaparece), sus chunks antiguos siguen en la colección con sus ids huérfanos — el upsert actualiza y añade, pero no borra lo que ya no se genera. La solución robusta es comparar los ids generados en la ingesta con los existentes (coleccion.get()) y borrar la diferencia; lo dejamos señalado como ejercicio, porque es exactamente el tipo de detalle que separa un script de demo de una pieza operable.
Errores Comunes y Consejos
- Chunks sin contexto propio. Un chunk que dice "Después, pulsa Aceptar y el cambio se aplica" no lo recupera ninguna pregunta ni ayuda al LLM. El prefijo
[Página > Sección]y el corte por encabezados existen para evitarlo; revisa una muestra de chunks a ojo después de cada cambio de estrategia. - Trocear por tamaño fijo teniendo estructura disponible. Si el documento tiene encabezados, úsalos: cortar "cada 1.000 caracteres" un markdown bien estructurado es tirar información de diseño que el autor ya te dio.
- Chunks enormes "para no perder nada". Recuperarás fragmentos que contienen la respuesta... diluida entre tres temas más, con similitudes planas y el bloque
<documentacion>inflado (y pagado, 03-04). Si dudas entre grande y pequeño, empieza pequeño-mediano y deja que la evaluación de 04-05 decida. - Ids no deterministas o derivados de un contador global. Un contador (
chunk-0001,chunk-0002...) cambia de asignación en cuanto se añade una página al principio del recorrido: los upserts sobrescribirán chunks equivocados. El id debe derivar del contenido estable (url + sección + índice). - Olvidar la invalidación de cachés en la re-ingesta. El síntoma es diabólico: la colección tiene la doc nueva, las pruebas directas al pipeline funcionan, pero los usuarios siguen recibiendo respuestas viejas (cacheadas). Invalidar en
ingesta.pycierra la puerta. - Consejo: versiona
chunking.pycon cuidado y anota (como conPROMPT_VERSIONen 02-04) qué versión del chunking generó el corpus actual. Cambiar la estrategia de chunking cambia los ids y el contenido de los chunks: exige re-ingesta completa y re-evaluación.
Ejercicios
- Auditoría de chunks. Ejecuta
trocear_markdown()sobre la páginaseguridad-y-acceso.mdde ejemplo y imprime, para cada chunk: id, primeros 80 caracteres y longitud. Verifica que (a) ningún chunk superaTAM_MAXen más de un párrafo, (b) los ids son únicos, y (c) el prefijo[Página > Sección]está presente. Después, duplica el contenido de una sección hasta hacerla superarTAM_MAXy comprueba que aparece el sub-troceo con solape. - Borrado de huérfanos. Implementa
purgar_huerfanos(coleccion, ids_vigentes)que borre de la colección los chunks cuyo id no esté en la ingesta actual, y llámala al final deingerir(). Pista:coleccion.get()sin filtros devuelve los ids existentes, ycoleccion.delete(ids=[...])borra por id. - El chunking como variable. Sin ejecutar nada: para la pregunta "¿cuánto dura el enlace de restablecer contraseña?" cuya respuesta ("24 horas") está en la última frase de una sección larga, razona qué pasaría con (a) chunks por página entera, (b) tamaño fijo de 300 caracteres sin solape, (c) nuestra estrategia. ¿Cuál recupera mejor y por qué?
Soluciones
- Con la página de ejemplo, la salida esperada es del estilo:
wiki-administracion-seguridad-y-acceso--restablecer-contrasena--00 | [Seguridad y acceso > Restablecer contraseña]\nPara resta... | 412 wiki-administracion-seguridad-y-acceso--bloqueo-de-cuenta--00 | [Seguridad y acceso > Bloqueo de cuenta]\nSi tu cuenta que... | 388
Al inflar una sección por encima de TAM_MAX, aparecen ids --00, --01... de la misma sección, y el inicio del chunk --01 repite los últimos ~300 caracteres del --00 (el solape). Si además observas que el chunk --01 empieza a mitad de una idea, acabas de ver en vivo por qué el solape existe: la idea completa está garantizada en el --00.
- Una implementación directa:
def purgar_huerfanos(coleccion, ids_vigentes: set[str]):
existentes = set(coleccion.get()["ids"])
huerfanos = list(existentes - ids_vigentes)
if huerfanos:
coleccion.delete(ids=huerfanos)
print(f"Purgados {len(huerfanos)} chunks huérfanos.")Y en ingerir(), tras el bucle de upserts: purgar_huerfanos(coleccion, {c['id'] for c in todos}). Con esto la ingesta pasa de "añade y actualiza" a espejo fiel del directorio de documentación. Matiz de operación: si la ingesta corre contra un directorio incompleto por error (p. ej. un checkout parcial), la purga borraría medio corpus — un guardarraíl razonable es abortar si ids_vigentes es sospechosamente pequeño respecto a los existentes. Las decisiones destructivas merecen cinturón y tirantes, como el techo de presupuesto de 03-04.
- (a) Página entera: el embedding de la página promedia contraseñas, bloqueos y auditoría; la similitud con la pregunta será mediocre y, aunque se recupere, el LLM recibirá 3.000 tokens donde el dato clave está enterrado al final — riesgo "lost in the middle" (01-04) y coste máximo. (b) Tamaño fijo 300 sin solape: alta probabilidad de que el corte caiga entre "recibirás un correo con un enlace" y "válido durante 24 horas" — el chunk recuperado contiene la mitad de la respuesta y DocuBot, fiel a la regla 2 de
SYSTEM_DOCUBOT, no podrá responder con precisión. (c) Nuestra estrategia: la sección completa "Restablecer contraseña" es un chunk (o dos con solape), temáticamente puro, con la frase de las 24 horas intacta en al menos un chunk, y con el prefijo de sección reforzando la similitud. Recupera mejor porque cada decisión (cortar en fronteras temáticas, no partir párrafos, solapar) protege exactamente el caso que las otras dos rompen.
Conclusión
La colección docs_nubelia ya no contiene cuatro frases de demo sino la documentación real de Nubelia, convertida en chunks con oficio: troceados por encabezados (respetando las fronteras temáticas que marcó el autor), sub-troceados con solape cuando una sección se alarga, prefijados con su [Página > Sección] para que el contexto viaje dentro del embedding, y cargados con los metadatos —url, titulo_seccion, version, fecha— que alimentarán los filtros de 04-02 y, sobre todo, el campo fuentes del contrato JSON con hechos en lugar de declaraciones. Todo orquestado por ingesta.py: leer → limpiar → trocear → embeber en lotes → upsert, re-ejecutable a voluntad gracias a los ids deterministas, y con la disciplina de invalidar CacheRespuestas y CacheSemantico en cada publicación, porque un caché desincronizado de la documentación es un generador silencioso de respuestas obsoletas. Ya están las tres piezas sobre la mesa: sabemos embeber y comparar (04-01), sabemos almacenar y recuperar con filtros (04-02) y sabemos poblar el almacén con material de calidad (04-03). Lo que falta es unirlas con los artefactos de los módulos anteriores —SYSTEM_DOCUBOT y su bloque <documentacion> que por fin se llenará solo, prompt_respuesta(), el responder() robusto de 03-02— en una sola función que reciba una pregunta y devuelva una respuesta fundamentada con fuentes reales. Ese es el momento culminante del módulo: la construcción del pipeline RAG completo (04-04).
Curso de IA Generativa y LLMs para Desarrolladores
Módulo 1: Fundamentos de la IA generativa
- Qué es la IA generativa y por qué importa a los desarrolladores
- Cómo funciona un LLM: tokens, embeddings y atención
- Panorama de modelos y proveedores
- Limitaciones y riesgos: alucinaciones, contexto y costes
Módulo 2: Prompt engineering
- Anatomía de un prompt: roles, instrucciones y contexto
- Técnicas de prompting: few-shot, cadena de razonamiento y plantillas
- Salidas estructuradas: JSON y control de formato
- Iteración y evaluación de prompts
Módulo 3: Integración de LLMs en aplicaciones
- Primera integración con la API de un LLM
- Streaming, errores y reintentos
- Conversaciones y gestión del contexto
- Costes, latencia y caching
Módulo 4: RAG - Generación aumentada por recuperación
- Embeddings y búsqueda semántica
- Bases de datos vectoriales
- Ingesta y chunking de documentos
- Construcción de un pipeline RAG completo
- Evaluación de sistemas RAG
Módulo 5: Function calling y agentes
- Function calling: conectar el LLM con tu código
- De LLM a agente: el bucle de razonamiento y acción
- Frameworks de orquestación
