Claude Haiku와 예산 제한을 사용하여 쌍별(pairwise) AI 모델 비교 페이지를 구축한 방법
요약
Claude Haiku를 활용하여 AI 모델 간의 쌍별(pairwise) 비교 페이지를 효율적으로 구축하는 방법을 다룹니다. 비용 최적화를 위해 모델 그룹화 및 생성 제한 전략을 사용하며, 결정론적 슬러그 생성을 통해 ETL 프로세스의 멱등성을 확보하는 기술적 접근법을 소개합니다.
핵심 포인트
- Claude Haiku를 사용해 비용 효율적인 모델 비교 콘텐츠 생성
- 조합론적 폭발을 방지하기 위한 파이프라인별 모델 그룹화 및 상위 모델 제한
- 결정론적 슬러그 생성을 통한 ETL 프로세스의 멱등성 유지
- 정적 사이트 생성(SSG) 환경에서의 빌드 타임 계산 최적화
Top AI Tools 디렉토리에 비교 페이지를 추가했을 때, 제가 가장 먼저 답해야 했던 질문은 '실제로 몇 개의 쌍(pair)을 살펴보고 있는가?'였습니다. 8개의 파이프라인 태그(pipeline tags)에 걸쳐 약 200개의 모델이 있으므로, 단순하게 계산한 상한선은 200 × 199 / 2 ≈ 19,900개의 쌍입니다. Claude Haiku를 사용하여 각 쌍에 대한 콘텐츠를 생성하는 데는 1회 실행당 약 20달러 정도의 비용이 들 것입니다. 파산할 정도는 아니지만, 신중하게 생각하지 않고 매일 실행하고 싶은 수준도 아니었습니다.
제가 실제로 구축한 것, 부족한 점, 그리고 처음부터 다시 시작한다면 다르게 할 점들을 소개합니다.
조합론 문제 (The combinatorics problem)
모델 비교 페이지는 특정 유형의 쿼리에 존재합니다: "llama 3 vs mistral 7b", "stable diffusion vs sdxl", "whisper vs wav2vec2". 이것들은 고의도(high-intent) 쿼리입니다. 즉, 사용자가 이미 후보군을 좁혔으며 구체적인 결정의 계기를 원한다는 뜻입니다. 제가 사용 중인 정적 SSG 방식은 빌드 타임에 각 비교 페이지를 미리 계산해야 함을 의미하며, 이는 제가 생성할 수 있는 페이지 수에 압박을 줍니다.
제가 도달한 해결책은 다음과 같습니다: pipeline_tag별로 그룹화하고, 각 그룹 내에서 다운로드 수 기준 상위 4개 모델을 쌍으로 묶은 다음, COMPARE_LIMIT 환경 변수(env var)로 총 쌍의 수를 제한하는 것입니다. text-generation과 같은 단일 파이프라인 내에서 상위 4개 모델은 6개의 쌍(4개 중 2개를 선택)을 생성합니다. 8개의 활성 파이프라인 전체를 적용하면 약 48개의 쌍이 됩니다. 환경 변수 제한을 50으로 설정하면, 성장할 여지를 남겨두면서도 해당 예산 내에 머물 수 있습니다.
const byPipe = new Map<string, typeof models>();
for (const m of models) {
if (!m.pipeline_tag) continue;
...
현재 페어링(pairing)은 완전히 파이프라인 내에서만 이루어집니다. 이는 제가 "llama vs mistral"(text-generation 둘 다 해당)은 다루고 있지만, "whisper vs gemini-vision"(교차 파이프라인)은 다루지 않음을 의미합니다. 교차 파이프라인 비교는 아직 지형을 잘 모르는 사용자들에게 실제로 더 가치 있는 정보입니다. 이것이 다음 반복(iteration) 단계입니다.
pair_slug 및 멱등적 삽입 (idempotent inserts)
각 비교 쌍(compare pair)의 슬러그(slug)는 결정론적(deterministically)으로 생성됩니다. 두 모델의 슬러그를 알파벳 순으로 정렬한 뒤 --vs--로 결합합니다. 따라서 ETL 프로세스가 (llama-3, mistral-7b)를 처리하든 (mistral-7b, llama-3)를 처리하든, 슬러그는 항상 llama-3--vs--mistral-7b가 됩니다.
const pairSlug = [a.slug, b.slug].sort().join("--vs--");
이 방식은 전체 ETL 과정을 멱등적(idempotent)으로 만듭니다. 스크립트는 매일 밤 실행됩니다. 만약 모든 쌍이 이미 DB에 존재한다면, Claude 호출을 단 한 번도 하지 않고 몇 초 만에 종료됩니다. 저는 SQL 레벨에서 INSERT OR IGNORE를 사용하는 대신 삽입 전에 미리 확인하는 방식을 사용합니다. 명시적인 확인을 통해 동일한 실행 내에서 건너뛴 항목과 생성된 항목의 수를 계산하여 로그로 남길 수 있기 때문입니다:
[compare] done — generated: 3, skipped: 47
이는 모니터링 측면에서 중요합니다. 0개를 생성하고 50개를 건너뛰는 실행은 정상입니다. 반면 0개를 생성하고 0개를 건너뛰는 실행(DB에 아무것도 없고, 처리된 것도 없음)은 버그를 나타냅니다.
시스템 프롬프트 캐싱(system-prompt caching)을 활용한 Claude Haiku
저는 1주 차에 구축한 공유 Haiku 클라이언트를 재사용하며, 이 클라이언트는 시스템 프롬프트에 대해 cacheSystem: true를 처리합니다. 시스템 프롬프트(JSON 스키마 지침)는 모든 비교 호출에서 동일하기 때문에, 첫 번째 호출이 캐시를 예열(primes)하면 이후 호출에서는 해당 접두사(prefix)에 대한 토큰 비용이 거의 제로에 가깝게 발생합니다.
사용자 프롬프트(user prompt)에는 두 모델의 이름, 제작자, 파이프라인 태그, 그리고 기존 요약문 중 최대 400자(이전 콘텐츠 생성 단계에서 생성됨)가 포함됩니다:
const userPrompt = `Compare these two AI models:
A: ${a.name} (author: ${a.author ?? "unknown"}, pipeline: ${a.pipeline_tag ?? "unknown"})
Summary: ${a.summary?.slice(0, 400) ?? "(none)"}
...
요약문을 400자에서 자르는 것은 사용자 프롬프트를 가볍게 유지하기 위함입니다. 비교 페이지는 각 모델을 개별적으로 다시 다루는 것이 아니라, 두 모델 사이의 차이점(delta)에 관한 것이어야 합니다. 심층적인 내용은 이미 전용 모델 페이지에 있으므로, 비교 페이지는 "어떤 용도로 무엇을 선택할 것인가"에 대한 답을 주어야 하며, 이는 총 6문장 정도면 충분합니다.
시스템 프롬프트(system prompt)는 summary, differences (배열), similarities (배열), 그리고 recommendation을 포함하는 JSON 객체를 요청합니다. 출력 형태를 좁게 유지하면 Haiku가 스키마(schema)를 벗어나는 일이 거의 발생하지 않습니다.
정규 표현식 구분자(regex fence)를 이용한 JSON 파싱
프롬프트를 엄격하게 작성하더라도, Haiku는 가끔 "Here is the comparison:"과 같은 설명 서문 뒤에 실제 객체를 붙여 JSON을 생성하곤 합니다. 원시 출력값(raw output)에 대해 엄격한 JSON.parse를 적용하면 오류가 발생합니다. 저는 파싱하기 전에 정규 표현식(regex)을 사용하여 가장 바깥쪽의 {...} 블록을 추출합니다:
function parseCompare(text: string, fb: CompareData): CompareData {
try {
const m = text.match(/\{[\s\S]*\}/);
...
각 필드는 수락되기 전에 개별적으로 검증됩니다. 만약 differences가 문자열로 반환될 경우(Haiku가 배열을 쉼표로 구분된 목록과 혼동할 때 발생하는 간헐적인 동작), 페이지는 충돌하는 대신 해당 필드에 대한 폴백(fallback) 템플릿을 사용합니다.
폴백 구조체(fallback struct)는 신중하게 작성할 가치가 있습니다. 저는 제 것을 작성하는 데 5분을 썼고, 그 결과가 나타납니다:
const fb: CompareData = {
summary: `${a.name}와 ${b.name}는 모두 ${a.pipeline_tag} 모델입니다. 자세한 내용은 각 항목을 참조하세요.`,
differences: ["아키텍처 및 사용 사례에 대해서는 개별 모델 페이지를 참조하세요."],
...
폴백으로 생성된 비교 페이지에 접속한 사용자는 빈 페이지나 에러 상태 대신, 모델 페이지로 안내하는 기술적으로 정확한 페이지를 보게 됩니다. DB의 model_used 컬럼에는 이러한 행에 대해 "fallback-template"이 기록되며, 저는 이를 재생성 대상 후보를 식별하는 데 사용합니다.
libSQL 저장 및 정적 JSON 덤프
비교 데이터는 Turso libSQL의 model_compare 테이블에 저장되며, pair_slug에 고유 제약 조건(unique constraint)이 설정되어 있습니다. ETL 루프가 완료된 후, 모든 데이터는 정적 빌드(static build)를 위해 compare.json으로 덤프됩니다:
const all = await db.execute(
`SELECT * FROM model_compare ORDER BY slug_a, slug_b`
);
...
Astro 빌드는 빌드 타임(build time)에 이 JSON을 읽어 각 쌍(pair)마다 하나의 정적 페이지(static page)를 생성합니다. 런타임 데이터베이스(DB) 호출도 없고, 콜드 스타트(cold starts)도 없습니다. 트레이드오프(tradeoff)는 최신성입니다. 비교 콘텐츠는 최대 24시간까지 지연될 수 있습니다. "llama 3.1 vs llama 3.2"의 경우, 모델이 매일 바뀌는 것은 아니므로 이 정도면 괜찮습니다.
저는 개별 모델 페이지에서 하는 것과 동일한 방식으로 배포 후 감사 CI 단계(post-deploy audit CI step)를 통해 비교 페이지의 JSON-LD를 검증합니다. 구조화된 데이터(Structured data)는 비교 쿼리(comparison queries)에서 더 중요합니다. 왜냐하면 이러한 쿼리들이 AI 개요(AI Overviews)에 노출되는 정확한 쿼리 유형이기 때문입니다. 따라서 스키마(schema)를 올바르게 설정하는 것은 CI 오버헤드(overhead)를 감수할 만한 가치가 있습니다.
비교 페이지를 위한 Astro 슬러그 생성(Astro slug generation)에는 pair_slug를 직접 사용합니다. URL 패턴은 /compare/llama-3--vs--mistral-7b/ 형식이 되는데, 다소 보기 좋지는 않지만 모호하지는 않습니다. 이중 대시(double-dash) 구분자를 사용함으로써 이것이 모델 이름에 포함된 하이픈이 아니라 두 부분으로 구성된 슬러그임을 명확히 해줍니다.
처음부터 다시 시작한다면 바꿀 점
첫날부터 교차 파이프라인 쌍(cross-pipeline pairs)을 생성하겠습니다. 가장 유용한 비교 쿼리는 "llama 3.1 vs llama 3.2"가 아닙니다. 그런 차이에 관심이 있는 사용자들은 이미 그 내용을 알고 있습니다. 흥미로운 쿼리는 카테고리를 넘나드는 것입니다: "텍스트 생성 모델(text-generation model)에서 추론(inference)을 실행해야 할까요, 아니면 RAG 파이프라인(RAG pipeline)을 사용해야 할까요?" 저는 예산 제한을 지키기 위해 이 부분을 건너뛰었지만, 이는 일반적인 모델 페이지와 실제로 차별화될 수 있는 롱테일 트래픽(long-tail traffic)을 놓치고 있음을 의미합니다.
검색 쿼리 로그를 통한 쌍(pair) 선택 유도. 현재는 다운로드 순위에 따라 쌍을 선택합니다. 더 나은 신호는 사용자가 실제로 검색하는 쌍이 무엇인지 파악하는 것입니다. Pagefind는 클라이언트 측에서 실행되며 서버로 쿼리를 로깅하지 않으므로, 가벼운 로깅 엔드포인트(logging endpoint)가 필요합니다. 예를 들어, JSONL 파일에 내용을 추가하는 GitHub Actions 트리거 함수로 POST 요청을 보내는 방식입니다. 그런 다음 ETL이 로그에서 아직 생성되지 않은 상위 N개의 쌍을 읽어옵니다. 이는 적은 양의 인프라를 필요로 하지만, 쌍 선택을 훨씬 더 수요 중심(demand-driven)으로 만들어 줄 것입니다.
예산 상한선(budget cap) 상향. MAX=50은 보수적인 수치입니다. 프롬프트 캐싱(prompt caching)을 적용한 현재의 Haiku 가격을 기준으로 하면, 500개의 쌍은 매일 밤 실행 시 약 0.10달러의 비용이 듭니다. 기본값을 설정할 때는 조심스러웠지만, 청구 내역을 면밀히 모니터링한 결과 실제 지출은 제가 모델링했던 것의 아주 일부분에 불과했습니다. 다음 ETL 설정 업데이트에서 이 값을 200으로 올릴 예정입니다.
인디 게임 디렉토리에 itch.io 항목을 추가하며 배운 방식을 통해 두 번째 데이터 소스를 더 일찍 계획해야 한다는 교훈을 얻었습니다. 비교 페이지도 동일한 구조를 가집니다. 즉, 두 행(row) 사이의 조인(join)입니다. 데이터베이스에 500개 이상의 행이 쌓이기 전에 추상화(abstraction)를 올바르게 설계하는 것이 나중에 사후 수정(retrofitting)하는 것보다 훨씬 쉽습니다.
FAQ
새로운 모델이 추가되지 않아도 ETL이 매일 밤 실행되나요?
네, 하지만 새로운 내용이 없을 때는 비용이 거의 들지 않습니다. 삽입 전 확인(check-before-insert) 과정을 거치기 때문에, 대부분의 밤에는 Claude API를 호출하지 않고 50번의 DB 읽기 작업을 수행한 뒤 3초 이내에 종료됩니다. 콘솔 출력에 generated: 0, skipped: 47이라고 표시된다면 모든 것이 최신 상태라는 신호입니다.
Claude가 잘못된 형식의 JSON을 반환하면 어떻게 되나요?
parseCompare가 오류를 포착하고 폴백(fallback) 구조체를 반환합니다. 해당 행은 model_used = "fallback-template"로 DB에 기록되며, 저는 이를 쿼리하여 재시도할 가치가 있는 행을 찾아낼 수 있습니다. 실제로 이런 현상은 생성된 결과물의 약 2~3%에서 발생하는데, 대개 두 모델의 메타데이터가 매우 희소하여 Haiku가 구조화된 출력(structured output)을 생성할 만큼 충분한 문맥을 확보하지 못했을 때 발생합니다.
쌍(pair)이 쌓이면서 compare.json 파일이 다루기 힘들어지지는 않나요?
50개의 쌍일 때는 약 25KB입니다. 500개의 쌍이 되면 약 250KB 정도가 될 것이며, 이는 Astro에서 빌드 타임(build-time)에 로드하기에 여전히 괜찮은 크기입니다. 만약 5,000개의 쌍에 도달하게 된다면, pipeline_tag별로 파일을 분할하고 각 페이지에 필요한 관련 서브셋만 지연 임포트(lazy-import)할 것입니다. 현재로서는 하나의 평면적인(flat) JSON 파일이 더 단순하고 충분히 빠릅니다.
왜 에지 함수(edge function)를 사용하여 요청 시점에 비교 콘텐츠를 계산하지 않나요?
콜드 스타트(Cold starts)와 비용 때문입니다. 비교 페이지를 조회할 때마다 에지 함수를 호출하면 200~500ms의 지연 시간(Haiku 추론 + DB 왕복 시간)이 추가되며, 매일 밤 배치(batch) 방식으로 처리하는 것보다 페이지 조회당 비용이 훨씬 많이 듭니다. 또한 콘텐츠가 매일보다 더 최신일 필요도 없습니다. 모델의 능력은 매시간 단위로 변하지 않기 때문입니다. 정적 사전 계산(Static precomputation)이 여기서 적절한 절충안이며, 이는 제가 운영하는 세 사이트 모두에서 취하고 있는 정적 SSG에 대한 광범위한 베팅과도 일치합니다.
HuggingFace에서 모델이 삭제되는 경우는 어떻게 처리하나요?
현재는 처리하지 않습니다. 만약 foo 모델이 HuggingFace에서 삭제되었지만 비교 행이 여전히 DB에 남아 있다면, 해당 비교 페이지들은 빌드 타임에 여전히 제공될 것입니다. models.json에서 해당 모델의 행이 삭제될 때까지(이는 매일 밤 수행되는 페치(fetch) 작업에서 모델이 상위 500위 안에 들지 못할 때만 발생합니다) 이전 데이터를 유지하게 됩니다. 이는 알려진 공백(gap)입니다. 현재로서는 위험이 낮습니다. 인기 있는 모델들은 사라지지 않기 때문입니다. 더 견고한 시스템이라면 비교 테이블을 모델 테이블과 교차 참조하여 고아(orphaned) 쌍들을 처리(tombstone)할 것입니다.
관련 글: 시스템 프롬프트 캐싱 (system-prompt caching)을 사용하여 공유 Claude Haiku 클라이언트를 구축한 방법 | Astro 모노레포 (monorepo)를 위한 Turso libSQL vs Cloudflare D1 비교
세 개의 AI 큐레이션 디렉토리 사이트를 운영하는 6개월간의 지속적인 실험 중 일부입니다. 여기에 기술된 주장들은 실제이며, 이 글은 AI의 도움을 받아 작성되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기