go-eval: la pieza que faltaba para probar agentes en Go

go dev.to

Hace un tiempo empecé a sentir una incomodidad rara construyendo aplicaciones con LLMs en Go.

Go tenía casi todo lo que quería para construir software agéntico: concurrencia simple, deploys predecibles, binarios pequeños, buen soporte para HTTP, workers, colas, bases de datos, observabilidad y testing. Pero justo en la parte más importante aparecía un vacío:

¿Cómo probamos que un agente está respondiendo bien?

No si compila.

No si devuelve un 200.

No si el JSON tiene una llave llamada answer.

Sino si la respuesta es fiel al contexto, si no inventó hechos, si usó bien los documentos recuperados, si siguió la rúbrica, si eligió la herramienta correcta, si bajó de calidad después de cambiar un prompt.

En Python existen varias herramientas maduras para evaluación de LLMs. En Go, muchas veces terminamos con scripts sueltos, checks de strings, distancia Levenshtein, planillas, o una persona mirando respuestas a mano.

Eso no escala.

Y, peor aún, saca la evaluación del lugar donde los gophers ya sabemos trabajar: go test.

El problema: los agentes no fallan como una API tradicional

Una API tradicional suele fallar de formas relativamente claras:

  • devuelve un error
  • rompe un contrato
  • persiste mal un dato
  • se demora demasiado
  • responde un código HTTP incorrecto

Para eso Go ya tiene un flujo excelente:

go test ./...
go test -race ./...
go test -bench=. ./...
Enter fullscreen mode Exit fullscreen mode

Pero una aplicación con LLMs puede fallar de formas más suaves y más peligrosas.

Puede responder con mucha confianza algo falso.

Puede ignorar el contexto que recuperó desde la base vectorial.

Puede contestar algo razonable, pero no responder la pregunta del usuario.

Puede devolver JSON válido, pero semánticamente inútil.

Puede mejorar en un caso y empeorar en otro después de cambiar el system prompt.

El problema no es solo técnico. Es cultural: si no podemos escribir pruebas repetibles sobre estos comportamientos, terminamos aceptando que la calidad de un agente se mide "probando un rato en el chat".

Eso está bien para una demo.

No está bien para software serio.

Qué es go-eval

go-eval es una librería de evaluación de LLMs para Go, pensada para correr dentro de go test.

La idea es simple:

go test
  └── Runner
        ├── Case
        ├── Metric
        └── Judge
              └── LLM
Enter fullscreen mode Exit fullscreen mode

Un Case describe lo que queremos evaluar.

Un Metric define cómo se mide la calidad.

Un Judge es la abstracción sobre el modelo que juzga.

Un Runner conecta todo con testing.TB, para que los resultados vivan en el mismo flujo que el resto de nuestros tests.

El core no depende de OpenAI, ni de una plataforma externa, ni de un dashboard hosted. Es Go normal, con interfaces pequeñas.

type Judge interface {
    Evaluate(ctx context.Context, prompt string) (JudgeResponse, error)
}

type Metric interface {
    Name() string
    Score(ctx context.Context, j Judge, c Case) (Result, error)
}

type Case struct {
    Input    string
    Output   string
    Expected string
    Context  []string
    Metadata map[string]any
}
Enter fullscreen mode Exit fullscreen mode

Esto es intencional. Si queremos que Go sea un lenguaje natural para aplicaciones agénticas, las evaluaciones no pueden sentirse como una herramienta ajena al ecosistema.

Tienen que sentirse como Go.

El primer principio: evaluaciones opt-in

Un test con LLMs puede ser lento, costoso y no siempre determinístico. Por eso go-eval no corre evaluaciones por accidente.

Todas las ejecuciones de Runner.Run están protegidas por GOEVAL.

go test ./...
Enter fullscreen mode Exit fullscreen mode

Con GOEVAL apagado, los evals se saltan.

