> Nivel 11 de Agent Harness Patterns, un recorrido de patrones sobre cómo funcionan los agentes de IA. Versión web: https://harnesspatterns.dev/es/patterns/observability · Todos los patrones (en inglés): https://harnesspatterns.dev/llms.txt

# Observabilidad y evaluaciones

Una traza muestra qué hizo el agente en una ejecución. Una evaluación lo califica en muchas ejecuciones. Necesitas las dos: la evaluación te dice que algo se rompió, la traza te dice por qué.

## El problema

Un agente no es una función que puedas leer de arriba abajo. El modelo decide los pasos en tiempo de ejecución, y el mismo prompt puede tomar otro camino mañana. Cuando un usuario dice “reservó lo que no era”, necesitas saber qué hizo, paso a paso. Y cuando cambias el prompt, una herramienta o el modelo, necesitas saber si lo mejoraste o lo empeoraste.

Ese es Blindspot, la caja negra. Parece funcionar, hasta que deja de hacerlo, y entonces nadie puede decir qué pasó, cuánto costó ni desde cuándo. Probar unos cuantos prompts a mano y decir “se ve bien” es la forma en que Blindspot llega a producción.

## La solución

**Observabilidad** significa registrar cada ejecución como una **traza**: un árbol de **spans**, uno por cada unidad de trabajo, cada uno con cuánto tardó y cuánto costó. La ejecución es un span; también lo es cada turno, cada llamada al modelo y cada herramienta. Un span que salió mal se marca como error. Cada bucle de este sitio ya emite los eventos; un tracer solo escucha el EventBus y les toma el tiempo. Envía los spans a cualquier herramienta de OpenTelemetry (el estándar abierto para trazas) y obtienes la línea de tiempo de la animación.

Las **evaluaciones** (evals) son tests para agentes. Un **dataset** guarda los casos: un prompt, y cómo se ve una buena ejecución. Corres cada caso con un agente nuevo, y los **scorers** califican cada ejecución. La proporción de casos que pasan es tu número a vigilar. Hay cuatro tipos de scorer, y una buena evaluación los mezcla:

- **La respuesta**
   Comparar el texto final con lo que espera el caso: exacto, por una palabra que debe contener o por un patrón.
   Barato y determinista. Solo ve el final: una respuesta correcta a la que se llegó por el camino equivocado igual pasa.
- **La trayectoria**
   Comparar las herramientas que llamó el agente, en orden, con las que espera el caso.
   Detecta un mal camino hacia una buena respuesta, como el hoyo 3. Si es demasiado estricto, falla ejecuciones que tomaron otra ruta válida: permite extras donde no hacen daño.
- **Tu propia verificación**
   Cualquier función que vaya de una ejecución a un puntaje: golpes en par o por debajo, un presupuesto de tokens, un campo en la salida.
   Lo que le importe a tu producto. Mantenla determinista cuando puedas.
- **Un modelo como juez**
   Otro modelo lee la pregunta, la respuesta y una rúbrica, y pone una nota.
   Para respuestas sin un único texto correcto: tono, utilidad, un resumen. Es más lento, cuesta una llamada y necesita una rúbrica precisa y algunos controles contra notas humanas.

El hoyo 3 es la razón por la que quieres más de uno. La respuesta decía “embocada en 4”, y 4 es el par. Solo el scorer de trayectoria vio el driver donde el caso esperaba un hierro. Y solo la traza mostró qué pasó ahí: un span rojo, una bola en el agua.

## El elenco

El mismo elenco de siempre, esta vez en una cancha de golf.

