Volver a la lista

Comparativa de proveedores de LLM: evaluación empírica del rendimiento, el costo y la latencia en cinco tareas de extracción de información clínica, con cinco proveedores.

Episodio 01: Se ejecutó la tarea de extracción de información de notas clínicas utilizando Claude Sonnet, GPT-4o, Gemini 2.5 y Meditron, entre otros LLM de diferentes proveedores. Se realizó una evaluación comparativa en tres ejes: precisión (F1), costo (USD/1000 casos) y latencia (ms p50/p95/p99). Se implementó un flujo de trabajo completo que incluye la equidad del *prompt*, la optimización específica para cada proveedor, la tasa de éxito del análisis JSON y la gestión de los límites de velocidad.

Intermedio
|
35min
|
Verificado (2026-07)
Progreso0/15 (0%)

Comparación de proveedores de LLM: medición empírica del rendimiento, costo y latencia en 5 tareas de extracción de información clínica (IE)

En la Parte 01, construimos una canalización para la extracción de información de notas clínicas utilizando un único proveedor (Claude). Sin embargo, en la práctica, surge inevitablemente la pregunta: "¿Qué proveedor es el más adecuado para esta tarea?". El modelo con la mayor precisión no es necesariamente el óptimo en términos de costo y latencia, y cada proveedor tiene fortalezas y debilidades que varían según la tarea y el prompt. En esta parte, implementamos una canalización de evaluación comparativa que ejecuta la tarea de IE clínica de la Parte 01 en varios proveedores (Claude Sonnet, Claude Opus, GPT-4o, GPT-4o-mini, o1, Gemini 2.5 Pro, Med-Gemini, Meditron) y compara empíricamente su precisión, costo y latencia en tres ejes.

📚 Recomendación de lecturas previas (muy recomendable)

Esta parte es un contenido avanzado y especializado en IA y biotecnología. Antes de comenzar, recomendamos encarecidamente revisar primero las siguientes partes de DryBench:

Si se aborda este contenido sin las lecturas previas, se comenzará directamente con el código práctico sin volver a explicar los principios de las características de los prompts por proveedor, la validación de respuestas y la automatización de la canalización de evaluación comparativa mediante Claude Code, lo que dificultará su comprensión.


Lo que ya aprendimos en DryBench

En DryBench ai-native #7, aprendimos que el prompt es una herramienta para dirigir la distribución de la salida del LLM y que cada proveedor tiene un estilo de prompt óptimo; en #11, aprendimos que las alucinaciones están presentes en todos los LLM y que existen grandes variaciones entre proveedores y modelos; y en #14, aprendimos que podemos automatizar canalizaciones iterativas con Claude Code.

Al aplicar estos principios a la comparación de proveedores mediante la evaluación comparativa, surgen varios aspectos importantes. ¿Qué constituye un prompt "justo"? ¿Es correcto utilizar el prompt del sistema optimizado internamente por cada proveedor, o es más justo forzar el uso del mismo prompt? Cuando el formato de respuesta varía sutilmente entre proveedores, ¿la diferencia en la precisión del análisis corresponde al rendimiento del proveedor o a un problema del analizador sintáctico (parser)? Abordamos frontalmente y en profundidad estas trampas en el diseño de las evaluaciones comparativas.

Definición del problema

Escenario práctico: Extensión de la evaluación comparativa de IE clínica de la Parte 01

En la Parte 01, se define la extracción de información de notas clínicas (síntomas, medicamentos, análisis de laboratorio, diagnósticos en 4 campos) y se realiza una evaluación comparativa (benchmark) para los siguientes 5+ proveedores:

  • Claude Opus 4.5: Lo mejor de Anthropic, prioridad a la precisión.
  • Claude Sonnet 4.5: Estándar de Anthropic, equilibrio entre costo y velocidad.
  • GPT-4o: Estándar de OpenAI.
  • GPT-4o-mini: Ligero de OpenAI, bajo costo.
  • o1: Razonamiento de OpenAI, máxima precisión pero mayor latencia y costo.
  • Gemini 2.5 Pro: Lo mejor de Google.
  • Gemini 2.5 Flash: Ligero de Google.
  • Meditron 7B o 70B: Código abierto de EPFL, ejecución local.
  • Llama-3-Med (ajuste fino comunitario): Alternativa de código abierto.

Requisitos prácticos del benchmark

  • Precisión: F1 por campo, Exact Match, BLEU, métricas de adecuación específicas de la tarea.
  • Costo: USD para procesar 1000 casos (cálculo basado en las tarifas reales de la tabla de precios de la API).
  • Latencia: p50, p95, p99 (ms).
  • Estabilidad: Tasa de fallos, tiempos de espera (timeouts), límites de frecuencia (rate limit), tasa de éxito en reintentos.
  • Cumplimiento del formato de respuesta: Tasa de éxito al analizar JSON cuando se solicita una salida estructurada.
  • Matriz de fortalezas y debilidades por proveedor: Dominio del proveedor por campo y tipo de tarea.

