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:
| Status | Bedeutung | Reaktion des Aufrufers |
|---|---|---|
ok | alle Pflichtfelder belegt | weiterarbeiten |
partial | teilweise gelöst, Felder fehlen | nachfassen oder anderen Agenten fragen |
refused | Agent hält sich für unzuständig | neu routen, diesen ausschließen |
failed | technischer Fehler oder Budget erschöpft | wiederholen oder Nutzer informieren |
needs_confirmation | Seiteneffekt braucht Freigabe | Rü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:
max_tokensim Aufruf. Der Server hört auf zu generieren. Verhindert das Weglaufen, schneidet aber mitten im Satz ab.- Die Schere nach der Validierung (oben). Fängt ab, was die erste Stelle
durchgelassen hat, und setzt ehrlich auf
partial. max_tool_callsals Schleifenabbruch. Schon in Kapitel 05 gebaut.max_wall_secondsals Zeitlimit. Beim synchronen Client ist das dertimeoutinhttpx; wer die Schleife asynchron baut, nimmtasyncio.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.