30 min

Brief, Digest, Budget

Ein Budget, das im Systemprompt steht, ist keins.

Meilenstein Eine Seite mit 38.000 Token kommt als Digest unter 800 Token zurück, mit genau einem Reparaturversuch am Schema.

Der Runner aus Kapitel 05 läuft, aber er gibt Fließtext zurück. Damit ist er ein kleinerer Agent, kein Tiny Agent - die Verdichtung fehlt.

In diesem Kapitel bekommt die Delegation ihre Form: Der Aufrufer schickt einen Brief, der Agent liefert einen Digest, und dazwischen steht ein Budget, das nicht verhandelbar ist.

Das Ziel

Ein Budget, das im Systemprompt steht, ist keins. „Fasse dich kurz“ wird von jedem Modell zuverlässig ignoriert, sobald der Inhalt interessant wird. Die Kompressionsgrenze ist der einzige Grund, warum diese Architektur überhaupt existiert. Sie muss außerhalb des Modells durchgesetzt werden.

Schritt 1: Der Brief

Der Aufrufer übergibt nicht die Aufgabe, sondern wonach er sucht. Der Unterschied wirkt akademisch, bis man ihn einmal gebaut hat:

# tiny/protokoll.py
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass
class Brief:
    goal: str                                    # ein Satz, wird fuer das Routing eingebettet
    must_return: list[str] = field(default_factory=list)   # Feldnamen, die belegt sein muessen
    inputs: dict = field(default_factory=dict)   # konkrete Eingaben (URLs, Suchbegriffe)
    context_hint: str = ""                       # hoechstens ~200 Token, KEIN Dialogverlauf
    max_output_tokens: int | None = None         # darf das Card-Budget nur senken

    def als_auftrag(self) -> str:
        zeilen = [f"ZIEL: {self.goal}"]
        if self.must_return:
            zeilen.append("PFLICHTFELDER: " + ", ".join(self.must_return))
        if self.inputs:
            zeilen.append("EINGABEN: " + json.dumps(self.inputs, ensure_ascii=False))
        if self.context_hint:
            zeilen.append(f"HINTERGRUND: {self.context_hint[:800]}")
        return "\n".join(zeilen)

must_return ist das Feld, das die Sache trägt. Der Aufrufer muss sagen, welche Felder er am Ende braucht - und weil er das sagen muss, merkt er beim Formulieren, wenn er es selbst nicht weiß. Mehrdeutigkeit, die sonst erst in der Antwort auffällt, fällt hier auf.

context_hint ist gedeckelt, und die Grenze ist der Punkt. Wer hier den Dialogverlauf durchreicht, hat die Kompressionsgrenze auf dem Hinweg wieder eingerissen.

Schritt 2: Der Digest

# tiny/protokoll.py (Fortsetzung)
@dataclass
class Digest:
    agent_id: str
    status: str                                  # ok | partial | refused | failed
    summary: str
    fields: dict = field(default_factory=dict)
    artifacts: list[dict] = field(default_factory=list)   # ab Kapitel 07
    notes_for_caller: str = ""
    cost: dict = field(default_factory=dict)

Die fünf Status-Werte sind kein Zierrat, sie steuern das Verhalten des Aufrufers:

StatusBedeutungReaktion des Aufrufers
okalle Pflichtfelder belegtweiterarbeiten
partialteilweise gelöst, Felder fehlennachfassen oder anderen Agenten fragen
refusedAgent hält sich für unzuständigneu routen, diesen ausschließen
failedtechnischer Fehler oder Budget erschöpftwiederholen oder Nutzer informieren
needs_confirmationSeiteneffekt braucht FreigabeRückfrage, dann erneut

refused ist der wichtigste davon. Der Router kann sich irren, und ein Agent, der seine eigene Card kennt, ist die zweite Verteidigungslinie. Ohne diesen Status würde er stattdessen irgendetwas antworten - und eine falsche Antwort ist teurer als eine Absage.

Schritt 3: Schema-Zwang mit genau einem Reparaturversuch

Ein Modell, das JSON liefern soll, liefert manchmal JSON mit Vorwort. Oder in einem Codeblock. Oder mit einem fehlenden Feld. Die Antwort darauf ist eine Reparaturrunde - und zwar genau eine:

# tiny/runner.py (erweitert)
import re

JSON_BLOCK = re.compile(r"\{.*\}", re.S)


def digest_lesen(text: str, pflichtfelder: list[str]) -> tuple[dict | None, list[str]]:
    """Gibt (Objekt, Fehlerliste) zurueck. Objekt ist None, wenn nichts zu retten war."""
    fund = JSON_BLOCK.search(text or "")
    if not fund:
        return None, ["Keine JSON-Struktur in der Antwort gefunden."]
    try:
        objekt = json.loads(fund.group(0))
    except json.JSONDecodeError as fehler:
        return None, [f"JSON nicht lesbar: {fehler}"]
    fehlend = [f for f in pflichtfelder if f not in objekt or objekt[f] in (None, "")]
    return objekt, [f"Pflichtfeld '{f}' fehlt oder ist leer." for f in fehlend]

