Brave como respaldo de DuckDuckGo, y arreglo de la guardia de bf16

Cadena de búsqueda con respaldo. DuckDuckGo sigue de primario porque no exige
credenciales: en el camino habitual ningún tercero se entera de qué busca la
familia. Pero su endpoint lite no es una API con contrato, así que cuando
limite por tasa o cambie el HTML, Brave responde.

Dos matices del encadenado:

- Cero resultados NO dispara el respaldo. Si el primario respondió bien y no
  encontró nada, esa es la respuesta correcta; encadenar gastaría cuota y
  devolvería resultados peores. Solo se avanza ante un error real.
- La cadena recuerda quién respondió: al depurar una respuesta rara, lo
  primero que hay que saber es de dónde salió.

Un respaldo sin credencial falla al arrancar y no en la primera consulta —
que es justo cuando el primario ya falló y el respaldo tiene que funcionar.

Aparte, verificando torch en la 2060 apareció un defecto real en backends.py:
torch.cuda.is_bf16_supported() usa including_emulation=True por defecto, así
que devuelve True en Turing, donde bf16 no existe en silicio. La guardia no
habría atrapado el perfil de la 5090 corriendo en la 2060: el entrenamiento
seguía, emulado y silencioso. Medido en la placa: 4,49 ms por matmul en bf16
contra 1,75 ms en fp16, unas 2,6x más lento. Ahora se consulta con
including_emulation=False, con respaldo por compute capability.