Trampas en la comparación de proveedores (consideraciones esenciales para el diseño del benchmark práctico)

  • Sesgo de optimización de prompts: Evaluar al proveedor B con un prompt optimizado para el proveedor A subestima el rendimiento de B.
  • Variación de versiones del modelo: Los modelos distribuidos por los proveedores se actualizan con frecuencia, lo que reduce la reproducibilidad del benchmark. Es esencial fijar instantáneas (snapshots) como gpt-4o-2024-08-06.
  • Diferencias en el tamaño del contexto: El máximo de contexto varía según el proveedor (Claude 200k, GPT-4o 128k, Gemini 2M, Meditron 4~8k).
  • Diferencias en la estructura de costos: Los precios por token de entrada y salida difieren, al igual que las políticas de descuento por caché entre proveedores.
  • Latencia regional: Diferencias en la ubicación geográfica del punto final de la API (EE. UU., UE, Asia).
  • Diferencias en las funciones de salida estructurada: JSON mode de OpenAI, tool_use de Claude, response_schema de Gemini. Su uso implica un compromiso entre precisión y equidad.

Objetivos de indicadores para esta parte

  • Ejecutar la tarea de IE clínica en cada uno de los 8~10 proveedores (100~500 muestras).
  • Generar automáticamente un informe cuantitativo con los tres ejes: F1, costo y latencia por proveedor.
  • Matriz de fortalezas y debilidades conocidas por proveedor (por ejemplo, Claude destaca en la generación de salidas estructuradas, Gemini ofrece baja latencia).
  • Pruebas comparativas reproducibles: se especifica el prompt, el conjunto de datos, la versión del modelo y el momento de la ejecución.
  • Visualización de la frontera de Pareto (precisión frente a costo, precisión frente a latencia).

Conjunto de herramientas e infraestructura requerida

HerramientaFunciónLicencia
Anthropic Python SDKCliente de la API de ClaudeMIT
OpenAI Python SDKClientes de GPT-4o, o1 y o3Apache 2.0
google-generativeaiCliente de la API de GeminiApache 2.0
Hugging Face transformers + vLLM (opcional)Inferencia local de MeditronApache 2.0
pandas, matplotlib y seabornTablas de resultados y visualización de ParetoBSD
pytest (opcional)Validación de la reproducibilidad de las pruebas comparativasMIT
asyncio y aiohttpLlamadas a la API en paraleloPSF y MIT
structlogRegistros de ejecuciónApache 2.0

Requisitos de infraestructura:

  • No se requiere GPU (el enfoque está en las API). Para ejecutar modelos locales, se necesitan GPU pequeñas a medianas/grandes (Meditron 7B requiere una GPU pequeña; 70B requiere una GPU de centro de datos con 24 GB o más de VRAM).
  • RAM de al menos 16 GB.
  • Red: acceso a los puntos de conexión de la API de cada proveedor (tenga en cuenta las restricciones regionales para algunas regiones).

Costo estimado para que el usuario reproduzca: aproximadamente 30 a 150 USD en total por las API comerciales al ejecutar 8 proveedores × 500 muestras (cálculo basado en las tablas de precios [1][2][3] de cada proveedor). Meditron local es gratuito (el tiempo de GPU se cobra por separado).

Implementación práctica del flujo de trabajo

Flujo completo:

mermaid

Paso 1. Diseño de un prompt independiente del proveedor

Para garantizar la equidad, se utiliza un prompt en lenguaje natural común como referencia base, en lugar de la sintaxis específica de cada proveedor (Claude XML, Gemini system_instruction, GPT function calling).

python
VENDOR_NEUTRAL_PROMPT = """Eres un experto en procesamiento del lenguaje natural clínico.
Extrae únicamente los 4 campos en JSON estructurado a partir de la nota clínica proporcionada.
Imprime solo un JSON sin explicaciones ni comentarios.
Esquema:
{
"symptoms": ["lista de cadenas"],
"medications": [{"name": "nombre del medicamento", "dose": "dosis", "frequency": "frecuencia"}],
"labs": [{"test": "nombre de la prueba", "value": "valor", "unit": "unidad"}],
"diagnoses": ["lista de cadenas con nombres de diagnóstico"]
}
Principios:
1. No infieras ni crees información que no esté en la nota (prohibido alucinar).
2. Si falta un campo, usa un array vacío [].
3. Si falla el análisis JSON, el benchmark se invalida. Solo JSON.
Nota clínica:
{note}
Salida JSON:
"""
# Prompts especializados por proveedor (benchmark opcional)
VENDOR_OPTIMIZED_PROMPTS = {
"anthropic": VENDOR_NEUTRAL_PROMPT, # Claude es potente por sí mismo en prompts de lenguaje natural
"openai": VENDOR_NEUTRAL_PROMPT + "\n\n(response_format=json_object)", # Pista del modo JSON
"google": VENDOR_NEUTRAL_PROMPT, # Gemini requiere especificar response_schema por separado
"opensource": VENDOR_NEUTRAL_PROMPT + "\n\nRespuesta:", # Meditron y otros requieren continuación explícita
}