GOEVAL=1 go test ./...
Enter fullscreen mode Exit fullscreen mode

Con GOEVAL=1, se ejecutan.

Este detalle parece pequeño, pero cambia la ergonomía. Puedes commitear tus evals junto al código, correr CI rápido por defecto, y activar evaluaciones semánticas cuando quieres validar calidad, comparar modelos o preparar un release.

Un ejemplo: evaluando una respuesta RAG

Supongamos que tenemos una aplicación RAG. El usuario pregunta algo, recuperamos documentos y generamos una respuesta.

Con tests tradicionales podemos verificar que el pipeline no explote. Pero eso no nos dice si la respuesta está fundada en el contexto.

Con go-eval, el test se ve así:

package rag_test

import (
    "os"
    "testing"

    eval "github.com/igcodinap/go-eval"
)

func TestRAGAnswerQuality(t *testing.T) {
    if os.Getenv(eval.EnvVar) == "" {
        t.Skip("eval skipped, set GOEVAL=1 to run")
    }

    judge := newJudge(t)
    r := eval.NewRunner(judge)

    question := "Cuál es la capital de Francia?"
    answer, docs := myRAG.Answer(question)

    c := eval.Case{
        Input:   question,
        Output:  answer,
        Context: docs,
        Metadata: map[string]any{
            "flow":    "rag.answer",
            "tier":    "critical",
            "dataset": "smoke-v1",
        },
    }

    r.Run(t, eval.Faithfulness{Threshold: 0.8}, c)
    r.Run(t, eval.Hallucination{Threshold: 0.9}, c)
    r.Run(t, eval.AnswerRelevancy{Threshold: 0.7}, c)
}
Enter fullscreen mode Exit fullscreen mode

Y se corre así:

GOEVAL=1 go test ./...
Enter fullscreen mode Exit fullscreen mode

La diferencia es conceptual. El test ya no solo pregunta "el código funcionó?". Ahora también pregunta:

  • la respuesta está apoyada por el contexto?
  • inventó información?
  • respondió realmente la pregunta?

Eso es lo que falta cuando probamos agentes solamente con asserts deterministas.

Métricas incluidas

go-eval parte por los casos más comunes en aplicaciones con LLMs:

Métrica Qué mide
Faithfulness Si los claims del output están soportados por el contexto
Hallucination Si el output inventa hechos fuera del contexto
AnswerRelevancy Si el output responde el input del usuario
ContextPrecision Si los documentos recuperados son relevantes
GEval Una rúbrica custom definida por el usuario
Compound Varias dimensiones de calidad en una sola llamada al judge
Contains Check determinístico de substring
Regex Check determinístico con expresión regular
JSONPath Check determinístico sobre una ruta JSON simple
FieldCount Cantidad mínima de campos JSON no nulos

Hay dos familias importantes:

Las métricas LLM-as-judge, que usan un modelo para evaluar calidad semántica.

Y las métricas determinísticas, que no necesitan modelo y sirven para contratos duros: formato, campos, tool calls, estructuras JSON, rutas, nombres de acciones.

Esa combinación es clave para agentes. No todo debe juzgarlo un LLM. Si el agente debe devolver {"tool":"search"}, probablemente quieres un check determinístico. Si debe explicar por qué eligió esa herramienta, quizás quieres una rúbrica.

Un detalle práctico: cuando estas métricas se ejecutan vía Runner, también heredan el mismo gate GOEVAL. Si quieres que un contrato determinístico falle siempre en CI, puedes usarlo como un test tradicional o llamar la métrica directamente. Si quieres que forme parte de la suite de evaluación, déjalo bajo Runner.

Evaluando outputs estructurados

Muchos agentes no solo responden texto. También deciden acciones.

Por ejemplo:

{"action":{"name":"search_docs","query":"go context cancellation"},"confidence":0.82}
Enter fullscreen mode Exit fullscreen mode

