
4일 연속 같은 옷: 프롬프트가 해결하지 못하는 문제 수정하기
요약
Wardrobe AI 구축 과정에서 LLM이 매일 같은 옷을 추천하는 앵커링 문제를 해결하는 방법을 다룹니다. 프롬프팅 대신 데이터 파이프라인 단계에서 후보군을 큐레이션하는 결정론적 로직과 확률적 가중치를 활용한 해결책을 제시합니다.
핵심 포인트
- LLM의 앵커링 편향으로 인해 동일한 결과가 반복되는 문제 발생
- 프롬프팅만으로는 모델의 강력한 편향을 제어하기 어려움
- 추천 품질은 AI 호출 전 단계인 후보군 큐레이션에서 결정됨
- 결정론적 로직과 확률적 샘플링을 결합한 파이프라인 구축
Wardrobe AI 구축 시리즈의 파트 2입니다. 파트 1에서는 앱의 기능과 그 이면에 있는 원칙, 즉 _선호도를 위한 확률적 가중치 (stochastic weights for preferences), 물리학을 위한 결정론적 로직 (deterministic logic for physics)_에 대해 다루었습니다. 이 포스트는 그 원칙이 실제로 가치를 증명하는 단계입니다. 피드백 수학 (feedback math)에 대해서는 여기서 개괄적으로 언급하고, 파트 3에서 자세히 다룰 예정입니다. 이 포스트의 모든 코드는 github.com/JiamanBettyWu/mise에 공개되어 있으며, 코드 스니펫은 해당 소스로 연결됩니다.
시작점이 된 버그: 4일 동안 한 가지 착장
추천 시스템의 첫 번째 버전은 이름만큼이나 단순했습니다. 데이터베이스에서 제가 가진 모든 옷을 가져와서, 그 전체 리스트를 모델에 전달하고, 착장을 요청하는 방식이었습니다. 작동은 했습니다. 하지만 4일 연속으로 똑같은 옷을 추천했습니다.
이것은 아주 특별한 종류의 실망감을 줍니다. 왜냐하면 그것이 틀린 것은 아니기 때문입니다. 그 옷은 4일 내내 괜찮았습니다. 문제는 매일 아침 똑같은 것만 제안하는 스타일리스트는 진정한 역할을 수행하고 있지 않다는 점입니다. 제가 파트 1에서 정의했듯이, 핵심 가치는 웜 스타트 (warm start), 즉 반응할 수 있는 새로운 자극을 주는 데 있습니다. 멈춰버린 추천은 트렌치코트를 입은 _콜드 스타트 (cold start)_와 같습니다.
왜 이런 일이 발생했을까요? 대규모 언어 모델 (Large language models, LLM)은 입력값에서 가장 먼저 나오는 것에 **앵커링 (anchor)**되는 잘 알려진 경향이 있습니다. 제 데이터베이스는 아이템을 일정한 순서로 반환했고, 모델은 리스트 상단에서 계속 같은 옷을 보게 되었고, 계속해서 그것들을 선택했습니다. 사람들이 가장 먼저 시도하는 해결책은 "모델에게 더 다양하게 제안하라고 말하는 것"이며, 저도 결국 그 방식의 버전을 시도했지만, 프롬프팅 (prompting)만으로는 강력한 편향(bias)에 맞서기에는 부드러운 자극에 불과했습니다. 더 큰 지렛대는 상류(upstream) 단계에 있었습니다.
핵심 아이디어: 모델은 옷장을 통째로 보지 않는다
이 기능 전체를 재편성하게 만든 깨달음은 다음과 같습니다: 추천의 품질은 AI가 호출되기 _전_에 대부분 결정된다.
모델에게 내 옷장 전체를 쏟아붓는 대신, 저는 먼저 더 작은 후보군 (candidate pool), 즉 의도적으로 선택되고 섞인 옷의 하위 집합을 큐레이션하는 파이프라인을 구축했고, 오직 그것만이 모델로 전달되도록 했습니다. 모델을 파티에서 손님을 맞이하는 호스트라고 생각하고, 파이프라인을 게스트 리스트를 들고 있는 보안 요원(bouncer)이라고 생각해보세요. 호스트가 인사를 건넬 때쯤이면, 보안 요원은 이미 누가 방 안에 있을지를 결정한 상태입니다. 흥미로운 결정의 대부분은 보안 요원이 내립니다.
이 파이프라인은 두 개의 작은 무작위성 (randomness) 요소를 감싸고 있는 거의 전적으로 결정론적 (deterministic) 인 배관 구조입니다. 전체 과정을 처음부터 끝까지 설명하겠습니다:
단계별로 살펴보겠습니다. 피드백 수학(feedback math)을 제외한 모든 것에 대해 깊이 있게 다루겠습니다. 피드백 수학은 별도의 포스트(Part 3)를 작성할 만큼 충분히 미묘한 차이가 있으므로, 여기서는 그저 "확률을 기울게 만드는 숫자"로만 취급하겠습니다.
1단계: 극단값 게이트 (물리 법칙, 그리고 가장 먼저 실행됨)
어떤 항목에 가중치를 두거나 샘플링하기 전에, 결정론적 필터가 오늘 날씨에 터무니없는 아이템들을 걸러냅니다. 예를 들어 기온이 30°C(86°F)인데 두꺼운 파카를 입는 것과 같은 경우입니다. 이것이 이 원칙의 "물리적" 측면입니다. "파카를 입기에는 너무 덥다"는 사실에는 모호함이 없으므로, 이는 확률이 아닌 엄격한 규칙(hard rule)입니다.
이 단계는 가장 먼저 실행되며, 순서가 중요합니다. 만약 샘플링 과정 중에 터무니없는 아이템들이 여전히 후보군에 남아 있다면, 그 아이템들은 입을 수 있는 옷에 할당되어야 할 슬롯을 차지하게 될 것입니다. 더운 날 후보군에 파카가 들어있다면, 기껏해야 모델이 무시하는 아이템이 될 것이고, 최악의 경우 모델이 실수로 선택하게 될 것입니다. 게이트를 먼저 통과시키면, 모델은 오직 오늘 날씨에 말이 되는 옷들에 대해서만 추론하게 됩니다.
게이트가 수행하지 않는 작업에 주목하세요. 게이트는 단순히 최선이 아닌(suboptimal) 선택지를 제거하는 것이 아니라, 터무니없는 극단적인 선택지만을 제거합니다. "이 옷이 서늘한 아침에 충분히 따뜻한가"와 같은 미세한(fine-grained) 판단은 나중에 모델 스스로가 처리합니다. 이러한 2단계 분리(터무니없는 것에 대한 하드 게이트, 미묘한 차이에 대한 모델의 판단)는 프롬프트(prompt) 단계로 넘어갈 때 다시 등장합니다.
2단계: 최신성 감쇠 (Recency decay, 어제의 옷이 뒤로 물러나도록)
이것이 '4일 연속 같은 옷' 버그를 해결하는 직접적인 방법입니다. 모든 아이템은 **최신성 점수(recency score)**를 부여받습니다. 최근에(그리고 더 자주) 추천되었을수록 점수가 높아지며, 시간이 지날수록 그 영향력은 조금씩 감소합니다. 구체적으로는 지수적 감쇠(exponential decay) 방식을 사용하며, 과거의 날짜가 지날수록 비중이 줄어듭니다.
DAILY_DECAY = 0.85 # 일일 승수; 약 4일의 반감기(half-life)
# d일 전에 입은 아이템은 최신성 점수에 DAILY_DECAY ** d 만큼 기여함
→ 전체 컨텍스트: backend/services/outfit_history.py
여기서 "반감기(half-life)"란 약 4일이 지나면 착용 기록의 가치가 절반으로 줄어들고, 8일이 지나면 4분의 1로 줄어든다는 것을 의미합니다. 이는 반복 재생을 스마트하게 관리하는 음악 셔플(shuffle) 기능과 유사합니다. 방금 들은 노래는 대기열에서 뒤로 밀려나지만, 플레이리스트에서 삭제되지는 것과 같습니다. 최근에 입었다는 이유로 어떤 아이템도 영구적으로 금지되지 않습니다. 단지 한동안 선택될 가능성이 낮아질 뿐이며, 이를 통해 순환(rotation)에 숨통을 틔워줍니다.
처음에 제가 실수하여 수정해야 했던 미묘한 차이점이 하나 있습니다. 최신성 압박은 반드시 _대체재(substitutes)_가 있을 때만 의미가 있다는 점입니다. 저에게는 격식 있는 구두가 딱 한 켤레뿐입니다. "어제 신었으니까"라는 이유로 구두에 페널티를 주면, 내일의 격식 있는 착장에는 신을 신발이 아예 없게 됩니다. 따라서 작은 카테그리(사용 가능한 아이템 수가 임계값 이하인 경우)에 속하는 아이템은 최신성 적용에서 완전히 제외됩니다. 순환(rotation)은 순환할 _대상_이 있을 때나 가능한 것입니다. 저는 이 규칙들을 대부분의 규칙이 그렇듯, 처음에는 멍청한 짓을 해본 뒤에야 배웠습니다. (저는 이를 '샌들 사건'이라고 부르며, 아래에서 다시 등장합니다.)
Stage 3: 피드백 넛지 (breadth only; 전체 이야기는 Part 3에서)
최신성 (recency)과 더불어, 각 항목은 저의 thumbs-up / thumbs-down 이력에서 유도된 **피드백 승수 (feedback multiplier)**를 가집니다. 좋아요를 누른 항목은 확률이 완만하게 높아지고, 싫어요를 누른 항목은 완만하게 낮아집니다. 결정적으로, 이는 제한된 (bounded) 넛지입니다 (항목의 확률을 0이나 1로 몰아넣지 않습니다). 덕분에 제가 thumbs-down을 했던 항목들에 대해서도 약간의 탐색 (exploration)이 살아있게 됩니다.
파이프라인 관점에서는 이것으로 충분합니다. 즉, 주사위의 눈을 기울게 만드는 숫자라는 점입니다. 이 숫자가 어떻게 계산되는지 (smoothed Bayesian estimate 및 blame attribution을 포함합니다)는 Part 3의 주제입니다.
Stage 4: 가중치 샘플링 (무작위성이 발생하는 지점)
이제 두 신호가 결합되어 항목당 하나의 **샘플링 가중치 (sampling weight)**가 됩니다:
w = recency_factor * feedback_multiplier
그 다음, 전체 풀(pool)의 약 70%에 해당하는 무작위 하위 집합을 추출하며, 이때 각 항목의 가중치는 선택될 확률이 됩니다. 높은 가중치(최근에 입지 않았고, 일반적으로 선호함)는 선택될 가능성이 높음을 의미하며, 낮은 가중치는 가능성이 낮음을 의미하지만 결코 불가능한 것은 아닙니다.
멋진 부분은 모든 항목이 서로 다른 가중치를 가질 때, 비복원 추출 (sampling without replacement)을 어떻게 수행하느냐 하는 점입니다. 이를 위한 우아한 한 줄짜리 코드가 있습니다 (Efraimidis–Spirakis 알고리즘):
각 항목에 무작위 키 (random key)를 부여하고 그 키를 기준으로 정렬하는 것입니다.
key = math.log(random.random()) / w # 가중치가 클수록 -> 키가 0에 가까워짐 -> 높은 순위 차지
# 모든 항목을 key 기준(내림차순)으로 정렬합니다; 상위 k개는 크기가 k인 올바른 가중치 샘플이 됩니다.
→ 전체 문맥: backend/services/outfit_history.py
이 코드는 표준 라이브러리만을 사용합니다. 의존성도 없고, 모델 호출도 없으며, 고정된 랜덤 시드 (random seed)가 주어지면 디버깅하기에 완전히 결정론적 (deterministic)입니다. 이것이 시스템의 "확률적 (stochastic)" 절반이며, 의도적으로 작고 격리되어 설계되었습니다.
이 지점은 또한 Part 1의 탐색 대 활용 (exploration vs. exploitation) 트레이드오프가 추상적인 개념을 넘어 실제 코드가 되는 지점이기도 합니다. 피드백 승수 (feedback multiplier)는 활용 (exploitation) 레버입니다. 이는 이미 효과가 입증된 것에 의존하여 검증된 아이템들을 위로 끌어올립니다. 추출 과정의 무작위성은 탐색 (exploration) 레버입니다. 이는 선호도가 낮거나 최근에 입지 않은 아이템들을 계속해서 제 앞에 제시하며, 바로 이 과정을 통해 제가 스스로는 절대 선택하지 않았을 조합들을 우연히 발견하게 됩니다. 탐색에는 더 미묘한 이유도 있습니다. 피드백 승수는 실제로 저에게 보여주는 아이템에 대해서만 학습할 수 있기 때문입니다. 샘플링되지 않은 아이템은 어떤 판정 (verdict)도 생성하지 못합니다. 따라서 순환 (rotation)은 단순히 다양성을 위한 것이 아니라, 취향 학습 (taste-learning)을 공급하는 역할을 합니다. 무작위성을 없애버린다면, 시간이 지남에 따라 더 똑똑해져야 할 바로 그 요소를 조용히 굶겨 죽이는 꼴이 될 것입니다.
좋은 점은 두 요소 사이의 균형이 로직에 하드코딩되어 있지 않다는 것입니다. 이는 몇 가지 조정 가능한 상수 (tunable constants) 안에 존재합니다. 예를 들어, '좋아요'가 아이템을 얼마나 강력하게 끌어올리는지, 옷장의 어느 정도 비율이 샘플링되는지 (~70%), 최신성 (recency)이 얼마나 빨리 감쇠하는지 (DAILY_DECAY = 0.85) 등이 있습니다. 이 값들을 한쪽으로 밀면 앱이 편애를 하게 되고, 반대쪽으로 밀면 모험적으로 변합니다. 각 다이얼을 얼마나 돌려야 하는지, 그리고 왜 피드백 유도 (feedback nudge)가 완전히 탐색을 멈추지 않도록 단단한 하한선 (hard floor)을 유지하는지는 Part 3의 주제입니다.
5단계: 카테고리 하한선 (모든 신발을 퇴출시키지 마세요)
무작위 샘플링에는 실패 모드 (failure mode)가 있습니다. 운이 나쁘면 추출 과정에서 특정 카테고리 전체가 사라질 수 있다는 점입니다. 70%를 무작위로 뽑다 보면 풀(pool) 안에 신발이 하나도 남지 않을 수도 있으며, 이 경우 모델이 아무리 영리하더라도 완전한 착장을 구성하는 것은 불가능해집니다.
따라서 샘플링(sampling)을 거치면 **카테고리 바닥값(category floors)**이 풀(pool)을 다시 채웁니다. 즉, 상의(tops), 하의(bottoms), 신발(shoes), 아우터웨어(outerwear)가 최소한의 개수를 갖도록 보장합니다. 만약 어떤 카테고리가 설정된 바닥값보다 적게 남았다면, 다음으로 좋은 아이템들(무작위 선택 과정 바로 밖에 있던 아이템들)이 바닥값이 충족될 때까지 다시 진열됩니다. 이는 저렴한 보험과 같으며(풀에 아이템을 보유하는 것은 비용이 들지 않아 모델은 항상 무시할 수 있음), 샌들 사고의 마지막 조각입니다. 단순히 '유일한 신발만 제거하지 마라'가 아니라, '어떤 경우에도 주사위가 전체 카테고리를 제거하도록 두지 마라'는 것입니다.
스테이지 6: 후보 풀(candidate pool)과 모델의 만남
바닥값 설정 이후에 남는 것이 바로 **후보 풀(candidate pool)**이며, 이는 모델이 최종적으로 보게 되는 선별되고 섞인 옷걸이입니다. 이제 모델을 호출합니다. 다음 단계에 중요한 세부 사항 하나가 있습니다. 모델은 모드당 단일 의상을 반환하는 것이 아니라, 세 가지 후보를 순서대로(가장 좋은 것부터) 반환한다는 것입니다. 이 점을 기억해 주세요. 이것이 마지막 필터링을 가능하게 하는 요소입니다. 첫째는 프롬프트가 호출 자체를 어떻게 유도하느냐입니다.
로그 기록 중 한 줄만으로 전체 파이프라인을 디버깅할 수 있습니다. 모든 실행은 구축된 풀(pool)을 출력하며, 이는 제가 의상이 놀라울 때 가장 자주 던지는 질문에 답해줍니다: '저 아이템이 샘플링 과정에서 빠진 것인가, 아니면 모델이 그냥 무시한 것인가?'
INFO wardrobe.recommend: candidate pool (52 of 73 after gate + sampling):
'90s Vintage Maxi Denim Skirt front slit,
Aritzia Wilfred black suit vest,
...
이것은 실제 실행 기록입니다. 총 73개의 아이템 중 오늘 게이트(gate)를 거치거나 샘플링 과정에서 제외된 것이 21개이고, 모델이 선택을 시작할 때 방에 남아있는 것이 52개입니다.
나머지 절반: 프롬프트를 이용한 모델 유도하기
위에 설명된 모든 내용은 모델이 고려하는 어떤(which) 옷들을 선별합니다. 시스템 프롬프트는 그것들을 어떻게(how) 조합할지를 처리합니다. 이 둘은 상호 보완적인 레버리지이며, 저는 파이프라인을 튜닝한 방식과 똑같이 프롬프트를 튜닝했습니다: 실패를 관찰하고 규칙을 작성함으로써 말입니다.
세 번의 시도가 공유할 가치가 있습니다.
따뜻함(Warmth), 미묘한 단계. 극단적인 경우만 걸러내는 것은 터무니없는 것만을 제거할 뿐입니다. 매일 아침 '이게 9°C (48°F) 날씨에 정말 따뜻한가?'라는 질문은 판단의 영역이기 때문에, 저는 모델에게 다음과 같이 지시했습니다: 모든 아이템에는 1부터 5까지의 warmth 등급이 있으며, 프롬프트는 오늘 최고 및 최저 기온(더위를 위한 가벼운 옷; 추위를 위한 고따뜻함 아이템 또는 여러 개의 가벼운 레이어)에 맞춰 의상의 결합된, 레이어드 따뜻함을 추론하도록 요청합니다. 터무니없는 경우를 위한 하드 게이트, 미묘한 차이를 위한 모델의 판단: 동일한 물리 법칙 대 상황적 맥락의 분할입니다.
나쁜 조합을 강요하기보다 슬롯 비우기. 제가 가장 좋아하는 실패 사례입니다. 모델이 스포츠 샌들을 우아한 드레스와 계속해서 페어링했습니다. 그 논리는 이해가 갔습니다: 의상은 신발이 필요하고, 샘플링 후 샌들이 때로는 유일하게 남은 신발이었기 때문에, 모델은 충실하게 그것을 끼워 넣었습니다. 해결책은 '완벽한' 조합을 필수가 아닌 선택 사항으로 만드는 것이었습니다:
신발이 빠진 의상이 어울리지 않는 신발이 있는 의상보다 낫다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기