Tipps & Tricks

Erweiterte Funktionen für Gemma 4: Eigenständige Tool-Nutzung und Berechnungen

11 min Lesezeit
Erweiterte Funktionen für Gemma 4: Eigenständige Tool-Nutzung und Berechnungen

Einführung in die erweiterten Funktionen von Gemma 4

In diesem Tutorial werden wir Gemma 4 mit zwei neuen Werkzeugen ausstatten und beobachten, wie das Modell eigenständig entscheidet, wann es seine Umgebung erkunden und wann es Berechnungen durchführen soll.

In einem kürzlich veröffentlichten Artikel über maschinelles Lernen wurde ein Agent vorgestellt, der Informationen wie Wetter, Nachrichten, Wechselkurse und Uhrzeiten über öffentliche APIs abruft. Während dieser Artikel die Synthese-Seite des Modells gut abdeckte, blieb der interessantere Teil unberührt: ein Agent, der über seine eigene Umgebung nachdenkt, seine Maschine inspiziert und Logik auslagert, derer er sich nicht sicher ist. Man könnte argumentieren, dass dies näher an einem echten „agentischen“ Verhalten ist.

Dieser Artikel setzt dort an, wo der vorherige aufgehört hat. Wir werden Gemma 4 zwei neue Werkzeuge geben – einen sandboxed lokalen Dateisystem-Explorer und einen eingeschränkten Python-Interpreter – und beobachten, wie das Modell selbstständig entscheidet, wann es sich umsehen und wann es Berechnungen anstellen soll.

Inhaltliche Schwerpunkte

  • Warum „agentisches“ Tool-Calling mehr als nur Web-APIs benötigt, um interessant zu sein
  • Wie man ein Werkzeug zur Dateisysteminspektion mit harten Pfad-Traversierungs-Schutzmaßnahmen erstellt
  • Wie man ein Python-Interpreter-Werkzeug mit dem Modell verbindet, ohne ihm die Kontrolle über die Maschine zu übergeben
  • Wie sich die gleiche Orchestrierungsschleife von zuvor auf diese neuen Fähigkeiten generalisiert

Es wird empfohlen, den vorherigen Artikel zu lesen, bevor Sie fortfahren.

Von der Konversation zur Agentur

Wenn die einzigen Werkzeuge, die Sie einem Sprachmodell geben, nur schreibgeschützte Web-APIs sind, haben Sie im Grunde genommen immer noch einen Chatbot, auch wenn dieser potenziell auf bessere Informationen zugreifen kann. Das Modell erhält einen Prompt, entscheidet, welche API es ansprechen möchte, und fügt die JSON-Antwort in einen Absatz ein. Es gibt kein echtes Konzept von Umgebung, keinen Zustand zu inspizieren und keine Konsequenzen zu berücksichtigen; es ist ein Szenario, das eher Retrieval-augmented Generation als echter Agentur ähnelt.

Agentur, im praktischen Sinne, zeigt sich, wenn ein Modell beginnt, mit dem System zu interagieren, auf dem es läuft. Das kann bedeuten, dass es auf ein lokales Dateisystem zugreift, Code ausführt, Dateien modifiziert, andere Prozesse aufruft oder eine Kombination dieser Möglichkeiten. Sobald ein Werkzeug mehr tun kann, als einen sauberen String von einem entfernten Dienst zurückzugeben, muss das Modell anfangen, sich selbst zu hinterfragen: Welche Dateien existieren, was bedeutet diese Zahl tatsächlich, was befindet sich in diesem Ordner, bevor ich behaupte, dass er etwas enthält.

Die Gemma 4-Familie, insbesondere die gemma4:e2b-Edge-Variante, die wir verwenden, ist klein genug, um lokal auf einem Laptop zu laufen, während sie gleichzeitig kompetent genug ist, um strukturierte Ausgaben zuverlässig zu erzeugen. Diese Kombination macht das lokale agentische Muster interessant. Der vollständige Code für dieses Tutorial ist hier zu finden.

Architektonische Wiederverwendung

Die Orchestrierungsschleife aus dem vorherigen Tutorial bleibt unverändert. Wir definieren Python-Funktionen, stellen sie über ein JSON-Schema zur Verfügung, übergeben das Register an Ollama zusammen mit dem Benutzer-Prompt, fangen jeden tool_calls-Block in der Antwort ab, führen die angeforderte Funktion lokal aus, fügen das Ergebnis als Werkzeugrolle-Nachricht hinzu und fragen das Modell erneut, damit es eine endgültige Antwort synthetisieren kann. Die gleiche call_ollama-Hilfe, das gleiche TOOL_FUNCTIONS-Dictionary und dasselbe verfügbare_tools-Schema-Array aus dem vorherigen Tutorial kommen zum Einsatz.