Ahí podemos combinar checks deterministas:

func TestAgentActionShape(t *testing.T) {
    r := eval.NewRunner(nil)

    output := runAgent("Busca documentación sobre cancelación de contextos en Go")

    c := eval.Case{
        Output:   output,
        Expected: "search_docs",
    }

    r.Run(t, eval.MustJSONPath("action.name"), c)
    r.Run(t, eval.FieldCount{MinFields: 2}, c)
}
Enter fullscreen mode Exit fullscreen mode

Esto no reemplaza una evaluación semántica. La complementa.

Primero verificamos que el agente habla el protocolo correcto. Después evaluamos si la decisión fue buena.

Rúbricas custom con GEval

Hay casos donde las métricas predefinidas no bastan.

Por ejemplo, podemos querer evaluar si una respuesta es útil para un usuario junior, si evita jerga innecesaria, o si sigue una política interna.

Para eso existe GEval:

r.Run(t, eval.GEval{
    Criteria: `
La respuesta debe ser técnicamente correcta, breve y accionable.
Debe explicar el problema sin asumir conocimiento avanzado de Go.
Debe incluir un siguiente paso concreto.
`,
    Steps: []string{
        "Verifica si la respuesta contesta directamente la pregunta.",
        "Evalúa si la explicación técnica es correcta.",
        "Penaliza respuestas vagas o demasiado largas.",
    },
    Threshold: 0.75,
}, c)
Enter fullscreen mode Exit fullscreen mode

Esto es poderoso porque permite convertir criterio humano en una prueba que vive junto al código.

No es perfecto. Sigue dependiendo de un juez. Pero es mucho mejor que no tener ninguna medición y confiar solamente en intuición.

Compound: varias dimensiones en una sola llamada

Llamar a un LLM tiene costo y latencia. Si queremos evaluar cinco dimensiones de una misma respuesta, no siempre queremos hacer cinco llamadas separadas.

Compound permite evaluar varias dimensiones en una sola llamada:

r.Run(t, eval.Compound{
    Dimensions: []eval.Dimension{
        {
            Name:      "correctness",
            Rubric:    "La respuesta debe ser factualmente correcta.",
            Threshold: 0.8,
        },
        {
            Name:      "clarity",
            Rubric:    "La respuesta debe ser clara para una persona técnica.",
            Threshold: 0.7,
        },
        {
            Name:      "actionability",
            Rubric:    "La respuesta debe incluir un próximo paso concreto.",
            Threshold: 0.7,
        },
    },
}, c)
Enter fullscreen mode Exit fullscreen mode

El resultado incluye un score promedio y también resultados por dimensión.

Esto es especialmente útil cuando el agente no tiene un solo eje de calidad. En software real, rara vez basta con "correcto". También importa si es claro, seguro, útil, consistente y barato.

Precheck: no gastes tokens si ya falló lo obvio

Una idea muy Go: primero los checks baratos.

Si una respuesta ni siquiera tiene el formato esperado, no tiene sentido gastar tokens evaluando su calidad semántica.

Precheck compone dos métricas. Si la primera falla, la segunda no corre.

r.Run(t, eval.Precheck{
    Pre:  eval.MustJSONPath("action.name"),
    Main: eval.GEval{Criteria: "La acción elegida debe resolver la tarea del usuario."},
}, c)
Enter fullscreen mode Exit fullscreen mode

Esto permite construir suites más eficientes:

  1. valida estructura
  2. valida decisión
  3. valida respuesta final

Resultados como JSONL

Los tests sirven para decir pass/fail. Pero en evaluación de LLMs también queremos observar tendencia:

  • bajó el score después de cambiar el prompt?
  • subió la latencia?
  • aumentaron los tokens?
  • qué casos están fallando?
  • qué dimensión del Compound bajó?

go-eval puede escribir un JSON por cada evaluación:

