
Agente de IA Local con Function Calling Autónomo: Guía Paso a Paso (Proyecto Barbara Liskov)
Los Modelos de Lenguaje (LLMs) son fantásticos procesando y generando texto, pero por sí solos están “aislados”: no pueden calcular operaciones matemáticas complejas con exactitud, no conocen los datos en tiempo real de tu base de datos ni pueden dibujar una gráfica en tu pantalla.
Para resolver esto nacen los Agentes de IA mediante Function Calling (Llamada a Funciones o Tool Calling). En este artículo aprenderás a construir un agente 100% local, privado y sin costes de API usando LM Studio, el modelo Qwen 2.5 3B, FastAPI y Python.
1. Fundamentos Teóricos: ¿Qué es el Function Calling?
El Function Calling no significa que el modelo de IA ejecute código Python directamente en tu ordenador. El flujo es mucho más elegante y seguro:
- Catálogo de Herramientas (Tools): Le enviamos al modelo una lista codificada en JSON que describe las funciones que tenemos disponibles en nuestro servidor Python (su nombre, qué hacen y qué parámetros reciben).
- Razonamiento del Modelo: El usuario hace una pregunta (ej: “¿Cuánto es (1450 * 1.21) / 12?”). El modelo analiza la petición y decide si necesita invocar una herramienta o responder directamente con texto.
- Petición Estructurada: Si necesita una herramienta, el modelo detiene su generación de texto y devuelve una orden formal en JSON:
JSON{ "name": "calcular_matematicas", "arguments": { "expresion": "(1450 * 1.21) / 12" } } - Ejecución Local: Nuestro servidor Python intercepta esa orden, ejecuta la función real (hace la matemática, lee un sensor o dibuja con Matplotlib) y le devuelve el resultado al modelo.
- Respuesta Final: El modelo recibe el resultado técnico y redacta una respuesta natural para el usuario final.
2. Configuración del Entorno: Qwen 2.5 y LM Studio
Para que el Tool Calling funcione de forma fiable con modelos pequeños en local (3B o 7B parámetros), la arquitectura del modelo es crítica. Aunque modelos como Llama 3.2 son excelentes en texto general, Qwen 2.5 es la familia líder del mercado en cumplimiento estricto de JSON y llamadas a herramientas.
Paso 1: Descargar Qwen 2.5 3B Instruct en LM Studio
- Abre LM Studio.
- Ve a la pestaña de búsqueda (icono de lupa) y busca:
Qwen2.5-3B-Instruct. - Selecciona la versión cuantizada recomendada:
Qwen2.5-3B-Instruct-GGUF(opciónQ4_K_M, que ocupa unos 1.9 GB en disco). - Haz clic en Download.
Paso 2: Configurar y Cargar el Modelo
- Ve al menú superior de LM Studio y carga el modelo Qwen 2.5 3B Instruct.
- En el panel derecho (Preset / Prompt Template), verifica que esté seleccionada la plantilla Qwen2 o Qwen2.5.
Paso 3: Iniciar el Servidor Local
- Ve a la pestaña Developer / Local Server (icono de servidor en el menú lateral).
- Asegúrate de que el puerto por defecto sea 1234 (
http://localhost:1234). - Comprueba que el estado cambie a ON (Servidor activo).
3. Instalación de Dependencias en Python
Abre tu terminal o consola de comandos y ejecuta el siguiente comando para instalar las librerías necesarias:
Bash
pip install openai fastapi uvicorn matplotlib pydantic
4. El Código Completo (app_liskov.py)
Crea un archivo llamado app_liskov.py y copia el siguiente código. Este script implementa:
- Matplotlib en modo no interactivo (
Agg) para generar gráficos en servidor. - FastAPI para montar la aplicación web y los endpoints.
- OpenAI SDK conectado a la API local de LM Studio (
localhost:1234). - Tres herramientas reales: Cálculo matemático, Consulta a sensores e Interpretación y trazabilidad de gráficos.
- Interfaz con panel doble (Respuesta del Agente + Logs de Trazabilidad).

Python
import json
import os
import time
import matplotlib
# 1. Configurar backend no interactivo de Matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from openai import OpenAI
from pydantic import BaseModel
app = FastAPI()
# Directorio para gráficos estáticos
os.makedirs("static", exist_ok=True)
app.mount("/static", StaticFiles(directory="static"), name="static")
# Conexión con LM Studio local
client = OpenAI(base_url="http://localhost:1234/v1", api_key="lm-studio")
# ==========================================
# 2. DEFINICIÓN DE HERRAMIENTAS (TOOLS)
# ==========================================
def calcular_matematicas(expresion: str) -> str:
"""Evalúa expresiones matemáticas simples de forma segura."""
try:
permitidos = {
"abs": abs,
"round": round,
"pow": pow,
"min": min,
"max": max,
}
resultado = eval(expresion, {"__builtins__": None}, permitidos)
return f"Resultado del cálculo ('{expresion}'): {resultado}"
except Exception as e:
return f"Error en el cálculo: {str(e)}"
def consultar_sensores_meteo() -> str:
"""Simula la consulta a sensores de un invernadero."""
return "Datos del Invernadero -> Temperatura: 22.5°C | Humedad: 64% | Radiación UV: Moderada | Estado: Óptimo"
def generar_grafico_barras(
etiquetas: list, valores: list, titulo: str = "Gráfico de Barras"
) -> str:
"""Genera y guarda un gráfico de barras PNG."""
try:
plt.figure(figsize=(6, 4))
plt.bar(etiquetas, valores, color="#007a3d")
plt.xlabel("Categorías")
plt.ylabel("Valores")
plt.title(titulo)
plt.grid(axis="y", linestyle="--", alpha=0.7)
plt.tight_layout()
ruta_archivo = "static/grafico.png"
plt.savefig(ruta_archivo, dpi=300, bbox_inches="tight")
plt.close()
return f"Gráfico '{titulo}' generado correctamente y guardado en {ruta_archivo}"
except Exception as e:
plt.close()
return f"Error al generar gráfico: {str(e)}"
HERRAMIENTAS = {
"calcular_matematicas": calcular_matematicas,
"consultar_sensores_meteo": consultar_sensores_meteo,
"generar_grafico_barras": generar_grafico_barras,
}
# Esquema OpenAI Tools
tools_schema = [
{
"type": "function",
"function": {
"name": "calcular_matematicas",
"description": "Calcula expresiones matemáticas numéricas.",
"parameters": {
"type": "object",
"properties": {
"expresion": {
"type": "string",
"description": "Expresión matemática a evaluar. Ej: (1450 * 1.21) / 12",
}
},
"required": ["expresion"],
},
},
},
{
"type": "function",
"function": {
"name": "consultar_sensores_meteo",
"description": "Consulta el estado actual de los sensores del invernadero.",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "generar_grafico_barras",
"description": "Genera una gráfica de barras con etiquetas y valores numéricos.",
"parameters": {
"type": "object",
"properties": {
"etiquetas": {
"type": "array",
"items": {"type": "string"},
"description": "Lista de nombres/categorías.",
},
"valores": {
"type": "array",
"items": {"type": "number"},
"description": "Lista de números correspondientes.",
},
"titulo": {
"type": "string",
"description": "Título del gráfico.",
},
},
"required": ["etiquetas", "valores", "titulo"],
},
},
},
]
class Consulta(BaseModel):
pregunta: str
# ==========================================
# 3. LÓGICA DEL AGENTE CON TRAZABILIDAD
# ==========================================
def ejecucion_agente(pregunta_usuario: str):
tiempo_inicio = time.time()
mensajes = [
{
"role": "system",
"content": (
"Eres el asistente del Proyecto Barbara Liskov. Tienes acceso a herramientas "
"(matemáticas, sensores meteo y gráficos). Si la consulta del usuario requiere una herramienta, "
"invócala obligatoriamente. Responde siempre en español."
),
},
{"role": "user", "content": pregunta_usuario},
]
respuesta = client.chat.completions.create(
model="qwen2.5-3b-instruct",
messages=mensajes,
tools=tools_schema,
tool_choice="auto",
temperature=0.0,
)
msg = respuesta.choices[0].message
logs = None
es_grafico = False
if msg.tool_calls:
tool_call = msg.tool_calls[0]
nombre_func = tool_call.function.name
args_str = tool_call.function.arguments
try:
argumentos = json.loads(args_str)
except Exception:
argumentos = {}
if nombre_func in HERRAMIENTAS:
resultado_python = HERRAMIENTAS[nombre_func](**argumentos)
if nombre_func == "generar_grafico_barras":
es_grafico = True
logs = {
"herramienta": nombre_func,
"parametros": json.dumps(argumentos, ensure_ascii=False),
"resultado": resultado_python,
}
mensajes.append(msg)
mensajes.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"name": nombre_func,
"content": str(resultado_python),
}
)
respuesta_final = client.chat.completions.create(
model="qwen2.5-3b-instruct", messages=mensajes, temperature=0.0
)
texto_respuesta = respuesta_final.choices[0].message.content
else:
texto_respuesta = f"Herramienta '{nombre_func}' no implementada."
else:
texto_respuesta = msg.content
tiempo_total = time.time() - tiempo_inicio
return {
"respuesta": texto_respuesta,
"es_grafico": es_grafico,
"imagen_url": "/static/grafico.png" if es_grafico else None,
"tiempo": f"{tiempo_total:.2f} s",
"logs": logs,
}
# ==========================================
# 4. ENDPOINTS FASTAPI & INTERFAZ WEB
# ==========================================
@app.post("/chat")
async def chat_endpoint(consulta: Consulta):
try:
return ejecucion_agente(consulta.pregunta)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/", response_class=HTMLResponse)
async def index():
return """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>PROYECTO BARBARA LISKOV</title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f7faf8; margin: 0; padding: 25px; color: #333; }
.container { max-width: 1000px; margin: auto; }
h1 { color: #006633; margin-bottom: 2px; font-size: 26px; }
.subtitle { color: #666; font-size: 14px; margin-top: 0; margin-bottom: 20px; }
.quick-tests { background: #ffffff; border: 1px solid #cce5d8; border-radius: 8px; padding: 15px; margin-bottom: 20px; }
.quick-title { font-size: 12px; font-weight: bold; color: #006633; margin-bottom: 10px; text-transform: uppercase; }
.grid-tests { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.card-test { border: 1px solid #e0e0e0; background: #fafafa; padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: 0.2s; }
.card-test:hover { background: #eef7f2; border-color: #006633; }
.card-test strong { display: block; font-size: 13px; color: #006633; margin-bottom: 3px; }
.card-test span { font-size: 13px; color: #555; }
.input-group { display: flex; gap: 10px; margin-bottom: 25px; }
input[type="text"] { flex: 1; padding: 12px 15px; border: 1px solid #ccc; border-radius: 6px; font-size: 15px; }
button { background: #007a3d; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-weight: bold; font-size: 15px; cursor: pointer; }
button:hover { background: #005a2d; }
.results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel { background: white; border: 1px solid #dcdcdc; border-radius: 8px; padding: 20px; min-height: 250px; }
.panel-header { font-size: 15px; font-weight: bold; color: #006633; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; }
.tiempo { font-size: 12px; color: #666; margin-top: 15px; border-top: 1px dotted #ccc; padding-top: 8px; }
.log-box { background: #f5f5f5; border-radius: 5px; padding: 10px; margin-bottom: 10px; font-family: monospace; font-size: 12px; color: #222; }
img { max-width: 100%; height: auto; border-radius: 6px; margin-top: 10px; border: 1px solid #ddd; }
footer { text-align: center; margin-top: 30px; font-size: 12px; color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>PROYECTO BARBARA LISKOV</h1>
<p class="subtitle">Agente de IA Local con Llamada Autónoma a Funciones (Tool Calling) · MonteSteam</p>
<div class="quick-tests">
<div class="quick-title">Pruebas directas de herramientas (haz clic para ejecutar):</div>
<div class="grid-tests">
<div class="card-test" onclick="ejecutarAccion('¿Cuánto es (1450 * 1.21) / 12?')">
<strong>🧮 HERRAMIENTA: MATEMÁTICAS</strong>
<span>¿Cuánto es (1450 * 1.21) / 12?</span>
</div>
<div class="card-test" onclick="ejecutarAccion('Consultar sensores del Invernadero')">
<strong>🌡️ HERRAMIENTA: SENSORES METEO</strong>
<span>Consultar sensores del Invernadero</span>
</div>
<div class="card-test" onclick="ejecutarAccion('Dibujar gráfica de barras con las ventas: Enero 150, Febrero 230, Marzo 180')">
<strong>📊 HERRAMIENTA: GRÁFICOS</strong>
<span>Dibujar gráfica de barras con datos</span>
</div>
<div class="card-test" onclick="ejecutarAccion('Hola, ¿quién eres y qué puedes hacer?')">
<strong>💬 SIN HERRAMIENTA: CONVERSACIÓN</strong>
<span>Respuesta directa sin invocar Python</span>
</div>
</div>
</div>
<div class="input-group">
<input type="text" id="pregunta" placeholder="Escribe tu consulta aquí..." value="Calcula exactamente cuánto es (1450 * 1.21) / 12">
<button onclick="enviar()">Consultar Agente</button>
</div>
<div class="results-grid">
<div class="panel">
<div class="panel-header">1. RESPUESTA FINAL DEL AGENTE</div>
<div id="respuesta-agente">Esperando consulta...</div>
<div id="tiempo-box"></div>
</div>
<div class="panel">
<div class="panel-header">2. RAZONAMIENTO Y TRAZABILIDAD (*LOGS*)</div>
<div id="logs-agente">Sin ejecución previa.</div>
</div>
</div>
<footer>PROYECTO BARBARA LISKOV · Puerto 8042 · Laboratorio MonteSteam</footer>
</div>
<script>
function ejecutarAccion(texto) {
document.getElementById('pregunta').value = texto;
enviar();
}
async function enviar() {
const preguntaInput = document.getElementById('pregunta');
const pregunta = preguntaInput.value.trim();
if (!pregunta) return;
const respBox = document.getElementById('respuesta-agente');
const timeBox = document.getElementById('tiempo-box');
const logsBox = document.getElementById('logs-agente');
respBox.innerHTML = '<em>Procesando con el LLM y ejecutando herramientas...</em>';
timeBox.innerHTML = '';
logsBox.innerHTML = '<em>Analizando trazabilidad...</em>';
try {
const res = await fetch('/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pregunta: pregunta})
});
const data = await res.json();
let htmlRespuesta = `<p>${data.respuesta}</p>`;
if (data.es_grafico && data.imagen_url) {
htmlRespuesta += `<img src="${data.imagen_url}?t=${new Date().getTime()}">`;
}
respBox.innerHTML = htmlRespuesta;
timeBox.innerHTML = `<div class="tiempo">⏱️ Tiempo total de razonamiento y ejecución: <strong>${data.tiempo}</strong></div>`;
if (data.logs) {
logsBox.innerHTML = `
<div class="log-box">🔧 <strong>Herramienta invocada:</strong><br><code>${data.logs.herramienta}</code></div>
<div class="log-box">📫 <strong>Parámetros JSON:</strong><br><code>${data.logs.parametros}</code></div>
<div class="log-box">💻 <strong>Resultado Python:</strong><br><code>${data.logs.resultado}</code></div>
`;
} else {
logsBox.innerHTML = '<p><em>No se requirió invocar ninguna herramienta interna para responder.</em></p>';
}
} catch (e) {
respBox.innerHTML = '<span style="color:red;">Error de conexión con el servidor.</span>';
logsBox.innerHTML = '-';
}
}
</script>
</body>
</html>
"""
if __name__ == "__main__":
import uvicorn
uvicorn.run("app_liskov:app", host="127.0.0.1", port=8042, reload=True)
5. Cómo Ejecutar el Proyecto
- Asegúrate de tener LM Studio con el modelo Qwen 2.5 3B cargado y el servidor local iniciado en el puerto
1234. - Lanza el servidor web ejecutando directamente el script desde tu terminal:
Bashpython app_liskov.py - Abre tu navegador web y entra a:
[http://127.0.0.1:8042](http://127.0.0.1:8042)
Al probar las distintas tarjetas superiores o hacer una pregunta personalizada, observarás cómo el panel de la izquierda muestra la respuesta procesada por la IA, mientras el panel de la derecha refleja la herramienta invocada en segundo plano y los parámetros JSON intercambiados en tiempo real.



