AI Agent Engineer · Módulo 3
Semantic Kernel
El SDK de orquestación de Microsoft: del bucle agéntico escrito a mano a una aplicación estructurada con kernel, plugins, invocación automática de funciones y filtros.
29 min de lectura
Objetivo del módulo
El Módulo 1 terminó con el mecanismo más importante del curso: function calling, el bucle en el que el modelo pide ejecutar una función, tu código la ejecuta y le devuelve el resultado. El Módulo 2 mostró dónde vive el modelo en una empresa: deployments, cuotas, filtros de contenido. Este módulo une las dos piezas y resuelve el problema que aparece inmediatamente después: escribir ese bucle a mano no escala.
Con dos funciones y una conversación, el bucle manual es un ejercicio didáctico. Con treinta funciones, historial que crece, permisos que comprobar, errores que capturar y trazas que registrar, se convierte en cientos de líneas de fontanería repetitiva que cada equipo reinventa mal. Semantic Kernel es el SDK con el que Microsoft resuelve esa fontanería: tú declaras qué modelos usas y qué funciones existen, y el kernel se encarga de armar las peticiones, ejecutar lo que el modelo pida y mantener el orden.
¿Por qué Semantic Kernel antes que LangChain (Módulo 4)? Porque el proyecto final del curso — un asistente de empleados sobre SharePoint, Microsoft Graph y SQL — vive en el ecosistema Microsoft, y Semantic Kernel es la columna vertebral natural ahí. Además, aprender un orquestador a fondo hace que el segundo se entienda en una tarde: los conceptos son los mismos con otro vocabulario.
Advertencia honesta antes de empezar: Semantic Kernel evoluciona rápido y algunos detalles de la API (nombres de parámetros, rutas de import) cambian entre versiones. Los conceptos de este módulo son estables; ante cualquier discrepancia, la referencia es la documentación oficial en learn.microsoft.com y el repositorio del proyecto en GitHub.
Qué es Semantic Kernel
El problema que resuelve
Analogía primero: trabajar con el modelo "a pelo", como en el Módulo 1, es ser el asistente personal de un experto brillante encerrado en una habitación. Tú le pasas notas por debajo de la puerta, copias sus respuestas, y cuando el experto escribe "necesito que alguien consulte la base de datos de empleados", eres tú quien corre a consultarla, apunta el resultado y se lo vuelve a pasar. Con un experto, dos recados posibles y una conversación, funciona. Con decenas de recados posibles, varias conversaciones a la vez, permisos que verificar y un registro de todo lo que ocurre, necesitas una oficina con procedimientos: un registro de recados disponibles, alguien que ejecute cada uno, un archivo de correspondencia y controles en la puerta. Semantic Kernel es esa oficina.
En concreto, sin un orquestador tu código tiene que: serializar el catálogo de funciones al formato JSON exacto que espera la API, detectar cuándo la respuesta del modelo es una petición de función y no texto, ejecutar la función correcta con los argumentos correctos, añadir el resultado al historial con el formato preciso, volver a llamar al modelo, repetir hasta que haya respuesta final, y capturar errores en cada paso. Semantic Kernel hace todo eso a partir de declaraciones: "estos son mis modelos, estas son mis funciones".
Qué es exactamente (y qué no es)
Semantic Kernel es un SDK open source (licencia MIT) disponible para Python, C# y Java. Es una biblioteca que se instala con pip install semantic-kernel y vive dentro de tu proceso, igual que cualquier otra dependencia.
Semantic Kernel no es un servicio en la nube, no es un modelo y no requiere desplegar infraestructura adicional. No sustituye a Azure OpenAI: lo usa. Lo único desplegado sigue siendo el deployment del Módulo 2.
Tu código Python
│ declara plugins y pide "responde a esto"
▼
Semantic Kernel ── arma la petición, adjunta el catálogo de funciones,
│ ejecuta las que el modelo pida, gestiona el historial
▼
Azure OpenAI ───── el deployment del Módulo 2 (por ejemplo, gpt-4.1)Semantic Kernel y Microsoft Agent Framework
En el ecosistema Microsoft conviven dos linajes: Semantic Kernel (orquestación para producción) y AutoGen (experimentación multi-agente, nacido en investigación). Ambos convergen en un sucesor común llamado Microsoft Agent Framework, que hereda los conceptos de los dos. Este curso enseña Semantic Kernel porque su documentación, su ecosistema y su base instalada en producción son los más maduros del mundo Microsoft — y porque todo lo que aprendas aquí (kernel, plugins, funciones, invocación automática, filtros) se traslada casi literalmente a Agent Framework. Aprender uno es aprender los dos.
Pregunta de comprensión. Un compañero afirma: "para usar Semantic Kernel hay que desplegarlo en Azure junto al modelo, como un recurso más". ¿Qué tiene de cierto y qué de falso?
Falso en lo esencial. Semantic Kernel es una biblioteca dentro de tu proceso, no un servicio: se instala con pip y corre donde corra tu aplicación — una terminal, una Azure Function, un contenedor, un backend de Node reescrito en Python. Lo único que se despliega en Azure es el modelo (el deployment del Módulo 2). Lo cierto a medias es que en producción tu aplicación con Semantic Kernel dentro sí acabará desplegada en algún cómputo de Azure, pero eso es tu aplicación, no el SDK.
La arquitectura: el kernel y sus piezas
Analogía primero: la centralita
El kernel es la centralita telefónica de una oficina clásica. La operadora conoce todas las extensiones internas (las funciones), sabe qué líneas salen al exterior (los servicios de IA) y puede escuchar, registrar o cortar cualquier llamada (los filtros). Nada se conecta directamente con nada: todo pasa por la centralita, y justo eso es lo que da orden y trazabilidad al sistema.
Las tres piezas
| Pieza | Qué es | En la analogía |
|---|---|---|
| Servicios de IA | Conexiones a modelos (chat, embeddings), registradas cada una con un service_id | Las líneas externas de la centralita |
| Plugins | Grupos de funciones que el modelo puede pedir que se ejecuten | El directorio de extensiones internas |
| Filtros | Código que intercepta cada invocación de función | La grabadora de llamadas y el control de acceso |
┌────────────────────────────────────┐
│ KERNEL │
│ │
Petición ─────► │ Servicios de IA │ ────► Azure OpenAI
│ └─ "chat" (gpt-4.1) │
│ │
│ Plugins │
│ ├─ empleados │
│ │ ├─ obtener_empleado() │
│ │ └─ listar_equipo() │
│ └─ vacaciones │
│ ├─ dias_disponibles() │
│ └─ solicitar_vacaciones() │
│ │
│ Filtros │
│ └─ interceptan cada invocación │
└────────────────────────────────────┘Inyección de dependencias, explicada desde cero
El patrón que hay detrás de kernel.add_service(...) y kernel.add_plugin(...) se llama inyección de dependencias, y conviene entenderlo porque aparece en todo el software serio, no solo aquí.
Analogía primero: en un taller mecánico desorganizado, cada mecánico trae su propia caja de herramientas; cuando el taller cambia de proveedor de llaves, hay que perseguir a cada mecánico para que cambie las suyas. En un taller organizado hay un panel central con herramientas etiquetadas: cada mecánico pide "la llave del 12" y le da igual la marca. Cambiar de proveedor es cambiar el panel una vez.
El kernel es ese panel. Registras una conexión al modelo una sola vez, con una etiqueta (service_id), y el resto del código pide "el servicio llamado chat" sin saber si detrás hay Azure OpenAI, OpenAI directo u otro proveedor. Por eso cambiar de modelo o de proveedor en Semantic Kernel suele ser una línea, no una refactorización.
Un ejemplo con números: registras dos servicios en el mismo kernel — "chat-potente" apuntando a un deployment de gpt-4.1 y "chat-rapido" apuntando a gpt-4.1-mini, unas 15 veces más barato por token. Las tareas visibles para el usuario usan el potente; clasificar la intención de cada mensaje o generar títulos de conversación usa el rápido. Es la misma decisión de triaje que el Model Router del Módulo 2, pero tomada por ti, en tu código, de forma determinista.
Instalación y primer programa
Preparar el entorno
python -m venv .venv
source .venv/bin/activate # en Windows: .venv\Scripts\activate
pip install semantic-kernel python-dotenvCrea un archivo .env junto a tu código con los datos del deployment del Módulo 2:
AZURE_OPENAI_ENDPOINT=https://TU-RECURSO.openai.azure.com/
AZURE_OPENAI_API_KEY=tu-clave
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4.1Para aprender en local, la API key es suficiente. En producción aplica lo del Módulo 2: identidad de Entra ID con DefaultAzureCredential y ninguna clave en texto plano.
Paréntesis necesario: async y await
Semantic Kernel en Python es asíncrono: casi todo lo que hace implica esperar respuestas de red, y Python permite aprovechar esas esperas en lugar de bloquearse.
Analogía primero: un camarero toma tu pedido y no se queda plantado frente a la cocina mirando cómo se hace tu plato; atiende otras mesas y vuelve cuando la cocina avisa. async marca las funciones que saben trabajar así, y await marca los puntos de espera: "cuando esto esté listo, sigue por aquí".
Para este curso bastan tres reglas mecánicas: las funciones que usan Semantic Kernel se declaran con async def, las llamadas al kernel llevan await delante, y el punto de entrada del programa se arranca con asyncio.run(main()). No hace falta más teoría de concurrencia para llegar al final del curso.
El primer chat
import asyncio
from dotenv import load_dotenv
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import (
AzureChatCompletion,
AzureChatPromptExecutionSettings,
)
from semantic_kernel.contents import ChatHistory
load_dotenv()
async def main():
kernel = Kernel()
kernel.add_service(AzureChatCompletion(service_id="chat"))
chat = kernel.get_service("chat")
historial = ChatHistory()
historial.add_system_message(
"Eres el asistente interno de Contoso. Responde en español, breve y directo."
)
historial.add_user_message(
"Explícame en dos frases qué es un deployment de Azure OpenAI."
)
respuesta = await chat.get_chat_message_content(
chat_history=historial,
settings=AzureChatPromptExecutionSettings(),
)
print(respuesta.content)
asyncio.run(main())Dos detalles importantes. Primero: AzureChatCompletion(service_id="chat") sin más argumentos lee automáticamente las variables AZURE_OPENAI_* del entorno; también acepta deployment_name, endpoint y api_key explícitos si prefieres pasarlos tú. Segundo: fíjate en que el código no menciona gpt-4.1 por ningún lado — el modelo concreto es una decisión de configuración, no de código. Eso es la inyección de dependencias funcionando.
Pregunta de comprensión. ¿Por qué registrar el servicio en el kernel con un service_id, en lugar de crear el cliente de Azure OpenAI directamente en cada sitio donde se necesita?
Por tres razones que se pagan caras a medio plazo. Mantenibilidad: la configuración vive en un solo punto; cambiar de deployment, de región o de proveedor es tocar una línea. Flexibilidad: puedes registrar varios servicios (uno potente, uno barato) y elegir por etiqueta según la tarea, sin duplicar código de conexión. Y pruebas: en tests puedes registrar bajo el mismo service_id un servicio falso que devuelve respuestas fijas, y el resto del código ni se entera. Crear el cliente en cada sitio funciona en un script de 30 líneas; en el proyecto final, con decenas de puntos de uso, sería deuda técnica desde el primer día.
Plugins y funciones
Analogía primero: la caja de herramientas etiquetada
El modelo nunca ve tu código Python. Lo que ve es un catálogo: el nombre de cada función, su descripción y sus parámetros — como un cajón de herramientas donde cada una lleva una etiqueta escrita a mano. Si la etiqueta dice "martillo" y dentro hay un destornillador, el modelo la usará mal, y no será culpa del modelo. La calidad de tus etiquetas determina la calidad de tu agente.
Ese catálogo es exactamente el JSON Schema de function calling que construiste a mano en el Módulo 1. La diferencia es que Semantic Kernel lo genera automáticamente a partir de tu código Python: de los decoradores, las anotaciones de tipo y las descripciones. Un plugin es simplemente un grupo de funciones relacionadas empaquetadas juntas — el cajón entero.
Funciones nativas
Una función nativa es código Python normal con etiquetas para el modelo:
import json
from typing import Annotated
from semantic_kernel.functions import kernel_function
EMPLEADOS = {
"ana torres": {
"puesto": "Ingeniera de datos",
"equipo": "Analítica",
"email": "ana.torres@contoso.com",
},
"luis mora": {
"puesto": "Diseñador UX",
"equipo": "Producto",
"email": "luis.mora@contoso.com",
},
}
class EmpleadosPlugin:
@kernel_function(
description="Devuelve los datos de un empleado a partir de su nombre completo."
)
def obtener_empleado(
self,
nombre: Annotated[str, "Nombre completo del empleado, por ejemplo 'Ana Torres'"],
) -> Annotated[str, "Datos del empleado en JSON, o un mensaje si no existe"]:
datos = EMPLEADOS.get(nombre.strip().lower())
if datos is None:
return f"No existe ningún empleado llamado {nombre}."
return json.dumps(datos, ensure_ascii=False)Se registra en el kernel dándole nombre al cajón:
kernel.add_plugin(EmpleadosPlugin(), plugin_name="empleados")A partir de aquí el modelo ve una herramienta llamada empleados-obtener_empleado. Cuatro reglas que separan un plugin profesional de uno de tutorial:
La descripción es prompt, no comentario. Cada description viaja al modelo en cada petición. Eso tiene dos consecuencias. La primera es de calidad: descripciones vagas producen elecciones vagas. La segunda es de coste, y se calcula: una función con sus parámetros ocupa en torno a 100-200 tokens de catálogo; un kernel con 20 funciones registradas añade unos 3.000 tokens a cada llamada, se usen o no. Es el mismo dinero y el mismo riesgo de "lost in the middle" del Módulo 1. Registra en el kernel lo que el agente necesita, no todo lo que tienes.
Devuelve texto. El resultado de la función vuelve al modelo como un mensaje más de la conversación, así que devuelve cadenas (o estructuras fácilmente serializables, como el JSON del ejemplo). Un objeto complejo de Python no significa nada para el modelo.
Los errores son información, no excepciones. Fíjate en que el caso "no existe" devuelve una frase, no lanza un error. El modelo puede leer "No existe ningún empleado llamado Carlos Pérez" y reaccionar con sentido: avisar al usuario, pedir que verifique el nombre. Una excepción sin capturar, en cambio, rompe el flujo y no le enseña nada a nadie.
Los parámetros también llevan etiqueta. El Annotated[str, "..."] no es decoración: esa descripción del parámetro entra en el catálogo y es lo que le permite al modelo saber qué poner en cada hueco, incluido el formato ("por ejemplo 'Ana Torres'").
Funciones de prompt
No todas las funciones son código. Una función de prompt es una plantilla de texto que se ejecuta contra el modelo, empaquetada con la misma interfaz que una función nativa:
from semantic_kernel.functions import KernelArguments
resumir = kernel.add_function(
plugin_name="redaccion",
function_name="resumir",
prompt="Resume el siguiente texto en una sola frase, en español neutro:\n\n{{$texto}}",
)
resultado = await kernel.invoke(resumir, KernelArguments(texto=texto_largo))
print(resultado)La sintaxis {{$texto}} marca una variable de plantilla que se rellena en la invocación. El criterio para elegir tipo de función es limpio: nativa para hechos y sistemas (consultar SQL, llamar a una API, calcular fechas — cosas con una respuesta correcta), de prompt para tareas de lenguaje (resumir, clasificar, redactar — cosas con muchas respuestas aceptables). Y se combinan: una función de prompt registrada en el kernel también aparece en el catálogo, así que un agente puede "delegarle" la redacción de un email a una función de prompt especializada, igual que llama a cualquier herramienta.
Pregunta de comprensión. Un compañero escribe una función nativa sin description y con un único parámetro llamado data, sin anotar. Sus tests unitarios pasan, pero el agente nunca la invoca, o la invoca con argumentos absurdos. ¿Qué está pasando?
Los tests unitarios prueban el código; el agente solo ve las etiquetas. Sin description, el modelo no tiene forma de mapear la intención del usuario con esa herramienta: en el catálogo aparece un nombre y un parámetro llamado data sin explicación, así que compite en desventaja contra cualquier otra opción (incluida la de no llamar a nada e inventar). Y cuando por azar la elige, no sabe qué formato debe tener data, así que mete lo que le parece plausible. La función está bien programada y mal etiquetada — y para el modelo, la etiqueta es la función.
Invocación automática de funciones
Del bucle manual a una línea
En el Módulo 1 el bucle agéntico se escribió a mano: detectar la petición de función en la respuesta, ejecutar, añadir el resultado al historial, volver a llamar al modelo. En Semantic Kernel todo ese bucle se activa con un ajuste de ejecución:
from semantic_kernel.connectors.ai import FunctionChoiceBehavior
settings = AzureChatPromptExecutionSettings(
function_choice_behavior=FunctionChoiceBehavior.Auto(),
)
respuesta = await chat.get_chat_message_content(
chat_history=historial,
settings=settings,
kernel=kernel, # imprescindible: sin el kernel no hay catálogo que adjuntar
)El parámetro kernel=kernel es el error de principiante número uno: si no pasas el kernel en la llamada, el servicio de chat no tiene acceso a los plugins, el catálogo viaja vacío y el modelo responde "no tengo acceso a esa información" aunque la función exista y esté registrada.
Lo que ocurre por dentro, paso a paso:
Usuario: "¿En qué equipo está Ana Torres?"
│
▼
1. El kernel envía historial + catálogo de funciones al modelo
│
▼
2. El modelo responde con una petición estructurada:
empleados-obtener_empleado(nombre="Ana Torres")
│
▼
3. El kernel ejecuta la función Python y añade el resultado
al historial como mensaje de herramienta
│
▼
4. El kernel vuelve a llamar al modelo, ya con el resultado a la vista
│
▼
5. El modelo responde en lenguaje natural:
"Ana Torres está en el equipo de Analítica."Y el bucle puede dar varias vueltas sin que escribas nada más. Ante "¿le quedan a Ana Torres días suficientes para tomarse dos semanas?", el modelo puede encadenar obtener_empleado para confirmar que existe, dias_disponibles para consultar el saldo, comparar 8 días disponibles contra los 10 laborables que pide, y responder que no alcanza. Eso — decidir qué herramientas usar, en qué orden, y componer los resultados — es la conducta que convierte a un chat en un agente.
Auto, Required y NoneInvoke
FunctionChoiceBehavior tiene tres modos, y elegir el correcto es una decisión de diseño:
| Modo | Qué hace | Cuándo usarlo |
|---|---|---|
Auto() | El modelo decide si llama a funciones, a cuáles y cuántas veces | Asistentes generales: la mayoría de los casos |
Required() | Obliga al modelo a llamar al menos a una función | Pipelines donde el resultado debe salir de una herramienta, nunca de la imaginación del modelo |
NoneInvoke() | El modelo ve el catálogo pero no puede ejecutar nada | Planificación y simulación: "dime qué funciones usarías y por qué", sin efectos |
Además, el catálogo se puede recortar por petición:
settings.function_choice_behavior = FunctionChoiceBehavior.Auto(
filters={"included_plugins": ["empleados"]}
)Esto conecta con el cálculo de tokens de la sección anterior: si en esta pantalla de tu aplicación solo tienen sentido las funciones de empleados, adjuntar solo ese plugin ahorra coste y le quita al modelo la oportunidad de equivocarse con herramientas irrelevantes.
Este mecanismo es la base de la mitad del curso que viene: el Módulo 5 conecta al kernel funciones que viven en otros procesos (MCP), el Módulo 6 profundiza en cómo diseñar buenas herramientas, y el Módulo 8 usa la invocación automática para que unos agentes deleguen trabajo en otros.
Pregunta de comprensión. Una invocación automática responde al usuario tras ejecutar dos funciones. ¿Cuántas llamadas al modelo se hicieron como mínimo, y qué contiene el historial al terminar?
Depende de si las funciones eran independientes. Si lo eran, el mínimo son 2 llamadas: en la primera el modelo pide las dos funciones a la vez (las peticiones en paralelo existen desde el Módulo 1), el kernel ejecuta ambas, y la segunda llamada produce la respuesta final. Si la segunda función dependía del resultado de la primera (primero obtener el empleado, luego consultar su saldo), son 3 llamadas: pedir la primera, ver su resultado y pedir la segunda, y con ambos resultados redactar la respuesta. El historial termina conteniendo la secuencia completa: mensaje de sistema, mensaje del usuario, los mensajes del asistente con las peticiones de función, los mensajes de herramienta con cada resultado, y la respuesta final en lenguaje natural. Nada se pierde — y eso es lo que hace posible auditar qué hizo el agente y por qué.
Historial, streaming y ajustes de ejecución
ChatHistory: la memoria que el modelo no tiene
Del Módulo 1: el modelo es amnésico por diseño — no recuerda nada entre llamadas. ChatHistory es el cuaderno donde tu aplicación apunta la conversación completa y se la reenvía entera en cada turno. Un bucle de chat de consola completo:
historial = ChatHistory()
historial.add_system_message(
"Eres el asistente de RR. HH. de Contoso. Usa las funciones disponibles "
"para responder con datos reales. Si un empleado no existe, dilo; "
"nunca inventes datos."
)
while True:
entrada = input("Tú: ")
if entrada.strip().lower() in ("salir", "exit"):
break
historial.add_user_message(entrada)
respuesta = await chat.get_chat_message_content(
chat_history=historial,
settings=settings,
kernel=kernel,
)
historial.add_message(respuesta)
print(f"Asistente: {respuesta.content}")La línea historial.add_message(respuesta) es la que cierra el circuito de memoria: si la olvidas, el modelo nunca ve sus propias respuestas anteriores y la conversación se vuelve incoherente al segundo turno.
Dos observaciones. Primera: durante la invocación automática, el kernel escribe en el historial también los mensajes intermedios — las peticiones de función y sus resultados — así que el historial es un registro completo y auditable de lo que hizo el agente (el Ejercicio 3 lo disecciona). Segunda: el historial crece, y del Módulo 1 sabes lo que eso significa — el coste de una conversación crece de forma cuadrática con los turnos, porque cada turno reenvía todo lo anterior. Semantic Kernel trae reductores de historial (truncado y resumen automático) para conversaciones largas; basta saber que existen, porque la gestión seria de memoria llega con RAG en el Módulo 7.
Streaming
Del Módulo 1: el primer token tarda (prefill) y el resto gotean (decode). Sin streaming, el usuario mira una pantalla congelada hasta que la respuesta está completa; con streaming, ve el goteo en directo y la aplicación se siente viva aunque tarde lo mismo:
print("Asistente: ", end="")
async for fragmento in chat.get_streaming_chat_message_content(
chat_history=historial,
settings=settings,
kernel=kernel,
):
print(str(fragmento), end="", flush=True)
print()La estructura async for consume los fragmentos según llegan. En una app de consola es un detalle estético; en el proyecto final, con respuestas largas apoyadas en documentos, es la diferencia entre una experiencia aceptable y una queja de usuario.
Temperatura y límites, ahora en el orquestador
Los ajustes de muestreo del Módulo 1 viven en los settings de ejecución:
settings = AzureChatPromptExecutionSettings(
function_choice_behavior=FunctionChoiceBehavior.Auto(),
temperature=0.2,
max_tokens=800,
)El criterio no cambia por usar un orquestador: un agente que decide llamadas a funciones quiere temperatura baja (0 a 0.3) — la creatividad en la elección de herramientas no es una virtud, es una fuente de bugs. Las funciones de prompt de redacción pueden llevar sus propios settings con temperatura más alta.
Filtros: el middleware del kernel
Analogía primero: la aduana
Un filtro es un punto de control por el que pasa toda invocación de función, sin excepción: puede registrar el paso, inspeccionar lo que lleva, medir cuánto tarda o directamente denegar el cruce. Como una aduana: las funciones son los viajeros y ninguna cruza sin pasar por ella.
El filtro más útil del mundo es el de trazabilidad, y son diez líneas:
from semantic_kernel.filters import FilterTypes, FunctionInvocationContext
@kernel.filter(FilterTypes.FUNCTION_INVOCATION)
async def registrar(context: FunctionInvocationContext, next):
nombre = f"{context.function.plugin_name}.{context.function.name}"
print(f"[filtro] llamando a {nombre} con argumentos {dict(context.arguments)}")
await next(context)
print(f"[filtro] {nombre} devolvió: {str(context.result)[:100]}")El patrón es idéntico al middleware de Express o ASP.NET: código antes de await next(context) se ejecuta antes de la función; código después, después. Y si no llamas a next, la función nunca se ejecuta — que es exactamente lo que quiere un control de acceso:
from semantic_kernel.functions import FunctionResult
FUNCIONES_PROTEGIDAS = {"solicitar_vacaciones"}
@kernel.filter(FilterTypes.FUNCTION_INVOCATION)
async def control_acceso(context: FunctionInvocationContext, next):
if context.function.name in FUNCIONES_PROTEGIDAS and not usuario_autenticado():
context.result = FunctionResult(
function=context.function.metadata,
value="Operación denegada: requiere un usuario autenticado.",
)
return # sin llamar a next, la función real nunca se ejecuta
await next(context)(usuario_autenticado() es aquí un marcador de posición; en el proyecto final será una comprobación real de identidad.)
¿Por qué un filtro y no meter estos controles dentro de cada función? Porque son preocupaciones transversales: aplican a todas las funciones por igual, y la garantía de cobertura importa más que la comodidad. Si el logging vive dentro de cada función, la función nueva que alguien añada dentro de seis meses nacerá sin logging; si vive en un filtro, es imposible saltárselo. Este mecanismo es el embrión de dos módulos: la evaluación del Módulo 9 necesita trazas de qué funciones se llamaron y con qué argumentos, y la puesta en producción del Módulo 10 convierte estos prints en telemetría estructurada y auditoría de seguridad.
Pregunta de comprensión. El agente de RR. HH. ejecuta a veces funciones sensibles y necesitas dos garantías: que toda invocación quede registrada y que solicitar_vacaciones no se ejecute sin usuario autenticado. ¿Dónde implementas cada garantía y por qué no dentro de las funciones?
Ambas en filtros de invocación de función. La razón es la palabra "toda": una garantía que debe cumplirse siempre no puede depender de que cada función la implemente por su cuenta, porque la disciplina se rompe con la primera función nueva que alguien añada sin acordarse. El filtro intercepta el 100% de las invocaciones por construcción. Además, el control de acceso en filtro actúa antes de que la función se ejecute — puede denegar sin efectos secundarios y devolver un resultado explicativo que el modelo lee y traslada al usuario con naturalidad. Dentro de la función, el control llegaría con el código sensible ya en ejecución y habría que duplicarlo en cada función protegida.
De los planners a los agentes
Los planners: nota histórica
Los tutoriales antiguos de Semantic Kernel giran en torno a los planners (SequentialPlanner, StepwisePlanner): componentes que le pedían al modelo generar un plan de pasos en texto y luego lo ejecutaban. Existieron porque los modelos de entonces no sabían llamar funciones de forma nativa y fiable. Con function calling nativo, el modelo planifica sobre la marcha dentro del propio bucle de invocación automática — decide el siguiente paso viendo el resultado del anterior, que es más robusto que un plan rígido escrito por adelantado. Los planners están obsoletos: si un tutorial te hace instalar uno, es material antiguo y puedes cerrarlo sin remordimiento.
Un vistazo a ChatCompletionAgent
Semantic Kernel incluye un marco de agentes que empaqueta lo que este módulo ha montado pieza a pieza — kernel, instrucciones, historial, invocación automática — en un objeto con nombre e identidad:
from semantic_kernel.agents import ChatCompletionAgent
agente = ChatCompletionAgent(
kernel=kernel,
name="asistente_rrhh",
instructions=(
"Eres el asistente de RR. HH. de Contoso. Usa las funciones disponibles "
"para responder con datos reales y nunca inventes empleados."
),
)
respuesta = await agente.get_response(messages="¿En qué equipo trabaja Ana Torres?")
print(respuesta.message.content)El agente activa por defecto la invocación automática sobre los plugins del kernel. La pregunta natural es: ¿para qué sirve el envoltorio, si ya sabemos hacer todo eso a mano? La respuesta es el Módulo 8: cuando hay varios agentes — uno de RR. HH., uno de calendario, uno de informes — que conversan entre sí y se delegan trabajo, necesitan ser objetos con nombre, instrucciones propias y herramientas propias. En este módulo seguiremos con las piezas explícitas, precisamente para que en el Módulo 8 el envoltorio no tenga nada de magia.
Ejercicios
Ejercicio 1. Dos servicios, un kernel
Objetivo: interiorizar la inyección de dependencias y el service_id.
- Crea (o reutiliza del Módulo 2) dos deployments: gpt-4.1 y gpt-4.1-mini.
- Registra ambos en el mismo kernel con
service_id="chat-potente"yservice_id="chat-rapido", pasando eldeployment_nameexplícito en cada uno. - Haz la misma pregunta de razonamiento a los dos servicios (por ejemplo, un problema de lógica de tres frases) y compara calidad y latencia.
- Comprueba que cambiar qué modelo responde es solo cambiar la etiqueta en
kernel.get_service(...)— el resto del código no se toca.
Ejercicio 2. La herramienta que el modelo no tiene
Objetivo: sentir en carne propia por qué existen las funciones. Del Módulo 1: el modelo no calcula, predice texto plausible — y las fechas son su punto débil clásico, porque ni siquiera sabe qué día es hoy.
- Sin ningún plugin registrado, pregunta al modelo cuántos días faltan para el 24 de diciembre. Observa la respuesta: o esquiva la pregunta o inventa un número.
- Crea este plugin y regístralo:
from datetime import date
from typing import Annotated
from semantic_kernel.functions import kernel_function
class FechasPlugin:
@kernel_function(
description="Calcula cuántos días faltan desde hoy hasta una fecha dada."
)
def dias_hasta(
self,
fecha: Annotated[str, "Fecha objetivo en formato AAAA-MM-DD, por ejemplo 2026-12-24"],
) -> Annotated[str, "Número de días que faltan"]:
objetivo = date.fromisoformat(fecha)
return str((objetivo - date.today()).days)- Repite la pregunta con
FunctionChoiceBehavior.Auto()ykernel=kernelen la llamada. Ahora la respuesta sale dedatetime, no de la imaginación. - Remate: pregunta "¿cuántos días faltan para Nochebuena?" — sin darle la fecha. Observa cómo el modelo traduce "Nochebuena" a
2026-12-24y llama a la función: él pone el conocimiento del mundo, la función pone la aritmética. Ese reparto de trabajo es la esencia de un agente.
Ejercicio 3. Radiografía del historial
Objetivo: ver con tus ojos que la invocación automática deja rastro completo.
- Tras una conversación que haya disparado al menos una función, recorre el historial:
for mensaje in historial.messages:
tipos = ", ".join(type(item).__name__ for item in mensaje.items)
print(f"{mensaje.role}: {tipos}")- Identifica la secuencia: el mensaje del asistente que contiene
FunctionCallContent(la petición) y el mensaje de herramienta que contieneFunctionResultContent(el resultado), antes de la respuesta final en texto. - Pregunta de control mientras lo miras: ¿cuántos de estos mensajes viajan al modelo en el siguiente turno? (Todos. Ahora el coste cuadrático del Módulo 1 tiene cara y ojos.)
Proyecto del módulo: asistente de RR. HH. en consola
Este proyecto es el embrión del proyecto final del curso: un asistente de empleados que en módulos posteriores hablará con SQL, SharePoint y Microsoft Graph. En esta versión los datos son ficticios y la interfaz es la terminal, pero la arquitectura — kernel, plugins, invocación automática, filtros — es ya la definitiva.
- Estructura. Un archivo
asistente.pyy un.envcon las variables del deployment. Kernel con unAzureChatCompletionregistrado como"chat". - EmpleadosPlugin. Un diccionario en memoria con 5 empleados (puesto, equipo, email y días de vacaciones anuales, por ejemplo 23) y dos funciones:
obtener_empleado(nombre)ylistar_equipo(equipo)que devuelve los miembros de un equipo. Etiqueta bien: descripciones específicas y parámetros anotados con ejemplos de formato. - VacacionesPlugin. Estado en memoria (diccionario nombre a días ya usados) y dos funciones:
dias_disponibles(nombre), que devuelve el saldo (con 23 anuales y 15 usados, debe responder 8), ysolicitar_vacaciones(nombre, dias), que valida el saldo y devuelve una confirmación con el nuevo saldo o un rechazo explicando cuántos días quedan. Todo en texto: recuerda que el modelo leerá estas respuestas. - Filtro de trazabilidad. Cada invocación imprime función, argumentos y resultado con un prefijo
[filtro], para distinguir la maquinaria de la conversación. - Mensaje de sistema. Define rol e idioma y añade la regla de oro: usar las funciones para todo dato de empleados, y decir "no existe" cuando no exista — nunca inventar.
- Bucle de consola.
ChatHistory,FunctionChoiceBehavior.Auto(),kernel=kernelen cada llamada, la respuesta se añade al historial, y el comando salir termina. - Pruebas guiadas. Ejecuta estas cinco y observa la traza del filtro en cada una:
- "¿Cuántos días de vacaciones le quedan a Ana Torres?" — una sola función.
- "¿Puede Luis Mora tomarse 15 días seguidos?" — encadena saldo más comparación; el modelo debe razonar sobre el resultado.
- "Resérvale 5 días a Ana Torres." — función con efecto: el saldo cambia y la siguiente consulta debe reflejarlo.
- "¿Quién es Carlos Pérez?" — empleado inexistente: la respuesta honesta viene de que la función devuelve un error legible.
- "¿Qué es un plugin de Semantic Kernel?" — el filtro debe quedarse en silencio: un buen agente también sabe cuándo no usar herramientas.
- Extensiones opcionales. Streaming en las respuestas; un segundo servicio barato (
"chat-rapido") para clasificar la intención de cada mensaje antes de responder; y un filtro de control de acceso que bloqueesolicitar_vacacionessalvo que una variableUSUARIO_ACTUALcoincida con el empleado afectado.
Criterio de éxito: la traza del filtro debe contar sola la historia de cada respuesta — qué se consultó, con qué argumentos, qué volvió — y las cinco pruebas deben comportarse como se describe. Si el modelo responde datos de empleados sin que el filtro haya impreso nada, está alucinando y tu mensaje de sistema o tus descripciones necesitan trabajo.
Hacia dónde crece este proyecto: en el Módulo 5 estas funciones se expondrán y consumirán mediante MCP, en el Módulo 6 el diccionario se convierte en SQL y Microsoft Graph reales, en el Módulo 7 el asistente responderá también sobre las políticas de RR. HH. con RAG, en el Módulo 8 el trabajo se repartirá entre varios agentes, y en los Módulos 9 y 10 el filtro de consola se convertirá en evaluación y telemetría de producción.
Resumen del módulo
- Semantic Kernel es una biblioteca de orquestación dentro de tu proceso — no un servicio ni un modelo — que elimina la fontanería del bucle agéntico del Módulo 1.
- El kernel es un contenedor de inyección de dependencias: servicios de IA con
service_id, plugins con funciones, y filtros. Cambiar de modelo o proveedor es cambiar una línea de registro. - Una función nativa es código Python etiquetado con
@kernel_functionyAnnotated; el modelo solo ve las etiquetas, y las descripciones son prompt que cuesta tokens en cada petición (20 funciones son unos 3.000 tokens de catálogo). Las funciones de prompt empaquetan plantillas de lenguaje con la misma interfaz. FunctionChoiceBehavior.Auto()activa la invocación automática: el kernel ejecuta el bucle completo — petición, ejecución, resultado, nueva llamada — y puede encadenar varias funciones. Pasarkernel=kernelen la llamada es imprescindible.ChatHistoryes la única memoria: registra también las llamadas a funciones y sus resultados, crece con cada turno y cuesta en proporción.- Los filtros son middleware transversal: trazabilidad y control de acceso garantizados para el 100% de las invocaciones, sin depender de la disciplina de cada función.
- Los planners son historia; la planificación vive hoy dentro del propio function calling. Los agentes (
ChatCompletionAgent) empaquetan kernel, instrucciones e invocación automática, y protagonizan el Módulo 8. - Todo lo aprendido — kernel, plugins, funciones, filtros — se traslada casi literalmente a Microsoft Agent Framework, el sucesor que unifica Semantic Kernel y AutoGen.
El Módulo 4 recorre LangChain y LangGraph, el otro gran ecosistema de orquestación: los mismos conceptos con otro vocabulario, que es la forma más rápida de separar lo esencial de lo accidental.
¿Te sirvió esta lección?
El curso es gratis y así seguirá. Un café ayuda a que salga el siguiente módulo.
Invítame un café¿Quieres llevar esto a tu negocio?
En Burnisoft convertimos estas ideas en apps, web y automatizaciones a la medida. Cuéntanos tu reto, sin compromiso.
Contáctanos