20 min

Veto und Abstain

Ein Router ohne Schwellwert routet auch Unsinn, die Ablehnung ist ein Feature.

Meilenstein „mach mir ein butterbrot“ wird ehrlich abgelehnt statt geroutet, und ein Counter-Example zieht seinen Agenten aus der Liste.

Der Router aus Kapitel 03 hat einen Fehler, den man nicht sieht, solange man ihn mit sinnvollen Anfragen füttert: Er antwortet immer. Ein Retrieval liefert Top-k, und Top-k ist auch dann gefüllt, wenn nichts passt. Frag ihn nach einem Butterbrot, und er schickt dich zum docs-finder, weil der von allen drei am wenigsten unpassend ist.

Das ist derselbe Kalibrierungsfehler wie bei einem Sprachmodell, das auf „kannst du das?“ grundsätzlich ja sagt. Nur an einer Stelle, an der man ihn reparieren kann - hier steht eine Zahl, kein Modell.

Das Ziel

Ein Router ohne Schwellwert routet auch Unsinn. Die Ablehnung ist kein Mangel, sondern das Feature, das den Router überhaupt erst benutzbar macht: Nur wer nein sagen kann, dessen Ja etwas bedeutet.

Schritt 1: Der Veto-Index

Die counter_examples aus deinen Cards liegen bisher ungenutzt herum. Sie kommen in einen eigenen Index - der Grund steht in Kapitel 02, und er ist wichtig genug für eine Wiederholung: „Ich mache keine Web-Suche“ enthält das Wort Web-Suche. Im Positiv-Index läge damit ausgerechnet der Satz, der eine Anfrage ausschließen soll, ganz nah an dieser Anfrage.

Getrennt indexiert dreht sich die Wirkung um. Ein Treffer im Veto-Index heißt nicht „passt gut“, sondern „der nicht“.

# tiny/concierge.py (erweitert; zusaetzlich noetig: import json, from pathlib import Path)
THETA_VETO = 0.72     # ab hier gilt ein Counter-Example als Ausschluss
THETA_ACCEPT = 0.62   # darunter: Abstain
DELTA_AMBIG = 0.04    # Abstand, unter dem zwei Agenten gleichwertig sind


class Concierge:
    def __init__(self, cards: dict[str, Card], gap_log: Path | None = None) -> None:
        self.cards = cards
        self.gap_log = gap_log or Path("gaps.jsonl")
        self.positiv = Index()
        self.veto = Index()
        for card in cards.values():
            self.positiv.hinzufuegen(card.id, card.examples)
            self.veto.hinzufuegen(card.id, card.counter_examples)
        self.positiv.bauen()
        self.veto.bauen()

    def route(self, ziel: str) -> Entscheidung:
        treffer = self.positiv.suchen(ziel, limit=20)
        veto_treffer = self.veto.suchen(ziel, limit=10)

        # Binaer, nicht als Punktabzug: wer hier ueber der Schwelle liegt,
        # ist raus - egal wie gut sein bester Positivtreffer war.
        gesperrt = {t.agent_id for t in veto_treffer if t.score >= THETA_VETO}

        beste: dict[str, float] = {}
        for t in treffer:
            if t.agent_id in gesperrt:
                continue
            beste[t.agent_id] = max(beste.get(t.agent_id, 0.0), t.score)

        rangliste = sorted(beste.items(), key=lambda p: -p[1])

        if not rangliste or rangliste[0][1] < THETA_ACCEPT:
            self._luecke_merken(ziel, rangliste)
            return Entscheidung("abstain", None, rangliste[0][1] if rangliste else 0.0, [])

        if len(rangliste) > 1 and rangliste[0][1] - rangliste[1][1] < DELTA_AMBIG:
            return Entscheidung("ambiguous", rangliste[0][0], rangliste[0][1], rangliste[1:3])

        return Entscheidung("match", rangliste[0][0], rangliste[0][1], rangliste[1:3])

    def _luecke_merken(self, ziel: str, rangliste: list[tuple[str, float]]) -> None:
        eintrag = {
            "ziel": ziel,
            "bester_kandidat": rangliste[0][0] if rangliste else None,
            "bester_score": round(rangliste[0][1], 4) if rangliste else 0.0,
        }
        with self.gap_log.open("a", encoding="utf-8") as datei:
            datei.write(json.dumps(eintrag, ensure_ascii=False) + "\n")

Der gesperrt-Filter steht vor der Punktevergabe, nicht danach. Ein Veto, das man später wieder wegrechnen kann, ist kein Veto.

Schritt 2: Drei Antworten statt einer

Der Router gibt jetzt drei verschiedene Dinge zurück, und der Unterschied ist für den aufrufenden Agenten wichtig:

ArtBedeutungWas der Host tut
matchgenau einer passtdelegieren
ambiguouszwei liegen gleichaufnachfragen, oder beide Karten zeigen
abstainkeiner passtehrlich sagen, dass es dafür niemanden gibt

