Function calling / tool use anhand praktischer Beispiele mit Spring AI

1. Überblick

Ein LLM kennt nur das, was in seinen Trainingsdaten enthalten ist. Er weiß nicht, wo deine Bestellung ORD-1001 gerade ist. Function calling (auch tool use genannt) löst genau dieses Problem. Du beschreibst dem Model eine Reihe von Funktionen. Das Model entscheidet, welche Funktion wann mit welchen Parametern aufgerufen werden soll. Deine Anwendung führt die Funktion aus und gibt das Ergebnis an das Model zurück.

In diesem Artikel schaue ich mir zuerst den Mechanismus auf JSON-Ebene an und baue dann einen Customer-Support-Assistenten mit Spring AI. Dieser kann Bestellstatus abfragen und Bestellungen stornieren. Nach dem Lesen kannst du Tools deklarieren, Kontext sicher übergeben, Fehler behandeln und Tests schreiben – ohne echte Model-Aufrufe.

2. Wie function calling funktioniert

2.1. Die Frage–Aufruf–Antwort-Schleife

Das häufigste Missverständnis: Das Model führt deinen Code nicht selbst aus. Es gibt nur eine Anfrage zum Aufrufen einer Funktion zurück – strukturiert als JSON. Ein Tool-Gespräch läuft so ab:

  1. Deine Anwendung sendet die Nutzerfrage zusammen mit einer Liste von Tools (Name, Beschreibung, JSON Schema der Parameter).
  2. Das Model gibt tool_calls zurück statt einer finalen Antwort.
  3. Deine Anwendung führt die entsprechende Funktion aus und sendet das Ergebnis als Message mit der Rolle tool zurück.
  4. Das Model liest das Ergebnis. Es kann weitere Tools aufrufen oder eine abschließende Antwort schreiben.

2.2. Schauen wir uns rohes JSON an

Das folgende Format stammt aus OpenAI. Andere Anbieter verwenden eine ähnliche Struktur, nur mit unterschiedlichen Feldnamen. Der Request enthält einen Abschnitt tools:

{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "user", "content": "Wo ist meine Bestellung ORD-1001 gerade?" }
  ],
  "tools": [{
    "type": "function",
    "function": {
      "name": "getOrderStatus",
      "description": "Einen Bestellstatus und das voraussichtliche Lieferdatum abfragen",
      "parameters": {
        "type": "object",
        "properties": { "orderId": { "type": "string" } },
        "required": ["orderId"]
      }
    }
  }]
}

Das Model antwortet nicht direkt. Es fordert stattdessen auf, eine Funktion aufzurufen:

{
  "role": "assistant",
  "tool_calls": [{
    "id": "call_abc123",
    "type": "function",
    "function": { "name": "getOrderStatus", "arguments": "{\"orderId\":\"ORD-1001\"}" }
  }]
}

Deine Anwendung führt die Funktion aus und sendet das Ergebnis mit der korrekten tool_call_id zurück:

{ "role": "tool", "tool_call_id": "call_abc123",
  "content": "{\"orderId\":\"ORD-1001\",\"status\":\"SHIPPED\",\"eta\":\"2026-10-30\"}" }

Jetzt schreibt das Model eine abschließende Antwort, zum Beispiel: “Bestellung ORD-1001 wurde an den Versanddienstleister übergeben und sollte am 30.10. ankommen.”

Zwei Dinge solltest du dir merken. Erstens: arguments ist ein String mit JSON, den das Model erzeugt hat. Dieser String kann fehlerhaft typisiert oder unvollständig sein. Zweitens: Die Qualität der description bestimmt, ob das Model das richtige Tool wählt.

Diese Schleife selbst zu schreiben ist nicht schwer, aber ziemlich aufwändig. Spring AI erledigt das für dich.

3. Spring AI einrichten

Füge das BOM und den Starter für OpenAI hinzu. Wenn du einen anderen Anbieter nutzt, musst du nur den Starter austauschen – der Tool-Code bleibt gleich.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

Konfiguriere den API-Schlüssel und das Model:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.2

Eine niedrige Temperature hilft dem Model, Tools stabiler zu wählen. Bei Abfrage-Aufgaben brauchst du keine “Kreativität”.

4. Tools mit @Tool deklarieren

Zur Vereinfachung nutzen wir einen In-Memory-Bestellungsspeicher:

public record Order(String id, String customerId, String status, String eta) {}

public record OrderStatusReply(String orderId, String status, String eta) {}

Die Tool-Klasse ist ein normales Bean. Jede Methode mit @Tool wird zu einem Tool:

@Component
public class OrderTools {

