LLM Function Calling: A Practical Guide for Backend Devs

LLM Function Calling: A Practical Guide for Backend Devs

Introduction

As backend developers, we’ve all been there: staring at a wall of unstructured text from an LLM, trying to parse out a date, a username, or a JSON blob that may or may not be valid. The promise of LLMs is immense, but the reality of integrating them into production systems often feels like a game of whack-a-mole. Enter function calling—a feature that transforms LLMs from pure text generators into actionable agents that can interact with your backend APIs.

In this guide, I’ll walk you through what function calling is, why it matters, and how to implement it in a Java backend. We’ll cover the nuts and bolts, from defining functions to handling edge cases, and I’ll share some hard-won lessons from the trenches.

What Is LLM Function Calling?

Function calling (also known as tool use) allows an LLM to output structured data that triggers a function in your code. Instead of asking the model to “return a JSON with the weather for London,” you define a get_weather function with parameters, and the model decides when to call it, with what arguments, based on the user’s prompt.

The magic is that the LLM doesn’t execute the function—it just produces a structured request. Your backend does the actual work and feeds the result back to the model, which then crafts a final response for the user.

Why It’s a Game-Changer

Before function calling, extracting structured data from LLM responses was a nightmare. You’d use regex, hope for valid JSON, and pray the model didn’t hallucinate a field name. Function calling shifts the burden: the model is trained to output a specific schema, which dramatically improves reliability.

How It Works: The Request/Response Cycle

  1. You send the user’s message plus a list of function definitions (name, description, parameters) to the LLM.
  2. The LLM either returns a normal text response or a tool_calls array with function names and arguments.
  3. Your backend executes the function(s) with the provided arguments.
  4. You send the function results back to the LLM as a new message (role: tool).
  5. The LLM synthesizes a final answer for the user.

This loop can repeat if the model needs to call multiple functions.

Setting Up a Java Example

Let’s get our hands dirty. We’ll use the OpenAI API (but the pattern applies to other providers like Anthropic or local models via Ollama). For HTTP, we’ll use the Java 11 HttpClient to avoid extra dependencies.

Dependencies

Just Java 11+ and an HTTP client. No need for Spring AI or LangChain4j unless you want higher-level abstractions.

Defining the Function Schema

Functions are described using JSON Schema. Here’s an example for a weather service:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}

In Java, we’ll build this as a Map or a String constant. I prefer a constant to keep it clean.

The Request Payload

We need to send a POST to /v1/chat/completions with the model, messages, and functions. Here’s a minimal Java method:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import java.net.URI;
import java.net.http.*;
import java.util.*;

public class FunctionCallingExample {
private static final String API_KEY = System.getenv("OPENAI_API_KEY");
private static final String URL = "https://api.openai.com/v1/chat/completions";

public static String callWithFunction(String userMessage) throws Exception {
String body = """
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "%s"}],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["city"]
}
}
}
],
"tool_choice": "auto"
}
""".formatted(userMessage);

HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(URL))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();

HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
return response.body();
}
}

Note: I’m using Java text blocks for readability. In a real app, you’d use a JSON library like Jackson to build the payload.

Parsing the Response

The response will contain either content (normal text) or tool_calls. Here’s how to inspect it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import com.fasterxml.jackson.databind.*;

public static void parseResponse(String json) throws Exception {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(json);
JsonNode choices = root.path("choices");
if (choices.isArray() && choices.size() > 0) {
JsonNode message = choices.get(0).path("message");
if (message.has("tool_calls")) {
JsonNode toolCalls = message.get("tool_calls");
for (JsonNode call : toolCalls) {
String functionName = call.path("function").path("name").asText();
String args = call.path("function").path("arguments").asText();
System.out.println("Call " + functionName + " with " + args);
}
} else {
System.out.println("Text: " + message.path("content").asText());
}
}
}

Executing the Function

Now we execute the function. In a real backend, you’d have a registry mapping function names to methods. Here’s a simple switch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static String executeFunction(String name, String argsJson) throws Exception {
ObjectMapper mapper = new ObjectMapper();
JsonNode args = mapper.readTree(argsJson);
switch (name) {
case "get_weather":
String city = args.get("city").asText();
String unit = args.has("unit") ? args.get("unit").asText() : "celsius";
return getWeather(city, unit); // returns JSON string
default:
throw new IllegalArgumentException("Unknown function: " + name);
}
}

