Futbol Report — AWS Lambda를 활용한 멀티 모델 LLM 비교 구축하기
요약
AWS Lambda, OpenRouter, Redis를 활용하여 여러 LLM의 성능을 비교하는 축구 요약 봇 구축 사례를 소개합니다. 검색 결과의 결정론적 특성 대응, 환각 방지 프롬프트 효과, 모델별 컨텍스트 활용 능력 차이 등 실제 운영 경험을 다룹니다.
핵심 포인트
- OpenRouter를 통한 단일 API 인터페이스로 멀티 모델 관리 효율화
- 검색 결과의 가변성을 고려하여 모델 간 동일 컨텍스트 제공 필수
- 단순한 환각 방지 문구만으로도 모든 모델에서 효과 확인
- 모델 크기와 비용에 따른 출력 길이 및 상세도 차이 존재
원문은 samiryuja.dev에 게시되었습니다.
몇 달 전, 저는 며칠마다 경기 일정, 결과, 이적 뉴스, 감독 교체 소식을 Telegram 메시지로 보내주는 축구 요약 봇을 설정했습니다. 처음에는 작은 Linux 서버에서 cron job에 의해 실행되는, Claude Code를 구동하는 tmux 세션으로 시작했습니다. 잘 작동했습니다. 하지만 가끔 작동이 중단되기도 했고, 무엇을 생성하고 있는지 확인할 마땅한 방법도 없었습니다.
저는 두 가지를 동시에 하고 싶었습니다. 더 신뢰할 수 있게 만드는 것
3일마다 EventBridge가 Lambda 함수를 실행합니다. Lambda는 약 12개의 쿼리로 Brave Search를 호출한 다음, 컴파일된 동일한 컨텍스트를 OpenRouter를 통해 네 개의 모델로 전송합니다. 각 모델의 보고서는 타임스탬프가 찍힌 키(key)와 함께 Redis에 저장됩니다. Vercel에 배포된 Next.js 사이트는 동일한 Redis에서 데이터를 읽어 비교 페이지를 렌더링합니다.
공개할 만한 몇 가지 결정 사항은 다음과 같습니다:
추론 계층(Inference layer)으로서의 OpenRouter. 4개의 API 대신 하나만 사용하며, 모델을 추가하거나 교체하는 것은 단 한 줄의 코드 변경으로 가능합니다.
서버 사이드 렌더링(Server-rendered) 비교 페이지. 데이터는 몇 일마다 한 번씩만 변경되므로, 브라우저에서 데이터를 가져올 이유가 없습니다. 서버가 Redis를 읽고 완성된 페이지를 다시 보냅니다. 브라우저에서는 투표 버튼만 실행됩니다.
보고서 키에 90일 TTL(Time To Live)을 설정한 Redis. Redis는 액세스 패턴에 적합했습니다. 작은 페이로드(보고서당 몇 KB)와 쿼리 없는 타임스탬프 기반의 순수 키 조회 방식이기 때문입니다. TTL을 설정하면 오래된 보고서는 자동으로 만료됩니다. 투표와 실행 인덱스(run index)에는 TTL이 없으므로, 메모리가 가득 차더라도 투표 기록은 절대 삭제되지 않습니다.
운영을 통해 배운 점
1. 검색 결과는 결정론적(Deterministic)이지 않습니다. 30분 간격으로 동일한 쿼리를 실행해도 다른 결과 세트가 반환됩니다. 이는 실시간 랭킹이 작동하는 방식입니다. 따라서 비교가 공정하려면 하나의 실행(run) 내에서 컨텍스트가 고정되어야 합니다(하나의 Brave 호출이 네 모델 모두에게 동일한 컨텍스트를 제공함).
2. 단순한 환각 방지(Anti-hallucination) 문구가 네 모델 모두에서 효과가 있었습니다. 첫 번째 실행에서 모델이 경기 일정을 환각(hallucinate)하여 생성한 후, 프롬프트에 _"제공된 검색 결과에 있는 사실만 사용하세요(use ONLY facts present in the provided search results)"_라는 문구를 추가했습니다. 그 이후로는 네 모델 중 어느 것도 데이터를 지어내지 않았습니다. 서로 다른 네 곳의 연구소(labs)에서 만든 모델임에도 동일한 문구로 동일한 효과를 보았습니다.
3. 모델마다 컨텍스트를 필터링하는 방식이 다릅니다. 한 실행에서 Brave의 검색 결과에 범위를 벗어난 인도 슈퍼리그(Indian Super League) 경기가 포함되었습니다. 세 개의 모델은 이를 필터링하여 제외했지만, 한 모델은 이를 보고서의 서두에 배치했습니다. 동일한 프롬프트와 동일한 데이터였음에도 우선순위 지정 방식이 달랐습니다.
4. 모델 크기에 따라 출력 길이(Output length)가 극명하게 달라집니다. Claude와 Kimi는 사용 가능한 컨텍스트(Context)를 대부분 활용했습니다. 반면, 단연코 가장 저렴한 모델인 Gemma는 동일한 입력을 단 한 줄의 요약으로 압축해 버렸습니다. 비용과 세부 정보의 수준은 상관관계가 있습니다.
5. 형식 준수(Format adherence) 능력도 제각각입니다. Claude가 프롬프트의 구조를 가장 충실하게 따랐습니다. Gemma는 대부분의 구조를 무시했습니다. Qwen과 Kimi는 그 중간 정도였습니다.
6. 파이프라인은 코드 변경 없이 비시즌(Off-season)을 버텨냈습니다. 세리에 A(Serie A)가 종료되었을 때, 경기 일정(Fixture) 쿼리는 유용한 정보를 반환하지 않았습니다. 저는 두 개의 새로운 검색 카테고리(이적, 감독 교체)를 추가하고 프롬프트에 한 줄을 더했습니다 — "만약 경기 일정이 부족하다면, 이적 뉴스를 우선적으로 배치하세요.". 보고서는 여전히 실질적인 내용을 담았습니다: 밀란의 알레그리 경질, 과르디올라의 시티 탈퇴, 월드컵 준비 과정 등.
배포 과정의 고군분투기 (The deployment war stories)
생성기를 Lambda로 옮기면서 가장 흥미로웠던 부분은 중간에 겪은 몇 시간 동안의 디버깅 과정이었습니다.
Python 런타임 불일치. AWS는 새 Lambda의 기본값을 Python 3.14로 설정했습니다. 하지만 제 배포 패키지는 3.12용으로 빌드되었습니다. 에러 메시지는 "잘못된 Python 버전"이라고 말하지 않고, No module named 'pydantic_core._pydantic_core'라고 출력했습니다. 이는 컴파일된 C 확장(C extension)이 3.14 환경에서는 로드될 수 없는 cpython-312 .so 파일이기 때문입니다. 해결책: 런타임을 빌드 대상과 일치시키십시오.
Mac vs Linux 바이너리. Python 버전을 고정했음에도 불구하고, pydantic-core는 계속해서 macOS 바이너리를 제 zip 파일에 포함시켰습니다. 저는 패키징을 위해 uv를 사용하고 있었는데, uv pip install --only-binary는 Linux 휠(Wheel)을 가져와야 하지만 여기서는 제대로 작동하지 않았습니다. 결국 순정(Vanilla) 방식인 python3 -m pip install --platform manylinux2014_x86_64 --only-binary=:all:로 전환하여 마침내 올바른 아티팩트(Artifact)를 가져올 수 있었습니다. 제가 신뢰했던 최신 도구가 문제였고, 오래되고 지루한 도구가 작동했습니다.
이중 환경을 위한 선택적 임포트(Optional imports). python-dotenv는 로컬 환경에서 .env 파일을 읽고 os.environ을 채워주기에 매우 훌륭합니다. 하지만 Lambda에서는 환경 변수가 AWS에서 직접 제공되므로, python-dotenv는 런타임에 포함되지 않는 불필요한 짐일 뿐입니다. 임포트 부분을 다음과 같이 감싸주세요:
try:
from dotenv import load_dotenv
load_dotenv()
...
동일한 코드가 두 환경 모두에서 작동합니다.
기본 타임아웃 (Default timeout). Lambda의 기본 타임아웃은 3초입니다. 저의 파이프라인은 약 4분이 필요합니다. 13번의 순차적인 Brave 호출과 4번의 순차적인 모델 생성(model generations)이 포함되기 때문입니다. 이를 900초(Lambda의 최대치)로 높여주세요.
브라우저 대신 CLI 사용하기. AWS 콘솔의 "․zip ․파일 업로드" 버튼이 배포된 코드를 안정적으로 새로고침하지 못했습니다. 새 파일을 선택한 후에도 SHA-256 해시가 이전 업로드와 계속 일치하는 문제가 발생했습니다. 대부분의 경우에는 잘 작동하겠지만, 저의 경우에는 CLI에서 aws lambda update-function-code를 사용하는 것이 더 빠르고 쉬웠으며, 현재 제가 사용하는 방식입니다.
콘솔 출력을 복사하여 붙여넣을 때 비밀 정보(Secrets)가 유출되기 쉽습니다. 이 프로젝트를 진행하는 동안 세 개의 API 키를 교체했습니다. 그중 두 번은 환경 변수가 포함된 명령어 출력 내용을 붙여넣었기 때문입니다. 충분히 방지할 수 있는 일임에도 불구하고 매우 고통스러운 경험이었습니다. GitHub Actions 워크플로에 gitleaks를 연결하는 것이 다음 계획입니다.
다음 단계
우선순위에 따라 나열하면 다음과 같습니다:
- Lambda를 통한 Telegram 전송 — 새로운 실행이 완료되었을 때 요약본이 저에게 알림을 보낼 수 있도록 원래의 유스케이스(use case)를 복구합니다.
- CI 보안 스캐닝 — 비밀 정보 탐지를 위한
gitleaks와 의존성 CVE(Common Vulnerabilities and Exposures) 탐지를 위한osv-scanner를 모두 각 리포지토리의 GitHub Actions 워크플로에 연결합니다. - 모델 호출 병렬화 — 현재는 순차적(sequential)으로 실행되어 약 4분이 소요되지만, 병렬(concurrent)로 처리하면 실행 시간을 약 75% 단축할 수 있습니다.
- 그라운딩(Grounding)을 위한 전체 페이지 콘텐츠 — Brave는 1~2문장의 스니펫(snippets)만 반환하므로, 빈약한 컨텍스트(context)는 빈약한 보고서를 생성합니다. Firecrawl이나 가독성 추출기(readability extractor)를 사용하면 이 문제를 해결할 수 있습니다.
- 서버 측 투표 중복 제거 (Server-side vote dedup) — 현재 투표는
localStorage를 통해 브라우저별로 중복 제거됩니다. 저장소를 삭제하거나 시크릿 모드(incognito)를 사용하면 이를 우회할 수 있습니다.
링크
- 실시간 비교 (The live comparison) — 최신 실행 결과, 히스토리, 투표
- 제너레이터 저장소 (Generator repo) — Python 파이프라인 (Python pipeline), Lambda 패키징 (Lambda packaging), EventBridge 스케줄 (EventBridge schedule)
- 사이트 저장소 (Site repo) — Next.js 측, 비교 페이지, 투표 API (vote API)
감사 인사
Ryan에게 — Tailscale을 통해 그의 머신에서 원본 봇을 실행할 수 있게 해준 것과, 이 프로젝트의 사고방식을 형성하는 데 큰 밑거름이 된 꾸준한 기사와 아이디어들에 대해 감사를 전합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기