    private final Map<String, Order> orders = new ConcurrentHashMap<>(Map.of(
        "ORD-1001", new Order("ORD-1001", "CUS-7", "SHIPPED", "2026-10-30"),
        "ORD-1002", new Order("ORD-1002", "CUS-7", "PENDING", "2026-11-02")
    ));

    @Tool(description = "Einen Bestellstatus und das voraussichtliche Lieferdatum abfragen")
    public OrderStatusReply getOrderStatus(
            @ToolParam(description = "Bestellnummer im Format ORD-xxxx") String orderId,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        return new OrderStatusReply(order.id(), order.status(), order.eta());
    }

    @Tool(description = "Storniere eine Bestellung, die noch nicht an den Versanddienstleister übergeben wurde. Rufe dies nur auf, wenn der Kunde deutlich bestätigt hat, stornieren zu wollen.")
    public String cancelOrder(
            @ToolParam(description = "Bestellnummer im Format ORD-xxxx") String orderId,
            @ToolParam(description = "Stornierungsgrund vom Kunden", required = false) String reason,
            ToolContext context) {
        Order order = findOwnedOrder(orderId, context);
        if (!"PENDING".equals(order.status())) {
            throw new IllegalStateException(
                "Bestellung " + orderId + " hat den Status " + order.status() + " und kann nicht storniert werden");
        }
        orders.put(orderId, new Order(order.id(), order.customerId(), "CANCELLED", null));
        return "Bestellung " + orderId + " wurde storniert";
    }

    private Order findOwnedOrder(String orderId, ToolContext context) {
        String customerId = (String) context.getContext().get("customerId");
        Order order = orders.get(orderId);
        if (order == null || !order.customerId().equals(customerId)) {
            throw new IllegalArgumentException("Bestellung " + orderId + " nicht gefunden");
        }
        return order;
    }
}

Einige wichtige Punkte:

  • @Tool(description = …) ist die description im JSON aus Abschnitt 2.2. Der Standard-Tool-Name ist der Methodenname.
  • @ToolParam beschreibt jeden Parameter. required = false entfernt den Parameter aus der required-Liste im Schema.
  • Ein Parameter vom Typ ToolContext kommt nicht im Schema vor, das an das Model gesendet wird. Das Model sieht ihn nicht und kann ihn nicht füllen. Wir nutzen ihn, um die customerId aus der Benutzersession zu übergeben. So kann das Model keine Kunden-ID vortäuschen.
  • Der Rückgabetyp OrderStatusReply wird von Spring AI zu JSON serialisiert, bevor er an das Model gesendet wird.
  • Für Bestellungen, die nicht zum aktuellen Kunden gehören, geben wir “nicht gefunden” zurück statt “keine Berechtigung”. So enthüllen wir nicht, dass die Bestellung überhaupt existiert.

5. Tools über ChatClient aufrufen

@Service
public class SupportAssistant {

    private final ChatClient chatClient;
    private final OrderTools orderTools;

    public SupportAssistant(ChatClient.Builder builder, OrderTools orderTools) {
        this.chatClient = builder
            .defaultSystem("""
                Du bist ein Customer-Support-Assistent des Online-Shops.
                Antworte nur auf Basis von Daten, die aus Tools stammen, nicht auf Vermutung von Bestellstatus.
                Frage zur Bestätigung nach, bevor du eine Bestellung stornierst.""")
            .build();
        this.orderTools = orderTools;
    }

    public String answer(String customerId, String question) {
        return chatClient.prompt()
            .user(question)
            .tools(orderTools)
            .toolContext(Map.of("customerId", customerId))
            .call()
            .content();
    }
}

.tools(orderTools) scannt alle Methoden mit @Tool im Bean und sendet deren Definitionen an das Model. Wenn das Model tool_calls zurückgibt, macht Spring AI drei Dinge: die arguments parsen, die richtige Methode aufrufen und das Ergebnis zurückschicken. Diese Schleife wiederholt sich, bis das Model mit Text antwortet. .content() gibt nur die abschließende Antwort zurück.

Probieren wir es mit einem kleinen Controller:

@RestController
@RequestMapping("/support")
public class SupportController {

    private final SupportAssistant assistant;

    public SupportController(SupportAssistant assistant) {
        this.assistant = assistant;
    }

    @PostMapping
    public String ask(@RequestBody String question, Principal principal) {
        return assistant.answer(principal.getName(), question);
    }
}

Wenn Kunde CUS-7 fragt “Wo ist meine Bestellung ORD-1001?”, erhältst du ein Ergebnis wie:

