How AI Invokes Local Functions (Based on the OpenAI Protocol)
This article, based on OpenAI-compatible Function Calling (tool calling) capabilities, explains how to design a universal and practical implementation.
Rendering...
When large models need to perform actions like querying databases, writing data, calling internal APIs, or sending notifications, we often need to execute real code within your service. Connecting "model decisions" with "local execution" is precisely what "AI calling local functions" aims to solve.
## Applicable Scenarios & Objectives
Typical scenarios include: conversational business operations (e.g., accounting tasks: "Record a transaction for me", "Check last month's summary"), ticket/task creation, data queries/statistics, and internal system command parsing. The common characteristics are: users express intentions in natural language, models parse and select appropriate "tools" with parameters, and your backend executes the logic before returning results to the model for final response generation.
The objectives can be summarized in three points:
- **Tools are Describable** (model knows available functions and parameter structures)
- **Execution Remains Local** (sensitive logic/data never leaves your infrastructure)
- **Results are Feedback-capable** (tool returns can re-enter dialogue flow for natural language response generation)
## Architectural Approach
Adopt a "description + execution" separation pattern: Use OpenAI's `tools` (Function Calling) to describe local capabilities as "name + parameter schema", let the model return `tool_calls` during conversation; your service parses these `tool_calls`, invokes actual functions locally based on names and parameters, then feeds the return values back into the message list with `role: "tool"` before requesting the model again. This keeps sensitive logic and data within your process while letting the model focus on "selecting tools, filling parameters, and writing responses".
If your current model or gateway doesn't support `tool_calls`, implement a fallback layer: Let the model output structured JSON (e.g., `{ "action": { "name", "args" }, "reply" }`), parse it on your side, and follow the same local execution and secondary request workflow to maintain a complete "parse → execute → summarize" loop even without `tool_calls`.
## Tool Definition: OpenAI Format
Models need to know "what functions exist and what parameters they require". OpenAI specifies this through `tools` arrays containing `type: "function"` items with `function.name`, `function.description`, and `function.parameters` (JSON Schema). Clearer descriptions reduce the probability of tool misselection or parameter omissions.
Below is a sanitized set of tool definitions (names and business meanings generalized):
```ts
import type { ChatCompletionTool } from "openai/resources/chat/completions";
export const CHAT_TOOLS: ChatCompletionTool[] = [
{
type: "function",
function: {
name: "create_item",
description: "Creates a business record. Used when users express intentions like 'record a transaction', 'add', or 'register'.",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Category, such as Type A, Type B" },
amount: { type: "number", description: "Amount or quantity" },
title: { type: "string", description: "Title/summary" },
date: { type: "string", description: "Date YYYY-MM-DD, defaults to today if not provided" },
remark: { type: "string", description: "Remarks" },
},
required: ["category", "amount", "title"],
},
},
},
{
type: "function",
function: {
name: "list_items",
description: "Queries business records by conditions. Used when users say 'check', 'list', or 'filter'.",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Filter by category" },
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
pageNum: { type: "number", description: "Page number, defaults to 1" },
pageSize: { type: "number", description: "Items per page, defaults to 15" },
},
},
},
},
{
type: "function",
function: {
name: "get_summary",
description: "Gets aggregated statistics. Used when users ask "What's the total?", "statistics", or "summary".",
parameters: {
type: "object",
properties: {
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
endDate: { type: "string", description: "End date YYYY-MM-DD" },
month: { type: "string", description: "Month YYYY-MM, mutually exclusive with date range" },
},
},
},
},
];
```
In practice, you can add/remove tools based on business needs, refine `description` and `properties`, and use `enum` constraints when necessary to prevent parameter errors.
## Local Executor: Mapping Names to Functions
The tool execution layer does two things: find the corresponding implementation by `name`, and pass `args` along with runtime context (like user ID, tenant ID) to return string results (typically JSON) for inclusion in `role: "tool"` messages.
Sanitized executor example:
```ts
export interface ToolExecutionContext {
userId: number;
// Extensible: 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: `Unknown tool: ${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: "Title and amount cannot be empty" });
}
// Integrate real persistence (database, 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 },
});
}
```
`list_items` and `get_summary` follow similar patterns: safely extract parameters from `args` (type conversion, defaults), call business layers, then return via `JSON.stringify`. This way the agent layer doesn't need to know about table structures or RPC details, only relying on the "tool name + parameter dictionary → string result" contract.
## Conversation Agent: Multi-round Loop with tool_calls
The agent layer maintains a "message chain": system prompt + user history + current model response. If the model returns `tool_calls`, iterate through each call, parse `function.name` and `function.arguments` (JSON), optionally perform parameter completion (e.g., converting "today" or "this month" to actual dates), call `executeTool`, append results to the message chain with `role: "tool"` and matching `tool_call_id`, then continue requesting the model; if no `tool_calls` exist or maximum rounds reached, use current `message.content` as final response (typically requesting one final model call to generate a natural language summary based on tool results if any tools were executed).
Sanitized core loop example:
```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 valid response returned" };
fullMessages.push(msg);
const toolCalls = msg.tool_calls;
if (!toolCalls?.length) {
lastContent = msg.content ?? "Operation completed.";
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++;
}
// If tools were executed this round, request one final model call for natural language summary
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 ?? "Operation completed.";
}
return { content: lastContent };
}
```
Key points: Pass `tools` only in requests where model needs to decide on tool calls; don't include `tools` in the final "generate response" request to avoid infinite loops. Use `maxToolRounds` to prevent infinite cycles under abnormal conditions.
## Fallback Plan: JSON Commands Without tool_calls
Some models or proxies don't support `tool_calls` or may return malformed formats. Implement a "JSON command" path: Use system prompts to require models to output fixed-structure JSON like `{ "action": { "name": "create_item" | "list_items" | "get_summary" | "none", "args": { ... } }, "reply": "optional pre-defined response" }`. Your code parses this JSON: if `action.name` isn't `"none"`, use `action.args` to call `executeTool`, then use the tool return and `reply` to make a "summarize" request for the final natural language response; if `action.name === "none"`, use `reply` directly. This maintains the "intent parsing → local execution → natural language summary" workflow even without tool_calls support.
```ts
const JSON_PLAN_SYSTEM = `You are a command parser. Output only a JSON without markdown or additional explanations.
Format: { "action": { "name": "create_item"|"list_items"|"get_summary"|"none", "args": {...} }, "reply": "response to user (required when name is none)" }
Prioritize filling action when tools are applicable, avoid using 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\nUser request: ${userText}` }],
});
const raw = res.choices[0]?.message?.content?.trim();
if (!raw) throw new Error("No content returned");
const parsed = parseJsonPlan(raw); // Extract and parse JSON from raw
const name = parsed.action?.name;
const args = parsed.action?.args ?? {};
if (name && name !== "none") {
const toolOutput = await executeTool(name, args, { userId });
// Optionally: Make one more model call to generate final content using toolOutput + parsed.reply
return { content: await summarizeWithModel(client, config, userText, name, toolOutput, parsed.reply) };
}
return { content: parsed.reply?.trim() ?? "Unable to parse your request." };
}
```
The main entry can first try `runWithToolCalls`, then fallback to `runWithJsonPlan` when it fails or returns empty, ensuring compatibility.
## Parameter Completion & Context
**Example: Temporal Concepts**
Users often say "today", "this month", or "last month", but models might not convert these to specific dates. Before calling `executeTool`, perform parameter completion based on server time and user input: For example, detect "today"/"today's" and set `date` or `startDate/endDay` to current date; "this month" sets `month` to current YYYY-MM; "yesterday" uses previous day. This reduces model burden and avoids ambiguities. Implementation: Apply `applyTemporalHints(toolName, args, userText, new Date())` to `args` in the agent, modifying only time-related fields while keeping other model outputs intact.
## Summary
Using "OpenAI tools description + local executeTool mapping" connects AI decisions with local capabilities. Through multi-round messages (including `role: "tool"`) and a final no-tools summary request, we obtain user-facing natural language responses. In environments without tool_calls support, use "fixed JSON commands + local parsing + secondary summarization" as fallback while maintaining the same execution layer and business logic. Clear tool descriptions, safe parameter conversions, and temporal context completion significantly improve usability and stability.Comments
Please login to view and post comments
Go to Login