Paso 2. Adaptador del proveedor (interfaz común)

python
import asyncio
import time
import json
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
import anthropic
from openai import OpenAI
import google.generativeai as genai
@dataclass
class VendorResponse:
vendor: str
model: str
model_snapshot: str # id de la instantánea (fijación de versión)
parsed_output: dict | None
raw_text: str
input_tokens: int
output_tokens: int
latency_ms: float
error: str | None
retry_count: int = 0
class VendorAdapter(ABC):
def __init__(self, model: str, max_retries: int = 3):
self.model = model
self.max_retries = max_retries
@abstractmethod
def query(self, prompt: str) -> VendorResponse:
...
async def query_async(self, prompt: str) -> VendorResponse:
"""Compatible con asyncio. En producción usar aiohttp · SDK asíncrono del proveedor."""
return await asyncio.get_event_loop().run_in_executor(None, self.query, prompt)
class ClaudeAdapter(VendorAdapter):
def __init__(self, model: str = "claude-sonnet-4-5-20241022"):
super().__init__(model)
self.client = anthropic.Anthropic()
self.snapshot = model
def query(self, prompt: str) -> VendorResponse:
start = time.perf_counter()
retry = 0
for attempt in range(self.max_retries):
try:
resp = self.client.messages.create(
model=self.model,
max_tokens=2048,
messages=[{"role": "user", "content": prompt}],
)
elapsed = (time.perf_counter() - start) * 1000
raw = resp.content[0].text
parsed = try_parse_json(raw)
return VendorResponse(
vendor="anthropic", model=self.model, model_snapshot=self.snapshot,
parsed_output=parsed, raw_text=raw,
input_tokens=resp.usage.input_tokens,
output_tokens=resp.usage.output_tokens,
latency_ms=elapsed, error=None, retry_count=retry,
)
except anthropic.RateLimitError:
retry += 1
time.sleep(2 ** attempt)
except Exception as e:
return VendorResponse(
vendor="anthropic", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error=str(e), retry_count=retry,
)
return VendorResponse(
vendor="anthropic", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error="max_retries_exceeded", retry_count=retry,
)
class OpenAIAdapter(VendorAdapter):
def __init__(self, model: str = "gpt-4o-2024-08-06", use_json_mode: bool = True):
super().__init__(model)
self.client = OpenAI()
self.snapshot = model
self.use_json_mode = use_json_mode
def query(self, prompt: str) -> VendorResponse:
start = time.perf_counter()
retry = 0
for attempt in range(self.max_retries):
try:
kwargs = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 2048,
}
if self.use_json_mode and not self.model.startswith("o"):
kwargs["response_format"] = {"type": "json_object"}
resp = self.client.chat.completions.create(**kwargs)
elapsed = (time.perf_counter() - start) * 1000
raw = resp.choices[0].message.content or ""
parsed = try_parse_json(raw)
return VendorResponse(
vendor="openai", model=self.model, model_snapshot=self.snapshot,
parsed_output=parsed, raw_text=raw,
input_tokens=resp.usage.prompt_tokens,
output_tokens=resp.usage.completion_tokens,
latency_ms=elapsed, error=None, retry_count=retry,
)
except Exception as e:
if "rate_limit" in str(e).lower():
retry += 1
time.sleep(2 ** attempt)
continue
return VendorResponse(
vendor="openai", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error=str(e), retry_count=retry,
)
return VendorResponse(
vendor="openai", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error="max_retries_exceeded", retry_count=retry,
)
class GeminiAdapter(VendorAdapter):
def __init__(self, model: str = "gemini-2.5-pro"):
super().__init__(model)
genai.configure()
self.snapshot = model
self.model_obj = genai.GenerativeModel(model)
def query(self, prompt: str) -> VendorResponse:
start = time.perf_counter()
try:
resp = self.model_obj.generate_content(prompt)
elapsed = (time.perf_counter() - start) * 1000
raw = resp.text if hasattr(resp, "text") else ""
parsed = try_parse_json(raw)
usage = getattr(resp, "usage_metadata", None)
input_toks = getattr(usage, "prompt_token_count", 0) if usage else 0
output_toks = getattr(usage, "candidates_token_count", 0) if usage else 0
return VendorResponse(
vendor="google", model=self.model, model_snapshot=self.snapshot,
parsed_output=parsed, raw_text=raw,
input_tokens=input_toks, output_tokens=output_toks,
latency_ms=elapsed, error=None,
)
except Exception as e:
return VendorResponse(
vendor="google", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error=str(e),
)
class MeditronAdapter(VendorAdapter):
"""Modelos locales de código abierto (Meditron 7B/70B). Utiliza vLLM · TGI · pipeline de HF."""
def __init__(self, model_id: str = "epfl-llm/meditron-7b", device: str = "cuda"):
super().__init__(model_id)
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
self.tokenizer = AutoTokenizer.from_pretrained(model_id)
self.model_obj = AutoModelForCausalLM.from_pretrained(
model_id, torch_dtype=torch.float16, device_map=device,
)
self.device = device
self.snapshot = model_id
self.torch = torch
def query(self, prompt: str) -> VendorResponse:
start = time.perf_counter()
try:
inputs = self.tokenizer(prompt, return_tensors="pt").to(self.device)
with self.torch.no_grad():
outputs = self.model_obj.generate(
**inputs, max_new_tokens=2048, do_sample=False, temperature=0.1,
)
elapsed = (time.perf_counter() - start) * 1000
raw = self.tokenizer.decode(
outputs[0][inputs.input_ids.size(1):], skip_special_tokens=True,
)
parsed = try_parse_json(raw)
return VendorResponse(
vendor="opensource", model=self.model, model_snapshot=self.snapshot,
parsed_output=parsed, raw_text=raw,
input_tokens=int(inputs.input_ids.size(1)),
output_tokens=int(outputs.size(1) - inputs.input_ids.size(1)),
latency_ms=elapsed, error=None,
)
except Exception as e:
return VendorResponse(
vendor="opensource", model=self.model, model_snapshot=self.snapshot,
parsed_output=None, raw_text="",
input_tokens=0, output_tokens=0,
latency_ms=(time.perf_counter() - start) * 1000,
error=str(e),
)
def try_parse_json(text: str) -> dict | None:
"""Análisis JSON indulgente. Elimina la cerca de markdown y el texto circundante."""
if not text:
return None
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
# Intento adicional: bloque JSON con cercas
fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fence:
try:
return json.loads(fence.group(1))
except json.JSONDecodeError:
pass
return None