ambiguous ist die Antwort, die man am liebsten wegoptimiert. Tu es nicht. Sie sagt etwas Wahres über deine Registry: Zwei Cards sind zu ähnlich geschnitten, und keine Schwellwertfummelei ändert daran etwas. DELTA_AMBIG ist damit weniger ein Router-Parameter als ein Messgerät für deinen Zuschnitt.

Schritt 3: Die Schwellen kalibrieren

Die drei Zahlen oben sind Startwerte, keine Wahrheiten. Sie hängen an deinem Embedding-Modell: Manche Modelle drücken Ähnlichkeiten in ein enges Band zwischen 0,6 und 0,9, andere spreizen von 0,1 bis 0,95. Ein THETA_ACCEPT von 0,62 kann bei dem einen Modell alles durchlassen und bei dem anderen alles abweisen.

Also durchprobieren. Erweitere dein Testset aus Kapitel 03 um sechs Anfragen, die nichts treffen sollen:

UNGEDECKT = [
    "mach mir ein butterbrot",
    "wie wird das wetter morgen in hamburg",
    "uebersetz das mal ins italienische",
    "ruf den kunden an und frag nach",
    "wie viel ist 17 prozent von 4200",
    "loesch das ganze projekt",
]
# scripts/04_schwellen_sweep.py
import numpy as np

from tiny import concierge as c
from scripts.router_eval import TESTSET, UNGEDECKT   # Testsets ausgelagert


def bewerten(concierge, theta_accept: float) -> tuple[float, float]:
    c.THETA_ACCEPT = theta_accept
    treffer = sum(concierge.route(a).agent_id == e for a, e in TESTSET)
    ablehnungen = sum(concierge.route(a).art == "abstain" for a in UNGEDECKT)
    return treffer / len(TESTSET), ablehnungen / len(UNGEDECKT)


def main() -> None:
    concierge = c.concierge_laden()
    print(f"{'theta':>7}{'Recall@1':>11}{'Abstain':>10}{'F1':>8}")
    for theta in np.arange(0.40, 0.86, 0.02):
        recall, abstain = bewerten(concierge, float(theta))
        f1 = 0.0 if recall + abstain == 0 else 2 * recall * abstain / (recall + abstain)
        print(f"{theta:>7.2f}{recall:>11.2f}{abstain:>10.2f}{f1:>8.2f}")


if __name__ == "__main__":
    main()

Was du siehst, ist eine Kurve mit einem Buckel: Bei niedrigem Schwellwert routest du alles richtig und allen Unsinn mit. Bei hohem lehnst du jeden Unsinn ab und die Hälfte der echten Anfragen gleich mit. Der beste F1-Wert markiert die Stelle dazwischen. Setz THETA_ACCEPT dorthin und schreib die Zahl mitsamt Datum und Modellnamen in dein README - sie gilt nur für dieses Embedding-Modell.

Schritt 4: Die Ablehnungen sind die Bauliste

Der gaps.jsonl-Schreiber oben sieht nach Buchhaltung aus. Er ist der unterschätzteste Teil der ganzen Architektur.

Jede Zeile darin ist eine Anfrage, die ein echter Nutzer gestellt hat und für die dein System niemanden hatte. Nach zwei Wochen Betrieb sieht die Datei so aus:

 47x  "termin verschieben mit ..."          -> Kandidat: calendar-writer
 31x  "wie war der umsatz im ..."           -> Kandidat: reporting
 12x  "uebersetz das mal ins italienische"  -> Kandidat: translator

Das ist keine Fehlerliste, das ist eine nach echter Nachfrage priorisierte Produkt-Roadmap. Ein System, das jede Anfrage irgendwohin routet, hätte diese Information nie erzeugt - die 47 Terminanfragen wären als schlechte Antworten des docs-finder verschwunden.

Das Zusammenfassen dieser Zeilen zu Themen ist ein eigenes kleines Projekt und gehört nicht in diesen Kurs (siehe den Ausblick in Kapitel 08). Die Datei schreiben musst du trotzdem jetzt - später hast du die Daten nicht mehr.

Der Meilenstein

python -c "
from tiny.concierge import concierge_laden
c = concierge_laden()
for anfrage in ['mach mir ein butterbrot',
                'zieh mir die preisliste von deren website',
                'google mal wer da geschaeftsfuehrer ist']:
    e = c.route(anfrage)
    print(f'{e.art:<10} {e.agent_id or \"-\":<14} {e.score:.3f}  {anfrage}')
"
abstain    -              0.412  mach mir ein butterbrot
match      web-reader     0.751  zieh mir die preisliste von deren website
abstain    -              0.583  google mal wer da geschaeftsfuehrer ist

Zwei Dinge müssen stimmen. Das Butterbrot wird abgelehnt, nicht geroutet. Und die dritte Zeile ist die interessantere: „google mal“ ist ein Counter-Example des web-reader. Ohne Veto-Index hätte er hier gewonnen - er kann ja irgendwas mit Webseiten. Mit Veto-Index ist er raus, und weil ihn keiner ersetzt, endet die Anfrage ehrlich im gaps.jsonl.

Schau danach in die Datei:

cat gaps.jsonl

Zwei Zeilen. Deine ersten beiden Kandidaten für Agent Nummer vier und fünf.