- **Un hoyo** (un caso de evaluación): Un prompt, y cómo se ve una buena ejecución: su par y los palos a usar.
- **El caddie** (el modelo): Lee el hoyo y elige un palo. Nunca golpea.
- **Los palos** (las herramientas): `drive`, `iron` y `putt`. Astor es quien golpea.
- **Las líneas de vuelo** (la traza): Cada vuelo queda dibujado en la cancha, y cada paso es un span en la línea de tiempo: llamadas al modelo en azul, herramientas en naranja, errores en rojo.
- **El comisario** (tu código de evaluación): Le pasa cada caso a un agente nuevo, y sella la tarjeta cuando termina.
- **La tarjeta** (el informe): Una fila por caso, una verificación por scorer, y la tasa de aprobación al pie.

## El código

**Con astorlm:** `attachTracer` convierte los eventos de un agente en spans para cualquier exportador. `runEval` corre un dataset con un agente nuevo por caso, aplica tus scorers y devuelve un informe con la tasa de aprobación por scorer. Los dos viven en `astorlm/experimental`.

**Desde cero:** El bucle del nivel 2 con un cronómetro alrededor de cada llamada al modelo y de cada herramienta, y un `for` sobre los casos con una función por scorer.

**Con astorlm**

```ts
import { OpenAIProvider, createLocalAgent } from 'astorlm'
import { attachTracer, createInMemoryExporter } from 'astorlm/experimental/tracing'
import { contains, runEval, toolTrajectory, type EvalCase, type Scorer } from 'astorlm/experimental/evals'

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = { baseURL: 'http://localhost:11434/v1', apiKey: 'YOUR_API_KEY' } // local servers usually ignore the key

const newGolfer = () =>
  createLocalAgent({
    provider: new OpenAIProvider({ ...LLM, model: 'your-model' }), // e.g. 'llama3.1', 'gpt-4o-mini'
    tools: [drive, iron, putt], // your code: each swing moves the ball and says where it landed
    maxTurns: 10,
  })

// 1. SEE one run: a tracer turns the agent's events into spans (run → turn → model call / tool).
const golfer = await newGolfer()
const exporter = createInMemoryExporter() // in production: an OpenTelemetry exporter instead
attachTracer(golfer, { exporter })
await golfer.run('Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m.')
for (const span of exporter.spans) {
  console.log(span.name, span.endTime! - span.startTime, 'ms', span.status) // e.g. "tool drive 320 ms error"
}

// 2. GRADE many runs: a dataset of cases, and scorers that decide pass or fail.
const dataset: EvalCase[] = [
  { id: 'hole-1', input: 'Hole 1: par 3, 150 m to the pin. Play it out and report your score.', expected: { par: 3, clubs: ['iron', 'putt'] } },
  { id: 'hole-2', input: 'Hole 2: par 4, 360 m to the pin. Play it out and report your score.', expected: { par: 4, clubs: ['drive', 'iron', 'putt'] } },
  {
    id: 'hole-3',
    input: 'Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m. Play it out and report your score.',
    expected: { par: 4, clubs: ['iron', 'iron', 'putt'] }, // lay up short of the water
  },
]
type Expected = { par: number; clubs: string[] }

// Each case expects its own clubs, so wrap the built-in trajectory scorer.
const path: Scorer = {
  name: 'trajectory',
  score: (result) => toolTrajectory((result.case.expected as Expected).clubs, { mode: 'ordered-subset' }).score(result),
}
// Your own scorer: any function from a run to a 0..1 score.
const par: Scorer = {
  name: 'par',
  score: (result) => {
    const strokes = Number(/in (\d+)/.exec(result.output)?.[1] ?? Infinity)
    const passed = strokes <= (result.case.expected as Expected).par
    return { scorer: 'par', score: passed ? 1 : 0, passed, details: `${strokes} strokes` }
  },
}

const report = await runEval({
  dataset,
  createAgent: () => newGolfer(), // a fresh agent per case: no shared history
  scorers: [contains('Holed out'), path, par], // add llmJudge({ provider, rubric }) for fuzzy answers
})

console.log(report.summary) // { total: 3, passed: 2, passRate: 0.67, byScorer: { … } }
```

**TypeScript**