11 tests nuevos (113 en total).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 00:39:13 -03:00
parent 5b9f2a58bc
commit 36040d7cc8
8 changed files with 433 additions and 43 deletions
+134 -11
View File
@@ -147,12 +147,72 @@ class DuckDuckGoBackend:
raise SearchError(f"DuckDuckGo no respondió tras {self.retries + 1} intentos: {ultimo}")
class BraveBackend:
"""Brave Search API. Una API con contrato, a diferencia del endpoint lite.
Existe como red de seguridad del primario: cuando DuckDuckGo limite por tasa
o cambie el HTML, esto responde. La contrapartida es que cada consulta pasa
por un tercero asociado a una cuenta, así que no se usa como primario salvo
que se elija explícitamente.
"""
name = "brave"
ENDPOINT = "https://api.search.brave.com/res/v1/web/search"
def __init__(
self, api_key: str, timeout: float = 10.0, country: str = "uy", language: str = "es"
) -> None:
self.api_key = api_key
self.timeout = timeout
self.country = country
self.language = language
def search(self, query: str, max_results: int) -> list[SearchResult]:
url = self.ENDPOINT + "?" + urllib.parse.urlencode(
{
"q": query,
"count": max_results,
"country": self.country,
"search_lang": self.language,
}
)
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"X-Subscription-Token": self.api_key,
"User-Agent": _USER_AGENT,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as respuesta:
payload = json.loads(respuesta.read())
except urllib.error.HTTPError as exc:
# 429 es el caso interesante: si el fallback también está limitado,
# el mensaje tiene que decirlo en vez de parecer un fallo genérico.
detalle = "límite de tasa alcanzado" if exc.code == 429 else f"HTTP {exc.code}"
raise SearchError(f"Brave rechazó la consulta: {detalle}") from None
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
raise SearchError(f"Brave no respondió: {exc}") from None
resultados = payload.get("web", {}).get("results", [])
return [
SearchResult(
title=_clean(item.get("title", "")),
url=item.get("url", ""),
snippet=_clean(item.get("description", "")),
)
for item in resultados[:max_results]
if item.get("url", "").startswith("http")
]
class SearxNGBackend:
"""SearxNG autoalojado: la opción estable y sin depender de terceros.
Es el destino natural cuando el endpoint lite empiece a fallar o cuando el
volumen de consultas de la casa lo justifique. SearxNG puede agregar
DuckDuckGo entre sus fuentes, así que el resultado es equivalente.
Es el destino natural cuando el volumen de consultas de la casa lo
justifique. SearxNG puede agregar DuckDuckGo entre sus fuentes, así que el
resultado es equivalente sin exponer las consultas a un tercero.
"""
name = "searxng"
@@ -183,6 +243,51 @@ class SearxNGBackend:
]
class CadenaDeBackends:
"""Prueba los backends en orden y devuelve el primero que responda.
La razón de existir: el endpoint lite de DuckDuckGo no es una API con
contrato — puede limitar por tasa o cambiar el HTML sin aviso. Con esto un
fallo deja de ser una respuesta vacía para la familia y pasa a ser un
reintento contra otro proveedor.
Dos matices que importan:
- **Cero resultados no es un fallo.** Si el primario responde bien y no
encontró nada, esa es la respuesta correcta; encadenar al siguiente
proveedor por eso gastaría cuota y devolvería resultados peores.
Solo se avanza ante un error real.
- **Se recuerda quién respondió** (`ultimo_backend`), porque al depurar
una respuesta rara lo primero que hay que saber es de dónde salió.
"""
def __init__(self, backends: list[SearchBackend]) -> None:
if not backends:
raise SearchError("la cadena de búsqueda no tiene ningún backend")
self.backends = backends
self.ultimo_backend: str | None = None
@property
def name(self) -> str:
return " -> ".join(getattr(b, "name", type(b).__name__) for b in self.backends)
def search(self, query: str, max_results: int) -> list[SearchResult]:
fallos: list[str] = []
for backend in self.backends:
nombre = getattr(backend, "name", type(backend).__name__)
try:
resultados = backend.search(query, max_results)
except SearchError as exc:
fallos.append(f"{nombre}: {exc}")
continue
self.ultimo_backend = nombre
return resultados
raise SearchError(
"ningún backend de búsqueda respondió — " + " | ".join(fallos)
)
class WebSearch:
"""Tool de búsqueda: backend + presupuesto de contexto + caché.
@@ -234,15 +339,23 @@ class WebSearch:
)
def build_backend(config) -> SearchBackend:
"""Construye el backend declarado en la config del agente."""
if config.backend == "duckduckgo":
def _construir_uno(nombre: str, config) -> SearchBackend:
if nombre == "duckduckgo":
return DuckDuckGoBackend(
region=config.region,
timeout=config.timeout,
retries=config.retries,
region=config.region, timeout=config.timeout, retries=config.retries
)
if config.backend == "searxng":
if nombre == "brave":
if not config.brave_api_key:
raise SearchError(
"backend brave sin brave_api_key: definí ENLACE_BRAVE_API_KEY en .env"
)
return BraveBackend(
api_key=config.brave_api_key,
timeout=config.timeout,
country=config.country,
language=config.language,
)
if nombre == "searxng":
if not config.searxng_url:
raise SearchError(
"backend searxng sin searxng_url: definí ENLACE_SEARXNG_URL en .env"
@@ -250,7 +363,17 @@ def build_backend(config) -> SearchBackend:
return SearxNGBackend(
base_url=config.searxng_url, timeout=config.timeout, language=config.language
)
raise SearchError(f"backend de búsqueda desconocido: {config.backend}")
raise SearchError(f"backend de búsqueda desconocido: {nombre}")
def build_backend(config) -> SearchBackend:
"""Construye la cadena declarada en la config: primario + respaldos.
Con un solo backend devuelve ese backend pelado, para no envolver en una
cadena algo que no la necesita.
"""
cadena = [_construir_uno(nombre, config) for nombre in config.cadena]
return cadena[0] if len(cadena) == 1 else CadenaDeBackends(cadena)
def build_search(config) -> WebSearch:
+7
View File
@@ -15,6 +15,8 @@ from typing import Any
from omegaconf import DictConfig, OmegaConf
from enlace.config.env import load_dotenv
from enlace.config.schema import AgentConfig, Config, DistillConfig
# Familias de capas que puede declarar un YAML raíz, en el orden en que se
@@ -114,6 +116,11 @@ def _load_single(path: Path, overrides: list[str] | None, model_cls, etiqueta: s
if not path.is_file():
raise ConfigError(f"no existe el archivo de config: {path}")
# `${oc.env:...}` lee os.environ al resolver, así que .env tiene que estar
# cargado antes: si no, una credencial bien escrita en el archivo se
# resuelve a vacío y el backend parece mal configurado.
load_dotenv()
merged = _load_yaml(path)
if overrides:
merged = OmegaConf.merge(merged, OmegaConf.from_dotlist(list(overrides)))
+40 -11
View File
@@ -202,15 +202,23 @@ class TrainConfig(_Base):
class SearchConfig(_Base):
"""Búsqueda web. Sin API key: DuckDuckGo no exige credenciales.
"""Búsqueda web, con cadena de respaldo.
`searxng` existe como alternativa autoalojada para cuando el endpoint de
DuckDuckGo falle o el volumen de consultas lo justifique; cambiar de backend
es cambiar este campo.
El primario es DuckDuckGo porque no exige credenciales: nada sale a un
tercero salvo la consulta misma. Pero su endpoint lite no es una API con
contrato — puede limitar por tasa o cambiar el HTML sin aviso — así que
`fallbacks` define a quién preguntarle cuando eso pase.
El orden importa y es explícito: se prueba `backend` y después cada entrada
de `fallbacks`, en orden, hasta que alguno responda.
"""
backend: Literal["duckduckgo", "searxng"] = "duckduckgo"
backend: Literal["duckduckgo", "brave", "searxng"] = "duckduckgo"
fallbacks: list[Literal["duckduckgo", "brave", "searxng"]] = Field(
default_factory=list
)
region: str = "es-es"
country: str = "uy"
language: str = "es"
max_results: int = Field(default=5, gt=0, le=20)
# Presupuesto de contexto: con seq_len 2048 hay que elegir entre resultados
@@ -221,14 +229,35 @@ class SearchConfig(_Base):
retries: int = Field(default=2, ge=0)
cache_ttl: float = Field(default=300.0, ge=0)
searxng_url: str | None = None
brave_api_key: str | None = None
@property
def cadena(self) -> list[str]:
"""El primario seguido de los respaldos, sin repetidos."""
orden = [self.backend, *self.fallbacks]
vistos: list[str] = []
for nombre in orden:
if nombre not in vistos:
vistos.append(nombre)
return vistos
@model_validator(mode="after")
def _check_backend(self) -> SearchConfig:
if self.backend == "searxng" and not self.searxng_url:
raise ValueError(
"search.backend='searxng' exige searxng_url "
"(definí ENLACE_SEARXNG_URL en .env)."
)
def _check_backends(self) -> SearchConfig:
# Un backend sin credencial se descubriría recién en la primera consulta
# real, que es justo cuando el primario ya falló y el respaldo tiene que
# funcionar. Se valida al arrancar.
requisitos = {
"searxng": ("searxng_url", "ENLACE_SEARXNG_URL"),
"brave": ("brave_api_key", "ENLACE_BRAVE_API_KEY"),
}
for nombre in self.cadena:
requisito = requisitos.get(nombre)
if requisito and not getattr(self, requisito[0]):
rol = "backend" if nombre == self.backend else "fallback"
raise ValueError(
f"search: el {rol} '{nombre}' exige {requisito[0]} "
f"(definí {requisito[1]} en .env)."
)
return self
+23 -4
View File
@@ -27,6 +27,25 @@ def resolve_dtype(name: str) -> torch.dtype:
return _DTYPES[name]
def _bf16_nativo() -> bool:
"""¿La GPU tiene unidades bfloat16 de verdad, o PyTorch lo emula?
Trampa verificada en hardware: `torch.cuda.is_bf16_supported()` usa
`including_emulation=True` por defecto, así que devuelve True en Turing —
donde bf16 no existe en silicio y se emula. Medido en una RTX 2060, esa
emulación es ~2,6x más lenta que float16 (4,49 vs 1,75 ms por matmul de
2048x2048). Confiar en el default deja pasar el perfil equivocado y el
entrenamiento corre a un tercio de velocidad sin una sola advertencia.
bfloat16 nativo llega con Ampere (sm_80).
"""
try:
return torch.cuda.is_bf16_supported(including_emulation=False)
except TypeError:
# Versiones de torch anteriores al parámetro: se decide por capability.
return torch.cuda.get_device_capability(0) >= (8, 0)
def describe_device(cfg: HardwareConfig) -> str:
if cfg.device == "cpu":
return "cpu"
@@ -64,12 +83,12 @@ def setup_device(cfg: HardwareConfig) -> torch.device:
f"sm_{cap[0]}{cap[1]}."
)
# Turing (sm_75) no tiene unidades bfloat16: PyTorch lo emula y el
# entrenamiento se vuelve inservible en vez de fallar. Mejor fallar.
if cfg.dtype == "bfloat16" and not torch.cuda.is_bf16_supported():
if cfg.dtype == "bfloat16" and not _bf16_nativo():
raise HardwareError(
f"el perfil '{cfg.name}' pide bfloat16, que esta GPU (sm_{cap[0]}{cap[1]}) "
"no soporta de forma nativa. Usá float16 con use_grad_scaler=true."
"no soporta de forma nativa: PyTorch lo emula. Medido en una RTX 2060, "
"la emulación es ~2,6x más lenta que float16. "
"Usá float16 con use_grad_scaler=true."
)
# FlashAttention-2 requiere Ampere o superior.