Generación de datos sintéticos con la Batch API

Etapa 3 del plan. Se elige la Batch API sobre llamadas sueltas porque cuesta
la mitad y generar un dataset no es sensible a la latencia: es exactamente su
caso de uso. A cambio el trabajo es asincrónico y puede tardar horas, así que
todo el estado vive en disco y es reanudable — matar el proceso, perder la
conexión o reiniciar el servidor no pierde nada ni gasta de nuevo.

Cuatro decisiones que sostienen eso:

- El custom_id se deriva del hash del contenido del pedido. El trabajo queda
  idempotente: re-generar la misma especificación produce los mismos IDs, así
  que lo ya resuelto se reconoce y no se vuelve a pagar.
- Los resultados se indexan por custom_id, nunca por posición. La API los
  devuelve en cualquier orden, y emparejarlos por índice mezcla las respuestas
  en silencio; un dataset mal alineado es peor que uno vacío porque parece
  correcto. El cliente falso de los tests los invierte a propósito.
- Los fallos se registran en errors.jsonl con su motivo en vez de descartarse,
  y --retry-failed reenvía solo esos.
- El prompt de sistema compartido va marcado para caché: es idéntico entre
  miles de pedidos, y a ~0,1x del precio de entrada deja de contar.

Los lotes se persisten después de cada envío y no al final, para que una caída
a mitad de camino no deje lotes huérfanos sin registrar.

El SDK de anthropic se importa de forma perezosa y queda como extra opcional:
el servidor de entrenamiento no lo necesita y el paquete importa sin él.