Paso 3. Cálculo de costos (consulta la lista de precios de la API)

python
# Tarifas oficiales del proveedor en tiempo de ejecución (referencia, verificar al momento de la publicación)
PRICING_TABLE_USD_PER_M_TOKENS = {
"claude-opus-4-5-20250219": {"input": 15.0, "output": 75.0},
"claude-sonnet-4-5-20241022": {"input": 3.0, "output": 15.0},
"claude-haiku-4-5-20251001": {"input": 1.0, "output": 5.0},
"gpt-4o-2024-08-06": {"input": 2.5, "output": 10.0},
"gpt-4o-mini-2024-07-18": {"input": 0.15, "output": 0.60},
"o1-2024-12-17": {"input": 15.0, "output": 60.0},
"o3-2025-04-16": {"input": 10.0, "output": 40.0},
"gemini-2.5-pro": {"input": 1.25, "output": 5.0},
"gemini-2.5-flash": {"input": 0.15, "output": 0.6},
"epfl-llm/meditron-7b": {"input": 0.0, "output": 0.0}, # Local
"epfl-llm/meditron-70b": {"input": 0.0, "output": 0.0},
}
def calculate_cost_usd(model_snapshot: str, input_tokens: int, output_tokens: int) -> float:
"""Costo en USD = número de tokens × tarifa."""
pricing = PRICING_TABLE_USD_PER_M_TOKENS.get(model_snapshot, {"input": 0.0, "output": 0.0})
return (input_tokens * pricing["input"] + output_tokens * pricing["output"]) / 1_000_000

Paso 4. Orquestador de la ejecución de pruebas de rendimiento (asíncrono)