```ts
// Tracing and evals, from scratch. Plain fetch, no SDK.

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
  model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
  apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}

// 1. A span: something that took time, with what you want to know about it.
type Span = { name: string; ms: number; tokens?: number; error?: boolean }

type ToolFn = (args: Record<string, number>) => { output: string; isError?: boolean }
const tools: Record<string, ToolFn> = { drive, iron, putt } // your code: each swing moves the ball
const toolSchemas = [/* one JSON Schema per club: drive(meters), iron(meters), putt(meters) */]

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

// 2. The loop from level 2, timing every model call and every tool run.
async function runAgent(prompt: string, maxTurns = 10) {
  const spans: Span[] = []
  const toolCalls: string[] = []
  const messages: Message[] = [{ role: 'user', content: prompt }]
  for (let turn = 1; turn <= maxTurns; turn++) {
    const start = performance.now()
    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages, tools: toolSchemas }),
    })
    const body = await res.json()
    spans.push({ name: 'model', ms: performance.now() - start, tokens: body.usage?.total_tokens })
    const [choice] = body.choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls') return { output: reply.content ?? '', spans, toolCalls }

    for (const call of reply.tool_calls ?? []) {
      const t0 = performance.now()
      const run = tools[call.function.name]
      const result = run ? run(JSON.parse(call.function.arguments)) : { output: `Unknown tool: ${call.function.name}`, isError: true }
      spans.push({ name: call.function.name, ms: performance.now() - t0, error: result.isError })
      toolCalls.push(call.function.name)
      messages.push({ role: 'tool', tool_call_id: call.id, content: result.output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// 3. The eval: cases with what a good run looks like, and one function per scorer.
const cases = [
  { input: 'Hole 1: par 3, 150 m to the pin. Play it out and report your score.', par: 3, clubs: ['iron', 'putt'] },
  { input: 'Hole 2: par 4, 360 m to the pin. Play it out and report your score.', par: 4, clubs: ['drive', 'iron', 'putt'] },
  { input: 'Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m. Play it out and report your score.', par: 4, clubs: ['iron', 'iron', 'putt'] },
]
type Case = (typeof cases)[number]
type Run = Awaited<ReturnType<typeof runAgent>>

// Every expected club shows up, in order (extra swings allowed).
const inOrder = (expected: string[], actual: string[]): boolean => {
  let i = 0
  for (const name of actual) if (name === expected[i]) i++
  return i === expected.length
}
const scorers: Record<string, (run: Run, c: Case) => boolean> = {
  holed: (run) => run.output.includes('Holed out'),
  trajectory: (run, c) => inOrder(c.clubs, run.toolCalls),
  par: (run, c) => Number(/in (\d+)/.exec(run.output)?.[1] ?? Infinity) <= c.par,
}

let passed = 0
for (const c of cases) {
  const run = await runAgent(c.input) // every case starts with an empty history
  const checks = Object.entries(scorers).map(([name, score]) => [name, score(run, c)] as const)
  if (checks.every(([, ok]) => ok)) passed++
  console.log(c.input.slice(0, 6), checks, run.spans) // failed a check? its spans say why
}
console.log(`passRate ${(passed / cases.length).toFixed(2)}`)
```

**Python**