Und in der Schleife aus Kapitel 05, an der Stelle, an der der Agent keine Werkzeuge mehr aufruft:

        aufrufe = nachricht.get("tool_calls") or []
        if not aufrufe:
            objekt, fehler = digest_lesen(nachricht.get("content", ""), card.returns_required)
            if not fehler:
                return budget_durchsetzen(card, objekt, antwort["usage"])

            if repariert:
                # Schon einmal probiert. Zweimal ist Trotz, nicht Robustheit.
                return Ergebnis(status="failed", grund="schema", fehler=fehler)

            repariert = True
            ctx.append(
                {
                    "role": "user",
                    "content": (
                        "Deine Antwort war nicht verwendbar:\n- "
                        + "\n- ".join(fehler)
                        + "\nGib jetzt NUR das JSON-Objekt aus, nichts sonst."
                    ),
                }
            )
            continue

Warum genau ein Versuch? Weil der zweite fast nie hilft. Ein Modell, das nach einer expliziten Fehlermeldung immer noch kein gültiges JSON produziert, hat kein Formatierungsproblem, sondern ein Verständnisproblem - und dann ist die dritte Runde nur teurer als die zweite. Ein failed mit Begründung ist an dieser Stelle das ehrlichere Ergebnis.

Schritt 4: Das Budget durchsetzen

Jetzt der Kern des Kapitels. Vier Stellen, an denen das Budget wirkt, und keine davon ist eine Bitte an das Modell:

# tiny/runner.py (Fortsetzung)
def budget_durchsetzen(card, objekt: dict, usage: dict) -> Ergebnis:
    grenze_zeichen = card.budget.max_output_tokens * 4   # grobe Naeherung: 1 Token ~ 4 Zeichen
    status = objekt.get("status", "ok")
    zusammenfassung = str(objekt.get("summary") or objekt.get("answer") or "")

    if len(zusammenfassung) > grenze_zeichen:
        # HART. Nicht neu zusammenfassen lassen - das kostet einen weiteren
        # Aufruf und liefert wieder etwas, das zu lang sein kann.
        zusammenfassung = zusammenfassung[:grenze_zeichen].rstrip() + " [gekuerzt]"
        status = "partial"

    return Ergebnis(
        agent_id=card.id,
        status=status,
        summary=zusammenfassung,
        fields={k: v for k, v in objekt.items() if k in card.returns_required},
        cost={
            "tokens_in": usage.get("prompt_tokens", 0),
            "tokens_out": usage.get("completion_tokens", 0),
        },
    )

Die vier Stellen im Überblick:

  1. max_tokens im Aufruf. Der Server hört auf zu generieren. Verhindert das Weglaufen, schneidet aber mitten im Satz ab.
  2. Die Schere nach der Validierung (oben). Fängt ab, was die erste Stelle durchgelassen hat, und setzt ehrlich auf partial.
  3. max_tool_calls als Schleifenabbruch. Schon in Kapitel 05 gebaut.
  4. max_wall_seconds als Zeitlimit. Beim synchronen Client ist das der timeout in httpx; wer die Schleife asynchron baut, nimmt asyncio.wait_for.

Dass Punkt 2 den Status auf partial setzt, statt still zu kürzen, ist die Stelle, an der dieses System nicht lügt. Der Aufrufer erfährt, dass er eine beschnittene Antwort in der Hand hält - und kann in Kapitel 07 gezielt nachfragen.

Der Meilenstein

Nimm die längste Seite, die du finden kannst - ein umfangreicher Wikipedia-Artikel liegt gut über 30.000 Token.

# scripts/06_digest_test.py
from tiny.cards import registry_laden
from tiny.protokoll import Brief
from tiny.runner import invoke
from tiny.tools import http_get, html_to_text

URL = "https://de.wikipedia.org/wiki/Zweiter_Weltkrieg"

roh = html_to_text(http_get(URL))
print(f"Seite als Text: ~{len(roh) // 4} Token")

brief = Brief(
    goal="Ermittle, wann der Krieg begann und wann er endete",
    must_return=["beginn", "ende"],
    inputs={"urls": [URL]},
)

card = registry_laden()["web-reader"]
ergebnis = invoke(card, brief.als_auftrag())

print(f"Digest:         ~{len(ergebnis['summary']) // 4} Token")
print(f"Status:         {ergebnis['status']}")
print(f"Felder:         {ergebnis['fields']}")
print(f"Verbraucht:     {ergebnis['cost']}")
Seite als Text: ~38412 Token
Digest:         ~74 Token
Status:         ok
Felder:         {'beginn': '1. September 1939', 'ende': '2. September 1945'}
Verbraucht:     {'tokens_in': 39104, 'tokens_out': 118}

Der Stand ist erreicht, wenn der Digest deutlich unter dem Card-Budget liegt und die Pflichtfelder belegt sind. Beachte den Unterschied zwischen den beiden Token-Zahlen unten: Der Tiny Agent hat 39.104 Token verbraucht. Der Aufrufer bekommt 118. Das ist die Kompressionsgrenze, und man sieht sie nur, weil hier beide Zahlen stehen.

Bau anschließend den Fehlerfall nach: Setz max_output_tokens in der Card auf 80 und lauf noch mal. Der Status muss auf partial springen und die Zusammenfassung auf [gekuerzt] enden. Wenn das passiert, ist dein Budget hart - und nicht bloß gut gemeint.

Und dann die Frage, die dieses Kapitel offen lässt: Der Agent hat 38.000 Token gelesen und 118 weitergegeben. Was ist mit den anderen 37.882? Er hat sie weggeworfen - und er wusste dabei nicht, was der Aufrufer eigentlich vorhatte. Genau das repariert Kapitel 07.