Deine Bestellung ORD-1001 wurde an den Versanddienstleister übergeben und soll am 30. Oktober 2026 ankommen.

Der genaue Wortlaut unterscheidet sich zwischen den Laufzeiten. Die Daten kommen aber immer aus dem Tool.

6. Unter die Haube: ToolCallback und schema

Spring AI wandelt jede Methode mit @Tool in einen ToolCallback um. Du kannst sie selbst erzeugen, um zu sehen, was das Model wirklich bekommt:

ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
for (ToolCallback cb : callbacks) {
    System.out.println(cb.getToolDefinition().name());
    System.out.println(cb.getToolDefinition().inputSchema());
}

Das Ergebnis (gekürzt):

cancelOrder
{"type":"object","properties":{"orderId":{"type":"string","description":"Bestellnummer im Format ORD-xxxx"},
 "reason":{"type":"string","description":"Stornierungsgrund vom Kunden"}},"required":["orderId"],...}
getOrderStatus
{"type":"object","properties":{"orderId":{"type":"string","description":"Bestellnummer im Format ORD-xxxx"}},
 "required":["orderId"],...}

Wie in Abschnitt 4 erwähnt, kommt ToolContext im Schema nicht vor. Wenn das Model das falsche Tool wählt, solltest du das Schema wie oben ausgeben, um es zuerst zu überprüfen.

7. Testen ohne Model-Aufrufe

Das echte Model in Unit Tests aufzurufen ist langsam, kostspielig und liefert instabile Ergebnisse. Stattdessen testst du zwei Dinge: das Schema, das das Model erhält, und das Verhalten des Tools, wenn es einen arguments-String erhält, der genau dem entspricht, was das Model sendet.

class OrderToolsTest {

    private final ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools());
    private final ToolContext customer7 = new ToolContext(Map.of("customerId", "CUS-7"));

    private ToolCallback tool(String name) {
        return Arrays.stream(callbacks)
            .filter(cb -> cb.getToolDefinition().name().equals(name))
            .findFirst().orElseThrow();
    }

    @Test
    void schemaShouldNotExposeToolContext() {
        String schema = tool("getOrderStatus").getToolDefinition().inputSchema();

        assertThat(schema).contains("\"orderId\"").doesNotContain("context");
    }

    @Test
    void shouldReturnStatusAsJson() {
        String result = tool("getOrderStatus")
            .call("{\"orderId\":\"ORD-1001\"}", customer7);

        assertThat(result).contains("\"status\":\"SHIPPED\"", "\"eta\":\"2026-10-30\"");
    }

    @Test
    void shouldHideOrdersOfOtherCustomers() {
        ToolContext stranger = new ToolContext(Map.of("customerId", "CUS-99"));

        assertThatThrownBy(() -> tool("getOrderStatus").call("{\"orderId\":\"ORD-1001\"}", stranger))
            .hasRootCauseInstanceOf(IllegalArgumentException.class)
            .hasRootCauseMessage("Bestellung ORD-1001 nicht gefunden");
    }

    @Test
    void shouldRefuseToCancelShippedOrder() {
        assertThatThrownBy(() -> tool("cancelOrder").call("{\"orderId\":\"ORD-1001\"}", customer7))
            .isInstanceOf(ToolExecutionException.class)
            .hasRootCauseMessage("Bestellung ORD-1001 hat den Status SHIPPED und kann nicht storniert werden");
    }

    @Test
    void shouldCancelPendingOrder() {
        String result = tool("cancelOrder")
            .call("{\"orderId\":\"ORD-1002\",\"reason\":\"Versehentlich bestellt\"}", customer7);

        assertThat(result).contains("Bestellung ORD-1002 wurde storniert");
    }
}

MethodToolCallback umhüllt alle Exceptions von deiner Methode in ToolExecutionException. Daher prüfen die Tests oben die Root Cause statt des ursprünglichen Exception-Typs. Diese Tests laufen in wenigen Millisekunden und prüfen genau die Teile, die du kontrollierst.

Das Verhalten des Models (wählt es das richtige Tool, fragt es zur Bestätigung nach, bevor es storniert) ist eine andere Art von Test. Du solltest es mit einem separaten Evaluation-Set mit dem echten Model durchführen, nicht in Unit Tests.

8. Fehlerbehandlung aus Tools

Was passiert, wenn cancelOrder während der Ausführung eine Exception wirft? Standardmäßig (spring.ai.tools.throw-exception-on-error=false) bricht Spring AI die Request nicht ab. Es nimmt die Exception-Nachricht und sendet sie als Tool-Ergebnis an das Model zurück. Das Model liest die Nachricht und erklärt sie dem Kunden:

