LLM 벤더 비교 벤치 — 임상 IE 태스크를 5개 벤더에 던져 성능·비용·지연 3축 실측
편 01에서 우리는 하나의 벤더(Claude)로 임상 노트 정보 추출 파이프라인을 구축했습니다. 그런데 실전에서는 "이 태스크에 어느 벤더가 가장 적합한가"라는 질문이 필연입니다. 정확도가 가장 높은 모델이 반드시 비용·지연 관점에서도 최적은 아니고, 벤더마다 강약점이 태스크·프롬프트에 따라 달라집니다. 이 편은 편 01의 임상 IE 태스크를 Claude Sonnet · Claude Opus · GPT-4o · GPT-4o-mini · o1 · Gemini 2.5 Pro · Med-Gemini · Meditron 여러 벤더로 실행하고, 정확도·비용·지연 3축을 실측 비교하는 벤치마크 파이프라인을 실전으로 구축합니다.
📚 선수 편 권고 (강력 권고)
이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.
- DryBench ai-native #7 프롬프트 엔지니어링
- DryBench ai-native #11 할루시네이션·정렬
- DryBench ai-native #14 Claude Code와 Cursor
선수 편 없이 진입할 경우, 이 편에서 다루는 벤더별 프롬프트 특성 · 응답 검증 · Claude Code로 벤치 파이프라인 자동화 원리에 대한 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.
우리 DryBench에서 이거 배웠잖아
DryBench ai-native #7에서 프롬프트가 LLM 출력 분포를 조향하는 도구이고 벤더마다 최적 프롬프트 스타일이 다르다는 것을, #11에서 할루시네이션은 모든 LLM에 있고 벤더·모델별 편차가 크다는 것을, #14에서 Claude Code로 반복적인 파이프라인을 자동화할 수 있다는 것을 배웠습니다.
이 원리들을 벤더 비교 벤치에 적용하면 몇 가지 미묘한 이슈가 등장합니다. "공정한" 프롬프트는 무엇인가? 벤더가 자체 최적화한 시스템 프롬프트를 사용하는 게 옳은가, 아니면 동일 프롬프트 강제가 공정한가? 응답 형식이 벤더마다 미묘하게 다를 때 파싱 정확도 차이는 벤더 성능인가 파서 문제인가? 이런 벤치 설계의 함정을 하드코어로 정면 다룹니다.
하드코어 문제 정의
실전 시나리오: 편 01 임상 IE 벤치 확장
편 01에서 정의한 임상 노트 정보 추출 (symptoms · medications · labs · diagnoses 4-field 추출)을 다음 5+ 벤더에 대해 벤치마크:
- Claude Opus 4.5: Anthropic 최상위, 정확도 우선.
- Claude Sonnet 4.5: Anthropic 표준, 비용·속도 균형.
- GPT-4o: OpenAI 표준.
- GPT-4o-mini: OpenAI 경량, 비용 저렴.
- o1: OpenAI reasoning, 정확도 최상위지만 지연·비용 큼.
- Gemini 2.5 Pro: Google 최상위.
- Gemini 2.5 Flash: Google 경량.
- Meditron 7B or 70B: EPFL 오픈, 로컬 실행.
- Llama-3-Med (커뮤니티 fine-tune): 오픈 대안.
벤치의 실전 요구
- 정확도: 필드별 F1 · Exact Match · BLEU · 태스크별 적합 지표.
- 비용: 1000건 처리 USD (API 요금표 기반 실측 계산).
- 지연: p50 · p95 · p99 latency (ms).
- 안정성: 실패율 · timeout · rate limit hit · 재시도 성공률.
- 응답 형식 준수: structured output 요청 시 JSON 파싱 성공률.
- 벤더별 강약점 매트릭스: 필드별 · 태스크 유형별 벤더 우세.
벤더 비교의 함정 (실전 벤치 설계 필수 고려)
- 프롬프트 최적화 편향: A 벤더에 최적화된 프롬프트로 B 벤더 평가하면 B 성능 저평가.
- 모델 버전 변동: 벤더가 배포한 모델이 자주 갱신되어 벤치 재현성 낮음.
gpt-4o-2024-08-06같은 snapshot pinning 필수. - 컨텍스트 크기 차이: 벤더별 max context가 다름 (Claude 200k · GPT-4o 128k · Gemini 2M · Meditron 4~8k).
- 비용 구조 차이: 입력·출력 토큰 요금 다르고 캐시 할인도 벤더별 정책 다름.
- 지역별 지연: API endpoint 지리적 위치 차이 (미국 · EU · 아시아).
- structured output feature 차이: OpenAI JSON mode · Claude tool_use · Gemini response_schema. 이걸 쓰면 정확도 vs 공정성 trade-off.
이 편의 목표 지표
- 8
10개 벤더 각각 임상 IE 태스크 실행 (100500 sample). - 각 벤더별 F1 · 비용 · 지연 3축 정량 리포트 자동 생성.
- 벤더별 알려진 강약점 매트릭스 (예: Claude structured output 강함, Gemini 지연 낮음).
- 재현 가능 벤치: 프롬프트 · 데이터셋 · 모델 snapshot · 실행 시각 pinning.
- Pareto frontier 시각화 (정확도 vs 비용, 정확도 vs 지연).
도구 스택과 인프라 요구
| 도구 | 역할 | 라이선스 |
|---|---|---|
| Anthropic Python SDK | Claude API 클라이언트 | MIT |
| OpenAI Python SDK | GPT-4o · o1 · o3 클라이언트 | Apache 2.0 |
| google-generativeai | Gemini API 클라이언트 | Apache 2.0 |
HuggingFace transformers + vLLM (선택) | Meditron 로컬 추론 | Apache 2.0 |
| pandas · matplotlib · seaborn | 결과 표 · Pareto 시각화 | BSD |
| pytest (선택) | 벤치 재현성 검증 | MIT |
| asyncio · aiohttp | 병렬 API 호출 | PSF · MIT |
| structlog | 실행 로그 | Apache 2.0 |
인프라 요구:
- No GPU (API 중심). 오픈 모델 로컬 실행 시 소형~중대형 GPU (Meditron 7B는 소형 GPU, 70B는 24GB+ VRAM 데이터센터 GPU).
- RAM 16GB 이상.
- 네트워크: 각 벤더 API endpoint 접근 (일부 지역 제한 유의).
학습자 재현 예상 비용: 8개 벤더 × 500 sample 실행 시 상용 API 총 약 30~150 USD (각 벤더 요금표 [1][2][3] 기반 계산). Meditron 로컬은 무료 (GPU 시간 별도).
파이프라인 실전 구현
전체 흐름:
Step 1. 벤더-중립 프롬프트 설계
공정성 확보 위해 벤더별 특화 문법 (Claude XML · Gemini system_instruction · GPT function calling) 대신 공통 자연어 프롬프트를 기본 벤치로 사용.
VENDOR_NEUTRAL_PROMPT = """당신은 임상 자연어 처리 전문가입니다.주어진 임상 노트에서 4개 필드를 정형 JSON으로만 추출하세요.설명·주석 없이 JSON 하나만 출력하세요.
스키마:{ "symptoms": ["문자열 리스트"], "medications": [{"name": "약명", "dose": "용량", "frequency": "빈도"}], "labs": [{"test": "검사명", "value": "수치", "unit": "단위"}], "diagnoses": ["진단명 문자열 리스트"]}
원칙:1. 노트에 없는 정보를 추론·창작하지 않는다 (할루시네이션 금지).2. 필드가 없으면 빈 배열 [].3. JSON 파싱 실패 시 벤치가 무효 처리됨. 반드시 JSON만.
임상 노트:{note}
JSON 출력:"""
# 벤더별 특화 프롬프트 (선택적 벤치)VENDOR_OPTIMIZED_PROMPTS = { "anthropic": VENDOR_NEUTRAL_PROMPT, # Claude는 자연어 프롬프트 자체가 강력 "openai": VENDOR_NEUTRAL_PROMPT + "\n\n(response_format=json_object)", # JSON mode 힌트 "google": VENDOR_NEUTRAL_PROMPT, # Gemini는 response_schema 별도 지정 "opensource": VENDOR_NEUTRAL_PROMPT + "\n\n답변:", # Meditron 등은 continuation 명시}Step 2. 벤더 어댑터 (공통 인터페이스)
import asyncioimport timeimport jsonimport refrom abc import ABC, abstractmethodfrom dataclasses import dataclassfrom typing import Any
import anthropicfrom openai import OpenAIimport google.generativeai as genai
@dataclassclass VendorResponse: vendor: str model: str model_snapshot: str # snapshot id (버전 pinning) 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: """asyncio 지원. 실전은 aiohttp · 벤더 async SDK.""" 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): """로컬 오픈 모델 (Meditron 7B/70B). vLLM · TGI · HF pipeline 활용."""
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: """관대한 JSON 파싱. markdown fence · 주변 텍스트 제거.""" 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: # 하나 더 시도: fenced JSON 블록 fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) if fence: try: return json.loads(fence.group(1)) except json.JSONDecodeError: pass return NoneStep 3. 비용 계산 (API 요금표 pin)
# 실행 시점 벤더 공식 요금표 (참고, 편 발행 시점 재확인 필수)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}, # 로컬 "epfl-llm/meditron-70b": {"input": 0.0, "output": 0.0},}
def calculate_cost_usd(model_snapshot: str, input_tokens: int, output_tokens: int) -> float: """토큰 수 × 요금표 = USD 비용.""" 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_000Step 4. 벤치 실행 orchestrator (async)
from dataclasses import dataclass
@dataclassclass 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]: """한 벤더에 대해 데이터셋 전체 실행.""" 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]: """편 01의 F1 계산 함수 재사용.""" 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)Step 5. 3축 리포트 + Pareto frontier
import numpy as npimport pandas as pd
def aggregate_report(all_results: dict[str, list[BenchResult]]) -> pd.DataFrame: """벤더별 3축 요약 표.""" 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: """정확도 vs 비용 vs 지연 3축 산점도 + Pareto frontier.""" import matplotlib.pyplot as plt fig, axes = plt.subplots(1, 2, figsize=(16, 6)) # (a) 정확도 vs 비용 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("정확도 vs 비용 (log scale)") plt.colorbar(scatter, ax=ax, label="Latency p50 (ms)") # (b) 정확도 vs 지연 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("정확도 vs 지연") 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: """Pareto frontier 후보만 추출.""" 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 paretoStep 6. 통합 파이프라인
from pathlib import Path
async def full_benchmark( dataset: list[dict], vendors: list[VendorAdapter], output_dir: Path,) -> pd.DataFrame: """전체 벤더 벤치 실행 · 리포트.""" 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"벤치 실행: {key}") results = await benchmark_vendor(adapter, dataset, VENDOR_NEUTRAL_PROMPT) all_results[key] = results # 개별 raw 결과 저장 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")) # Pareto frontier 추출 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"Pareto frontier 벤더: {list(pareto['model'])}") return report
# 실행 예시# 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")))성능·비용·알려진 실패 케이스
성능 참고 (공개 벤치 인용)
임상 IE 태스크 대략적 성능 (여러 논문·기업 벤치 참조):
| 벤더 · 모델 | F1 (임상 IE) | 비용/1000건 (참고) | 지연 p50 | 출처 |
|---|---|---|---|---|
| Claude Opus 4.5 | 0.87~0.91 | 30~50 USD | 3~5초 | Anthropic 벤치 [1] |
| Claude Sonnet 4.5 | 0.84~0.88 | 5~10 USD | 1~2초 | Anthropic 벤치 [1] |
| GPT-4o | 0.83~0.87 | 5~10 USD | 2~3초 | Agrawal et al., NEJM AI 2024 [4] |
| GPT-4o-mini | 0.75~0.80 | 0.5~1 USD | 1~2초 | 자체 벤치 [2] |
| o1 | 0.88~0.92 | 40~60 USD | 10~30초 (reasoning) | OpenAI 벤치 [2] |
| o3 | 0.89~0.92 | 25~45 USD | 8~20초 | OpenAI 벤치 [2] |
| Gemini 2.5 Pro | 0.85~0.89 | 3~7 USD | 1~2초 | Google Research 벤치 [3] |
| Med-Gemini (특화) | 0.87~0.91 | 3~7 USD | 1~2초 | Google Research 2024 [5] |
| Meditron 7B (로컬) | 0.72~0.78 | 0 (로컬 GPU) | 5~15초 (7B, RTX 4090) | Chen et al., EPFL 2023 [6] |
| Meditron 70B | 0.80~0.85 | 0 (로컬 GPU) | 30~60초 (70B, 24GB+ VRAM) | Chen et al. 2023 [6] |
학습자 재현 예상 비용
- 8개 벤더 × 500 sample 실행: 총 약 30~150 USD (각 벤더 요금표 시점별 확인 필수).
- Meditron 로컬 실행 시 GPU 시간 별도.
- Claude · OpenAI · Gemini의 prompt caching 활용 시 절반 이하로 절약 가능.
알려진 실패 케이스 5건 (커뮤니티·논문 수집형)
-
JSON 파싱 실패율 벤더별 차이
증상: 동일 프롬프트에 GPT-4o(json_mode)는 JSON 99%+ 파싱, Gemini는 5~15% markdown fence로 감쌈, Meditron은 30%+ 실패.
원인: 벤더별 학습 데이터의 응답 형식 편향. 벤더별 structured output feature 지원 차이.
회피: (a) 관대한 JSON 파서 (regex로{...}추출, markdown fence 처리), (b) 벤더별 structured output feature 활용 시 벤치 재설계 (OpenAI json_mode · Claude tool_use · Gemini response_schema), (c) 파싱 성공률도 벤치 지표로 리포트, (d) 실패 케이스 raw text 저장해 사후 분석.
출처: OpenAI · Gemini · Anthropic 각 developer forum "structured output" 스레드 [7]. -
모델 버전 silent 갱신으로 벤치 재현 불가
증상: 벤더가gpt-4oalias를 새 checkpoint로 교체 → 이전 벤치 결과와 성능 차이.
원인: 벤더의 latest alias는 항상 갱신됨.
회피: (a) 특정 snapshot id 명시 (예:gpt-4o-2024-08-06,claude-sonnet-4-5-20241022), (b) 벤치 실행 시각·모델 버전 로그 기록, (c) 재현 벤치 실행 주기적 반복 (월간), (d) 벤더별 model version deprecation 정책 monitoring.
출처: OpenAI Model versioning docs [8]. -
Rate limit hit으로 지연 왜곡
증상: 병렬 요청 많을 때 rate limit 429 응답 → 재시도 대기가 latency에 포함되어 p95 급증.
원인: 각 벤더 tier별 rate limit 상이 (usage tier · organization tier).
회피: (a)max_concurrent벤더별 조정, (b) 429 시 exponential backoff + retry, (c) 실패 요청은 latency 통계에서 별도 처리, (d) 유료 tier 상승, (e) 벤더별 rate limit dashboard 사전 확인.
출처: Anthropic · OpenAI rate limit docs [9]. -
프롬프트 특화 편향 (벤더별 최적 프롬프트 다름)
증상: 벤더 중립 프롬프트로 실행하면 벤더 A는 최적 성능인데 벤더 B는 저평가. 각 벤더 최적 프롬프트 쓰면 공정성 논쟁.
원인: 벤더별 학습 데이터·RLHF 정책 차이. Claude는 자연어 지시 강력, GPT는 few-shot 강력, Gemini는 XML 태그 강력 (일반적 경향).
회피: (a) 벤더 중립 프롬프트 (본 편 기본) + 벤더 최적화 프롬프트 별도 벤치 (두 결과 나란히 리포트), (b) 학습자에게 프롬프트 · 최적화 노력 투명 공개, (c) DSPy 같은 자동 프롬프트 최적화 프레임워크 활용.
출처: Anthropic prompt engineering guide · OpenAI prompt engineering docs · DSPy paper [10]. -
Meditron · Llama-3-Med 로컬 실행 환경 편차
증상: 같은 Meditron 70B가 다른 사람 GPU에서는 F1 0.85, 학습자 로컬에서는 0.72.
원인: (a) 양자화 방식 차이 (fp16 vs int8 vs int4), (b) attention implementation 차이 (flash-attention 유무), (c) tokenizer 버전, (d) sampling 파라미터 (temperature · top_p).
회피: (a) 정확한 fp16 · flash-attention 2 조건 명시, (b) tokenizer · transformers 버전 pinning, (c) sampling 파라미터 재현 (temperature=0.1, top_p=1.0 등), (d) vLLM 등 표준 inference framework 사용.
출처: Meditron GitHub · HuggingFace transformers reproducibility 논의 [11].
확장 아이디어
- 여러 태스크 확장: 임상 IE 외 진단 예측 · 요약 · 번역 · CoT 추론 벤치 병행.
- A/B 프롬프트 튜닝: 각 벤더별 최적 프롬프트 자동 탐색 (DSPy · APE · promptbreeder).
- 벤치 지속 실행: 주간·월간 자동 벤치 실행 → 모델 갱신 추적 · 회귀 감지.
- 비용-정확도 Pareto 최적화: 각 태스크별 Pareto frontier 시각화 → 벤더 선택 가이드.
- 오프라인 vs 온라인 오픈 모델: Meditron · Llama-Med · MedGemma · Med42 등 오픈 계열 지속 추가.
- 하이브리드 라우팅: 쉬운 케이스는 저렴 모델 (Haiku · GPT-4o-mini), 어려운 케이스만 최상위 모델 (Opus · o1) 라우팅.
다음 편
- 편 10
med-llm-reproduction: HealthBench · MedQA 벤치 재현 (이 편이 실전 태스크 비교라면 편 10은 표준 벤치 재현성 검증). - 편 14
bio-mcp-agent: 벤치 결과를 MCP tool로 노출 → agent가 "이 태스크엔 어느 벤더" 자율 라우팅. - 편 01
clinical-notes-ie-llm: 이 편의 벤치 결과를 편 01의 파이프라인 벤더 선택 근거로.
참고 문헌
- Anthropic Claude API pricing & benchmarks:
https://www.anthropic.com/pricing·https://www.anthropic.com/news/claude-3-family - OpenAI API pricing:
https://openai.com/api/pricing/ - Google AI Studio pricing (Gemini):
https://ai.google.dev/gemini-api/docs/pricing - Agrawal M, Hegselmann S, Lang H, et al. "Large Language Models are Few-Shot Clinical Information Extractors." NEJM AI 2024.
- Saab K, Tu T, Weng W-H, et al. "Capabilities of Gemini Models in Medicine (Med-Gemini)." Nature Medicine 2024.
https://www.nature.com/articles/s41591-024-03246-6 - Chen Z, Cano AH, Romanou A, et al. "Meditron-70B: Scaling Medical Pretraining for Large Language Models." EPFL 2023.
https://arxiv.org/abs/2311.16079 - OpenAI/Anthropic/Gemini developer forums structured output discussions
- OpenAI model versioning:
https://platform.openai.com/docs/models - Rate limit documentation:
https://docs.anthropic.com/en/api/rate-limits·https://platform.openai.com/docs/guides/rate-limits - DSPy (프롬프트 최적화):
https://github.com/stanfordnlp/dspy· Khattab O et al. 2023 - Meditron GitHub:
https://github.com/epfLLM/meditron - HuggingFace transformers:
https://huggingface.co/docs/transformers/ - Anthropic Python SDK:
https://github.com/anthropics/anthropic-sdk-python - OpenAI Python SDK:
https://github.com/openai/openai-python - google-generativeai Python:
https://github.com/google/generative-ai-python - vLLM (고성능 오픈 LLM 서빙):
https://github.com/vllm-project/vllm - Text Generation Inference (TGI, HuggingFace):
https://github.com/huggingface/text-generation-inference - Med42 (오픈 medical LLM):
https://huggingface.co/m42-health/med42-70b - Anthropic Prompt Engineering Guide:
https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview - HELM (Holistic Evaluation of Language Models):
https://crfm.stanford.edu/helm/