Tool-Calling verstehen: vom Schema zum Round-Trip

Tool-Calling verstehen: vom Schema zum Round-Trip

Artikel 4 · Serie: Ein lokaler Coding-Agent mit apfel

In Artikel 3 stand die Verbindung: Prompt rein, gestreamte Antwort raus. Damit kann das Modell reden, aber nichts tun. Der Schritt vom Chat zum Agenten ist das Tool-Calling — das Modell darf Werkzeuge aufrufen, wir führen sie aus und speisen das Ergebnis zurück. In diesem Artikel bauen wir genau diese Mechanik: eine Tool-Definition im OpenAI-Schema, den Round-Trip aus Aufruf, Ausführung und Fortsetzung, und eine kleine Abstraktion in Swift, die ein Werkzeug als Protokoll und eine Registry fasst. Als Demo dient get_time, ein triviales Werkzeug ohne Seiteneffekte. Unterwegs prüfen wir, wie zuverlässig das kleine Modell das überhaupt mitmacht — und stoßen auf ein paar Eigenheiten, die sich nicht aus dem OpenAI-Standard ableiten lassen. Der Stand ist eingefroren als Tag v0.4: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.4

Was eine Tool-Definition dem Modell verspricht

Ein Werkzeug ist für das Modell zunächst nur eine Beschreibung: ein Name, ein Satz Erklärung und ein JSON-Schema seiner Parameter. Im OpenAI-Format, das apfel an das Foundation Model durchreicht, sieht die Definition von get_time so aus:

{
  "type": "function",
  "function": {
    "name": "get_time",
    "description": "Get the current date and time in ISO-8601 format. Takes no arguments.",
    "parameters": { "type": "object", "properties": {}, "required": [] }
  }
}

Mehr sieht das Modell nicht. Es kennt weder den Code hinter dem Werkzeug noch dessen Rückgabe — nur das Versprechen, dass es ein get_time gibt, das keine Argumente nimmt. Auf Basis dieser Beschreibung entscheidet es, ob und wie es das Werkzeug ruft. In Swift modellieren wir dieselbe Struktur als Codable-Typen:

public struct ToolDefinition: Codable, Sendable, Equatable {
    public let type: String          // immer "function"
    public let function: FunctionDefinition
}

public struct FunctionDefinition: Codable, Sendable, Equatable {
    public let name: String
    public let description: String
    public let parameters: JSONSchema
}

public struct JSONSchema: Codable, Sendable, Equatable {
    public let type: String
    public let properties: [String: Property]
    public let required: [String]
}

Das JSONSchema halten wir bewusst minimal: Objekt-Typ, getypte Properties, eine required-Liste. Für get_time ist properties leer, und das reicht zunächst; reichere Schemas kommen, wenn ein Werkzeug sie braucht — die echten Datei- und Shell-Werkzeuge in Artikel 5.

Der Round-Trip: Aufruf, Ausführung, Fortsetzung

Tool-Calling ist kein einzelner Request, sondern eine kleine Choreografie über zwei Anfragen:

  1. Wir schicken die Unterhaltung samt Tool-Definitionen an /v1/chat/completions.
  2. Statt einer Textantwort kommt eine Assistant-Message mit tool_calls und finish_reason: "tool_calls". Inhalt (content) ist dann null.
  3. Wir führen jeden Tool-Call aus und hängen das Ergebnis als role: "tool"-Message an, verknüpft über die tool_call_id.
  4. Wir schicken die erweiterte Unterhaltung erneut. Jetzt antwortet das Modell mit Text — der finalen Antwort.

Wie apfel den Tool-Call genau zurückgibt, haben wir am echten Response abgelesen, statt es aus dem OpenAI-Standard abzuleiten. Die rohe Antwort von apfel auf den get_time-Request:

{
  "choices": [{
    "finish_reason": "tool_calls",
    "message": {
      "content": null,
      "role": "assistant",
      "tool_calls": [{
        "id": "call_1",
        "type": "function",
        "function": {
          "name": "get_time",
          "arguments": "{\"current_time\": \"2023-10-29T15:48:30.567Z\"}"
        }
      }]
    }
  }]
}

Zwei Details springen ins Auge. Erstens ist function.arguments ein String, kein Objekt — dazu gleich mehr. Zweitens hat das Modell ein Argument current_time erfunden, obwohl das Schema von get_time gar keine Parameter kennt. Das ist kein Zufall, sondern eine Eigenart des kleinen Modells, die uns später noch beschäftigt.

