La parte 2 analizaba una herramienta que acota la varianza del juez limitando lo que puede decir. Ragas cambia la puntuación de 1 a 5 por un veredicto de sí o no, así que el juez ya no se mueve por una escala y solo puede caer a un lado u otro de una línea.
Esta vez toca otra herramienta. Estoy construyendo AgentAnvil, un framework de pruebas guiado por contratos para agentes basados en LLM, y leer otros evaluadores me sirve para averiguar qué deberían restringir esos contratos. DeepEval acota la varianza cambiando la pregunta, sin tocar en absoluto la salida del juez. Divide el juicio, que planteado de golpe es una sola pregunta difícil, en una secuencia (o un grafo) de preguntas más pequeñas, cada una más fácil que el conjunto. En eso consiste la estrategia de descomposición estructural.
DeepEval la ofrece de dos formas. G-Eval organiza el juicio como una lista de comprobación ordenada, y el DAG, como un grafo ramificado que el juez está obligado a recorrer. Aquí leo las dos directamente en el código fuente.
La intuición
«¿Es bueno este resumen?» es una sola pregunta, pero contestarla exige dar al menos cuatro respuestas: si es fiel a la fuente, si cubre los puntos principales, si es conciso y si es coherente. Un juez que recibe la pregunta entera tiene que sopesar las cuatro y reducirlas a un solo número. Todas esas subdecisiones se toman dentro del modelo (como mucho quedan parafraseadas en el motivo, nunca desglosadas), y el peso de cada una es el que el modelo haya decidido en esa ejecución.
La herramienta de la parte 2 recortaba la salida del juez para dejarle menos cosas que decir y menos margen para oscilar. La descomposición deja la salida como está y recorta la pregunta haciendo las cuatro subpreguntas por separado. «¿Todas las frases tienen respaldo en la fuente, sí o no?» es una pregunta más concreta que «¿esto es bueno?», y una pregunta más concreta tiene menos varianza. El juez abarca menos cosas a la vez y tiene menos formas de contradecirse.
También cambia de sitio la regla de combinación. En un juez monolítico esa regla está implícita en los pesos del modelo y es distinta en cada ejecución. Al descomponer, la regla pasa a código escrito por ti, ya sea una media, un umbral o una rama. Y el código no tiene varianza. No has hecho más fiable al juez (cada subllamada sigue siendo una llamada a un LLM), pero has sacado del modelo la tarea de juntar las partes. Ya no te puede sorprender cómo se han ponderado, porque las has ponderado tú.
Las dos formas se diferencian en una sola cosa: una sigue pidiéndole al juez una puntuación, solo que le da mejores instrucciones, y la otra no se la pide nunca.
G-Eval, paso a paso
G-Eval es la forma más suave. La métrica GEval de DeepEval parte de una cadena criteria en lenguaje natural o de una lista explícita evaluation_steps.
class GEval(BaseMetric): def __init__( self, name: str, evaluation_params: Optional[List[SingleTurnParams]] = None, criteria: Optional[str] = None, evaluation_steps: Optional[List[str]] = None, ... ): ... if criteria is not None or evaluation_steps is not None: validate_criteria_and_evaluation_steps(criteria, evaluation_steps)El validador exige al menos una de las dos, y si pasas las dos mandan los pasos explícitos. Si le das criteria, la métrica convierte esa única frase en pasos antes de que el juez puntúe un caso de prueba.
def _generate_evaluation_steps(self, multimodal: bool) -> List[str]: if self.evaluation_steps: return self.evaluation_steps
g_eval_params_str = construct_g_eval_params_string(self.evaluation_params) prompt = self._get_prompt( "generate_evaluation_steps", criteria=self.criteria, parameters=g_eval_params_str, multimodal=multimodal, ) return generate_with_schema_and_extract( metric=self, prompt=prompt, schema_cls=gschema.Steps, ... )El prompt es una sola instrucción: «generate 3-4 concise evaluation steps based on the criteria». El artículo de G-Eval, Liu et al. (2023), llama a estos pasos cadena de razonamiento (chain of thought). DeepEval genera esa cadena a partir de tu criterio antes de puntuar, con lo que una instrucción vaga se convierte en una lista de comprobación explícita y numerada. Esa lista es la descomposición. La instancia de la métrica la guarda, pero evaluate() (asíncrona por defecto) puntúa cada caso de prueba con una copia nueva de la métrica, de modo que en una ejecución por defecto cada caso recibe su propia lista recién generada. Si quieres la misma lista para todo un dataset, pasa evaluation_steps de forma explícita.
Luego viene la llamada de puntuación. El juez ve los pasos numerados y el caso de prueba y devuelve un objeto JSON con una puntuación y un motivo. La puntuación es un entero dentro de un rango (de 0 a 10 por defecto) que DeepEval normaliza a una métrica entre 0 y 1.
Una puntuación entera es justo la salida de poca resolución que señalaba la parte 1, con los valores amontonados en un mismo entero. Si le pides un entero a un modelo, toda su distribución se reduce a uno solo. Cuando el juez da acceso a las log-probabilidades de los tokens (el juez de OpenAI que DeepEval usa por defecto lo hace), DeepEval no se queda con el entero. Lee las log-probabilidades del token de la puntuación (cada entero que ha considerado el modelo y cuánta probabilidad le ha dado a cada uno) y devuelve la media ponderada por probabilidad.
# inside calculate_weighted_summed_score(...)min_logprob = math.log(0.01) # filter out tokens with <1% linear probabilityfor token_logprob in score_logprobs.top_logprobs: logprob = token_logprob.logprob if logprob < min_logprob: continue if not token_logprob.token.isdecimal(): continue linear_prob = math.exp(logprob) token_score = int(token_logprob.token) # ... accumulate token_score weighted by linear_prob ...weighted_summed_score = sum_of_weighted_scores / sum_linear_probabilityLos tokens con menos del 1 % de probabilidad se descartan, y los que quedan se suman ponderados por su probabilidad. El juez dice «4», pero la puntuación ponderada se queda cerca de 3,8 porque el modelo ponía más probabilidad en el 3 que en el 5. Con eso se recupera una puntuación continua a partir de enteros amontonados, sin hacer una segunda llamada. Los jueces que no dan acceso a las logprobs (los wrappers de DeepEval para Anthropic y Gemini, la mayoría de los modelos personalizados) se quedan con el entero en bruto, y strict_mode hace lo mismo.
Para ser justos con G-Eval, aquí la descomposición es orientativa. Los pasos guían al juez, pero no lo atan, y nada impide al modelo leer el paso tres por encima. A cambio de esa holgura, G-Eval gana alcance. Funciona con cualquier criterio que puedas escribir en una frase y, como la lista se genera sola, no necesitas conocer las subpreguntas de antemano. El juez sigue emitiendo el juicio entero, solo que con mejores instrucciones.
El paradigma DAG
En el DAG, la descomposición sí ata al juez.
Mientras que G-Eval guía al juez y aun así le pide una puntuación, DAGMetric no se la pide nunca. Le das un grafo, un DeepAcyclicGraph, y el juez solo responde a un nodo cada vez. Hay cuatro tipos de nodo.
Un TaskNode es un paso de extracción que hace una llamada al LLM para sacar material intermedio («enumera las afirmaciones del resumen») y se lo pasa a los nodos siguientes, sin puntuar nada por sí mismo.
Un BinaryJudgementNode hace una única pregunta de sí o no y necesita exactamente dos hijos, uno por respuesta.
def __post_init__(self): if len(self.children) != 2: raise ValueError("BinaryJudgementNode must have exactly 2 children.") ... verdicts = [child.verdict for child in self.children] if verdicts.count(True) != 1 or verdicts.count(False) != 1: raise ValueError("BinaryJudgementNode must have one True and one False VerdictNode child.")Un NonBinaryJudgementNode hace una única pregunta de opción múltiple, y como DeepEval construye el esquema del veredicto en tiempo de ejecución, el modelo no puede salirse de la lista.
self._verdict_schema = create_model( "NonBinaryJudgementVerdict", verdict=(Literal[tuple(self._verdict_options)], ...), reason=(str, ...),)Por último, un VerdictNode es el desenlace de una rama y lleva un score o un child, nunca las dos cosas.
def __post_init__(self): if self.score is not None and self.child is not None: raise ValueError( "A VerdictNode can have either a 'score' or a 'child', but not both." ) if self.score is None and self.child is None: raise ValueError( "A VerdictNode must have either a 'score' or a 'child'." )Si lleva un score, el nodo es una hoja, y si lleva un child, el recorrido continúa.
Ejecutar la métrica consiste en recorrer el grafo. Cada nodo de juicio hace su única llamada y obtiene un veredicto, que decide qué hijo se ejecuta. Los demás hijos terminan sin hacer nada.
# VerdictNode._execute — a branch runs only if its verdict# matches the parent judgement's verdictif self._parent._verdict.verdict != self.verdict: returnAl final llegas a una hoja, y su score dividido entre diez es la métrica.
DAGMetric de DeepEval que mide la calidad de un resumen con tres juicios binarios y cuatro hojas con puntuación. Responde a cada pregunta: el camino se ilumina, las ramas descartadas se apagan y la hoja a la que llegas fija la puntuación.Eso es lo que distingue al DAG de G-Eval. En G-Eval todos los pasos desembocan en una única llamada de puntuación y el juicio lo sigue emitiendo el juez, eso sí, bien informado. En el DAG, en cambio, el juez nunca llega a emitir el juicio. Solo se le pide un veredicto binario o n-ario por nodo, y la puntuación la decide el grafo, que es código escrito por ti. Con los mismos veredictos se recorre el mismo camino y sale el mismo número en todas las ejecuciones. DeepEval rechaza un grafo con algún ciclo antes de ejecutar nada, con un escueto «Cycle detected in DAG graph».
Además, las dos formas se pueden combinar, ya que el hijo de un VerdictNode puede ser un GEval. Cuando una rama ya no necesita bifurcarse más y pide una puntuación graduada, cuelgas un G-Eval de la hoja. El grafo decide el camino y G-Eval puntúa el último tramo. No son rivales; una puede contener a la otra.
Lo que pierdes
La descomposición tiene dos costes.
El primero es que hay más sitios donde equivocarse. Un juez monolítico hace una sola llamada que puede salir mal; un DAG con un nodo de tarea, dos nodos de juicio y cuatro hojas hace tres o cuatro, cada una con su propio riesgo de fallar. Además, esas llamadas se ejecutan en secuencia a lo largo del camino, así que en una puerta de CI ese DAG cuesta entre tres y cuatro veces la latencia y el gasto en API de un único juez global. La falta de fiabilidad del juez sigue ahí, repartida ahora entre más nodos. La apuesta, que se puede perder, es que cada llamada pequeña sea lo bastante fiable para que el producto siga ganando al monolito. El peor caso es un nodo interior, donde un veredicto erróneo manda el recorrido por una rama del todo equivocada y la puntuación cambia de golpe. Si el nodo se equivoca de forma sistemática, se equivoca igual en todas las ejecuciones; si su veredicto está en el límite, la puntuación salta de una rama a otra de una ejecución a la siguiente.
El segundo es que ahora tienes que acertar con la estructura. El grafo es tu teoría del juicio: qué subpreguntas importan, en qué orden y cómo se combinan. Si la teoría está mal, la métrica se equivoca de forma determinista con todas las entradas. G-Eval tiene el mismo problema en versión más suave, porque sus pasos generados automáticamente también los escribe un LLM y la mayoría de la gente no los lee nunca.
Conviene verlo con buenos ojos, como el precio justo de hacer legible un juicio. Un juez monolítico esconde su razonamiento y no puedes ver dónde se ha equivocado; uno descompuesto te señala exactamente de qué nodo desconfiar. Los dos tienen bugs, pero solo uno tiene stack trace.
Cuándo encaja este enfoque
La descomposición compensa cuando se dan tres condiciones.
El juicio tiene partes reales. «Fiel, conciso y completo» se separa limpiamente en tres comprobaciones, pero «¿es elegante esta explicación?» no. Meter con calzador un grafo en un juicio sin costuras solo añade llamadas y una falsa sensación de rigor. Descompón donde el juicio tenga costuras.
Conoces las subpreguntas mejor de lo que el modelo las adivinaría. Si tu descomposición se limita a poner por escrito los pasos implícitos del propio modelo, eso ya lo hace gratis la lista que genera G-Eval. El DAG justifica su complejidad cuando sabes algo que el modelo no deduciría, como un orden, una dependencia o un cortocircuito. Por ejemplo, si falla la comprobación de fidelidad, el recorrido debería pararse antes de gastar una sola llamada en puntuar el estilo.
Necesitas un resultado auditable o estable. Un DAG te da un camino fijo que puedes señalar en una revisión. Cuando hay que explicarle el veredicto de una puerta a alguien que no la ha escrito, el recorrido ya es la explicación, cosa que no ocurre con una puntuación global.
Como regla práctica, recurre a G-Eval cuando quieras una puntuación global mejor a bajo coste, y al DAG cuando el juicio tenga una estructura real que necesites imponer, sin conformarte con sugerirla. Ten en cuenta que entre las dos no hay una frontera nítida y que un G-Eval en la hoja de un DAG es un término medio con todas las de la ley. En el fondo, la herramienta es lo de menos: lo que eliges es cuánto del juicio puedes permitirte dejar dentro del modelo.