Dies ist die vollstandige Implementierung des Beispiels aus dem Artikel Warum Agenten scheitern: LLM-Grenzen.
Wenn du den Artikel noch nicht gelesen hast, beginne dort. Hier konzentrieren wir uns auf den Code: wie sich das Verhalten des Agents trotz der naturlichen Grenzen des Modells stabiler machen lasst.
Was Dieses Beispiel Zeigt
- LLM kann beim Format Fehler machen oder Quellenverweise erfinden
- Der Agent muss die Antwort deterministisch prufen und darf dem Text nicht nur "auf den ersten Blick" vertrauen
- Niedrige Modellkonfidenz soll die Aufgabe in einen Handoff an einen Menschen uberfuhren
- Schritt-Limit und begrenzter Kontext sind auch fur einen einfachen Fall erforderlich
Projektstruktur
foundations/
└── llm-limits-agents/
└── python/
├── main.py # agent loop with retry + handoff
├── llm.py # model call
├── validator.py # strict output validation
├── knowledge.py # local KB for grounding
└── requirements.txt
Die Aufteilung in Module ist wichtig: Das Modell generiert, wahrend Validator und Policy Entscheidungen treffen.
Ausfuhren
1. Repository klonen und in den Ordner wechseln:
git clone https://github.com/AgentPatterns-tech/agentpatterns.git
cd foundations/llm-limits-agents/python
2. Abhangigkeiten installieren:
pip install -r requirements.txt
3. API-Key setzen:
export OPENAI_API_KEY="sk-..."
4. Starten:
python main.py
Was Wir Im Code Bauen
Wir bauen einen einfachen Guarded-Loop, um eine Kundenfrage zu beantworten.
- wir holen relevante Ausschnitte aus einer kontrollierten Wissensbasis
- wir geben dem Modell einen klaren JSON-Antwortvertrag
- der Validator pruft Format, Zitate und Konfidenzniveau
- wenn die Antwort die Prufung nicht besteht, machen wir Retry; ist die Konfidenz niedrig, eskalieren wir an einen Menschen
Dies ist ein Lernbeispiel, bei dem nicht die "schone Antwort" zahlt, sondern kontrollierbares und uberprufbares Agentenverhalten.
Code
knowledge.py — kontrollierte Quellen
from typing import Any
KB = [
{
"id": "KB-101",
"title": "Refund Policy",
"text": "Refunds are available within 14 days after payment. "
"After 14 days, refunds are not available.",
},
{
"id": "KB-102",
"title": "Pro Plan",
"text": "Pro customers have priority support and a 4-hour SLA.",
},
{
"id": "KB-103",
"title": "Free Plan",
"text": "Free customers get business-hours support with no SLA.",
},
]
def search_kb(question: str, limit: int = 2) -> list[dict[str, Any]]:
q = question.lower()
scored: list[tuple[int, dict[str, Any]]] = []
for item in KB:
score = 0
text = f"{item['title']} {item['text']}".lower()
for token in ("refund", "pro", "free", "sla", "support"):
if token in q and token in text:
score += 1
scored.append((score, item))
scored.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in scored[:limit]]
def build_context(snippets: list[dict[str, Any]], max_chars: int = 700) -> str:
parts: list[str] = []
total = 0
for s in snippets:
line = f"[{s['id']}] {s['title']}: {s['text']}\n"
if total + len(line) > max_chars:
break
parts.append(line)
total += len(line)
return "".join(parts).strip()
LLM antwortet nur auf Basis dieses Kontexts. Das verringert den Spielraum fur Erfindungen.
validator.py — strikte Validierung der Modellausgabe
import json
from dataclasses import dataclass
from typing import Any
@dataclass
class ValidationResult:
ok: bool
data: dict[str, Any] | None
errors: list[str]
def validate_model_output(raw: str, allowed_sources: set[str]) -> ValidationResult:
errors: list[str] = []
try:
data = json.loads(raw)
except json.JSONDecodeError:
return ValidationResult(False, None, ["invalid JSON"])
if not isinstance(data, dict):
return ValidationResult(False, None, ["output must be a JSON object"])
answer = data.get("answer")
citations = data.get("citations")
confidence = data.get("confidence")
needs_human = data.get("needs_human")
if not isinstance(answer, str) or not answer.strip():
errors.append("answer must be non-empty string")
if not isinstance(citations, list) or not all(isinstance(x, str) for x in citations):
errors.append("citations must be a list of strings")
else:
unknown = [x for x in citations if x not in allowed_sources]
if unknown:
errors.append(f"unknown citations: {unknown}")
if not isinstance(confidence, (int, float)) or not (0 <= float(confidence) <= 1):
errors.append("confidence must be a number in [0, 1]")
if not isinstance(needs_human, bool):
errors.append("needs_human must be boolean")
return ValidationResult(len(errors) == 0, data if len(errors) == 0 else None, errors)
Das schutzt vor "selbstsicherem Mull": Eine Antwort kann gut aussehen und trotzdem ungueltig sein.
llm.py — Modellaufruf mit klarem Vertrag
import os
from openai import OpenAI
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise EnvironmentError(
"OPENAI_API_KEY is not set.\n"
"Run: export OPENAI_API_KEY='sk-...'"
)
client = OpenAI(api_key=api_key)
SYSTEM_PROMPT = """
You are a support agent.
Reply with VALID JSON only in this format:
{
"answer": "short answer",
"citations": ["KB-101"],
"confidence": 0.0,
"needs_human": false
}
Use only sources that exist in the provided context.
If data is insufficient, set needs_human=true.
""".strip()
def ask_model(question: str, context: str, feedback: str | None = None) -> str:
user_prompt = (
f"Customer question:\n{question}\n\n"
f"Context:\n{context}\n\n"
"Return JSON only."
)
if feedback:
user_prompt += f"\n\nFix your previous response using this error feedback: {feedback}"
completion = client.chat.completions.create(
model="gpt-4.1-mini",
temperature=0,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
content = completion.choices[0].message.content
return (content or "").strip()
Wir bitten nicht darum, "einfach zu erklaren". Wir definieren einen strikten Vertrag und prufen ihn.
main.py — Agentenzyklus mit Retry, Confidence-Gate und Handoff
from knowledge import build_context, search_kb
from llm import ask_model
from validator import validate_model_output
MAX_STEPS = 4
MIN_CONFIDENCE = 0.65
QUESTION = "Can I get a refund for my subscription if 10 days have passed since payment?"
def run():
snippets = search_kb(QUESTION, limit=2)
allowed_sources = {s["id"] for s in snippets}
context = build_context(snippets, max_chars=700)
print("Allowed sources:", sorted(allowed_sources))
print("Context:")
print(context)
feedback: str | None = None
for step in range(1, MAX_STEPS + 1):
print(f"\n=== STEP {step} ===")
raw = ask_model(QUESTION, context, feedback=feedback)
print("Model raw output:", raw)
validation = validate_model_output(raw, allowed_sources)
if not validation.ok:
print("Validation failed:", validation.errors)
feedback = "; ".join(validation.errors)
continue
data = validation.data
assert data is not None
if data["needs_human"] or data["confidence"] < MIN_CONFIDENCE:
print("\nHandoff required:")
print(
"Model confidence is too low. Escalate this case to a human "
f"(confidence={data['confidence']})."
)
return
print("\nFinal answer:")
print(data["answer"])
print("Citations:", data["citations"])
return
print("\nStop: MAX_STEPS reached without a valid answer. Escalate to human.")
if __name__ == "__main__":
run()
Das ist die Kernidee: Der Agent vertraut dem Modell ohne Prufung nicht, selbst wenn der Text uberzeugend wirkt.
requirements.txt
openai>=1.0.0
Beispielausgabe
python main.py
Allowed sources: ['KB-101', 'KB-102']
Context:
[KB-101] Refund Policy: Refunds are available within 14 days after payment. After 14 days, refunds are not available.
[KB-102] Pro Plan: Pro customers have priority support and a 4-hour SLA.
=== STEP 1 ===
Model raw output: {
"answer": "Yes, you can get a refund for your subscription if 10 days have passed since payment, as refunds are available within 14 days after payment.",
"citations": ["KB-101"],
"confidence": 1.0,
"needs_human": false
}
Final answer:
Yes, you can get a refund for your subscription if 10 days have passed since payment, as refunds are available within 14 days after payment.
Citations: ['KB-101']
Hinweis: Der genaue
raw outputdes Modells kann zwischen Ausfuhrungen variieren.
Korrektheitskriterium des Beispiels: Der Validator filtert ungueltige/halluzinierte Antworten heraus, und die finale Antwort besteht die Prufung.
Was Man In Der Praxis Sieht
| Naiver Ansatz | Guarded-Ansatz | |
|---|---|---|
| Akzeptiert jeden Modelltext | ✅ | ❌ |
| Validiert Format und Quellen | ❌ | ✅ |
| Hat Fallback bei niedriger Konfidenz | ❌ | ✅ |
| Hat ein Schritt-Limit | ❌ | ✅ |
Wo Du Als Nachstes Vertiefen Kannst
- Fuge ein separates
max_tokens_per_run-Budget hinzu und logge die tatsachliche Nutzung - Fuge ein zweites Reviewer-Modell als unabhangiges Quality Gate hinzu
- Fuge
needs_human_reasonhinzu, damit der Operator den Eskalationsgrund sieht - Fuge Replay-Tests mit festen raw outputs des Modells hinzu
Vollstandiger Code auf GitHub
Im Repository liegt die vollstandige Version dieser Demo: Retrieval-Kontext, Quality-Gate-Prufungen und Eskalation an einen Menschen.
Vollstandigen Code auf GitHub ansehen ↗