
El problema
Usted tiene un Pregunte a, que le dice a un LLM: Devuelve JSON. Su código analiza este JSON. Alguien edita el prompt - renombra un campo, cambia un valor de String a Int, borra una clave. Tu código se rompe en tiempo de ejecución. En producción.
Esto ocurre continuamente. Los avisos viven en archivos Markdown. El código vive en Python. Nada conecta a los dos. El prompt dice "score", pero el código dice "rating". El indicador muestra "status": "done", pero el código comprueba "ready". El prompt tiene 5 campos, el código espera 6.
La brecha entre lo que tu prompt le dice al LLM y lo que tu código espera es un contrato. Si nadie pone a prueba este contrato, se romperá.
La solución: Pruebas contractuales inmediatas
Escriba a JSON-ejemplos en sus indicaciones. Añádalos a las pruebas. Valide la estructura contra lo que su código realmente lee.
Sin llamadas a la API. Sin mocking. Sin LLM. Sólo regex + JSON parsing + aserciones. Las pruebas se ejecutan en milisegundos.
Ejemplo mínimo
Supongamos que tiene un archivo de consulta que ordena a un LLM que analice los comentarios de los clientes:
<!-- prompts/analyze_feedback.md -->
# Analyse Kundenfeedback
Pruefe das Kundenfeedback und klassifiziere es.
## Output Format
Antworte ausschliesslich mit JSON:
```json
{
"status": "ready",
"sentiment": "positive",
"topics": ["pricing", "support"],
"urgency": 3,
"suggestions": {
"summary": "Kunde ist zufrieden mit dem Support, aber besorgt ueber Preise.",
"follow_up": true
}
}
```
Falls mehr Kontext noetig ist:
```json
{
"status": "needs_info",
"questions": ["Welches Produkt nutzt der Kunde?", "Wann trat das Problem auf?"],
"suggestions": {}
}
```Y tu código hace algo así:
# app/feedback.py
def process_feedback(llm_response: dict):
status = llm_response["status"] # muss existieren
if status == "ready":
sentiment = llm_response["sentiment"] # muss String sein
topics = llm_response["topics"] # muss Liste sein
urgency = llm_response["urgency"] # muss Int 1-5 sein
summary = llm_response["suggestions"]["summary"] # muss existieren
elif status == "needs_info":
questions = llm_response["questions"] # muss nicht-leere Liste seinHe aquí la prueba que combina ambas:
# tests/test_prompt_contracts.py
import json
import re
from pathlib import Path
PROMPTS_DIR = Path(__file__).resolve().parent.parent / "prompts"
def _read_prompt(filename: str) -> str:
path = PROMPTS_DIR / filename
assert path.exists(), f"Fehlt: {path}"
return path.read_text(encoding="utf-8")
def _extract_json_blocks(text: str) -> list[dict]:
"""Alle ```json ... ``` Bloecke aus einer Markdown-Datei extrahieren."""
pattern = r"```json\s*\n(.*?)```"
matches = re.findall(pattern, text, re.DOTALL)
results = []
for match in matches:
results.append(json.loads(match.strip()))
return results
# --- Hat der Prompt ueberhaupt parsebare JSON-Beispiele? ---
def test_prompt_has_output_format():
text = _read_prompt("analyze_feedback.md")
assert "## Output Format" in text
def test_json_blocks_are_valid():
text = _read_prompt("analyze_feedback.md")
examples = _extract_json_blocks(text)
assert len(examples) >= 2, "Brauche mindestens ein 'ready' und ein 'needs_info' Beispiel"
# --- Passen die Beispiele zu dem, was der Code liest? ---
def test_status_values_are_valid():
text = _read_prompt("analyze_feedback.md")
for example in _extract_json_blocks(text):
assert example["status"] in {"ready", "needs_info"}, (
f"Code prueft auf 'ready' oder 'needs_info', bekommen: '{example['status']}'"
)
def test_ready_example_has_required_fields():
text = _read_prompt("analyze_feedback.md")
ready = [e for e in _extract_json_blocks(text) if e["status"] == "ready"]
assert len(ready) > 0, "Kein 'ready'-Beispiel im Prompt"
for example in ready:
# Genau die Felder, die process_feedback() liest
assert "sentiment" in example
assert isinstance(example["sentiment"], str)
assert "topics" in example
assert isinstance(example["topics"], list)
assert "urgency" in example
assert isinstance(example["urgency"], int)
assert 1 <= example["urgency"] <= 5
assert "summary" in example["suggestions"]
def test_needs_info_has_questions():
text = _read_prompt("analyze_feedback.md")
needs_info = [e for e in _extract_json_blocks(text) if e["status"] == "needs_info"]
assert len(needs_info) > 0, "Kein 'needs_info'-Beispiel im Prompt"
for example in needs_info:
assert "questions" in example
assert isinstance(example["questions"], list)
assert len(example["questions"]) > 0, "Leere Fragenliste"Ejecutar:
pytest tests/test_prompt_contracts.py -vtest_prompt_has_output_format PASSED
test_json_blocks_are_valid PASSED
test_status_values_are_valid PASSED
test_ready_example_has_required_fields PASSED
test_needs_info_has_questions PASSEDLo que esto revela
Imagina que alguien edita el aviso y lo nombra "urgency" en "priority" alrededor. O cambia"status": "ready" a "status": "complete". O borrar el "summary"-campo de las sugerencias.
Las pruebas fallan inmediatamente. Antes del despliegue. Antes de que el LLM vea el nuevo prompt. Antes de que un cliente detecte el fallo.
Ejemplos reales de la deriva puntual que capta este patrón:
| Qué cambia en el prompt | Lo que se rompe en el código | Prueba que lo recoge |
|---|---|---|
urgencyha pasado a llamarse priority | KeyError: 'urgency' | test_ready_example_has_required_fields |
"ready"cambiado a "complete" | if status == "ready" | test_status_values_are_valid |
suggestions.summary eliminado | KeyError: 'summary' | test_ready_example_has_required_fields |
urgency de Int a Cadena | El código hace urgency > 3 en una cadena | test_ready_example_has_required_fields |
| Ejemplo JSON completamente borrado | LLM devuelve un formato impredecible | test_json_blocks_are_valid |
Escala
Para varias solicitudes: parametrizar.
PROMPTS = {
"analyze_feedback.md": "feedback",
"classify_ticket.md": "ticket",
"summarize_report.md": "report",
}
@pytest.mark.parametrize("filename", PROMPTS.keys())
def test_has_output_format(filename):
text = _read_prompt(filename)
assert "## Output Format" in text
@pytest.mark.parametrize("filename", PROMPTS.keys())
def test_json_is_valid(filename):
text = _read_prompt(filename)
for block in _extract_json_blocks(text):
assert "status" in block # jeder Prompt muss Status zurueckgebenA continuación, clases de prueba específicas para los campos que sólo aparecen en esta solicitud:
class TestFeedbackContract:
def test_has_sentiment(self):
...
class TestTicketContract:
def test_has_priority_level(self):
...El principio
Los ejemplos JSON en el prompt son los siguientes Especificación. Los accesos de campo en el código son los Aplicación. La prueba combina ambos.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ prompt.md │────▶│ test.py │◀────│ app.py │
│ (JSON-Spec) │ │ (Vertrag) │ │ (liest JSON)│
└─────────────┘ └──────────────┘ └─────────────┘Si el indicador cambia, la prueba falla. Si el código lee otros campos, se actualiza la prueba, lo que obliga a comprobar el indicador. El contrato permanece sincronizado.
Lo que NO se comprueba
Este patrón valida Estructura, no Calidad. Responde:
- ¿Muestra el indicador al LLM qué campos debe devolver? Sí/No.
- ¿Coinciden estos campos con lo que analiza el código? Sí/No.
- ¿Son correctos los tipos (String, Int, List)? Sí/No.
- ¿Son válidos los valores enum („listo“ en lugar de „completo“)? Sí/no.
No responde:
- ¿Sigue el LLM la indicación de forma fiable? (Esto requiere pruebas de integración).
- ¿Está bien escrito? (Para eso hace falta gente).
- ¿Alucina el LLM los valores de los campos? (Esto requiere validación de salida en el código).
Pero la deriva estructural es la causa número uno de los errores de producción relacionados con la prontitud, y este patrón lo atrapa sin coste y sin latencia. Se ejecuta en CI, con cada commit, antes de cada despliegue.
Pruébalo
Output Formatescriba en sus archivos prompt con ejemplos JSON_extract_json_blocks()implementar (15 líneas, simplemente copiar de arriba)- Compruebe que los campos que lee su código existen en los ejemplos
- Integrar en CI
Este es el patrón completo. Sin framework, sin librerías, sin dependencias. Sólo Regex, JSON y pytest.