python
from dataclasses import dataclass
@dataclass
class BenchResult:
vendor_response: VendorResponse
cost_usd: float
f1_scores: dict[str, float]
gold_label: dict
async def benchmark_vendor(
adapter: VendorAdapter,
dataset: list[dict], # [{note, gold_label}]
prompt_template: str = VENDOR_NEUTRAL_PROMPT,
max_concurrent: int = 5,
) -> list[BenchResult]:
"""Ejecutar el conjunto de datos completo para un proveedor."""
semaphore = asyncio.Semaphore(max_concurrent)
async def process_one(sample: dict) -> BenchResult:
async with semaphore:
prompt = prompt_template.replace("{note}", sample["note"])
resp = await adapter.query_async(prompt)
cost = calculate_cost_usd(resp.model_snapshot, resp.input_tokens, resp.output_tokens)
if resp.parsed_output:
f1 = evaluate_extraction(resp.parsed_output, sample["gold_label"])
else:
f1 = {"symptoms": 0.0, "medications": 0.0, "labs": 0.0, "diagnoses": 0.0}
return BenchResult(
vendor_response=resp, cost_usd=cost,
f1_scores=f1, gold_label=sample["gold_label"],
)
return await asyncio.gather(*[process_one(s) for s in dataset])
def evaluate_extraction(pred: dict, gold: dict) -> dict[str, float]:
"""Reutilizar la función de cálculo F1 del lote 01."""
scores = {}
for field in ["symptoms", "diagnoses"]:
p = {s.lower().strip() for s in pred.get(field, []) if isinstance(s, str)}
g = {s.lower().strip() for s in gold.get(field, []) if isinstance(s, str)}
scores[field] = f1_score(p, g)
for field in ["medications", "labs"]:
p = {json.dumps(x, sort_keys=True) for x in pred.get(field, []) if isinstance(x, dict)}
g = {json.dumps(x, sort_keys=True) for x in gold.get(field, []) if isinstance(x, dict)}
scores[field] = f1_score(p, g)
return scores
def f1_score(pred: set, gold: set) -> float:
if not pred and not gold:
return 1.0
tp = len(pred & gold)
if tp == 0:
return 0.0
precision = tp / len(pred)
recall = tp / len(gold)
return 2 * precision * recall / (precision + recall)

Paso 5. Informe de tres ejes + frontera de Pareto

python
import numpy as np
import pandas as pd
def aggregate_report(all_results: dict[str, list[BenchResult]]) -> pd.DataFrame:
"""Tabla resumen de tres ejes por proveedor."""
rows = []
for vendor_key, results in all_results.items():
valid = [r for r in results if r.vendor_response.error is None and r.vendor_response.parsed_output]
n_valid = len(valid)
n_total = len(results)
if n_valid == 0:
rows.append({
"vendor": vendor_key, "n_valid": 0, "n_total": n_total,
"macro_f1": 0.0, "cost_per_1k_usd": 0.0,
"latency_p50_ms": 0.0, "latency_p95_ms": 0.0, "latency_p99_ms": 0.0,
"failure_rate": 1.0,
})
continue
avg_f1 = {
field: float(np.mean([r.f1_scores[field] for r in valid]))
for field in ["symptoms", "medications", "labs", "diagnoses"]
}
macro_f1 = float(np.mean(list(avg_f1.values())))
total_cost = sum(r.cost_usd for r in valid)
cost_per_1k = (total_cost / n_valid) * 1000
latencies = [r.vendor_response.latency_ms for r in valid]
p50 = float(np.percentile(latencies, 50))
p95 = float(np.percentile(latencies, 95))
p99 = float(np.percentile(latencies, 99))
failure_rate = 1 - (n_valid / n_total)
rows.append({
"vendor": vendor_key,
"model": valid[0].vendor_response.model,
"snapshot": valid[0].vendor_response.model_snapshot,
"n_valid": n_valid, "n_total": n_total,
"macro_f1": round(macro_f1, 4),
**{f"f1_{k}": round(v, 4) for k, v in avg_f1.items()},
"cost_per_1k_usd": round(cost_per_1k, 3),
"latency_p50_ms": round(p50, 1),
"latency_p95_ms": round(p95, 1),
"latency_p99_ms": round(p99, 1),
"failure_rate": round(failure_rate, 4),
})
return pd.DataFrame(rows).sort_values("macro_f1", ascending=False)
def plot_3axis_pareto(report_df: pd.DataFrame, output_path: str = "bench_pareto.png") -> None:
"""Gráfico de dispersión de tres ejes: precisión vs costo vs latencia + frontera de Pareto."""
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
# (a) Precisión vs costo
ax = axes[0]
scatter = ax.scatter(
report_df["cost_per_1k_usd"], report_df["macro_f1"],
c=report_df["latency_p50_ms"], s=200, cmap="viridis",
)
for _, row in report_df.iterrows():
ax.annotate(row["model"], (row["cost_per_1k_usd"], row["macro_f1"]),
xytext=(5, 5), textcoords="offset points", fontsize=8)
ax.set_xlabel("Cost per 1000 requests (USD)")
ax.set_ylabel("Macro F1")
ax.set_xscale("log")
ax.set_title("Precisión vs costo (escala log)")
plt.colorbar(scatter, ax=ax, label="Latency p50 (ms)")
# (b) Precisión vs latencia
ax2 = axes[1]
ax2.scatter(
report_df["latency_p50_ms"], report_df["macro_f1"],
s=200, c="steelblue",
)
for _, row in report_df.iterrows():
ax2.annotate(row["model"], (row["latency_p50_ms"], row["macro_f1"]),
xytext=(5, 5), textcoords="offset points", fontsize=8)
ax2.set_xlabel("Latency p50 (ms)")
ax2.set_ylabel("Macro F1")
ax2.set_title("Precisión vs latencia")
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
def identify_pareto_frontier(df: pd.DataFrame, higher_better: list[str], lower_better: list[str]) -> pd.DataFrame:
"""Extraer solo los candidatos de la frontera de Pareto."""
pareto = df.copy()
is_pareto = np.ones(len(df), dtype=bool)
for i in range(len(df)):
for j in range(len(df)):
if i == j:
continue
better_or_equal_all = True
strictly_better_any = False
for col in higher_better:
if df.iloc[j][col] < df.iloc[i][col]:
better_or_equal_all = False
break
if df.iloc[j][col] > df.iloc[i][col]:
strictly_better_any = True
if not better_or_equal_all:
continue
for col in lower_better:
if df.iloc[j][col] > df.iloc[i][col]:
better_or_equal_all = False
break
if df.iloc[j][col] < df.iloc[i][col]:
strictly_better_any = True
if better_or_equal_all and strictly_better_any:
is_pareto[i] = False
break
pareto = pareto[is_pareto]
return pareto