Das Tool als Protokoll

Damit der Round-Trip nicht für jedes Werkzeug eigenen Code braucht, fassen wir ein Werkzeug als Protokoll. Die Signatur ist absichtlich am Wire-Format ausgerichtet — an dem, was tatsächlich über HTTP geht —, nicht an Swift-Bequemlichkeit:

public protocol Tool: Sendable {
    var name: String { get }
    var description: String { get }
    var parametersSchema: JSONSchema { get }

    func call(_ arguments: Data) async throws -> String
}

Data rein, weil das Modell die Argumente als JSON-String liefert — jedes Werkzeug dekodiert sie selbst und ist damit auch selbst dafür verantwortlich, kaputte Argumente abzufangen. String raus, weil das Ergebnis als Inhalt der role: tool-Message zurück an das Modell geht. Die Tool-Definition leiten wir aus dem Werkzeug selbst ab, damit Schema und Implementierung nicht auseinanderlaufen können:

extension Tool {
    public var definition: ToolDefinition {
        ToolDefinition(function: FunctionDefinition(
            name: name, description: description, parameters: parametersSchema
        ))
    }
}

Die Registry: Werkzeuge nachschlagen und anbieten

Die Registry hat genau zwei Aufgaben, je eine pro Richtung des Round-Trips. Auf dem Hinweg sammelt sie alle Definitionen für den Request ein, auf dem Rückweg schlägt sie das vom Modell genannte Werkzeug nach und führt es aus.

public struct ToolRegistry: Sendable {
    private var tools: [String: any Tool]

    public var definitions: [ToolDefinition] {
        tools.values.map(\.definition)
    }

    public func dispatch(name: String, arguments: Data) async throws -> String {
        guard let tool = tools[name] else {
            throw ToolError.unknownTool(name)
        }
        return try await tool.call(arguments)
    }
}

Ein unbekannter Name wird zu einem typisierten ToolError, nicht zu einem Absturz. Das ist die erste Verteidigungslinie gegen halluzinierte Werkzeugnamen — und sie wird gebraucht, sobald das Modell sich einen Namen ausdenkt, den es nie angeboten bekam.

arguments ist ein String, kein Objekt

Die naheliegende Annahme ist, ein Tool-Call sei ein Funktionsaufruf mit fertigen Objekt-Argumenten. Tatsächlich kommt function.arguments als JSON-String im Antwort-Body — ein String, der erst geparst werden muss, und beim kleinen Modell ist dieser String nicht garantiert valide gegen das Schema. Wir haben oben gesehen, dass get_time ohne Parameter ein current_time-Argument zurückbekam. Wer den String ungeprüft als Objekt behandelt oder direkt durchreicht, bekommt Abstürze oder still falsche Aufrufe.

Deshalb bleibt arguments in unserem ToolCall-Typ ein roher String, und das Dekodieren liegt beim Werkzeug — das die Argumente ablehnen oder, wie get_time, schlicht ignorieren darf:

public struct ToolCall: Codable, Sendable, Equatable {
    public let id: String
    public let type: String
    public let function: FunctionCall
    public let index: Int?           // nur im Stream gesetzt

    public struct FunctionCall: Codable, Sendable, Equatable {
        public let name: String
        public let arguments: String  // roher JSON-String
    }
}

Das Demo-Werkzeug get_time

get_time ist bewusst trivial: keine Parameter, keine Seiteneffekte, ein vorhersagbares Ergebnis. Es zeigt den Round-Trip, ohne die Sicherheitsmechanik, die schreibende Werkzeuge brauchen werden. Die Uhrzeit injizieren wir, damit das Werkzeug testbar bleibt:

public struct GetTimeTool: Tool {
    public let name = "get_time"
    public let description = "Get the current date and time in ISO-8601 format. Takes no arguments."
    public let parametersSchema = JSONSchema()

    private let now: @Sendable () -> Date

    public func call(_ arguments: Data) async throws -> String {
        // Das Modell schickt manchmal Argumente, obwohl das Schema leer ist.
        // Wir ignorieren sie: get_time nimmt keine.
        let payload = ["time": ISO8601DateFormatter().string(from: now())]
        return String(decoding: try JSONEncoder().encode(payload), as: UTF8.self)
    }
}

Der Round-Trip selbst lebt in einem eigenen Typ. Er macht genau einen Durchlauf — kein Loop. Ruft das Modell nach den Ergebnissen erneut ein Werkzeug, wird diese zweite Runde hier nicht ausgeführt; die vollständige Plan/Act/Observe-Schleife ist Artikel 7.

