
점수를 LLM에 맡기는 것을 그만두다: 정직한 AI 포트폴리오 리뷰어 구축하기
요약
LLM이 포트폴리오 점수를 매길 때 발생하는 상향 편향과 환각 문제를 해결하기 위한 아키텍처 설계 방안을 다룹니다. 결정론적 규칙 엔진이 점수 산출의 기준을 잡고, LLM은 텍스트 생성과 보조적인 역할만 수행하도록 책임을 분리하는 것이 핵심입니다.
핵심 포인트
- LLM은 격려하는 어조를 선호하여 점수를 상향 편향시키는 경향이 있음
- 채점과 설명이 결합되면 모델이 설명에 맞춰 점수를 끼워 맞춤
- 결정론적 규칙 엔진을 통해 객관적인 점수 기준점(Baseline) 확보 필요
- 규칙 엔진은 판사, LLM은 기록원 역할을 수행하도록 역할 분리
언어 모델(Language Model)에게 개발자 포트폴리오를 100점 만점으로 채점해 달라고 요청하면, 모델은 확신에 찬 숫자를 내놓습니다. 이름과 깨진 아바타만 있는 거의 빈 페이지를 건네주어도, 모델은 종종 92점 같은 점수를 말하곤 합니다. "멋진 레이아웃입니다. 강력한 퍼스널 브랜딩이 돋보입니다."와 같이 말이죠. 모델은 정확한 것이 아니라 예의를 차리고 있는 것입니다. 이것이 제가 getfolio 내부의 리뷰어인 Leon을 만들면서 마주한 첫 번째 벽이었습니다. 점수를 신뢰할 수 없다면, 그 이후의 모든 과정인 비평(Critique), 제안(Suggestions), 수정 버튼(Fix button)은 모델이 격려를 위해 지어낸 숫자에 매달려 있는 셈이 되어 아무런 의미가 없게 됩니다.
이 글은 제가 모델에게 펜을 쥐여주는 것을 어떻게 그만두게 되었는지에 대한 빌드 로그(Build log)입니다. 요약하자면 이렇습니다: 결정론적 규칙 엔진(Deterministic rules engine)이 점수를 관리하고, 언어 모델은 그 주변의 텍스트만을 담당합니다.
실패 모드: 사랑받고 싶어 하는 LLM 심판
LLM 평가기(LLM evaluator)를 사용하여 무언가를 배포해 본 적이 있다면 아마 이런 현상을 본 적이 있을 것입니다. 루브릭(Rubric), JSON 스키마(JSON schema), 심지어 작동 예시까지 제공하더라도 점수는 여전히 상향 편향됩니다. 빈 입력값에는 격려 섞인 점수를 주고, 약한 입력값에는 의심의 여지 없이 관대한 점수를 줍니다. 강한 입력값은 그저 찬사가 더 길어질 뿐, 약한 입력값과 비슷한 범위의 점수를 받게 됩니다.
그 원인은 대략 다음과 같으며, 피해가 큰 순서대로 나열했습니다:
- 튜닝(Tuning)은 도움이 되고 격려하는 어조에 보상을 줍니다. 가혹한 채점은 도움이 되지 않는 것으로 간주되기에, 모델은 점수를 완화합니다.
- 모델은 당신의 특정 도메인에서 70점과 85점이 구체적으로 어떤 모습인지에 대한 그라운드 트루스(Ground truth)가 없습니다. 모델은 분위기(Vibes)에 따라 점수를 매기고 있는 것입니다.
- 채점과 설명이 얽혀 있습니다. 모델은 먼저 친절한 설명을 작성한 다음, 방금 말한 좋은 내용에 맞춰 숫자를 선택합니다.
- 동일한 입력값에 대해 두 번 실행하면 서로 다른 두 숫자가 나옵니다. 기준점(Anchor)이 없습니다.
실제 채용 담당자와 개발자들이 참고할 만한 포트폴리오 리뷰어로서, 이는 용납할 수 없는 문제였습니다. 만약 Leon이 64점이라고 말했다면, 빈 페이지가 우연히 64점에 도달해서는 안 되며, 강력한 포트폴리오가 64점 근처로 과소평가되어서도 안 됩니다. 숫자는 반드시 의미를 가져야 합니다.
해결책: 규칙 엔진이 점수를 관리하고, 모델은 언어를 관리한다
아키텍처는 책임을 엄격하게 분리합니다.
- 결정론적 엔진 (Deterministic engine)은 관찰 가능한 증거를 바탕으로 기준점 (Baseline)을 계산합니다. GitHub를 연결했는가? 고정된 리포지토리 (Pinned repos)에 실제 설명이 있는가? 소개 (About) 섹션이 한 줄 이상인가? 커스텀 도메인이 있는가? 모델이 개입하지 않는, 순수한 TypeScript와 몇 가지 쿼리만으로 이루어집니다.
- 모델 또한 각 기둥 (Pillar)별로 점수를 매기지만, 최종 숫자를 설정하지는 않습니다. 모델의 기둥별 점수는 고정된 범위 내에서 결정론적 기준점에 맞춰 제한 (Clamped)되며, 그 후 코드에서 가중치가 재조정됩니다. 모델이 자신의 응답에서 반환하는 전역 점수 (Global score)는 파싱된 후 버려집니다.
- 재작성 (Rewrites)의 경우, 모델은 제자리에서 작업하며 프롬프트 (Prompt)를 통해 사용자가 제공하지 않은 사실을 절대 지어내지 말라는 지시를 받습니다.
제가 계속해서 되새기는 사고 모델 (Mental model)은 다음과 같습니다: 규칙 엔진은 판사이고, 모델은 법정 기록원 (Court reporter)입니다. 기록원은 판결을 잘 표현할 수는 있지만, 판결을 바꿀 수는 없습니다.
역할별로 가중치가 부여된 5가지 기둥
Leon은 다섯 가지 기둥, 즉 증거 (Proof), 명확성 (Clarity), 신뢰성 (Credibility), 표현력 (Presentation), 그리고 차별성 (Stand Out)을 기준으로 채점합니다. 각 기둥 아래에는 고정된 체크 항목들이 있으며, 다섯 가지 기둥 전체에 걸쳐 약 20개의 결정론적 항목이 있고, 깨진 링크를 표시하는 것과 같이 요청 시점에 실행되는 항목들이 몇 개 더 있습니다.
제가 만족하는 부분은 가중치가 전역적이지 않다는 점입니다. 가중치는 역할 (Role)별로 부여됩니다. 왜냐하면 백엔드 엔지니어와 프로덕트 디자이너의 포트폴리오는 동일한 방식으로 채점되어서는 안 되기 때문입니다. 8개의 역할 프로필이 있으며, 각 프로필은 고유한 가중치 맵 (Weight map)을 가집니다. 또한 각 맵은 모듈이 로드될 때 합계가 1.0이 되도록 단언 (Asserted)되므로, 오타로 인해 모든 점수가 조용히 왜곡되는 일을 방지할 수 있습니다.
단순화된 개발자 프로필은 다음과 같습니다:
// 단순화됨. 실제 가중치는 역할별로 존재함
const PILLAR_WEIGHTS = {
proof: 0.30,
...
역할에는 GitHub가 얼마나 중요한지를 나타내는 플래그 (Flag)도 포함되어 있습니다. 개발자에게 GitHub 신호는 핵심입니다. 하지만 디자이너, PM, 또는 보안 전문가에게는 보너스로 취급되므로, 작업 증명 (Proof of work)이 다른 곳에 있는 사람이 GitHub 활동이 적다는 이유로 조용히 불이익을 받지 않습니다.
빈 포트폴리오가 실제로 받는 점수
결정론적 계층 (deterministic layer)이 배치됨에 따라, 아무것도 채워지지 않은 완전히 새로운 개발자 프로필이 0점을 받지는 않습니다. 20점 중반대의 점수를 받게 됩니다. 저는 왜 그런지에 대해 솔직하게 말씀드리고 싶은데, 이것이 이 글의 핵심이기 때문입니다. 몇 가지 체크 항목은 기본적으로 통과됩니다. 무료 티어 (free tier)에서 충족되는 글쓰기 체크 항목이나, 아직 프로젝트가 없어서 실패할 대상이 없는 프로젝트 체크 항목들이 그렇습니다. 따라서 최저점은 0점이 아닙니다.
빈 페이지가 할 수 없는 것은 점수를 올리는 것입니다. 더 높은 점수를 받기 위한 증거가 계산될 만큼 존재하지 않으며, 모델은 이를 임의로 만들어낼 수 없습니다. 그것이 제가 실제로 중요하게 생각하는 속성입니다. 빈 페이지가 0점을 받는 것이 아니라, 빈 페이지가 실제 포트폴리오가 도달하는 범위까지 아부(flattered)를 통해 올라갈 수 없다는 점입니다.
모델이 일정 범위 내에서 조정하도록 허용하기
순수한 규칙 엔진 (rules engine)은 맥락 (context)을 놓칩니다. 고정된(pinned) 저장소 두 개는 서류상으로는 빈약해 보일 수 있지만, 그중 하나가 명확한 README가 있는 잘 문서화된 라이브러리라면, 이는 방치된 5개의 튜토리얼 클론 (tutorial clones)보다 더 강력한 신호입니다. 저는 Leon이 이를 알아볼 수 있기를 원했습니다.
따라서 모델은 각 기둥 (pillar)에 대해 0점에서 100점 사이의 점수를 매기며, 각 점수는 가중치가 부여되기 전에 고정된 변동 폭 내에서 결정론적 기준선 (deterministic baseline)으로 제한 (clamped)됩니다.
const AI_SWING = 15; // 모델이 기둥의 점수를 양방향으로 움직일 수 있는 포인트
function clampAdjustment(baseline, aiScore) {
...
기둥당 15점은 규칙이 볼 수 없는 품질을 반영하기에는 충분하지만, 30점을 90점으로 바꾸기에는 충분하지 않은 수치입니다. 모델은 척도 (scale)에 기대어 움직일 수는 있지만, 척도 자체를 다시 그릴 수는 없습니다.
실제로 존재하는 가드레일 (guardrails)
여기서 저는 정확하게 말하고 싶습니다. 과장하기는 쉽기 때문입니다. 모델의 거짓말을 잡아내는 마법 같은 게이트는 없습니다. 대신 존재하는 것은 다음과 같습니다:
- 위에서 언급한 기둥(pillar)별 클램프(clamp)는 모델이 값을 얼마나 이동시킬 수 있는지에 대한 실질적인 상한선 역할을 합니다.
- 중복 제거 (Deduplication). 모델의 제안은 텍스트 유사도 검사(text-similarity check)를 통해 결정론적(deterministic)인 결과와 비교됩니다. 따라서 모델이 엔진이 이미 찾아낸 문제를 다시 언급함으로써 목록을 채우는 행위를 할 수 없으며, 목록의 개수도 제한됩니다.
- 이상 징후 감사 (An anomaly audit). 모든 실행은 작은 감사 기록을 작성하며, 모든 점수가 100점이라거나 이전 실행 이후 변경되지 않은 콘텐츠에 대해 큰 변동이 발생하는 것과 같은 의심스러운 형태를 플래그(flag)로 표시합니다. 현재는 차단하기보다는 로그를 남기는 방식이지만, 이는 프롬프트 인젝션 (prompt injection)이나 점수 조작을 감지하는 지뢰(tripwire) 역할을 합니다.
- 허위 사실 생성 방지. 이는 재작성(rewrites)을 위한 프롬프트 내에서 강제되며, 온보딩(onboarding) 경로에서 코드 수준의 엄격한 화이트리스트(whitelist)로 적용됩니다. 만약 모델이 사용자의 실제 고정된 리포지토리(pinned repositories)에 없는 리포지토리를 제안하면, 해당 제안은 제외됩니다.
따라서 정직함의 일부는 코드에서 오고, 일부는 프롬프트 규율 (prompt discipline)에서 옵니다. 모든 보장이 타입 수준 (type level)에서 강제된다고 가장하기보다는, 차라리 솔직하게 말씀드리고 싶습니다.
모델이 제 역할을 다하는 곳
점수 산정이 통제된 상태에서, 모델은 자신이 잘하는 일을 수행합니다:
- 사용자에게 특화된 기둥(pillar)별 비평 텍스트. 단순히 "소개 섹션을 개선하세요"라고 하는 것이 아니라, "소개 섹션이 한 줄뿐이며 무엇을 만드는지 명시되어 있지 않습니다"와 같은 노트를 제공합니다.
- 재작성 (A rewrite). 사용자가 소개 섹션이나 프로젝트 설명에 대한 재작성을 요청하면, 모델은 프로필에 이미 있는 사실에 국한된 단일 재작성 버전을 반환합니다.
- 우선순위가 지정된 수정 사항. 엔진은 어떤 검사가 실패했는지 알고 있습니다. 모델은 예상되는 영향력에 따라 이를 순위 매기고 실행 가능한 동작으로 표현합니다.
모델이 생성하는 그 어떤 것도 스스로 실제 포트폴리오에 영향을 주지 않습니다. 리뷰(review), 수정(fix), 재작성(rewrite) 엔드포인트는 제안 사항과 수정 전후(before and after)를 반환하며, 적용(applied) 플래그는 false로 설정됩니다. 승인된 결과는 인메모리 초안(in-memory draft)에 저장되며, 공개 프로필은 별도의 게시(publish) 과정을 거쳐야만 변경됩니다. 결정론적 계층 (deterministic layer)은 출력을 신뢰할 수 있게 만들고, 승인 단계는 출력을 적용할 수 있게 만듭니다.
참고로, 여기서 사용된 모델은 Claude입니다. Sonnet이 더 무거운 리뷰 및 재작성(rewrite) 작업을 수행하고, Haiku가 빠른 경로(fast paths)를 처리합니다. 하지만 핵심은 아키텍처(architecture)에 있습니다. 이 방식은 어떤 모델에서도 유효하며, 이것이 바로 이 논의의 핵심입니다.
시사점, 그리고 의견이 다를 수 있는 부분
제가 다른 제품에도 적용하고 싶은 패턴은 다음과 같습니다. 신뢰할 수 있어야 하는 모든 것에는 결정론적 시스템(deterministic systems)을 사용하고, 그 위에 얹히는 부드러운 언어 계층(soft language layer)에는 모델을 사용하는 것입니다. 점수(Scores), 자격 요건(eligibility), 가격 책정(pricing), 순위 매기기(ranking), 중재 임계값(moderation thresholds) 같은 것들 말입니다. 이것들은 읽을 수 있고 테스트할 수 있는 코드여야 합니다. 문구(Phrasing), 비평(critique), 요약(summaries), 제한된 범위 내의 재작성(bounded rewrites) 등은 모델이 처리하기에 적합합니다.
이것은 하나의 입장일 뿐, 법칙은 아닙니다. 타당한 반론도 있습니다. 강력한 평가(evals)와 엄격한 루브릭(rubric)이 있다면 현대의 모델들은 충분히 일관되게 점수를 매길 수 있으며, 직접 만든 규칙 엔진(rules engine)은 관리가 안 되어 노후화되는 오버헤드(overhead)일 뿐이라는 의견입니다. 낮은 이해관계(low-stakes)가 걸린 점수 산정이라면 저도 그 의견에 동의합니다. 하지만 채용 담당자가 후보자의 이름 옆에서 읽게 될 숫자라면, 저는 아직 그 단계에 도달하지 못했습니다. 만약 여러분이 높은 이해관계(high-stakes)가 걸린 흐름에서 LLM-as-judge를 배포하여 성공적으로 안착시켰다면, 그 방법을 꼭 읽어보고 싶습니다. 저는 시도해 보았지만, 신뢰할 수 없었습니다.
여러분의 포트폴리오가 증거(evidence) 측면에서 어떤 점수를 받는지 확인하고 싶다면, Leon이 getfolio.dev에서 서비스 중입니다. 그리고 규칙 엔진과 모델 사이의 분리가 잘못되었다고 생각하신다면, 댓글 창은 열려 있습니다. 저 또한 이 문제의 작은 부분들에 대해 한두 번 이상 생각을 바꾼 적이 있습니다.
태그: ai, webdev, architecture, typescript
Leon은 getfolio 내부에서 작동하며, 매번 동일한 다섯 가지 기둥(pillars)을 기준으로 여러분의 포트폴리오를 채점하는 리뷰어 역할을 합니다. 그 후, 무언가가 실제로 게시되기 전에 여러분이 승인할 수 있는 수정 사항을 제안합니다. 여러분의 포트폴리오가 증거 측면에서 실제로 어떤 점수를 받는지 확인하고 싶다면 getfolio.dev에서 직접 체험해 보실 수 있습니다. 만약 규칙 엔진과 LLM 사이의 분리에 동의하지 않으신다면, 댓글 창은 열려 있습니다. 저는 이 문제의 작은 부분들에 대해 이미 두 번이나 생각을 바꾼 적이 있습니다.
원문 게시: getfolio.dev
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기