Incident s49 — Varianza extrema Auto-Plan en Vertex (C1+C2+C3)
Incident s49 — Varianza extrema Auto-Plan en Vertex (C1+C2+C3)
Resumen ejecutivo
El audit interno s49 detectó una cascada de 3 bugs críticos en el pipeline Auto-Plan que explicaban por qué la misma configuración de blueprint producía resultados completamente distintos entre ejecuciones consecutivas contra el modelo Vertex/Gemma 4 en OpenRouter: 45, 60 o 78 racks con prompt y seed idénticos.
- Detectado: 2026-05-04 (audit s49)
- Fix mergeado: PR#19 · 2026-05-04T12:50:11Z
- Archivos:
blueprints/services/autoplan.py,blueprints/services/openrouter_driver.py - Impacto: reproducibilidad nula de Auto-Plan con cualquier proveedor LLM vía OpenRouter
- Severidad: Alta — output funcional impredecible para el usuario final
Cronología
| Momento | Evento |
|---|---|
| s46 | Primeros glitches JSON detectados en Gemma 4 con outputs >70 elementos (plano 70) |
| s49 | Audit identifica cascada de 3 bugs. Varianza confirmada: 45/60/78 racks misma config |
| PR#19 merge | Fixes C1+C2+C3 aplicados en un único commit a main |
Bugs raíz
C1 — Mutación de seed en retries sin rastreo
Síntoma: En el bucle de retries por JSONDecodeError, el seed en create_kwargs se incrementaba correctamente en el retry, pero la lógica era implícita: si json_attempt == 0 no se tocaba el seed, pero el seed podía no estar en create_kwargs desde el inicio dependiendo de si seed_raw estaba vacío.
Causa: seed se usaba como variable única sin separar la semilla base del desplazamiento de intento. Tras un retry, seed + json_attempt se calculaba sobre la variable local ya modificada en iteraciones anteriores.
Fix: Se introduce base_seed (fijo, leído de env) y effective_seed = base_seed + json_attempt calculado explícitamente en cada iteración. El seed efectivo se loggea siempre en el diagnóstico.
# Antes (buggy):
seed = int(seed_raw) if seed_raw.strip() else None
# ...
if json_attempt > 0 and seed is not None:
create_kwargs["seed"] = seed + json_attempt # mutaba la var local
# Después (fix C1):
base_seed = int(seed_raw) if seed_raw.strip() else None
# ...
effective_seed = (base_seed + json_attempt) if base_seed is not None else None
if effective_seed is not None:
create_kwargs["seed"] = effective_seed
elif "seed" in create_kwargs:
del create_kwargs["seed"]
C2 — allow_fallbacks=True permitía fallback silencioso a otro provider
Síntoma: Aunque el provider_order apuntaba a Vertex, OpenRouter podía silenciosamente redirigir la petición a otro provider (con diferente comportamiento estocástico) sin que el log lo evidenciara. Esto explicaba la varianza extrema entre ejecuciones.
Causa: El campo extra_body.provider.allow_fallbacks estaba hardcodeado a True. Cuando Vertex caía o superaba cuota, OpenRouter reasignaba a otro provider con distribución de probabilidad distinta — haciendo irreproducible el resultado incluso con el mismo seed.
Fix:
allow_fallbackspor defectoFalse, configurable via envOPENROUTER_ALLOW_FALLBACKSrequire_parameters: Trueañadido — rechaza providers que no soportenseed/top_k- Extracción del provider real desde la respuesta mejorada:
model_dump()+model_extrafallback - Log diagnóstico envuelto en try/except para no enmascarar errores de parsing
- Warning explícito si
finish_reason == "length"(truncamiento por max_tokens)
# Antes (buggy):
"extra_body": {"top_k": 64, "provider": {"order": provider_order, "allow_fallbacks": True}}
# Después (fix C2):
allow_fallbacks_raw = (os.environ.get("OPENROUTER_ALLOW_FALLBACKS") or "false").lower().strip()
allow_fallbacks = allow_fallbacks_raw in ("true", "1", "yes")
"extra_body": {
"top_k": 64,
"provider": {
"order": provider_order,
"allow_fallbacks": allow_fallbacks,
"require_parameters": True,
},
}
Hipótesis de causa principal: C2 es el bug más probable como causa raíz de la varianza 45/60/78 racks. Si tras el fix la varianza desaparece, queda confirmado. Si persiste, la variabilidad es estructural del modelo Vertex.
C3 — _clean_llm_json aplicaba regex agresivas a ciegas
Síntoma: En ocasiones, el cleanup de la respuesta LLM corrompía un JSON originalmente válido porque las regex diseñadas para reparar glitches Gemma 4 disparaban falsos positivos en claves de string que contenían :.
Causa: La función _clean_llm_json combinaba transformaciones conservadoras (strip markdown, normalizar comillas) con regex agresivas (reparar "key: sin comilla de cierre) en una sola pasada, sin posibilidad de diagnóstico diferencial.
Ejemplo de falso positivo:
{"text": "PLANTA M0", "x": 140, ": 700, "color": "blue"}
La regex ([{,]\s*)"([A-Za-z_][\w-]*): podía activarse sobre claves con valores string que contenían :.
Fix: Split en dos funciones con responsabilidades distintas:
| Función | Comportamiento | Cuándo aplicar |
|---|---|---|
_clean_llm_json_basic | Solo transformaciones no destructivas: strip markdown, thinking blocks, slicing {…}, trailing commas, smart quotes | Siempre, primera pasada |
_clean_llm_json_aggressive | Regex de reparación Gemma 4 (con lookahead mejorado) | Solo si json.loads falla sobre basic |
_clean_llm_json | Compat alias = basic + aggressive | Para callers legacy (ollama, deepseek) |
El driver openrouter_driver.py ahora aplica la cascada explícitamente:
basic → json.loads → (si falla) aggressive → json.loads → (si falla) retry con seed+1
La regex agresiva se mejoró con lookahead para evitar falsos positivos:
# Antes:
re.sub(r'([{,]\s*)"([A-Za-z_][\w-]*):', r'\1"\2":', text)
# Después (con lookahead):
re.sub(r'([{,]\s*)"([A-Za-z_][A-Za-z0-9_-]*):(?=\s*[\d\-"\[{tfn])', r'\1"\2":', text)
Variables de entorno afectadas
| Variable | Default anterior | Default nuevo | Descripción |
|---|---|---|---|
OPENROUTER_ALLOW_FALLBACKS | N/A (hardcoded True) | false | Controla si OpenRouter puede hacer fallback a otros providers |
OPENROUTER_SEED | Sin cambio | Sin cambio | Semilla base; ahora se preserva correctamente entre retries |
Plan de verificación
- CI verde (Backend + Frontend, modo B)
- Merge → Dokploy redeploya
- Lanzar 3-4 tests con misma config (seed 43, 2048, prompt original)
- Log debe mostrar
served by Googleconsistente Yseed=43en cada attempt - Si varianza desaparece → C2 era la causa principal ✓
- Si varianza persiste → variabilidad estructural de Vertex, no hay más palancas SDK
Impacto en backward compatibility
_clean_llm_jsonse mantiene como alias de compatibilidad para callers existentes (ollama, deepseek). El comportamiento es idéntico al pre-s49 (basic + aggressive).- El cambio de
allow_fallbacksaFalsesí puede impactar en entornos donde Vertex tiene saturación frecuente — en ese caso, activarOPENROUTER_ALLOW_FALLBACKS=trueexplícitamente.
Véase también
- [[entity—blueprints—service—openrouter-driver]]
- [[entity—blueprints—service—autoplan]]
- [[entity—blueprints—model—aiprompt]]
- [[entity—blueprints—endpoint—autoplan-import]]