Cómo la inteligencia artificial puede llamar a funciones locales mediante el protocolo de OpenAI
Este artículo se basa en la capacidad de llamada a funciones (herramienta de llamada) compatible con OpenAI para explicar cómo diseñar un sistema general e implementable.
Renderizando...
uando los modelos grandes necesitan «tomar acciones» — consultar bases de datos, escribir en bases de datos, llamar a API internas, enviar notificaciones — generalmente se requiere ejecutar código real en tu servicio. Conectar la «decisión del modelo» con la «ejecución local» es exactamente lo que resuelve la funcionalidad de «IA que llama a funciones locales»
## Escenarios y objetivos aplicables
Escenarios típicos incluyen: operaciones comerciales conversacionales (ej. gestión contable: «Registra una entrada», «Consulta el resumen del mes pasado»), creación de órdenes de trabajo/tareas, consultas y estadísticas de datos, interpretación de instrucciones en sistemas internos. Lo común es: el usuario expresa su intención en lenguaje natural, el modelo identifica la «herramienta» y parámetros a utilizar, tu backend ejecuta la lógica específica y devuelve el resultado al modelo para generar la respuesta final.
Los objetivos se pueden resumir en tres puntos:
- **Herramientas descriptivas** (el modelo conoce las funciones disponibles y sus parámetros)
- **Ejecución local** (no se envían lógica sensible o datos a terceros)
- **Resultados retroalimentables** (los valores devueltos por las herramientas pueden participar nuevamente en la conversación para generar respuestas en lenguaje natural).
## Enfoque arquitectónico general
Adoptamos una separación «descripción + ejecución»: usamos `tools` de OpenAI (Function Calling) para describir capacidades locales como «nombre + esquema de parámetros», el modelo devuelve `tool_calls` durante la conversación; tu servicio analiza estos `tool_calls`, ejecuta las funciones reales localmente según nombre y parámetros, e inserta los resultados con `role: "tool"` de vuelta a la lista de mensajes. Luego se solicita nuevamente al modelo que genere la respuesta final basada en los resultados de las herramientas. Así, la lógica sensible y los datos permanecen siempre dentro de tu proceso, el modelo solo se encarga de «seleccionar herramientas, completar parámetros y redactar respuestas».
Si el modelo o gateway actual no soporta `tool_calls`, se puede añadir un nivel de degradación: hacer que el modelo produzca un JSON estructurado (ej. `{ "action": { "name", "args" }, "reply" }`), tú analizas y ejecutas localmente siguiendo el mismo flujo, garantizando que incluso sin `tool_calls` se mantenga el ciclo cerrado de «análisis → ejecución → resumen».
## Definición de herramientas: Formato OpenAI
El modelo necesita conocer «qué funciones existen y qué parámetros requiere cada una». OpenAI define esto mediante un array `tools`, donde cada elemento tiene `type: "function"` con `function.name`, `function.description` y `function.parameters` (esquema JSON). Cuanto más clara sea la descripción, menor será la probabilidad de que el modelo elija herramientas incorrectas o omita parámetros.
A continuación se muestra un ejemplo de definiciones de herramientas anonimizadas (nombres y significados comerciales generalizados):
```ts
import type { ChatCompletionTool } from "openai/resources/chat/completions";
export const CHAT_TOOLS: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "create_item",
description: "Crea un registro comercial. Úsese cuando el usuario exprese intenciones como «Registrar una entrada», «Agregar», «Inscribir».",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Categoría, como Tipo A, Tipo B" },
amount: { type: "number", description: "Monto o cantidad" },
title: { type: "string", description: "Título/Resumen" },
date: { type: "string", description: "Fecha YYYY-MM-DD, si se omite se usa la fecha actual" },
remark: { type: "string", description: "Observaciones" },
},
required: ["category", "amount", "title"],
},
},
},
{
type: "function",
function: {
name: "list_items",
description: "Consulta registros comerciales según condiciones. Úsese cuando el usuario diga «Buscar», «Listar», «Filtrar».",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Filtrar por categoría" },
startDate: { type: "string", description: "Fecha inicial YYYY-MM-DD" },
endDate: { type: "string", description: "Fecha final YYYY-MM-DD" },
pageNum: { type: "number", description: "Número de página, por defecto 1" },
pageSize: { type: "number", description: "Elementos por página, por defecto 15" },
},
},
},
},
{
type: "function",
function: {
name: "get_summary",
description: "Obtiene estadísticas resumidas. Úsese cuando el usuario pregunte «¿Cuánto en total?», «Estadística», «Resumen».",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Fecha inicial YYYY-MM-DD" },
endDate: { type: "string", description: "Fecha final YYYY-MM-DD" },
month: { type: "string", description: "Mes YYYY-MM, opción alternativa al rango de fechas" },
},
},
},
},
];
```
En proyectos reales, puedes añadir/eliminar herramientas según el negocio, detallar `description` y `properties`, usando `enum` cuando sea necesario para restringir valores y reducir errores.
## Ejecutor local: Mapeo de nombres a funciones
La capa de ejecución solo hace dos cosas: encontrar la implementación correspondiente según `name`, e ingresar `args` y contexto en tiempo de ejecución (como ID de usuario actual, ID de inquilino) para devolver el resultado en formato cadena (generalmente JSON), para insertarlo en mensajes con `role: "tool"`.
Ejemplo de ejecutor anonimizado:
```ts
export interface ToolExecutionContext {
userId: number;
// Se puede extender: tenantId, requestId, locale, etc.
}
export async function executeTool(
name: string,
args: Record<string, unknown>,
ctx: ToolExecutionContext,
): Promise<string> {
switch (name) {
case "create_item":
return execCreateItem(args, ctx);
case "list_items":
return execListItems(args, ctx);
case "get_summary":
return execGetSummary(args, ctx);
default:
return JSON.stringify({ success: false, message: `Herramienta desconocida: ${name}` });
}
}
async function execCreateItem(
args: Record<string, unknown>,
ctx: ToolExecutionContext,
): Promise<string> {
const category = String(args.category ?? "");
const amount = Number(args.amount ?? 0);
const title = String(args.title ?? "");
const date = args.date ? new Date(String(args.date)) : new Date();
const remark = args.remark ? String(args.remark) : null;
if (!title || Number.isNaN(amount)) {
return JSON.stringify({ success: false, message: "Título y valor numérico no pueden estar vacíos" });
}
// Aquí se integra la persistencia real (base de datos, API, etc.)
const created = await yourCreateItemService({
userId: ctx.userId,
category,
amount,
title,
date,
remark,
});
return JSON.stringify({
success: true,
item: { id: created.id, title: created.title, amount: created.amount },
});
}
```
Lo mismo aplica para `list_items`, `get_summary`: extraer parámetros de `args` de forma segura (conversión de tipos, valores por defecto), llamar a la capa de negocio, y devolver con `JSON.stringify`. Así, la capa de agente no necesita conocer estructuras de tablas o RPC, solo depende del contrato «nombre de herramienta + diccionario de parámetros → resultado en cadena».
## Agente conversacional: Ciclo multipaso con tool_calls
La capa de agente mantiene una «cadena de mensajes»: indicación del sistema + historial de usuario + respuesta actual del modelo. Si el modelo devuelve `tool_calls`, se itera cada llamada, se analiza `function.name` y `function.arguments` (JSON), opcionalmente se completa parámetros (ej. convertir «hoy», «este mes» a fechas reales), se llama a `executeTool`, y se añade el resultado con `role: "tool"` y `tool_call_id` correspondiente a la cadena de mensajes, luego se solicita nuevamente al modelo; si no hay `tool_calls` o se alcanza el máximo de ciclos, se usa `message.content` actual como respuesta final, y si se ejecutaron herramientas en este ciclo, generalmente se solicita una vez más al modelo para que genere un resumen en lenguaje natural basado en los resultados.
Ejemplo anonimizado del ciclo principal:
```ts
async function runWithToolCalls(opts: {
userId: number;
messages: ChatCompletionMessageParam[];
maxToolRounds: number;
client: OpenAI;
config: { model: string; temperature?: number; maxTokens?: number };
}): Promise<{ content: string }> {
const { userId, messages, maxToolRounds, client, config } = opts;
const fullMessages: ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM_PROMPT },
...messages,
];
let round = 0;
let lastContent = "";
while (round < maxToolRounds) {
const response = await client.chat.completions.create({
model: config.model,
temperature: config.temperature ?? 0.5,
max_tokens: config.maxTokens ?? 3000,
messages: fullMessages,
tools: CHAT_TOOLS,
});
const msg = response.choices[0]?.message;
if (!msg) return { content: "No se devolvió una respuesta válida" };
fullMessages.push(msg);
const toolCalls = msg.tool_calls;
if (!toolCalls?.length) {
lastContent = msg.content ?? "Operación completada.";
break;
}
for (const tc of toolCalls) {
if (tc.type !== "function") continue;
const name = tc.function.name;
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function.arguments || "{}");
} catch {
args = {};
}
const output = await executeTool(name, args, { userId });
fullMessages.push({
role: "tool",
tool_call_id: tc.id!,
content: output,
});
}
round++;
}
// Si se ejecutaron herramientas en este ciclo, solicitar una vez más para generar resumen en lenguaje natural
if (fullMessages.some((m) => m.role === "tool")) {
const finalResponse = await client.chat.completions.create({
model: config.model,
temperature: config.temperature ?? 0.5,
max_tokens: config.maxTokens ?? 3000,
messages: fullMessages,
});
const finalMsg = finalResponse.choices[0]?.message;
lastContent = finalMsg?.content ?? lastContent ?? "Operación completada.";
}
return { content: lastContent };
}
```
Puntos clave: `tools` solo se incluye en solicitudes donde el modelo debe decidir si llamar herramientas; en la última solicitud «solo generar respuesta» no debe incluirse `tools` para evitar que el modelo genere nuevamente tool_calls. `maxToolRounds` evita bucles infinitos en situaciones anómalas.
## Plan de degradación: Instrucciones JSON sin tool_calls
Algunos modelos o proxies no soportan `tool_calls`, o pueden devolver formatos incorrectos. Se puede añadir una ruta alternativa de «instrucciones JSON»: usar una indicación del sistema que exija al modelo producir solo un JSON con estructura fija, ej. `{ "action": { "name": "create_item" | "list_items" | "get_summary" | "none", "args": { ... } }, "reply": "Respuesta predefinida opcional" }`. Tu código analiza este JSON, si `action.name` no es `"none"`, usa `action.args` para llamar a `executeTool`, luego con la respuesta de la herramienta y `reply` se hace una solicitud de «resumen» para que el modelo genere la respuesta final basado en los resultados; si `action.name === "none"`, se usa directamente `reply`. Así, incluso sin soporte para tool_calls, se mantiene el flujo de «interpretar intención → ejecutar localmente → resumen en lenguaje natural».
```ts
const JSON_PLAN_SYSTEM = `Eres un asistente analizador de instrucciones. Solo produce un JSON, sin markdown ni explicaciones adicionales.
Formato: { "action": { "name": "create_item"|"list_items"|"get_summary"|"none", "args": {...} }, "reply": "Respuesta al usuario (obligatorio si name es none)" }
Cuando puedas llamar a herramientas, prioriza llenar 'action', no uses 'none'.`;
async function runWithJsonPlan(opts: {
userId: number;
userText: string;
client: OpenAI;
config: { model: string; maxTokens?: number };
}): Promise<{ content: string }> {
const { userId, userText, client, config } = opts;
const res = await client.chat.completions.create({
model: config.model,
temperature: 0.1,
max_tokens: config.maxTokens ?? 3000,
messages: [{ role: "user", content: `${JSON_PLAN_SYSTEM}\n\nSolicitud del usuario: ${userText}` }],
});
const raw = res.choices[0]?.message?.content?.trim();
if (!raw) throw new Error("No se devolvió contenido");
const parsed = parseJsonPlan(raw); // Extraer y parsear JSON desde raw
const name = parsed.action?.name;
const args = parsed.action?.args ?? {};
if (name && name !== "none") {
const toolOutput = await executeTool(name, args, { userId });
// Opcional: llamar nuevamente al modelo con toolOutput + parsed.reply para generar contenido final
return { content: await summarizeWithModel(client, config, userText, name, toolOutput, parsed.reply) };
}
returnComentario
Inicia sesión para ver y publicar comentarios
Ir a iniciar sesión