Was sich ändert, ist die Art der Werkzeuge selbst. Während die vorherige Gruppe alle dünnen Clients über entfernte APIs waren, führen die Werkzeuge, die wir jetzt erstellen, Code auf der Maschine aus. Das verschiebt das Designproblem von „Wie parse ich diese Antwort?“ zu „Wie stelle ich sicher, dass das Modell nicht, selbst versehentlich, etwas tun kann, was ihm nicht erlaubt ist?“

Werkzeug 1: Ein sandboxed Dateisystem-Explorer

Das erste Werkzeug, list_directory_contents, gibt dem Modell die Fähigkeit, zu sehen, welche Dateien in einem bestimmten Ordner existieren. Dies klingt trivial, bis man bedenkt, dass os.listdir jeden String akzeptiert, einschließlich /, ~ und ../../etc. Eine naive Implementierung könnte das „Neugier“ des Modells direkt zu Ihren API-Schlüsseln führen.

Die Designentscheidung hier ist, ein sicheres Basisverzeichnis zu Beginn des Skripts festzulegen und jede Anfrage, die außerhalb davon aufgelöst wird, abzulehnen:

# Sicherheit: beschränke list_directory_contents auf dieses Basisverzeichnis und seine Nachkommen
# Setze das aktuelle Arbeitsverzeichnis, wenn das Skript gestartet wird
SAFE_BASE_DIR = os.path.abspath(os.getcwd())

def list_directory_contents(path: str = ".") -> str:
    """Listet Dateien und Verzeichnisse innerhalb eines Pfades auf, beschränkt auf das sichere Basisverzeichnis."""
    try:
        # Auf absolute Pfade auflösen und überprüfen, ob sie innerhalb von SAFE_BASE_DIR liegen
        requested = os.path.abspath(os.path.join(SAFE_BASE_DIR, path))
        if not (requested == SAFE_BASE_DIR or requested.startswith(SAFE_BASE_DIR + os.sep)):
            return (
                f"Fehler: Zugriff verweigert. Der Pfad '{path}' liegt außerhalb des "
                f"erlaubten Arbeitsbereichs ({SAFE_BASE_DIR})."
            )
    ...

Das Muster ist einfach, aber es lohnt sich, weiter darüber nachzudenken. Wir vertrauen niemals dem String, den das Modell erzeugt. Wir fügen ihn dem Basisverzeichnis hinzu, lösen ihn absolut auf (sodass .. normalisiert wird) und überprüfen dann, ob der aufgelöste Pfad immer noch mit dem Basisverzeichnis beginnt. Sowohl /etc/passwd als auch ../../somewhere kollabieren in Pfade, die diesen Präfix-Check nicht bestehen und abgelehnt werden, bevor os.listdir jemals aufgerufen wird.

Der Rest der Funktion kümmert sich um die Hauskeeping-Aufgaben: Bestätigen, dass der Pfad existiert und ein Verzeichnis ist, dessen Inhalte auflisten und jeden Eintrag entweder als [DIR] oder [FILE] mit einer Byte-Größe formatieren. Der zurückgegebene String ist einfaches Englisch mit einer Struktur, die das Modell beim zweiten Durchlauf parsen kann:

    entries = sorted(os.listdir(requested))
    if not entries:
        return f"Das Verzeichnis '{path}' ist leer."
    lines = [f"Inhalt von '{path}' ({len(entries)} Element(e)): "]
    for name in entries:
        full = os.path.join(requested, name)
        if os.path.isdir(full):
            lines.append(f" [DIR] {name}/")
        else:
            try:
                size = os.path.getsize(full)
                lines.append(f" [FILE] {name} ({size} Bytes)")
            except OSError:
                lines.append(f" [FILE] {name}")
    return "\\n".join(lines)

Das JSON-Schema, das wir dem Modell übergeben, ist absichtlich großzügig hinsichtlich der Parameter – der Pfad ist optional und standardmäßig auf das Wurzelverzeichnis des Arbeitsbereichs gesetzt, da die meisten nützlichen ersten Fragen über den aktuellen Ordner gestellt werden:

