Startseite / Artikel

Ein lokaler Support-Agent mit SmolLM3: Denkmodus, Tools und Schutzmechanismen

Ein 3B-Modell auf einem Laptop als Support-Agent ausführen: Wann sollte man think oder no_think verwenden, wie die Aufrufung von Tools funktioniert und welche Schutzmechanismen Platzhalter-Tool-Aufrufe verhindern.

Verlag: REACTAPP.TOP9 Min. Lesezeit1979 Wörter

Quellen: **A Local Support Agent with SmolLM3: Think Mode, Tools, and When Not to Reason**

Kann ein Modell mit 3 Milliarden Parametern einen Support-Anfrage bearbeiten, ohne dass ein Modell mit 70 Milliarden Parametern im Hintergrund steht? Für die 90 Prozent der einfachen Anfragen ja – allerdings nur, wenn man auf die eigenen Tool-Aufrufe des Modells verzichtet. Hier läuft SmolLM3 lokal als kleiner Support-Agent und zeigt an, wo es sich korrekt verhält und wo der umgebende Code strenger sein muss als das Modell selbst.

Kundenservice ist kein geeigneter Bereich für allgemeine Chatbots. Typische Anfragen erfordern eine Suche, eine Klassifizierung sowie eine kurze Antwort nach den geltenden Richtlinien, während nur ein kleiner Teil der Anfragen mit doppelten Gebühren, verlorenen Bestellungen oder unzufriedenen Kunden zu tun hat. Um beide Anforderungen mit einem einzigen Modell gut zu erfüllen, muss es auf zwei verschiedene Arten betrieben werden.