public func run(_ messages: [ChatMessage]) async throws -> Result {
    var conversation = messages
    let first = try await complete(conversation, toolChoice)

    guard let calls = first.choices.first?.message.toolCalls, !calls.isEmpty else {
        // Das Modell hat direkt geantwortet, kein Werkzeug nötig.
        return Result(finalContent: first.choices.first?.message.content, toolCalls: [])
    }

    conversation.append(ChatMessage(assistantToolCalls: calls))
    for call in calls {
        conversation.append(ChatMessage(toolCallID: call.id, content: await result(for: call)))
    }

    let final = try await complete(conversation, nil)
    return Result(finalContent: final.choices.first?.message.content, toolCalls: calls)
}

Ein fehlschlagendes oder unbekanntes Werkzeug kommt als Ergebnis zurück, nicht als geworfener Fehler — das Modell bekommt so die Chance, sich zu fangen, statt dass der Durchlauf abbricht:

private func result(for call: ToolCall) async -> String {
    do {
        return try await registry.dispatch(name: call.function.name,
                                            arguments: Data(call.function.arguments.utf8))
    } catch {
        let payload = ["error": String(describing: error)]
        return (try? String(decoding: JSONEncoder().encode(payload), as: UTF8.self))
            ?? #"{"error":"tool failed"}"#
    }
}

Ein Round-Trip gegen das echte Modell

Das CLI bekommt einen --tools-Pfad, der die Registry mit get_time bestückt und den Round-Trip auslöst. Die Tool-Calls gehen auf stderr, damit stdout nur die finale Antwort trägt:

$ swift run apfel-agent --tools "What time is it right now? Use the get_time tool."
→ tool call: get_time({"current_time": "2025-02-02T14:34:56.789Z"})
The current time is June 6, 2026, at 9:12 PM UTC.

Der ganze Bogen ist hier zu sehen: Das Modell ruft get_time (mit erfundenem Argument, das get_time ignoriert), bekommt die echte Zeit als Ergebnis zurück und formuliert daraus eine Antwort in natürlicher Sprache. Der Round-Trip steht.

Warum eine Abstraktion statt Sonderfall

Protokoll, Registry und abgeleitete Definition sind als docs/adr/002-tool-abstraktion.md im Repo begründet. Der Kern: Die Definition entsteht aus dem Werkzeug, nicht als zweiter, separat gepflegter Datensatz — Schema und Code können nicht driften. Schema-Encoding, Tool-Call-Decoding und Dispatch sind offline gegen aufgezeichnete Fixtures und ein gescriptetes Fake-Backend getestet, ohne laufenden apfel. Und weil Argumente als nicht vertrauenswürdiger String behandelt und Tool-Fehler als Ergebnis zurückgegeben werden, hält der Round-Trip auch dann, wenn das Modell unsauber arbeitet. Was es genau heißt, dass das Modell unsauber arbeitet, haben wir empirisch ermittelt.

Wo das kleine Modell wackelt

Das Tool-Calling funktioniert — aber unzuverlässig, und zwar auf eine Weise, die erst der wiederholte Aufruf zeigt. Wir haben get_time wiederholt gegen apfel 1.5.1 laufen lassen und gezählt, wie oft das Modell den Tool-Call tatsächlich macht.

VarianteTool-Call-Quote
direktiver Prompt, kein tool_choice12/15
direktiver Prompt, tool_choice: "auto"6/15
neutraler Prompt, kein tool_choice2/10
direktiver Prompt, tool_choice: "required"1/15

Quelle: Eigene Erhebung apfel 1.5.1, 2026-06-06, scripts/tool-choice-experiment.sh.

Drei Befunde stechen heraus. Erstens: Selbst wenn wir das Werkzeug ausdrücklich nennen, ruft das Modell es nur in rund vier von fünf Läufen — ohne ausdrückliche Nennung deutlich seltener. Zweitens, und kontraintuitiv: tool_choice: "required", das im OpenAI-Standard einen Tool-Call erzwingt, bewirkt bei apfels Foundation Model das Gegenteil. Das Modell lehnt ab:

$ # Request mit tool_choice: "required"
"content": "I'm sorry, but I can't assist with that.", "finish_reason": "stop"