private static String getWeather(String city, String unit) {
// Call your weather API, or mock for demo
return String.format("{\"temperature\": 22, \"unit\": \"%s\"}", unit);
}

Feeding the Result Back

After executing, we send a second request to the LLM with the tool result. The message role must be tool and include the tool_call_id from the previous response.

1
2
3
4
5
6
7
8
9
10
11
12
13
public static String continueWithToolResult(String originalUserMsg, String toolCallId, String functionResult) throws Exception {
String body = """
{
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "%s"},
{"role": "assistant", "content": null, "tool_calls": [{"id": "%s", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]},
{"role": "tool", "content": "%s", "tool_call_id": "%s"}
]
}
""".formatted(originalUserMsg, toolCallId, functionResult, toolCallId);
// send request...
}

In practice, you’d keep the entire message history in a list and append to it dynamically.

Best Practices for Production

1. Use a Robust JSON Library

Don’t hand-roll JSON. Use Jackson or Gson. Building payloads with string concatenation is a maintenance nightmare and error-prone.

2. Validate Arguments

Never trust the LLM’s arguments blindly. Validate them against your schema (e.g., using a JSON Schema validator) before executing. The model might output a city name that doesn’t exist or a negative number for a quantity.

3. Handle Errors Gracefully

What if the function throws an exception? Return the error message as the tool result, so the LLM can explain the issue to the user. Don’t let the whole request fail.

1
2
3
4
5
6
String result;
try {
result = executeFunction(name, args);
} catch (Exception e) {
result = "Error: " + e.getMessage();
}

4. Set Timeouts and Limits

LLM calls can be slow. Use a timeout (e.g., 30 seconds) and consider a maximum number of tool call iterations (e.g., 5) to prevent infinite loops.

5. Log Everything

For debugging, log the full request/response cycles, including tool calls and results. This is invaluable when things go wrong.

6. Security Considerations

Advanced Patterns

Parallel Function Calling

Modern models can return multiple tool calls in one response. Execute them in parallel using CompletableFuture to reduce latency.

1
2
3
4
5
6
7
List<CompletableFuture<String>> futures = toolCalls.stream()
.map(call -> CompletableFuture.supplyAsync(() -> executeFunction(call)))
.toList();

List<String> results = futures.stream()
.map(CompletableFuture::join)
.toList();

Then send all results back in one request.

Dynamic Function Registration

Instead of a switch statement, use a Map<String, Function> to register callables. This makes it easy to add new functions without modifying core logic.

1
2
Map<String, java.util.function.Function<JsonNode, String>> registry = new HashMap<>();
registry.put("get_weather", args -> getWeather(args.get("city").asText(), ...));

Streaming with Function Calling

If you’re using streaming, the tool call might be split across chunks. You’ll need to accumulate the arguments until the stream ends. This is more complex but doable with a stateful accumulator.

Real-World Example: A Booking Assistant

Let’s tie it all together. Imagine a chatbot that helps users book meeting rooms. We define functions: check_availability, book_room, cancel_booking. The user says: “Is room A free tomorrow at 2pm?”

The model calls check_availability with {room: 'A', date: '2024-03-15', time: '14:00'}. Your backend checks the calendar and returns {available: true}. The model then says: “Yes, room A is free. Would you like to book it?”

If the user says yes, the model calls book_room with the same parameters. Your backend creates the booking and returns a confirmation. The model relays it to the user.

This pattern turns a chat interface into a full transactional system.

Common Pitfalls and How to Avoid Them

  1. Model chooses the wrong function: Provide clear descriptions and parameter names. If it still fails, consider few-shot examples in the system prompt.
  2. Arguments are malformed: Enforce required fields and use enum for constrained values. Validate server-side.
  3. Infinite loop: Always cap the number of iterations and detect if the model keeps calling functions without making progress.
  4. Latency: Multiple round-trips can be slow. Use parallel calls and consider caching function results for identical arguments.
  5. Cost: Each function call iteration is a new API call. Optimize by combining multiple tasks into one function if possible.

Conclusion

Function calling is a powerful tool that bridges the gap between natural language and structured APIs. As backend devs, we can leverage it to build intelligent assistants that actually do things, not just talk. The key is to treat the LLM as a smart router, not a data processor. By defining clear function schemas, validating inputs, and handling errors gracefully, you can create robust integrations that delight users.

Now go forth and make your LLM do the dishes (or at least book a meeting room).

Key Takeaways

Happy coding, and may your functions always be called with valid arguments!