{
    "type": "function",
    "function": {
        "name": "list_directory_contents",
        "description": (
            "Listet Dateien und Unterverzeichnisse innerhalb eines Pfades im Arbeitsbereich des Benutzers auf. "
            "Verwenden Sie dies, um die Umgebung zu inspizieren, bevor Sie Fragen zu lokalen Dateien beantworten."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": (
                        "Ein relativer Pfad innerhalb des Arbeitsbereichs, z.B. '.', 'data' oder 'src/utils'. "
                        "Standardmäßig auf das Wurzelverzeichnis des Arbeitsbereichs gesetzt."
                    )
                }
            },
            "required": []
        }
    }
}

Beachten Sie, dass die Beschreibung eine kleine Menge an Prompt-Engineering enthält: „Verwenden Sie dies, um die Umgebung zu inspizieren, bevor Sie Fragen zu lokalen Dateien beantworten.“ Dieser Satz lenkt Gemma 4 dazu, das Werkzeug aufzurufen, wenn der Benutzer eine vage Frage zu „meinen Dateien“ stellt, anstatt zu raten, was dort sein könnte.

Werkzeug 2: Ein eingeschränkter Python-Interpreter

Das zweite Werkzeug, execute_python_code, ist das gefährlichere und lehrreichere der beiden. Die Prämisse ist, dass Sprachmodelle, insbesondere kleine, unzuverlässig bei präzisen Berechnungen, exakter String-Manipulation und allem sind, was mehr als ein paar Schritte von Verzweigungslogik umfasst. Ein Werkzeug, das dem Modell erlaubt, einen deterministischen Code-Schnipsel zu schreiben und auszuführen, ist eine viel bessere Antwort auf diese Probleme, als es zu bitten, sie in natürlicher Sprache zu durchdenken.

Die Implementierung verwendet exec() mit einem absichtlich reduzierten Builtins-Namespace:

def execute_python_code(code: str) -> str:
    """Führt einen Schnipsel von Python-Code aus und gibt zurück, was an stdout ausgegeben wurde.
    Dies ist ein Lern-Sandbox. exec() ist grundsätzlich unsicher; setzen Sie dieses Werkzeug
    nicht untrusted Benutzern oder Netzwerken aus. Die untenstehenden Einschränkungen stoppen die alltäglichen Fälle, nicht einen entschlossenen Angreifer.
    """
    try:
        # Eine minimale eingeschränkte Umgebung. Wir reduzieren __builtins__ auf eine kleine
        # Whitelist, sodass z.B. open(), eval(), und __import__ nicht direkt
        # im globalen Scope des Schnipsels verfügbar sind.
        safe_builtins = {
            "abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
            "divmod": divmod, "enumerate": enumerate, "filter": filter, "float": float,
            "int": int, "len": len, "list": list, "map": map, "max": max, "min": min,
            "pow": pow, "print": print, "range": range, "repr": repr, "reversed": reversed,
            "round": round, "set": set, "sorted": sorted, "str": str, "sum": sum,
            "tuple": tuple, "zip": zip,
        }
        # Ein paar sichere, nützliche Module vorab importieren, damit das Modell sie nicht selbst importieren muss.
        import math, statistics
        restricted_globals = {
            "__builtins__": safe_builtins,
            "math": math,
            "statistics": statistics,
        }

Einige Entscheidungen sind erwähnenswert. Wir ersetzen __builtins__ vollständig, anstatt einzelne Funktionen auf eine Blacklist zu setzen, was bedeutet, dass open, eval, exec, compile, __import__, input und alles andere, das nicht in unserer Whitelist ist, einfach nicht im Schnipsel existiert. Wir importieren math und statistics vorab in die Globals des Schnipsels, da das Modell ständig darauf zugreifen wird, und wir möchten nicht, dass es gegen die __import__-Einschränkungen kämpfen muss. Wir erfassen stdout mit contextlib.redirect_stdout, sodass das Modell genau das zurückbekommt, was sein Schnipsel ausgegeben hat:

        # stdout erfassen, damit wir die ausgegebene Ausgabe an das Modell zurückgeben können
        buffer = io.StringIO()
        with contextlib.redirect_stdout(buffer):
            exec(code, restricted_globals, {})
        output = buffer.getvalue().strip()
        if not output:
            return "Code wurde erfolgreich ausgeführt, aber es gab keine Ausgabe. Verwenden Sie print(), um einen Wert zurückzugeben."
        return f"Ausgabe:\\n{output}"