Drittens schlägt das Weglassen von tool_choice den expliziten Wert "auto" deutlich (12/15 gegen 6/15), obwohl beide nominell dasselbe bedeuten. Aus diesen Befunden folgt eine konkrete Entscheidung: Unser Agent lässt tool_choice weg. Der Mechanismus bleibt im Code für spätere Artikel, aber die Demo erzwingt nichts.

Es gibt noch einen dritten Fehlertyp neben dem ausbleibenden Aufruf und dem halluzinierten Argument. In einem Lauf rief das Modell get_time korrekt, bekam die echte Zeit zurück — und antwortete trotzdem „It seems there was an error retrieving the current time." Das Werkzeug lief fehlerfrei; das Modell hat sein Ergebnis falsch gedeutet. Das ist die Sorte Verhalten, die einen lokalen 3-Milliarden-Parameter-Agenten von einem Cloud-Modell trennt — und der Grund, warum Artikel 6 die Leistungsgrenze systematisch evaluiert.

Demo-Repo: apfel-coding-agent v0.4

Der Stand dieses Artikels ist eingefroren als Tag v0.4: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.4

Demo-Repo apfel-coding-agent v0.4 einrichten

Klonen (falls noch nicht geschehen) und auf den Tag einsteigen:

git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.4

Neu in v0.4 gegenüber v0.3:

  • Sources/AgentCore/Tools/Tool-Protokoll, ToolRegistry, Schema-Typen, GetTimeTool, ToolRoundTrip
  • Sources/AgentCore/Client/ChatModels.swift — erweitert um tools, tool_calls, role: tool und tool_choice
  • Sources/apfel-agent/AgentCommand.swift — neuer --tools-Pfad
  • docs/adr/002-tool-abstraktion.md — die Tool-Abstraktion
  • scripts/smoke-tool.sh — End-to-End-Test des Round-Trips
  • scripts/tool-choice-experiment.sh — reproduziert die Erhebung der Tool-Call-Quote

Bauen, testen, laufen lassen:

swift build
swift test                        # offline, kein apfel nötig
swift run apfel-agent --tools "What time is it? Use the get_time tool."

Die Unit-Tests laufen ohne apfel. Der End-to-End-Test und das Experiment brauchen einen laufenden apfel-Serve:

./scripts/smoke-tool.sh
./scripts/tool-choice-experiment.sh

Der Tool-Call ist unzuverlässig, deshalb versucht es der Smoke-Test mehrfach und ist grün, sobald ein Lauf den Round-Trip schafft. Voraussetzungen stehen in docs/setup.md.

Stolpersteine aus dem Bau

arguments ist ein String, kein Objekt. Das ist die häufigste Fehlannahme. Wer im Codable-Modell ein [String: Any] erwartet, scheitert am Decoding. Der Wert ist ein JSON-String im JSON, und er muss als solcher behandelt, dekodiert und validiert werden.

Tool-Calls non-stream konsumieren. Im Stream liefert apfel den Tool-Call doppelt: erst als rohe delta.content-Textfragmente, dann als einen gebündelten delta.tool_calls-Chunk am Ende. Wer im Stream nur delta.content sammelt, hält den rohen Tool-Call-JSON für die Antwort. Für den Round-Trip nehmen wir die non-stream-Antwort; das ist einfacher und eindeutig.

tool_choice: "required" erzwingt nichts. Anders als der OpenAI-Standard nahelegt, lehnt das Foundation Model unter required ab, statt ein Werkzeug zu rufen. Wer Verlässlichkeit über diesen Parameter erzwingen will, erreicht das Gegenteil.

tool_choice weglassen schlägt "auto". Den Default explizit zu setzen verschlechtert die Quote deutlich. Im Zweifel den Parameter ganz weglassen.

Wie es weitergeht

get_time ist harmlos: keine Argumente, keine Seiteneffekte. Artikel 5 nimmt sich die ersten echten Werkzeuge vor — read_file, list_dir, write_file, run_shell. Damit verändert der Agent zum ersten Mal etwas außerhalb seiner selbst, und genau da brauchen wir das, was get_time noch nicht braucht: eine Pfad-Sandbox, Bestätigungs-Gates vor schreibenden Aktionen und das Anzeigen von Diffs, bevor der Mensch entscheidet. Die Tool-Abstraktion aus diesem Artikel trägt das alles — die neuen Werkzeuge sind nur weitere Tool-Implementierungen in derselben Registry.


Vorheriger Artikel: Der Swift-Client: erste Verbindung zum Modell. Nächster Artikel: Die ersten echten Werkzeuge: Dateisystem und Shell. Repo-Tag: v0.4.