EP 11

94%는 착각이었다 — triage 33%에서 90%로


2026년 8월·12 min read·#prompt-engineering#llm#debugging

벤치마크가 33%를 찍었다.

6개 카테고리를 랜덤으로 찍으면 17%다. triage는 그것보다 조금 나은 수준이었다.

프롬프트가 문제라고 생각했다. 틀렸다 — 완전히는 아니지만.


triage가 하는 일

페퍼에게 오는 모든 메시지는 triage를 먼저 통과한다.

small_talk, recall, task, lookup, capability, cascade — 6개 카테고리. 단어 하나가 나오고, 나머지 시스템이 그 뒤를 따른다. 틀리면 다운스트림 전체가 틀린다.

EP 10에서 triage는 50개 테스트 케이스 기준 94%였다. 그걸로 충분하다고 생각했다.

그런데 500개 케이스로 벤치를 다시 돌렸다. 제대로 된 러너도 만들었다.

33%.

50개와 500개 사이 어딘가에서 환상이 깨졌다.


블랙홀

실패 패턴은 한눈에 보였다.

small_talk → 98% ✅
recall     → 23%
task       → 13%
lookup     → 19%
capability → 12–28%

small_talk이 전부 빨아들이고 있었다. 다른 카테고리로 가야 할 499개 중 322개가 small_talk으로 빠졌다.

"오늘 날씨 어때?" → small_talk. "아빠 생신 언제야?" → small_talk. "화요일 3시 치과 캘린더 추가해줘" → small_talk.

모델이 분류하는 게 아니었다. 그냥 디폴트로 뱉고 있었다.


첫 번째 시도: 프롬프트 전면 개선

당연한 수순으로 프롬프트를 갈아엎었다. decision tree, 1번부터 5번까지 우선순위, 모든 경계 케이스 명시, 최상단에 ⚠️ small_talk은 마지막 수단.

이전: 33%. 이후: 31%.

더 나빠졌다.

원인은 모델이었다. Gemini Flash-Lite는 프롬프트가 길어질수록 instruction following이 나빠진다. 구조가 많을수록 더 헷갈려한다. 프롬프트가 잘못된 게 아니었다. 모델이 따라갈 수 없었던 거다.


그러면 모델을 바꾸자

Flash-Lite → Flash. 의미 있는 업그레이드.

이전: 31%. 이후: 32%.

1%p.

모델도 문제가 아니었다.


Payload

이쯤에서 추측을 멈추고, 실제로 API에 뭘 보내고 있는지 봤다.

model:          gemini-2.5-flash
thinkingBudget: 0       ← thinking 완전 비활성화
temperature:    미설정  ← Gemini Flash 기본값 = 1.0

두 가지가 걸렸다.

thinkingBudget: 0은 Flash-Lite 시절 설정이 그대로 남아있던 거다. Flash-Lite는 thinking 기능 자체가 없다. 모델을 바꾸면서 설정을 안 건드렸던 것.

temperature: 1.0은 기본값이다. 분류 태스크에서 temperature 1.0은 사실상 매 요청마다 주사위를 굴리는 것과 같다.

나는 망가진 온도계로 프롬프트를 평가하고 있었다.


temperature: 0

한 가지만 바꿨다.

이전: 32%
이후: 49%

17%p. 프롬프트 변경 없음. 모델 변경 없음. 그냥 모델한테 랜덤을 멈추라고 했을 뿐.

벤치가 패턴을 확인해줬다. recall이 17%에서 100%로 순식간에 올라갔다. 프롬프트는 괜찮았다. 모델이 결정론적으로 동작하도록 설정하지 않은 게 문제였다.


한국어 프롬프트, 영어 모델

49%는 나아졌다. 충분하지 않았다.

다음으로 시도한 건 프롬프트 언어를 한국어에서 영어로 바꾸는 것이었다. 같은 규칙, 같은 예시, 같은 구조 — 번역만 했다.

이전: 49%
이후: 63%

task가 33%에서 100%로 갔다. 번역 하나로.

이건 temperature 수정보다 더 놀라웠다. 내용이 바뀐 게 없다. 논리가 바뀐 게 없다. 그런데 모델이 지시를 따르는 능력이 극적으로 달라졌다.

모든 LLM은 영어 데이터로 주로 훈련됐다. Instruction following, 분류, decision tree 따라가기 — 이 능력들은 영어로 만들어졌다. 한국어 지시문은 모델에게 이해하기 전에 번역을 먼저 요구한다. 그 과정에서 뭔가 빠진다.

여기서 나온 원칙: 모델 지시문은 영어로. 사용자에게 보여주는 출력은 한국어로. 예시는 혼용 가능. 프롬프트의 언어와 응답의 언어는 별개의 결정이다.

이걸 확인한 다음, 페퍼의 다른 LLM 호출들을 전부 확인했다. slot-filler, extractor, toss.transfer, summary-cache, tools router — 전부 영어로 바꿨다.