Der Zweig für leere Ausgaben ist wichtiger, als es aussieht. Kleine Modelle schreiben routinemäßig Ausdrücke wie x = sum(range(101)) und vergessen das print(x). Eine spezifische Fehlermeldung, die ihnen sagt, sie sollen print() verwenden, gibt der Orchestrierungsschleife die Möglichkeit, es erneut zu versuchen; ohne sie würde das Modell eine endgültige Antwort basierend auf einem leeren String synthetisieren und selbstbewusst einen Wert erfinden.

Ein abschließendes Wort zur Sicherheit, da die Docstring des Skripts dies direkt anspricht: Dies ist eine Lern-Sandbox, keine gehärtete. Ein entschlossener Angreifer kann auf verschiedene Weise aus einer Python-exec-Sandbox ausbrechen, die meisten davon beinhalten Objekt-Introspektion durch ().__class__.__mro__. Für einen Einzelbenutzer-Agenten, der auf Ihrem eigenen Laptop mit Ihren eigenen Prompts läuft, ist die Whitelist ausreichend. Für alles andere sollten Sie eine echte Isolationsschicht verwenden – einen Subprozess mit seccomp, einen Container oder RestrictedPython.

Die Orchestrierungsschleife

Die Hauptschleife ist in ihrer Struktur unverändert im Vergleich zum vorherigen Tutorial. Das Modell wird mit dem Benutzer-Prompt und dem Werkzeugregister abgefragt, und wenn es mit tool_calls antwortet, wird jeder Aufruf gegen TOOL_FUNCTIONS dispatcht:

if "tool_calls" in message and message["tool_calls"]:
    print("[WERKZEUGAUSFÜHRUNG]")
    messages.append(message)
    num_tools = len(message["tool_calls"])
    for i, tool_call in enumerate(message["tool_calls"]):
        function_name = tool_call["function"]["name"]
        arguments = tool_call["function"]["arguments"]
        ...
        if function_name in TOOL_FUNCTIONS:
            func = TOOL_FUNCTIONS[function_name]
            try:
                result = func(**arguments)
                ...
                messages.append({
                    "role": "tool",
                    "content": str(result),
                    "name": function_name
                })

Die CLI-Formatierung ist für dieses Skript wert, eine kleine Anpassung vorzunehmen. Das code-Argument des execute_python_code-Werkzeugs kann ein mehrzeiliger String mit Zeilenumbrüchen sein, was einen ASCII-Baum bei naiver Ausgabe ruinieren würde. Wir flatten und truncaten String-Argumente nur für die Anzeige; das Modell erhält beim Ausführen der Funktion den vollständigen String:

def _short(v):
    if isinstance(v, str):
        flat = v.replace("\\n", "\\\\n")
        if len(flat) > 60:
            flat = flat[:57] + "..."
        return f"'{flat}'"
    return str(v)
args_str = ", ".join(f"{k}={_short(v)}" for k, v in arguments.items())

Sobald jedes Werkzeugergebnis wieder als „role“: „tool“-Eintrag in die Nachrichtenhistorie eingefügt wird, rufen wir Ollama mit der angereicherten Nutzlast erneut auf, und das Modell produziert seine fundierte endgültige Antwort. Das gleiche zweiphasige Muster, dieselbe Logik.

Testen der Werkzeuge

Nun testen wir unser Tool-Calling. Ziehen Sie gemma4:e2b mit dem Befehl ollama pull gemma4:e2b, falls Sie dies noch nicht getan haben, und führen Sie das Skript aus einem Ordner aus, in den das Modell einen Blick werfen kann.

Beginnen wir mit dem Dateisystem-Werkzeug. Aus dem Projektverzeichnis:

Was sind die Skripte in meinem aktuellen Ordner, und welches sieht so aus, als sollte es zur Verarbeitung von CSVs verwendet werden?

Ergebnis:

[SYSTEM]
 ○ Werkzeug: execute_python_code......................[GELADEN]
 ○ Werkzeug: list_directory_contents..................[GELADEN]
 ○ Arbeitsbereich: /Users/matt/projects/gemma_agent.....[SANDBOXED]
[PROMPT]
Was sind die Skripte in meinem aktuellen Ordner, und welches sieht so aus, als sollte es zur Verarbeitung von CSVs?
[EXECUTION]
 ● Abfrage des Modells...
[WERKZEUGAUSFÜHRUNG]
 └── Aufruf: list_directory_contents
 ├─ Args: path='.'
 └─ Ergebnis: Inhalt von '.' (...

Mehr zum Thema