
Llevaba meses preguntándome lo mismo: si tengo una buena GPU en casa, ¿puedo dejar de pagar por la API de Claude o GPT y usar un modelo local para mis tareas técnicas? El discurso de la comunidad open-source dice que sí: que Llama 3.1, Qwen 3.5 o Gemma están “casi al nivel” de los modelos cerrados. Pero “casi” es una palabra peligrosa cuando estás escribiendo código que va a producción.
Decidí salir de la duda con un experimento concreto: medir qué tan bien escriben medidas DAX (el lenguaje de fórmulas de Power BI) tres modelos distintos. Spoiler: los resultados me sorprendieron, y el camino para llegar a esos números fue mucho más interesante de lo que esperaba.
En este artículo te cuento cómo armé un evaluador automático en Python, los tres bugs que casi arruinan la comparación, y por qué terminé usando un modelo de Claude para juzgar a otros modelos de Claude.
¿Por qué DAX?
Si trabajás con Power BI sabés que DAX es uno de esos lenguajes que se ven simples desde lejos y se vuelven hostiles cuando entrás en serio. Tiene contexto de fila, contexto de filtro, transición de contexto, funciones de inteligencia de tiempo, y una sintaxis que mezcla lo familiar de Excel con conceptos que vienen del mundo de las bases de datos analíticas.
La mayoría de la gente que sabe DAX lo aprendió a fuerza de copiar-pegar de blogs (saludos, SQLBI). Es exactamente el tipo de tarea donde un LLM podría brillar: hay mucha documentación pública, los patrones son repetitivos, y la respuesta correcta es verificable (corre o no corre, y si corre, devuelve el número correcto o no).
Es decir: DAX es un buen banco de pruebas porque no admite ambigüedad. Una medida está bien o está mal.
El experimento: 3 modelos, 11 preguntas, 5 niveles
Elegí tres modelos con perfiles distintos:
| Modelo | Tipo | Tamaño | Costo |
|---|---|---|---|
qwen3.5:9b |
Local (Ollama) | 9B | $0 (mi GPU) |
gemma4:e4b |
Local (Ollama) | ~4B efectivos | $0 (mi GPU) |
claude-haiku-4-5 |
API (Anthropic) | No revelado | ~$1/MTok input |
Diseñé 11 preguntas en 5 niveles de dificultad, con puntajes máximos crecientes:
- Nivel 1 (10 pts): medidas básicas como
[Total Ventas] = SUM(...). - Nivel 2 (20 pts): división con manejo de cero,
CALCULATEcon filtros simples,DISTINCTCOUNT. - Nivel 3 (30 pts): inteligencia de tiempo (
SAMEPERIODLASTYEAR,TOTALYTD, crecimiento YoY). - Nivel 4 (40 pts): ranking denso, media móvil con
DATESINPERIOD. - Nivel 5 (50 pts): clasificación ABC con variables y
RANKX, métrica dinámica conSWITCH+SELECTEDVALUEy tabla desconectada.
A cada modelo le pasé el mismo prompt con el mismo contexto del modelo de datos (tablas Ventas, Calendario, Productos, Clientes y sus relaciones), y le pedí solo el código DAX, sin explicaciones.
El problema central: ¿cómo califico 33 medidas DAX sin escribir DAX a mano?
Acá está la trampa de cualquier benchmark de LLMs: si tenés que evaluar miles de respuestas manualmente, no escala. La solución estándar es usar otro LLM como juez (lo que se llama “LLM-as-a-judge”).
La rúbrica que armé tiene cuatro dimensiones, sumando 100 puntos por respuesta:
| Dimensión | Peso | Qué evalúa |
|---|---|---|
| Sintaxis | 30 | Funciones DAX válidas, formato correcto |
| Lógica | 40 | ¿Resuelve realmente la tarea pedida? |
| Contexto/Filtros | 20 | Uso correcto de CALCULATE, ALL, FILTER |
| Calidad de código | 10 | Variables VAR/RETURN, comentarios, claridad |
Después, ese score 0-100 se escala al puntaje máximo del nivel (un nivel 5 vale 50 pts, no 10).
Primer intento: usar uno de los modelos locales como juez
Mi primera idea fue obvia: usar qwen3.5:9b como juez para mantener todo local. Mala idea.
Dos problemas aparecieron de inmediato:
- Sesgo: Qwen calificándose a sí mismo. No es objetivo.
- Parsing roto: Qwen 3.5 emite bloques
<think>...</think>antes del JSON final. Mi regex\{.*\}(greedy con DOTALL) terminaba capturando llaves dentro del thinking en vez del JSON real. Resultado: “Error de parsing” en cascada.
Segundo intento: Claude Sonnet 4.6 como juez
Moví el juez a la API de Claude usando claude-sonnet-4-6 con structured outputs (output_config.format con un JSON schema). Esto me dio dos garantías:
- El JSON siempre es válido (sin excepciones de parsing).
- Sonnet 4.6 es de otra familia y otro tier que
claude-haiku-4-5, así que no se está auto-evaluando.
El costo total de juzgar las 33 respuestas: ~$0.05. Barato para la calidad.
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=512,
system=[{
"type": "text",
"text": RUBRIC_INSTRUCTIONS,
"cache_control": {"type": "ephemeral"}, # prompt caching
}],
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
messages=[{"role": "user", "content": user_prompt}],
)
El cache_control: ephemeral cachea las instrucciones del system prompt entre llamadas (ahorra ~90% en esa porción del input).
Los tres bugs que casi arruinan el experimento
Bug 1: Cargar dos modelos al mismo tiempo en una GPU modesta
Mi primera versión hacía:
for question in questions:
for model in models:
response = ollama.generate(model, question)
score = judge(response) # judge usa otro modelo
Resultado: Ollama swappeaba constantemente entre qwen3.5:9b (modelo evaluado) y el juez en cada pregunta. Mi VRAM colapsaba, el disco se ponía rojo, y cada pregunta tardaba minutos solo en cargar/descargar pesos.
Solución: dos fases secuenciales con descarga explícita de modelos vía keep_alive: 0:
- Fase 1 (Generación): cargo un modelo, genero las 11 medidas, lo descargo, paso al siguiente.
- Fase 2 (Calificación): cargo el juez una sola vez, califico las 33 respuestas.
Para los modelos en la nube no hace falta nada de esto: la API maneja todo del otro lado.
Bug 2: El streaming roto con modelos que “piensan”
Cuando un modelo como Qwen 3.5 razona antes de responder, Ollama emite los tokens de pensamiento en un campo separado del JSON streaming:
{"response": "", "thinking": "Para calcular el total...", "done": false}
{"response": "", "thinking": "...debo usar SUMX porque...", "done": false}
{"response": "[Total Ventas] = SUMX(...)", "thinking": "", "done": false}
Mi código original solo reseteaba el “watchdog” de actividad cuando llegaba contenido en response. Mientras Qwen pensaba (y en preguntas nivel 4-5 podía pensar varios minutos), response estaba vacío → mi timeout de 180s se disparaba aunque el modelo estuviera trabajando perfectamente.
Resultado: gemma4:e4b (que no usa thinking) completaba todo, mientras qwen3.5:9b “fallaba” en las preguntas más difíciles. No estaba fallando, lo estaba abortando yo.
Solución: cualquier línea parseable del stream resetea el watchdog, no solo las que tienen response. Y guardo el thinking por separado para el log.
last_chunk_time = time.perf_counter() # cualquier chunk = modelo activo
response_chunk = data.get("response", "")
if response_chunk:
chunks.append(response_chunk)
thinking_chunk = data.get("thinking", "")
if thinking_chunk:
thinking_chunks.append(thinking_chunk)
Lección: cuando incorporás “modelos que piensan” a tu pipeline, repensá tu definición de “modelo colgado”.
Bug 3: JSON Schema con restricciones numéricas
Cuando moví el juez a Claude API, intenté ser preciso con el schema:
{"type": "integer", "minimum": 0, "maximum": 30}
Error 400:
output_config.format.schema: For 'integer' type, properties maximum, minimum are not supported
Los structured outputs de Claude solo soportan tipos básicos sin restricciones numéricas. La solución es trivial — sacar minimum/maximum del schema y hacer el clamping en el cliente:
def _clamp(value, max_value):
return max(0, min(int(value), max_value))
Pero la lección general es importante: las features de “structured outputs” de los distintos proveedores no son intercambiables. Cada uno tiene su sub-set de JSON Schema soportado.
La arquitectura final
Quedaron 7 archivos:
dax-evaluator/
├── main.py # CLI orquestador con menú
├── config.py # modelos, timeouts, pesos de rúbrica
├── questions.py # 11 preguntas con metadata por nivel
├── evaluator.py # generación: Ollama streaming + Claude API
├── scorer.py # juez: Claude API con structured outputs
├── reporter.py # Excel + tablas en consola con rich
├── claude_client.py # cliente Anthropic compartido
└── results/ # reportes generados (Excel + logs)
El flujo en consola se ve así:
Verificacion:
OK Ollama disponible
OK Modelos Ollama: qwen3.5:9b, gemma4:e4b
OK Modelos Claude API: claude-haiku-4-5
OK Juez: Claude API (claude-sonnet-4-6)
Fase 1/2 - Generacion DAX
Modelo: claude-haiku-4-5 (Claude API, sin warmup)
GEN ok x 11
Modelo: qwen3.5:9b (Cargando en VRAM...)
GEN ok x 11
Liberando VRAM...
Modelo: gemma4:e4b (Cargando en VRAM...)
GEN ok x 11
Liberando VRAM...
Fase 2/2 - Calificacion con juez: Claude API (claude-sonnet-4-6)
JUDGE x 33 respuestas
[Tabla comparativa final por pregunta + totales]
Y al terminar, un Excel con dos hojas: Comparativa (scores lado a lado por pregunta) y Detalle (DAX completo + comentarios del juez por cada respuesta).
Decisiones de diseño que me importan
Streaming en lugar de request/response: la conexión vive mientras el modelo emita tokens. Solo aborto si está 180s sin actividad o 30 minutos totales. Antes usaba un timeout fijo de 300s y los modelos lentos morían a mitad de generación.
Validación heurística post-generación: si la respuesta no contiene =, [ o CALCULATE, marco la generación como fallida. Es un sanity check barato que detecta cuando el modelo se va por las ramas (por ejemplo, devolviendo solo “Aquí tienes la medida:” sin código).
Logs por corrida: cada ejecución genera un .log con timestamp en results/logs/. Cuando algo falla, no tengo que correr de nuevo — abro el log y veo qué pasó por modelo, por pregunta.
Configuración por archivo .env: la API key de Anthropic se carga vía python-dotenv. Cero variables de entorno permanentes en mi sistema, cero secretos en el código.
Cuándo usar cada uno (mi conclusión personal)
Después de correr el experimento varias veces, mi balance es este:
Para tareas DAX sencillas (niveles 1-2) — los modelos locales rinden bastante bien. Si tu uso es escribir medidas básicas mientras armás un dashboard, vale la pena tenerlos a mano. Privacidad total, latencia razonable, costo cero.
Para tareas DAX complejas (niveles 4-5) — la diferencia con Claude se hace notoria. Las medidas con RANKX y variables anidadas, los SWITCH con SELECTEDVALUE, los patrones de inteligencia de tiempo no triviales: ahí los modelos locales empiezan a flaquear. Son el tipo de medida donde un error pasa desapercibido en code review y aparece tres semanas después como “los números no cuadran”.
El uso pragmático que terminé adoptando: borrador con local, revisión final con Claude. El modelo local me da una primera versión rápida, y para medidas críticas le pido a Claude que me la audite o reescriba. Eso me deja gastar centavos donde antes gastaba dólares.
¿Qué sigue?
Si te interesa armar tu propio evaluador, el código está pensado para extenderse:
- Más modelos: cualquier modelo Ollama o cualquier modelo Claude. Solo agregás el nombre a
MODELSenconfig.py. - Más preguntas: agregás un
Questional banco enquestions.pycon su nivel y puntaje máximo. - Otros lenguajes: el evaluador no es específico de DAX. Podrías cambiarlo para SQL, Python, M (Power Query) o lo que sea — solo cambiás el contexto del prompt y la rúbrica.
La idea de fondo me parece replicable: si querés decidir entre dos LLMs para una tarea repetitiva, no preguntes en Twitter qué dice la gente. Armá un benchmark con 10-20 preguntas representativas, dejá que un modelo más fuerte las califique, y mirá los números. Una tarde de trabajo te ahorra meses de “supongo que este es mejor”.
Si querés el repo o tenés ideas para extender el experimento, escribime.
¿Tenés un caso parecido en tu trabajo? ¿Probaste otros modelos locales para tareas técnicas? Me interesa lo que encontraste — dejá un comentario o mandame un mensaje.

Deja una respuesta