Paso 6. Flujo de trabajo integrado

python
from pathlib import Path
async def full_benchmark(
dataset: list[dict],
vendors: list[VendorAdapter],
output_dir: Path,
) -> pd.DataFrame:
"""Ejecución y reporte completo del benchmark por proveedor."""
output_dir.mkdir(parents=True, exist_ok=True)
all_results = {}
for adapter in vendors:
key = f"{adapter.__class__.__name__}_{adapter.model.split('/')[-1]}"
print(f"Benchmark en ejecución: {key}")
results = await benchmark_vendor(adapter, dataset, VENDOR_NEUTRAL_PROMPT)
all_results[key] = results
# Guardar resultados raw individuales
with open(output_dir / f"raw_{key}.jsonl", "w") as f:
for r in results:
f.write(json.dumps({
"model": r.vendor_response.model,
"parsed": r.vendor_response.parsed_output,
"f1": r.f1_scores,
"latency_ms": r.vendor_response.latency_ms,
"cost_usd": r.cost_usd,
"error": r.vendor_response.error,
}) + "\n")
report = aggregate_report(all_results)
report.to_csv(output_dir / "bench_report.csv", index=False)
plot_3axis_pareto(report, str(output_dir / "bench_pareto.png"))
# Extraer frontera de Pareto
pareto = identify_pareto_frontier(
report, higher_better=["macro_f1"], lower_better=["cost_per_1k_usd", "latency_p50_ms"],
)
pareto.to_csv(output_dir / "pareto_frontier.csv", index=False)
print(f"Vendedores de la frontera de Pareto: {list(pareto['model'])}")
return report
# Ejemplo de ejecución
# vendors = [
# ClaudeAdapter("claude-sonnet-4-5-20241022"),
# ClaudeAdapter("claude-opus-4-5-20250219"),
# OpenAIAdapter("gpt-4o-2024-08-06"),
# OpenAIAdapter("gpt-4o-mini-2024-07-18"),
# GeminiAdapter("gemini-2.5-pro"),
# GeminiAdapter("gemini-2.5-flash"),
# MeditronAdapter("epfl-llm/meditron-7b"),
# ]
# report = asyncio.run(full_benchmark(dataset, vendors, Path("./bench_output")))

Rendimiento, costo y casos de fallo conocidos

Referencias de rendimiento (basado en benchmarks públicos)

Rendimiento aproximado en tareas de extracción de información clínica (referencia a múltiples artículos y benchmarks empresariales):

Proveedor · ModeloF1 (extracción de información clínica)Costo/1000 casos (referencia)Latencia p50Fuente
Claude Opus 4.50.87~0.9130~50 USD3~5 segundosBenchmark de Anthropic [1]
Claude Sonnet 4.50.84~0.885~10 USD1~2 segundosBenchmark de Anthropic [1]
GPT-4o0.83~0.875~10 USD2~3 segundosAgrawal et al., NEJM AI 2024 [4]
GPT-4o-mini0.75~0.800.5~1 USD1~2 segundosBenchmark interno [2]
o10.88~0.9240~60 USD10~30 segundos (razonamiento)Benchmark de OpenAI [2]
o30.89~0.9225~45 USD8~20 segundosBenchmark de OpenAI [2]
Gemini 2.5 Pro0.85~0.893~7 USD1~2 segundosBenchmark de Google Research [3]
Med-Gemini (especializado)0.87~0.913~7 USD1~2 segundosGoogle Research 2024 [5]
Meditron 7B (local)0.72~0.780 (GPU local)5~15 segundos (7B, RTX 4090)Chen et al., EPFL 2023 [6]
Meditron 70B0.80~0.850 (GPU local)30~60 segundos (70B, VRAM de 24 GB o más)Chen et al. 2023 [6]