21 tests nuevos (92 en total), todos contra un cliente falso — lo que hay que
verificar es el harness, no el SDK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:26:41 -03:00
parent 1d2fcef4d0
commit 8805b0541b
5 changed files with 419 additions and 18 deletions
+9 -1
View File
@@ -4,11 +4,17 @@ Todo hiperparámetro, ruta y umbral vive en `configs/*.yaml`, se compone por
capas y se valida con pydantic antes de que arranque cualquier proceso largo.
"""
from enlace.config.load import load_agent_config, load_config, load_config_from_argv
from enlace.config.load import (
load_agent_config,
load_config,
load_config_from_argv,
load_distill_config,
)
from enlace.config.schema import (
AgentConfig,
Config,
DataConfig,
DistillConfig,
HardwareConfig,
ModelConfig,
OptimizerConfig,
@@ -21,6 +27,7 @@ __all__ = [
"AgentConfig",
"Config",
"DataConfig",
"DistillConfig",
"HardwareConfig",
"ModelConfig",
"OptimizerConfig",
@@ -30,4 +37,5 @@ __all__ = [
"load_agent_config",
"load_config",
"load_config_from_argv",
"load_distill_config",
]
+38 -17
View File
@@ -15,7 +15,7 @@ from typing import Any
from omegaconf import DictConfig, OmegaConf
from enlace.config.schema import AgentConfig, Config
from enlace.config.schema import AgentConfig, Config, DistillConfig
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
# fusionan. El orden solo importa para los mensajes de error.
@@ -100,19 +100,17 @@ def load_config(path: str | Path, overrides: list[str] | None = None) -> Config:
raise ConfigError(f"config inválida ({path}):\n{exc}") from None
def load_agent_config(
path: str | Path | None = None, overrides: list[str] | None = None
) -> AgentConfig:
"""Carga la config del runtime del agente.
CONFIGS_ROOT = Path(__file__).resolve().parents[2] / "configs"
Es un árbol aparte del de entrenamiento: el agente no necesita saber nada
del optimizador ni del schedule. Los valores que salen de `.env` se resuelven
con `${oc.env:...}`, así que los secretos y las URLs de la instalación nunca
entran en un archivo commiteado.
def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: str):
"""Carga un YAML suelto y lo valida. No compone capas.
Lo usan los árboles de config que no son de entrenamiento (agente,
destilación): no necesitan saber nada del optimizador ni del schedule.
Los valores que salen de `.env` se resuelven con `${oc.env:...}`, así que
los secretos nunca entran en un archivo commiteado.
"""
if path is None:
path = Path(__file__).resolve().parents[2] / "configs" / "agent" / "tools.yaml"
path = Path(path)
if not path.is_file():
raise ConfigError(f"no existe el archivo de config: {path}")
@@ -124,14 +122,37 @@ def load_agent_config(
# OmegaConf devuelve cadena vacía cuando la variable de entorno no está
# definida; para pydantic eso tiene que ser ausencia, no un valor vacío.
search = data.get("search")
if isinstance(search, dict) and search.get("searxng_url") == "":
search["searxng_url"] = None
_vaciar_a_none(data)
try:
return AgentConfig.model_validate(data)
return model_cls.model_validate(data)
except Exception as exc:
raise ConfigError(f"config de agente inválida ({path}):\n{exc}") from None
raise ConfigError(f"config de {etiqueta} inválida ({path}):\n{exc}") from None
def _vaciar_a_none(data: dict) -> None:
"""Convierte las cadenas vacías de `${oc.env:VAR,""}` en ausencia, recursivo."""
for clave, valor in data.items():
if isinstance(valor, dict):
_vaciar_a_none(valor)
elif valor == "":
data[clave] = None
def load_agent_config(
path: str | Path | None = None, overrides: list[str] | None = None
) -> AgentConfig:
"""Carga la config del runtime del agente."""
path = Path(path) if path is not None else CONFIGS_ROOT / "agent" / "tools.yaml"
return _load_single(path, overrides, AgentConfig, "agente")
def load_distill_config(
path: str | Path | None = None, overrides: list[str] | None = None
) -> DistillConfig:
"""Carga la config de generación de datos sintéticos por lotes."""
path = Path(path) if path is not None else CONFIGS_ROOT / "data" / "distill.yaml"
return _load_single(path, overrides, DistillConfig, "destilación")
def load_config_from_argv(argv: list[str] | None = None) -> Config:
+34
View File
@@ -242,6 +242,40 @@ class AgentConfig(_Base):
search: SearchConfig = SearchConfig()
class DistillConfig(_Base):
"""Generación de datos sintéticos por lotes (Etapa 3 del plan).
Se usa la Batch API y no llamadas sueltas por dos razones: cuesta la mitad,
y generar un dataset no es sensible a la latencia — es exactamente su caso
de uso. A cambio hay que tratar el trabajo como asincrónico, con estado en
disco y reanudable, que es lo que hace `enlace/data/distill.py`.
"""
model: str = "claude-opus-5"
# En este modelo el pensamiento está activo por defecto y `max_tokens` acota
# pensamiento + respuesta juntos: sin holgura la salida se corta a la mitad.
max_tokens: int = Field(default=8000, gt=0)
effort: Literal["low", "medium", "high", "xhigh", "max"] = "low"
# Límites de la Batch API: 100.000 pedidos o 256 MB por lote. Se parte en
# trozos bien por debajo del tope para que un trabajo grande no dependa de
# que una sola request HTTP enorme llegue entera.
requests_per_batch: int = Field(default=10_000, gt=0, le=100_000)
poll_interval_seconds: float = Field(default=60.0, ge=5.0)
# La API promete resultados en menos de 24 h; el tope propio es una red de
# seguridad para que un proceso olvidado no quede colgado para siempre.
max_wait_hours: float = Field(default=26.0, gt=0)
output_dir: str = "data/distill"
# Solo para el reporte de costo estimado; los precios reales los factura
# Anthropic. Se declaran acá para no hardcodearlos en el código.
usd_per_mtok_input: float = Field(default=5.0, ge=0)
usd_per_mtok_output: float = Field(default=25.0, ge=0)
batch_discount: float = Field(default=0.5, gt=0, le=1.0)
class Config(_Base):
"""Config raíz: la composición de todas las capas."""