SmolLM3-3B eignet sich aus drei Gründen dafür – Gründe, die durch die Anzahl der Parameter verborgen bleiben:

  • Drei Denkmodi sind bereits enthalten. Durch das Hinzufügen von /no_think antwortet das Modell sofort, während /think dazu führt, dass es vorher eine versteckte Denkspur erstellt.
  • Die Aufruffunktion für Tools ist integriert: Als JSON-Schemata beschriebene Tools werden in das Chat-Template aufgenommen, und das Modell antwortet mit einem <tool_call>-Block, wenn es einen Aufruf benötigt.
  • Es ist klein genug für ein Laptop. Alles hier lief auf einem MacBook mit 24 GB Unified Memory: das Modell in voller Präzision über Transformers auf MPS sowie eine 8-Bit-MLX-Version für den Agentenloop.
  • Es findet keine Trainierung statt, und die Gewichte werden niemals verändert. Die Arbeit besteht darin, Denken von Handeln zu trennen und den umgebenden Code strenger als das Modell zu gestalten.

    Die Einrichtung

    Zwei Skripte verwenden zwei Laufzeiten, aber dieselbe Modellfamilie. Bei dem Vergleich im Denkmodus folgt das Sampling den Vorgaben der Modellkarte, mit temperature=0.6 und top_p=0.95. Der Agent arbeitet bei temperature=0.3 ruhiger, sodass die Tool-JSON-Daten unverändert bleiben.

    Auf diesem Gerät dauerte die 8-Bit-Agent-Rundreise etwa vier Sekunden. Das vollständige 3B-Modell unter Transformers erzeugte rund 10 Tokens pro Sekunde, was für das Später Lesen von Protokollen ausreicht, aber zu langsam ist für einen Kunden, der ein Chat-Fenster beobachtet. Transformers eignet sich gut zum Studieren des Templates. Wenn am anderen Ende eine echte Person ist, wechseln Sie zu MLX oder llama.cpp.

    Denken ist eine Produktentscheidung

    SmolLM3 denkt standardmäßig. Es gibt zwei äquivalente Wege, das auszuschalten. Eine Möglichkeit ist eine Systemnachricht:

    messages = [
        {"role": "system", "content": "/no_think"},
        {"role": "user", "content": prompt},
    ]
    

    Der andere ist eine Flagge bei der Darstellung des Templates:

    tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=True,
        enable_thinking=False,  # the /no_think flag in the system prompt wins if both are set
    )
    

    Das Template tut mehr als nur einen booleschen Wert umzukehren. Es schreibt den Denkmodus in die Systemüberschrift, und wenn /no_think gewählt wird, fügt es außerdem im Voraus einen leeren Denkblock ein, sodass die Erstellung direkt mit der Antwort fortgesetzt werden kann:

    <|im_start|>assistant
    <think>
    
    </think>
    

    Deshalb kann ein einfacher Aufruf von generate() im no-think-Modus dennoch <think></think> im Text zurückgeben. Für eine an Kunden gerichtete Antwort sollte dies entfernt werden:

    final = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip()
    

    Derselbe Support-Aufruf wurde in beiden Modi gesendet: Ein Kunde wird für eine Bestellung zweimal belastet – welche drei konkreten Schritte sollte der Support daher unternehmen?

    Zwei Ansätze

    Die Durchsatzrate war in beiden Modi ungefähr gleich, doch die Arbeitsmenge nicht. Der „Think-Modus“ verbrachte den größten Teil seiner 939 Tokens innerhalb des Think-Blocks. Der Kunde liest diesen Ablauf nie, wartet aber dennoch darauf ab.

    Zwei Handlungspläne

    /no_think führte direkt zu einer Standard-Checkliste: Bestellung bestätigen, sich an den Händler wenden, die Gebühr erstatten. Dies dient als Erstentwurf, ist aber für das eigene Support-Team des Händlers etwas unpassend, da „sich an den Händler wenden“ ein Ratschlag einer Bank ist und nicht etwas, was ein interner Agent seinem eigenen Kunden mitteilt.

    /think hat etwa eine Seite lang mit sich selbst darüber diskutiert, ob eine Rückerstattung oder Gutschrift gewährt werden soll, wie es mit der Eskalation weitergehen soll und ob die Lieferung überprüft werden muss, bevor schließlich ein strafferes Vorgehen festgelegt wurde:

    1. Entschuldigen und die Bestellnummer, das Datum sowie die Zahlungsmethode erfragen.
    2. Die doppelte Transaktion finden und stornieren.
    3. Dem Kunden erklären, was schiefgelaufen ist, und bestätigen, dass das Geld als Rückerstattung oder Gutschrift zurückgegeben wird.

    Daraus ergibt sich die Aufteilung der Aufgaben bezüglich der Lieferung:

    • /no_think für arbeitsintensive Aufgaben: Intent-Klassifizierung, Anfragen wie „Wo ist meine Bestellung?“, vorgefertigte Antwortmuster und Aufrufe von Tools.
    • /think bei Situationen, in denen ein falscher Schritt teure Folgen haben kann: Rückerstattungen, Chargebacks, Sicherheitsprobleme sowie alles, was vor der Antwort eine menschenähnliche Checkliste erfordert.

    Die von Hugging Face selbst veröffentlichten Zahlen deuten bei der Nutzung von Tools in dieselbe Richtung. Im BFCL-Benchmark wird SmolLM3 ohne erweitertes Denken mit 92,3 bewertet und mit diesem Feature bei 88,8. Das Nachdenken ist nicht kostenlos, und für diese Art von Aufgabe brachte es sogar keinen Vorteil. Der untenstehende Agent setzt enable_thinking=False, weil eine Bestellabfrage eine Funktionsaufruf sein sollte und kein Essay.

    Ein Tool und eine Zwei-Schritte-Schleife

    SmolLM3 erwartet Tools als JSON-Schema-Objekte, die über xml_tools übergeben werden. Das Template kopiert sie in einen Tools-Block und weist das Modell an, in dieser Struktur zu antworten:

    <tool_call>
    {"name": <function-name>, "arguments": <args-json-object>}
    </tool_call>
    

    Das Support-Tool ist absichtlich eingeschränkt:

    TOOLS = [
        {
            "name": "lookup_order_status",
            "description": (
                "Look up the current status, estimated delivery date, and carrier "
                "for a specific customer order. Call this when the customer mentions "
                "an order number or asks where their order is."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "The order ID, usually in the format ORD-XXXXXX.",
                    }
                },
                "required": ["order_id"],
            },
        }
    ]
    

    Hinter diesem befindet sich eine falsche Datenbank. In der Produktion wäre das eine SQL-Anfrage oder ein Aufruf eines Bestellservices.

    ORDERS = {
        "ORD-4821": {"status": "shipped", "eta": "June 18, 2026", "carrier": "DHL"},
        "ORD-3307": {"status": "processing", "eta": "June 20, 2026", "carrier": None},
        "ORD-1190": {"status": "delivered", "eta": None, "carrier": "FedEx"},
    }
    

    Der Loop ist das gesamte Produkt. Das Modell erhält die Kundennachricht, erstellt eine erste Antwort, und wenn es sich um einen Tool-Aufruf handelt, führt der Code die Funktion aus, gibt das Ergebnis zurück und bittet um eine zweite Antwort:

    def respond_with_tools(user_message: str) -> str:
        messages = [{"role": "user", "content": user_message}]
        turn1 = generate_reply(messages)
        name, args = parse_tool_call(turn1)
        if name == "lookup_order_status":
            result = lookup_order_status(**args)
            messages += [
                {"role": "assistant", "content": turn1},
                {"role": "tool", "content": json.dumps(result), "name": name},
            ]
            return generate_reply(messages)
        return turn1
    

    Der glückliche Weg

    Ein Kunde fragt nach einer Woche, wo die Bestellung ORD-4821 ist. Das Modell antwortet nicht. Es ruft das Tool auf:

    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "ORD-4821"}}
    </tool_call>
    

    Die Funktion gibt an, dass die Lieferung am 18. Juni 2026 per DHL erfolgt ist. Bei der zweiten Abfrage, insgesamt 4,2 Sekunden, kommt eine Antwort, dass die Bestellung versandt wurde und voraussichtlich am 18. Juni per DHL ankommt.

    Für ORD-3307 ist der Status „in Bearbeitung“ angegeben, ohne Angabe eines Transportdienstleisters, wobei eine Lieferung für den 20. Juni erwartet wird. Das Modell gab an, dass kein Transportdienstleister verfügbar sei, anstatt einen zu erfinden – genau das möchte man von einem kleinen Modell: Es soll die JSON-Daten des Tools lesen und diese nicht verfälschen.

    Für ORD-9999 war der Tool-Aufruf korrekt und die Funktion antwortete mit not_found. In der Antwort wurde Entschuldigung angeboten und der Kunde gebeten, die Nummer zu überprüfen. Eine strukturierte „not_found“-Antwort der Funktion allein reichte aus, ohne zusätzliche Hinweise.

    Wo der naive Loop scheitert

    Die dritte Anfrage diente als Negativkontrolle: Der Kunde möchte lediglich seine E-Mail-Adresse ändern. Da es kein entsprechendes Tool gibt, teilt ein guter Agent dies mit und beendet die Bearbeitung. Der naive Algorithmus handelte jedoch anders:

    1. Er begann korrekt, indem er mitteilte, dass persönliche Informationen nicht geändert werden können und der Kunde sich an den Service wenden solle.
    2. Dann setzte er seine Ausführung fort und sendete einen Tool-Aufruf mit einem Platzhalter:
    <tool_call>
    {"name": "lookup_order_status", "arguments": {"order_id": "your_order_id"}}
    </tool_call>
    
    1. Der Parser führte alles aus, was in <tool_call> eingebettet war. Er suchte in der falschen Datenbank nach your_order_id, erhielt den Wert not_found und gab diesen an das Modell weiter.
    2. In der zweiten Runde gab das Modell dem Kunden das Schema des Tools preis und wies ihn an, your_order_id durch eine echte Bestellnummer zu ersetzen.

    Vier Dinge versagten gleichzeitig und überschlugen sich gegenseitig. Der Teil des Modells ist am wenigsten interessant. Kleine Modelle gehen vorsichtig vor, und das ist normal. Wenn Ihre Schleife im Grunde eine reguläre Ausdruck für <tool_call> ist, gefolgt vom Aufruf der Funktion mit den vorhandenen Argumenten, werden Sie letztendlich in der Produktion lookup_order_status("your_order_id") ausführen – das passiert jedoch nicht während einer Demo.

    Schutzmechanismen, die keine Feinabstimmung benötigen

    Zwei einfache Regeln beheben den größten Teil des Problems, ohne die Gewichte ändern zu müssen.

    Zuerst soll eine Tool-Aufruf nur dann ausgeführt werden, wenn der gesamte Turn ausschließlich aus diesem Aufruf besteht. Ein Absatz, gefolgt von einem Aufruf, gilt als gewöhnlicher Text, und der XML-Teil muss entfernt werden, bevor der Kunde ihn sieht.

    Zweitens sollten die Argumente vor dem Erreichen der Datenbank überprüft werden. Die Bestellnummern bestehen hier aus ORD- gefolgt von Ziffern, sodass your_order_id niemals durchkommt.

    ORDER_RE = re.compile(r"^ORD-\d{4,}quot;)
    PURE_TOOL_RE = re.compile(r"^\s*<tool_call>(.*?)</tool_call>\s*quot;, re.DOTALL)
    
    def parse_executable_tool_call(output: str):
        cleaned = re.sub(r"<think>.*?</think>", "", output, flags=re.DOTALL).strip()
        match = PURE_TOOL_RE.match(cleaned)
        if not match:
            return None
        payload = json.loads(match.group(1).strip())
        order_id = str(payload.get("arguments", {}).get("order_id", "")).strip()
        if not ORDER_RE.match(order_id):
            return None
        return payload
    

    Verfeinern Sie auch die Beschreibung der Funktion, indem Sie angeben, dass sie nur dann aufgerufen werden soll, wenn der Kunde bereits eine konkrete Bestellnummer bereitgestellt hat. Nach dieser Änderung führte die E-Mail-Frage bei dem geschützten Ablauf überhaupt keinen Aufruf der Funktion durch und dauerte 3,5 Sekunden, im Vergleich zu 15,4 Sekunden bei der fehlerhaften Zwei-Schritte-Version. Die Antwort entschuldigte sich, gab an, keine Änderungen am Konto bearbeiten zu können, und verweies auf den Kundenservice. Das ist zwar weiterhin allgemein gehalten, aber ohne die update_email-Funktion sollte es ohnehin keinen automatischen Selbstbedienungsweg geben. Zumindest hörte es auf, so zu tun, als würde es eine Bestellung abrufen.

    Die erfolgreichen Abfragen funktionierten weiterhin, und ORD-9999 wurde weiterhin als verloren gemeldet. Das Modell wurde nicht intelligenter. Betrachten Sie dies eher als Sicherheitsgurt denn als echte Verbesserung.

    Was das nicht beweist

    Es handelt sich um eine Demo – ein echter Support-Copilot benötigt weitaus mehr als nur eine Funktion sowie ein Wörterbuch mit drei Bestellungen. Mehrere Behauptungen sollten zurückgewiesen werden:

    • Es handelt sich nicht um eine Multi-Tool-Planung. Ein Tool dient nur zur Abfrage, und ab etwa zwei Tools beginnen die 3B-Modelle, Order-ID-Einträge zu erfinden, um Schemata zu erfüllen, die sie nicht vollständig verstehen.
    • Die Datenbank ist falsch, daher waren Authentifizierung, personenbezogene Daten sowie die Frage „Kann dieser Benutzer diesen Auftrag sehen?“ niemals ein Problem des Modells.
    • Überall wurde Sampling aktiviert, wodurch ein erneuter Ausführung der E-Mail-Abfrage nicht zu denselben Ergebnissen führt. Was von Lauf zu Lauf wiederkehrte, war das Versagen selbst – ein Toolaufruf, der mit Platzhaltern gefüllt war. Die genaue Formulierung variiert, daher sollte man sich darauf nicht verlassen.
    • Nichts wurde mit einem Cloud-Modell verglichen oder gegen eine echte Exportdatei geprüft, sodass die Zahlen nur einen einzelnen Rechner beschreiben und keinen Vergleichswert darstellen.

    Das führt zu einer engeren Aussage als „Kleine Modelle können Unterstützung leisten“: Ein 3B-Modell kann ein gut beschriebenes Tool aufrufen, das Ergebnis zusammenfassen und sich danach zurückziehen – vorausgesetzt, der Toolaufruf selbst wird als etwas betrachtet, das einer Genehmigungsprüfung bedarf und nicht einfach deshalb vertrauenswürdig ist, weil das Modell ihn erzeugt hat.

    Ausführen

    Vom Projektverzeichnis aus, in dem die virtuelle Umgebung aktiv ist, führen Sie die drei Skripte nacheinander aus: den Vergleich mit und ohne Nachdenken, den einfachen Agenten sowie den geschützten Agenten.

    python llm-learning/smollm3-local-support-agent/code/think_vs_nothink.py
    python llm-learning/smollm3-local-support-agent/code/support_agent.py
    python llm-learning/smollm3-local-support-agent/code/support_agent_guarded.py
    

    Die Skripte erstellen Transkripte, die Sie später lesen können, um Ausführungen zu vergleichen.