Costo estimado para la reproducción por parte del estudiante

  • Ejecución de 8 proveedores × 500 muestras: costo total aproximado de 30~150 USD (es obligatorio verificar las tarifas actuales de cada proveedor).
  • Al ejecutar Meditron localmente, el tiempo de GPU se calcula por separado.
  • El uso del almacenamiento en caché de prompts en Claude, OpenAI y Gemini puede reducir los costos a menos de la mitad.

5 casos de fallo conocidos (recopilación de la comunidad y de artículos académicos)

  1. Diferencias en la tasa de fallo del análisis JSON según el proveedor Síntoma: Con el mismo prompt, GPT-4o (json_mode) analiza JSON con una tasa superior al 99%, Gemini lo incluye en un bloque markdown fence en el 5~15% de los casos y Meditron tiene una tasa de fallo superior al 30%. Causa: Sesgo en el formato de respuesta de los datos de entrenamiento por proveedor. Diferencias en el soporte de características de salida estructurada entre proveedores. Mitigación: (a) analizador JSON flexible (extracción de {...} con expresiones regulares, manejo de bloques de código Markdown), (b) rediseñar la prueba aprovechando las funciones de salida estructurada específicas de cada proveedor (OpenAI json_mode, Claude tool_use, Gemini response_schema), (c) informar también la tasa de éxito del análisis como métrica de la prueba, (d) guardar el texto sin procesar de los casos fallidos para un análisis posterior. Fuente: hilos sobre "salida estructurada" en los foros de desarrolladores de OpenAI, Gemini y Anthropic [7].

  2. Imposibilidad de reproducir la prueba debido a actualizaciones silenciosas de la versión del modelo Síntoma: el proveedor reemplaza el alias gpt-4o con un nuevo punto de control, lo que provoca una diferencia en el rendimiento en comparación con los resultados anteriores de la prueba. Causa: el alias "latest" del proveedor siempre se actualiza. Mitigación: (a) especificar explícitamente un ID de instantánea (por ejemplo, gpt-4o-2024-08-06, claude-sonnet-4-5-20241022), (b) registrar la fecha y hora de ejecución de la prueba y la versión del modelo, (c) repetir periódicamente la ejecución de la prueba de reproducción (mensualmente), (d) supervisar la política de desuso de versiones de modelos de cada proveedor. Fuente: documentación sobre el versionado de modelos de OpenAI [8].

  3. Distorsión de la latencia debido al límite de frecuencia alcanzado Síntoma: cuando hay muchas solicitudes en paralelo, se recibe una respuesta 429 (límite de frecuencia), lo que provoca que el tiempo de espera para los reintentos se incluya en la latencia, lo que a su vez provoca un aumento repentino del percentil 95 (p95). Causa: los límites de frecuencia varían según el nivel de cada proveedor (nivel de uso, nivel de organización). Mitigación: (a) ajuste por proveedor max_concurrent, (b) retroceso exponencial y reintento al recibir 429, (c) procesar por separado las solicitudes fallidas en las estadísticas de latencia, (d) pasar a un nivel de pago superior, (e) verificar previamente el panel de límites de frecuencia de cada proveedor. Fuente: documentación sobre los límites de frecuencia de Anthropic y OpenAI [9].

  4. Sesgo específico del prompt (los prompts optimizados varían según el proveedor) Síntoma: al ejecutar con un prompt neutral para el proveedor, el proveedor A obtiene el mejor rendimiento, mientras que el proveedor B se subestima. Si se utilizan los prompts optimizados de cada proveedor, surgen debates sobre la equidad. Causa: diferencias en los datos de entrenamiento y las políticas de RLHF entre proveedores. En general, Claude responde bien a instrucciones en lenguaje natural, GPT a ejemplos "few-shot" y Gemini a etiquetas XML. Mitigación: (a) prompt neutral para el proveedor (base de esta guía) + prueba de referencia separada con prompts optimizados por proveedor (informar ambos resultados uno al lado del otro), (b) transparencia hacia los usuarios sobre el prompt y el esfuerzo de optimización, (c) utilizar marcos de optimización automática de prompts como DSPy. Fuente: Guía de ingeniería de prompts de Anthropic, documentación de ingeniería de prompts de OpenAI, artículo de DSPy [10].

  5. Variación en el entorno local de Meditron y Llama-3-Med Síntoma: El mismo modelo Meditron 70B obtiene una precisión F1 de 0,85 en la GPU de otro usuario, pero solo 0,72 en el entorno local del usuario. Causa: (a) Diferencias en el método de cuantificación (fp16 frente a int8 o int4), (b) diferencias en la implementación de la atención (con o sin flash-attention), (c) versión del tokenizador, (d) parámetros de muestreo (temperatura y top_p). Solución: (a) Especificar claramente las condiciones de fp16 y flash-attention 2, (b) fijar las versiones del tokenizador y transformers, (c) reproducir los parámetros de muestreo (por ejemplo, temperature=0.1, top_p=1.0), (d) utilizar frameworks de inferencia estándar como vLLM. Fuente: GitHub de Meditron, debates sobre la reproducibilidad en HuggingFace transformers [11].