파이프라인이 두 개다

벤치가 드러낸 또 한 가지. triage 정확도와 capability matching은 다른 숫자다.

triage가 "이건 capability 요청이야"라고 분류하면, matcher가 그다음으로 어떤 capability인지 찾는다 — calendar.create_event인지, naver.shopping인지, toss.transfer인지.

triage 90%는 matcher가 망가져 있으면 의미가 없다.

이 시점에서 capability_cal_list는 0%였다. capability_shopping도 0%였다.

matcher 프롬프트 문제라고 생각했다. capability description을 다시 썼다. 트리거 키워드를 추가했다. 예시를 늘렸다. 아무것도 안 움직였다.

그러다 raw response를 직접 찍어봤다.

모델 출력: {"match":"naver.shopping"}
코드 결과: null

모델은 맞았다. 코드가 틀렸다.


파싱 버그

matcher는 JSON 파싱 전에 마크다운 코드펜스를 제거하는 코드가 있었다.

// before
const cleaned = result.text.replace(/^```json\s*|\s*```$/g, '').trim()

근데 모델이 이런 식으로 반환할 때가 있다.

{"match":"naver.shopping"}

The user is asking for the cheapest AirPods...

정규식이 앞 펜스는 벗기는데, 뒤에 붙는 설명 텍스트는 못 처리한다. JSON.parse 실패. null 반환. 조용히.

// after
const block = result.text.match(/```json\s*([\s\S]*?)```/)
const cleaned = (block ? block[1] : result.text).trim()

코드블록 내용만 직접 추출한다. 바깥에서 펜스를 벗기려 하지 않는다.

triage.ts에도 같은 패턴이 있었다. 둘 다 고쳤다.


"ALWAYS capability"

파싱 버그를 고쳐도 capability가 완전히 잡히지 않았다.

프롬프트에 규칙이 있었다. 모델이 일관되게 따르지 않았다. 그래서 capability 섹션에 한 줄을 추가했다.

아래 액션 동사 중 하나라도 포함되면
무조건 capability — small_talk 절대 아님.

명시적 강제. 예외 없음.

capability_toss: 50% → 100%. capability_cal_create: 25% → 100%. capability_shopping: 0% → 100%.

한 문장이었다.


시스템 전체의 temperature

triage를 고친 다음, 코드베이스의 다른 모든 LLM 호출을 확인했다.

드러난 패턴:

generateWithFallback (단일 프롬프트)    → gemini.call() → temperature: 0
generateMessagesWithFallback (messages) → gemini.callMessages() → temperature: 미설정
claude.call()                           → temperature: 미설정

두 군데 더 고쳤다.

분류와 추출 태스크: temperature 0. 결정론적 질문에는 결정론적 출력이 필요하다.

응답 생성 태스크: temperature 0.7. 페퍼의 대답은 자연스러워야 한다. 로봇 같으면 안 된다.

triage temperature 버그는 유일한 게 아니었다. 전체에 있었다.


최종 숫자

TRIAGE ACCURACY    186/207  (90%)
CAPABILITY MATCH    54/54  (100%)

그룹별:

small_talk       98%
recall           90%
task            100%
lookup           73%  ← 아직 약한 부분
capability_*    83–100%
cascade          75%

lookup 73%가 남은 과제다. 실시간 정보 요청 중 경계 케이스들 — "지금 비 와?" 같은 건 이 모든 수정 이후에도 모델이 small_talk으로 읽는다. 다음 세션이다.


실제로 일어난 일

타임라인을 깔끔하게 정리하면:

33%  →  시작
49%  →  temperature: 0              (+17%p)
63%  →  영어 프롬프트               (+14%p)
81%  →  capability 섹션 + 수정      (+18%p)
90%  →  파싱 버그 + 정책 수정       (+9%p)

근데 솔직한 버전은 다르다.

프롬프트 전면 개선이 오히려 더 나빠졌다. 모델 업그레이드는 거의 아무것도 안 했다. "JSON only" 한 문장을 프롬프트에 추가했더니 63%가 32%로 회귀했다 — 그 한 문장이 모델의 분류 추론 자체를 망가뜨린 것이다. 롤백했다. 다시 앞으로 갔다.

효과가 있었던 수정들은 내가 기대하던 프롬프트 엔지니어링이 아니었다.

  1. payload를 확인했다. 실제로 뭘 보내고 있는지 봤다.
  2. raw response를 찍어봤다. 실제로 뭘 받고 있는지 봤다.
  3. 한 번에 하나만 바꿨다. 각각이 뭘 했는지 알았다.

Payload, raw response, 하나씩 분리. 이게 실제 작업이었다.

500개 합성 케이스 기준 90%. 진짜 숫자는 쓰면서 드러날 거다. 그리고 그 "쓰면서"가, 생각보다 빨리 다가오고 있었다.