r := eval.NewRunner(
    judge,
    eval.WithResultSink(eval.DefaultResultSink()),
)
Enter fullscreen mode Exit fullscreen mode

Y luego:

GOEVAL=1 GOEVAL_RESULTS_DIR=.goeval/results go test ./...
Enter fullscreen mode Exit fullscreen mode

Eso genera:

.goeval/results/results.jsonl
Enter fullscreen mode Exit fullscreen mode

Cada línea incluye campos como:

{"timestamp":"2026-04-27T12:00:00Z","test_name":"TestRAGAnswerQuality","metric":"Faithfulness","score":0.91,"passed":true,"reason":"All factual claims are supported by the provided context.","tokens":312,"latency_ns":1200000000,"metadata":{"flow":"rag.answer","tier":"critical","dataset":"smoke-v1"}}
Enter fullscreen mode Exit fullscreen mode

Esto abre la puerta a comparaciones entre runs, reportes y regresiones. El objetivo no es reemplazar go test, sino enriquecerlo.

Tracing cuando el judge se porta raro

Cuando trabajamos con LLMs, a veces el problema está en el prompt de evaluación, no en el código evaluado.

Para debuggear eso existe GOEVAL_TRACE:

GOEVAL=1 GOEVAL_TRACE=1 go test -v -run TestRAGAnswerQuality
Enter fullscreen mode Exit fullscreen mode

Esto loguea el prompt enviado al judge y la respuesta recibida usando t.Log.

Es muy útil localmente. No debería activarse en CI compartido si los prompts contienen datos sensibles.

Benchmarks para prompts y modelos

Una parte bonita de hacerlo dentro del toolchain de Go es que también podemos usar benchmarks.

func BenchmarkRAGFaithfulness(b *testing.B) {
    r := eval.NewRunner(newJudge(b))

    c := eval.Case{
        Input:   "Cuál es la capital de Francia?",
        Output:  "Paris es la capital de Francia.",
        Context: []string{"Paris es la capital de Francia."},
    }

    eval.Bench(b, r, eval.Faithfulness{Threshold: 0.8}, c)
}
Enter fullscreen mode Exit fullscreen mode

Y correr:

GOEVAL=1 go test -bench=. -count=5 > old.txt
# cambiar prompt, modelo o pipeline
GOEVAL=1 go test -bench=. -count=5 > new.txt
benchstat old.txt new.txt
Enter fullscreen mode Exit fullscreen mode

eval.Bench reporta ns/op, tokens/op, score_mean y score_stddev.

Esto importa porque una mejora de calidad que duplica costo y latencia no siempre es una mejora.

El adapter de OpenAI vive fuera del core

El core de go-eval no tiene dependencias externas.

El adapter de OpenAI vive en un módulo separado:

import (
    "os"
    "testing"

    openai "github.com/sashabaranov/go-openai"

    eval "github.com/igcodinap/go-eval"
    openaieval "github.com/igcodinap/go-eval/adapters/openai"
)

func newJudge(t testing.TB) eval.Judge {
    t.Helper()

    if os.Getenv(eval.EnvVar) == "" {
        t.Skip("eval skipped, set GOEVAL=1 to run")
    }

    key := os.Getenv("OPENAI_API_KEY")
    if key == "" {
        t.Skip("OPENAI_API_KEY not set")
    }

    return openaieval.NewJudge(openai.NewClient(key), openai.GPT4oMini)
}
Enter fullscreen mode Exit fullscreen mode

La interfaz es lo suficientemente pequeña para escribir adapters propios: OpenAI, Ollama, Anthropic, Gemini, un modelo interno, o incluso un judge determinístico para tests locales.

Lo importante es que el resto de tu suite no depende del proveedor.

Depende de eval.Judge.

Por qué esto importa para Go

Mi apuesta es simple: Go puede ser uno de los mejores lenguajes para construir aplicaciones agénticas.

No porque tenga más hype.

