AI Function Calling in Production: Complete Guide from Concept to Deployment
AI Function Calling in Production: Complete Guide for 2026
Function Calling has become infrastructure-level capability for LLM applications. But there's a huge gap between "running a demo" and "running in production." This guide covers the full chain from protocol understanding to production deployment.
The essence of Function Calling is not "letting AI call functions" — it's making LLMs output structured JSON instead of free text. Whether the function actually executes is entirely up to your code.
The Real Nature: Structured Output, Not Execution
User Query → LLM Reasoning → Output JSON (function name + args) → Your code decides → Return result to LLM → Final answer
Key insight: The LLM never directly executes any function. It only "suggests" which function to call and what arguments to pass. Your application code is the real executor.
This means:
- Safety: You can refuse dangerous operations
- Control: You can modify/validate arguments before execution
- Observability: Every call has complete audit logs
- Testability: LLM output is pure JSON, easy to assert
Protocol Comparison: OpenAI / Anthropic / Google
OpenAI Format
{
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
}]
}
Key Differences
| Dimension | OpenAI | Anthropic | Google Gemini |
|---|---|---|---|
| Schema field | parameters |
input_schema |
parameters |
| Type format | JSON Schema | JSON Schema | Gemini Schema |
| Parallel calls | ✅ | ✅ | ✅ |
| Force call | tool_choice: "required" |
tool_choice: {type, name} |
function_calling_config |
Spring AI + Function Calling in Practice
Auto Function Registration
@Configuration
public class ToolConfig {
@Bean
@Description("Get current weather for a city including temperature and humidity")
public Function<WeatherRequest, WeatherResponse> getWeather() {
return request -> weatherService.getCurrentWeather(request.city(), request.unit());
}
@Bean
@Description("Query user's recent orders")
public Function<OrderQueryRequest, OrderQueryResponse> queryOrders() {
return request -> orderService.queryRecentOrders(request.userId(), request.limit());
}
}
Spring AI magic: Define a
Function<Req, Resp>bean with@Description, and the framework auto-registers it as an LLM tool, generates JSON Schema, and handles the call loop.
Chat Service
@Service
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You are a smart customer service assistant.")
.defaultFunctions("getWeather", "queryOrders", "createTicket")
.build();
}
public String chat(String userMessage) {
return chatClient.prompt()
.user(userMessage)
.call()
.content();
}
}
LangChain4j Tool Annotation-Driven Development
@Tool("Search knowledge base for relevant documents")
public List<DocumentResult> searchKnowledge(
@P("Search query") String query,
@P("Number of results, default 5") int topK
) {
return vectorStore.similaritySearch(query, topK);
}
@Tool("Execute read-only SQL query")
public QueryResult executeQuery(@P("SQL query, SELECT only") String sql) {
if (!sql.trim().toUpperCase().startsWith("SELECT")) {
throw new IllegalArgumentException("Only SELECT queries allowed");
}
return databaseService.executeQuery(sql);
}
Parallel Tool Calls: Scheduling Strategies
| Strategy | Use Case | Pros | Cons |
|---|---|---|---|
| Sequential | Strict dependencies | Simple, reliable | Slow |
| Fully Parallel | No dependencies | Fastest | May violate business rules |
| DAG Scheduling | Mixed dependencies | Performance + correctness | Complex implementation |
| Rate-Limited Parallel | API rate limits | Avoids throttling | Needs config |
public List<ToolResult> executeParallel(List<ToolCall> calls) {
var dag = buildDependencyGraph(calls);
List<ToolResult> results = new ArrayList<>();
for (List<ToolCall> layer : dag.getLayers()) {
List<CompletableFuture<ToolResult>> futures = layer.stream()
.map(call -> CompletableFuture.supplyAsync(() -> executeWithSafety(call)))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
futures.forEach(f -> results.add(f.join()));
}
return results;
}
Error Handling & Retry: Graceful Degradation
public class ResilientToolExecutor {
public ToolResult executeWithResilience(ToolCall call, String userId) {
// Layer 1: Circuit breaker check
if (circuitBreakers.isOpen(call.name())) {
return ToolResult.fallback(call.name(), "Service temporarily unavailable");
}
// Layer 2: Parameter validation
ValidationResult validation = registry.validate(call.name(), call.arguments());
if (!validation.isValid()) {
return ToolResult.error(call.name(), "Validation failed: " + validation.getErrors());
}
// Layer 3: Permission check
if (!registry.hasPermission(call.name(), userId)) {
return ToolResult.error(call.name(), "Permission denied");
}
// Layer 4: Retry execution
try {
return retryPolicy.execute(() -> registry.execute(call.name(), call.arguments(), userId));
} catch (RetryExhaustedException e) {
circuitBreakers.recordFailure(call.name());
return ToolResult.fallback(call.name(), getFallbackMessage(call.name()));
}
}
}
Security: Permission Control & Sandbox Execution
Permission Model
ToolPermissionManager.builder()
.role("USER", Set.of("getWeather", "queryOrders", "searchKnowledge"))
.role("VIP", Set.of("getWeather", "queryOrders", "searchKnowledge", "createTicket", "processRefund"))
.role("ADMIN", Set.of("*"))
.dangerousOperations(Set.of("processRefund", "deleteAccount"))
.build();
Input Sanitization
public class ToolInputSanitizer {
public SanitizedArgs sanitize(String toolName, Map<String, Object> args) {
// Prevent SQL injection, path traversal, command injection
// Enforce length limits
// Validate types
}
}
Production Case: Multi-Turn Customer Service Orchestration
User: "My order from yesterday hasn't shipped. I want a refund if it doesn't ship today."
Turn 1: LLM → queryOrders(userId, "yesterday")
Turn 2: LLM analyzes status → processRefund(orderId)
Turn 3: LLM → createTicket(userId, "Refund request", ...)
Turn 4: LLM → Generate final response
@Service
public class SmartCustomerService {
public CustomerServiceResponse handle(String userMessage, String userId, String sessionId) {
var response = chatClient.prompt()
.system(buildSystemPrompt(userId))
.messages(conversationStore.getHistory(sessionId))
.user(userMessage)
.functions("queryOrders", "processRefund", "createTicket", "sendNotification")
.advisors(
new LoggingAdvisor(),
new RateLimitAdvisor(),
new SafetyCheckAdvisor(),
new MetricsAdvisor()
)
.call();
conversationStore.append(sessionId, userMessage, response.content());
return new CustomerServiceResponse(response.content(), extractToolUsage(response));
}
}
Debugging: Function Call Tracing
Call Chain Visualization
┌──────────────────────────────────────────────────┐
│ Session: sess_abc123 User: user_456 │
├──────────────────────────────────────────────────┤
│ Turn 1: User → "Order not shipped, want refund" │
│ ├─ LLM → queryOrders(userId, days=1) │
│ │ └─ Result: [Order#1234 Not Shipped] 120ms│
│ ├─ LLM → processRefund(orderId="1234") │
│ │ └─ Result: Refund success 850ms │
│ └─ LLM → "Refund processed for order 1234..." │
│ │
│ Total: 2 tool calls, 970ms tool time │
│ Tokens: 450 input + 280 output = 730 total │
└──────────────────────────────────────────────────┘
Production Checklist
| Item | Priority | Description |
|---|---|---|
| Precise descriptions | ⭐⭐⭐ | LLM relies entirely on descriptions |
| Parameter validation | ⭐⭐⭐ | Never trust LLM output |
| Permission control | ⭐⭐⭐ | Role-based function access |
| Circuit breaker | ⭐⭐ | Graceful degradation |
| Turn limit | ⭐⭐ | Prevent infinite loops (5-10 turns) |
| Timeout control | ⭐⭐ | Per-function timeout |
| Log tracing | ⭐⭐ | Full traceId chain |
| Cost monitoring | ⭐ | Token and API call costs |
Most important principle: Function Calling is a "suggestion" not an "instruction." Always validate, authorize, and degrade before execution — never trust LLM output.
Try these browser-local tools — no sign-up required →