```python
# Tracing and evals, from scratch. Standard library only, no SDK.
import json
import re
import time
import urllib.request

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

def post(path, payload):
    request = urllib.request.Request(
        f"{LLM['base_url']}{path}",
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)

TOOLS = {"drive": drive, "iron": iron, "putt": putt}  # your code: each returns (output, is_error)
TOOL_SCHEMAS = [...]  # one JSON Schema per club: drive(meters), iron(meters), putt(meters)

# 1 + 2. The loop from level 2, timing every model call and every tool run as a span.
def run_agent(prompt, max_turns=10):
    spans, tool_calls = [], []
    messages = [{"role": "user", "content": prompt}]

    for _ in range(max_turns):
        start = time.perf_counter()
        body = post("/chat/completions", {"model": LLM["model"], "messages": messages, "tools": TOOL_SCHEMAS})
        spans.append({"name": "model", "ms": (time.perf_counter() - start) * 1000, "tokens": body.get("usage", {}).get("total_tokens")})
        choice = body["choices"][0]
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return {"output": reply.get("content") or "", "spans": spans, "tool_calls": tool_calls}

        for call in reply.get("tool_calls", []):
            name = call["function"]["name"]
            t0 = time.perf_counter()
            output, is_error = TOOLS[name](**json.loads(call["function"]["arguments"]))
            spans.append({"name": name, "ms": (time.perf_counter() - t0) * 1000, "error": is_error})
            tool_calls.append(name)
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")

# 3. The eval: cases with what a good run looks like, and one function per scorer.
CASES = [
    {"input": "Hole 1: par 3, 150 m to the pin. Play it out and report your score.", "par": 3, "clubs": ["iron", "putt"]},
    {"input": "Hole 2: par 4, 360 m to the pin. Play it out and report your score.", "par": 4, "clubs": ["drive", "iron", "putt"]},
    {
        "input": "Hole 3: par 4, 330 m to the pin. Water crosses the fairway at 200 m. Play it out and report your score.",
        "par": 4,
        "clubs": ["iron", "iron", "putt"],
    },
]

def in_order(expected, actual):
    """Every expected club shows up, in order (extra swings allowed)."""
    i = 0
    for name in actual:
        if i < len(expected) and name == expected[i]:
            i += 1
    return i == len(expected)

def strokes(output):
    match = re.search(r"in (\d+)", output)
    return int(match.group(1)) if match else float("inf")

SCORERS = {
    "holed": lambda run, case: "Holed out" in run["output"],
    "trajectory": lambda run, case: in_order(case["clubs"], run["tool_calls"]),
    "par": lambda run, case: strokes(run["output"]) <= case["par"],
}

passed = 0
for case in CASES:
    run = run_agent(case["input"])  # every case starts with an empty history
    checks = {name: score(run, case) for name, score in SCORERS.items()}
    passed += all(checks.values())
    print(case["input"][:6], checks, run["spans"])  # failed a check? its spans say why
print(f"passRate {passed / len(CASES):.2f}")
```

## Qué vigilar

- **Corre las evaluaciones en cada cambio.** Un prompt nuevo, una descripción de herramienta o una versión de modelo pueden arreglar un caso y romper dos. Mantén el dataset en el repo, córrelo en CI y haz fallar el build cuando baja la tasa de aprobación.
- **Haz crecer el dataset con fallas reales.** Cada bug que reporta un usuario se convierte en un caso, con su traza como evidencia. Un dataset solo de casos fáciles pasa para siempre y no prueba nada.
- **Las ejecuciones varían.** El mismo caso puede pasar hoy y fallar mañana. Corre los casos importantes varias veces y mira la tasa, no un solo resultado.
- **Las trazas guardan datos de tus usuarios.** Los prompts y las entradas y salidas de herramientas terminan en ellas. Oculta lo que debas (un hook, nivel 6), y trata el almacén de trazas como una base de datos con datos personales.
- **El costo también es una métrica.** Registra tokens por span. Un agente que pasa todos los casos con el doble de llamadas es una regresión que tu tasa de aprobación no va a mostrar.

## Patrones relacionados

- [6 · Hooks](https://harnesspatterns.dev/es/patterns/hooks.md)
- [5 · Errores en el bucle](https://harnesspatterns.dev/es/patterns/errors-in-the-loop.md)
- [3 · Diseñar una herramienta](https://harnesspatterns.dev/es/patterns/designing-a-tool.md)
- [10 · Vueltas limpias](https://harnesspatterns.dev/es/patterns/fresh-laps.md)
- [12 · Planificar y reflexionar](https://harnesspatterns.dev/es/patterns/plan-and-reflect.md)