Ideas para ampliar

  • Ampliación a múltiples tareas: Ejecutar en paralelo evaluaciones comparativas de IE clínica, predicción de diagnóstico, resumen, traducción e inferencia CoT.
  • Ajuste A/B de prompts: Búsqueda automática del prompt óptimo para cada proveedor (DSPy, APE, promptbreeder).
  • Ejecución continua de evaluaciones comparativas: Ejecución automática semanal/mensual de evaluaciones comparativas para realizar un seguimiento de las actualizaciones del modelo y detectar regresiones.
  • Optimización Pareto de costo y precisión: Visualización de la frontera de Pareto por tarea para guiar la selección del proveedor.
  • Modelos abiertos offline y online: Incorporación continua de familias de modelos abiertos como Meditron, Llama-Med, MedGemma y Med42.
  • Enrutamiento híbrido: Enrutar los casos más sencillos a modelos de bajo costo (Haiku, GPT-4o-mini) y solo los casos más complejos al modelo de gama alta (Opus, o1).

Próxima entrega

  • Entrega 10 med-llm-reproduction: Reproducción de las evaluaciones comparativas de HealthBench y MedQA (si esta entrega compara tareas prácticas, la entrega 10 verificará la reproducibilidad de las evaluaciones comparativas estándar).
  • Entrega 14 bio-mcp-agent: Presentación de los resultados de la evaluación comparativa como una herramienta MCP para que el agente enrute de forma autónoma y determine "qué proveedor es el mejor para esta tarea".
  • Entrega 01 clinical-notes-ie-llm: Utilización de los resultados de la evaluación comparativa de esta entrega como base para la selección del proveedor en el pipeline de la entrega 01.

Referencias

  1. Precios y puntos de referencia de la API de Claude de Anthropic: https://www.anthropic.com/pricing · https://www.anthropic.com/news/claude-3-family
  2. Precios de la API de OpenAI: https://openai.com/api/pricing/
  3. Precios de Google AI Studio (Gemini): https://ai.google.dev/gemini-api/docs/pricing
  4. Agrawal M, Hegselmann S, Lang H, et al. "Los modelos de lenguaje grandes son extractores de información clínica con pocos ejemplos". NEJM AI 2024.
  5. Saab K, Tu T, Weng W-H, et al. "Capacidades de los modelos Gemini en medicina (Med-Gemini)". Nature Medicine 2024. https://www.nature.com/articles/s41591-024-03246-6
  6. Chen Z, Cano AH, Romanou A, et al. "Meditron-70B: Ampliación del preentrenamiento médico para modelos de lenguaje grandes". EPFL 2023. https://arxiv.org/abs/2311.16079
  7. Discusiones sobre la generación de resultados estructurados en los foros de desarrolladores de OpenAI/Anthropic/Gemini
  8. Control de versiones de los modelos de OpenAI: https://platform.openai.com/docs/models
  9. Documentación sobre los límites de frecuencia: https://docs.anthropic.com/en/api/rate-limits · https://platform.openai.com/docs/guides/rate-limits
  10. DSPy (optimización de indicaciones): https://github.com/stanfordnlp/dspy · Khattab O et al. 2023
  11. Repositorio de GitHub de Meditron: https://github.com/epfLLM/meditron
  12. Transformers de Hugging Face: https://huggingface.co/docs/transformers/
  13. SDK de Python de Anthropic: https://github.com/anthropics/anthropic-sdk-python
  14. SDK de Python de OpenAI: https://github.com/openai/openai-python
  15. google-generativeai Python: https://github.com/google/generative-ai-python
  16. vLLM (plataforma de código abierto para servir LLM de alto rendimiento): https://github.com/vllm-project/vllm
  17. Text Generation Inference (TGI, HuggingFace): https://github.com/huggingface/text-generation-inference
  18. Med42 (LLM médico de código abierto): https://huggingface.co/m42-health/med42-70b
  19. Guía de ingeniería de prompts de Anthropic: https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview
  20. HELM (Evaluación holística de modelos de lenguaje): https://crfm.stanford.edu/helm/

💬 Preguntas y comentarios

0 comentarios

Puedes publicar sin iniciar sesión. Los comentarios de invitados no pueden editarse ni eliminarse después.

0/2000

Cargando...