Leider kann die Bestellung ORD-1001 nicht storniert werden, da sie bereits an den Versanddienstleister übergeben wurde.
Möchtest du, dass ich dir das Rückgabeverfahren erklären kann, wenn du die Ware erhältst?

Dieses Verhalten ist sehr praktisch, hat aber zwei Folgen, die du beachten solltest:

  • Die Exception-Nachricht wird vom Model gelesen und normalerweise an den Benutzer weitergegeben. Wirf keine Exceptions mit Stack Traces, SQL-Statements oder internen Informationen.
  • Wenn Infrastruktur-Fehler (z.B. Datenbankausfälle) direkt nach oben propagiert werden sollen, setze:
spring.ai.tools.throw-exception-on-error=true

Eine sinnvolle Herangehensweise: Geschäftslogik-Fehler (Bestellung bereits versendet, nicht gefunden) sendest du an das Model zurück, Infrastruktur-Fehler lässt du hochbubbling, damit Monitoring sie erfasst.

9. Häufige Fehler und wann man function calling nicht verwenden sollte

Hinweis: Behandle die vom Model generierten arguments wie unsichere Eingaben vom Benutzer. Prompt Injection könnte das Model veranlassen, cancelOrder mit beliebigen Bestellnummern aufzurufen. Alle Berechtigungsprüfungen müssen im Tool-Code erfolgen – basierend auf ToolContext oder Security Context, nicht auf Anweisungen im System Prompt.

Häufige Fehler:

  • Verschwommene Beschreibungen. Ein Tool mit der Beschreibung “Bestellung abrufen” kann das Model verwirren – unterscheidet sich das von “Bestellverlauf abrufen”? Beschreib genau, was das Tool tut, wann es sinnvoll ist und wann nicht.
  • Zu viele Tools in einer Request. Jede Tool-Definition kostet Token, und das Model wählt schlechter aus, wenn die Liste lang ist. Übergib nur die Tools, die für den aktuellen Use Case relevant sind.
  • Tools mit Nebeneffekten ohne Bestätigungsschritt. Bei Stornierungen, Rückerstattungen oder E-Mail-Versand solltest du zwei Schritte in Betracht ziehen. Das erste Tool bereitet vor und gibt eine Zusammenfassung zurück. Die eigentliche Aktion wird erst ausgeführt, nachdem der Benutzer auf der UI “bestätigen” geklickt hat – nicht nur, weil das Model “denkt”, dass der Kunde zugestimmt hat.
  • Mangelnde Idempotenz. Das Model könnte das gleiche Tool zweimal aufrufen. Entwirf cancelOrder so, dass ein zweiter Aufruf keinen Schaden anrichtet.
  • Zu große Datenmengen zurückgeben. 500 Zeilen Bestellverlauf in den Context zu pumpen kostet Token und verwirrt das Model. Filtere und fasse dich kurz im Tool.
  • Vergessen, dass jeder Tool-Aufruf eine zusätzliche Roundtrip zum Model ist. Eine Frage, die drei Tools braucht, kann mehrere Sekunden dauern. Miss die Latenz, bevor du es in Zeit-kritische Pfade bringst.

Wann solltest du function calling nicht verwenden:

  • Der Workflow ist bereits festgelegt und vorhersehbar (immer Order abfragen, dann antworten). Dann rufe den Service direkt auf und gib das Ergebnis in den Prompt ein. Das ist billiger, schneller und vorhersagbarer.
  • Du brauchst nur strukturierte Daten vom Model. Verwende structured output (z.B. .call().entity(MyRecord.class)) statt function calling zu simulieren.
  • Empfindliche, nicht rückgängig zu machende Operationen (Geldtransfers, Daten löschen) ohne menschliche Bestätigung.

10. Fazit

Function calling ist keine Magie. Das Model gibt nur eine Funktionsaufrufsanfrage als JSON zurück – deine Anwendung führt sie aus. Spring AI macht die Ausführung eleganter: @Tool und @ToolParam erzeugen das Schema, ChatClient.tools(...) kümmert sich um die Aufrufs-Schleife, und ToolContext übergibt Session-Daten, die das Model nicht anfassen kann.

Das Wichtigste: Setz die Vertrauensgrenzen richtig. Schreib klare Beschreibungen und prüf Berechtigungen im Tool selbst. Bei Operationen mit Nebeneffekten brauchst du menschliche Bestätigung. Schließlich testest du das Tool über ToolCallback mit echten JSON-Strings – so weißt du sicher, dass dein Code funktioniert, egal wie das Model antwortet.

llmspring-aifunction-callingtool-usejava