Sino porque las aplicaciones agénticas reales son sistemas distribuidos pequeños:

  • reciben eventos
  • llaman APIs
  • hacen I/O
  • usan contexto y cancelación
  • ejecutan herramientas
  • mantienen estado
  • necesitan observabilidad
  • deben correr barato
  • deben fallar de forma entendible

Ese mundo se parece mucho más a Go que a un notebook.

Pero para que Go salte como lenguaje por defecto en este espacio, no basta con buenos SDKs para llamar modelos. Necesitamos buenas formas de probar comportamiento.

Necesitamos que escribir un test de calidad para un agente sea tan natural como escribir un test de una API HTTP.

Ese es el lugar que quiero que ocupe go-eval.

Estado actual

Hoy go-eval está en una etapa temprana, pero útil.

La versión v0.3 incluye:

  • métricas RAG básicas
  • rúbricas custom con GEval
  • evaluación multi-dimensión con Compound
  • checks determinísticos para texto y JSON
  • Precheck para ahorrar llamadas al judge
  • adapters OpenAI y Ollama
  • resultados JSONL
  • comparación de resultados JSONL
  • loaders JSON para datasets y golden cases
  • tracing de prompts y respuestas
  • benchmarks integrados al flujo de Go
  • una CLI mínima con goeval test, goeval compare y goeval version

Todavía no pretende ser una plataforma completa.

No hay dashboard hosted.

No hay evaluación multi-turn first-class todavía.

No hay loaders YAML oficiales en el core.

Y eso está bien. La idea no es clonar todo el ecosistema Python de una vez. La idea es construir una base idiomática, pequeña y extensible.

Para las próximas versiones, el roadmap apunta a:

  • evaluación de conversaciones
  • reportes más ricos sobre resultados JSONL
  • más adapters
  • mejores workflows para comparar calidad, costo y latencia entre modelos

Cómo empezar

Instalación:

go get github.com/igcodinap/go-eval
Enter fullscreen mode Exit fullscreen mode

Crear un test:

func TestAgentAnswer(t *testing.T) {
    judge := newJudge(t)
    r := eval.NewRunner(judge)

    output := agent.Answer("Explica context.Context en Go")

    c := eval.Case{
        Input:  "Explica context.Context en Go",
        Output: output,
    }

    r.Run(t, eval.GEval{
        Criteria:  "La respuesta debe ser correcta, breve y útil para un gopher intermedio.",
        Threshold: 0.75,
    }, c)
}
Enter fullscreen mode Exit fullscreen mode

Ejecutar:

GOEVAL=1 go test ./...
Enter fullscreen mode Exit fullscreen mode

Guardar resultados:

GOEVAL=1 GOEVAL_RESULTS_DIR=.goeval/results go test ./...
Enter fullscreen mode Exit fullscreen mode

Debuggear prompts:

GOEVAL=1 GOEVAL_TRACE=1 go test -v ./...
Enter fullscreen mode Exit fullscreen mode

Cierre

Los tests tradicionales nos dicen si el software hizo lo que especificamos.

Las evaluaciones nos ayudan a descubrir si el sistema se comportó como esperábamos.

En aplicaciones con agentes, necesitamos ambas cosas.

Mi objetivo con go-eval es que la comunidad Go tenga una librería pequeña, idiomática y poderosa para medir calidad de sistemas con LLMs sin abandonar el flujo natural del lenguaje.

Quiero que cuando alguien construya un agente en Go, el siguiente paso obvio no sea abrir una planilla ni escribir un script en otro lenguaje.

Quiero que sea crear un _test.go.

GOEVAL=1 go test ./...
Enter fullscreen mode Exit fullscreen mode

Si logramos eso, Go no solo va a ser una buena opción para servir aplicaciones agénticas.

Va a ser una de las mejores opciones para construirlas con confianza.

Recursos

Source: dev.to

arrow_back Back to Tutorials