AI 엔지니어링 AI LLM Jev Cloudflare Workers AI AI Gateway 마이그레이션 아키텍처

일정 AI의 카테고리만 Jev에게 (1): 생성형 LLM 대신 판별 모델 도입하기

···
일정 AI의 카테고리만 Jev에게 (1): 생성형 LLM 대신 판별 모델 도입하기

제가 최근에 캘린더 모바일 앱을 하나 만들고 있는데요, 여기에 사용자의 자연어 문장을 분석해 일정으로 변환해 주는 「AI 일정 입력」 기능이 있으면 좋을 것 같아서 새롭게 개발중입니다 🤔

예를 들어, 「내일 3시 병동 회의」라고 치면 제목·날짜·시간·장소·메모, 그리고 사용자가 만들어 둔 카테고리 중 하나를 골라 초안을 만들어 주는 기능이에요.

처음에는 이 전부를 LLM 한 번의 호출로 처리했는데요, 제목이나 날짜·시간은 답이 문장 안에 있는 서술형인데, 카테고리는 없던걸 생성하는게 아니라 정해진 선택지에서 고르는 객관식이니까 굳이 LLM에게 맡길 필요가 없지 않을까요???

마침 요즘 핫한 TypeSafe의 Jev 제 상황에 최적(?)의 모델이 아닐까 하는 생각이 번쩍 들더라고요 ㅎㅎ 그래서 카테고리 판단만 Jev로 떼어내 보기로 했습니다 🫡


AS-IS

사용자가 추가하고자 하는 일정에 대한 자연어와 함께 사용자 카테고리 목록(id·이름) 딕셔너리를 보내면, category_decision에 existing(목록 안의 id) 또는 none을 담아 돌려줍니다. 기존 LLM 으로 사용하던 프롬프트의 CATEGORY 절에는 그동안 실측으로 쌓아 온 규칙이 길게 들어 있습니다 !

  • 이름의 뜻으로 판단한다 — 「운동」은 헬스, 「건강관리」는 치과 진료
  • 병원이 직장이면(병원 야간 근무, 인계) 진료가 아니라 업무다 간호사 사용자가 많아서 꼭 필요한 규칙이었습니다
  • 「개인」, 「할 일」, 「기타」는 아무거나 담는 칸이 아니다
  • 느슨하게 맞으면 none

실제 프롬프트는 아니지만 생각보다 간단한 프롬프트 형태인데요, 사용한 LLM 모델이 낮은 성능의 모델이 아니었기 때문에 생각보다 너무 잘 해주긴 했습니다. 하지만 벤치를 돌리고 나보면 아쉬움이 남는데요!


벤치를 돌려보자

기존에도 간단하게 일정 AI 전체를 채점하는 하네스가 있었지만, 카테고리 문장은 22개뿐이라서 추가로 카테고리만 떼어 보는 벤치를 새로 만들었습니다.

  • 카테고리 목록 6종: 기본형(회사/업무·개인/약속·운동/건강·병원/진료), 간호사형(근무·건강관리·약속·운동·공부·가족), 짧은이름형(개인·약속·할 일·운동), 취미형, 영문 혼합형(Work·Gym·데이트…), 소수형(알바·스터디)
  • 92문장: 정답 명확 56 · 정답 없음 11 · 함정 11 · 정답 모호 14
  • expect는 받아들일 수 있는 답의 목록입니다. 「금요일 저녁 회식」은 약속도 업무도 정답으로 칩니다.

함정은 아래와 같은 것들을 준비했습니다 :)

문장목록정답노리는 것
모레 병원 야간 근무기본형회사/업무병원 = 직장
기타 레슨취미형취미「기타」 동음어
내일 2시 치과 진료짧은이름형없음「개인」을 아무거나 칸으로 쓰기
연말정산 서류 제출취미형없음「기타」를 아무거나 칸으로 쓰기

기존 LLM 기준선: 약 94%

그런데 틀리는 방식이 한쪽으로 쏠림 📐

92문장함정없음p50 · p95
Solar Pro 486–8711/1110–11/112.6초 · 11–22초

모델 자체 성능이 좋기 때문에 생각보다 꽤 잘합니다 ! 그런데 틀린 5~6개 중 5개가 전부 놓친 카테고리 더라구요

  • 관리비 납부하기 → 할 일이 있는데 없음
  • 택배 반품 보내기 → 없음
  • 약 타러 가기 → 없음

“「할 일」은 아무거나 담는 칸이 아니다”라는 규칙이 너무 세게 먹혀서, 진짜 할 일까지 밀어내는 걸로 파악이 됩니다. 프롬프트로 부탁한 판단은 이렇게 한쪽으로 기울면 조절할 손잡이가 없습니다.


Jev v1

그대로 옮겼더니 비겼습니다 ⚔️

사실 처음 도입할 때 가장 크게 걸렸던 건 한국어 지원이었습니다. Jev는 해외 모델이라 한국어 학습 데이터가 충분하지 않을 것 같았거든요 😩

벽 뒤에 숨어 불안한 표정으로 빼꼼 내다보는 햄스터 캐릭터(햄타로)
한국어도 알아들을까…?

지시문(instructions)이나 판단 기준은 영어로 잘 번역해서 프롬프트 엔지니어링을 해준다 쳐도, 모델이 실제로 보고 판단해야 할 사용자의 자연어와 카테고리 이름은 대부분 한글이니까요. “과연 ‘내일 3시 병동 회의’를 보고 ‘회사/업무’를 제대로 연결할 수 있을까????” 반신반의하는 마음으로 시작을 했습니다.

Jev의 입력은 state(판단할 상태)와 questions(타입이 있는 질문들)입니다. 첫 판은 기존 LLM 프롬프트의 CATEGORY 절을 영어로 옮겨 choice 질문 하나로 만들었어요. 사용자 카테고리는 UUID 대신 c0, c1… 키로 바꿔 보내고, none 선택지를 하나 더 둡니다.

{
  "state": { "sentence": "내일 3시 팀 회의" },
  "questions": {
    "category": {
      "type": "choice",
      "instructions": "A shift worker wrote `sentence` into their personal calendar app. …",
      "criteria": {
        "c0": "The user's category named \"회사/업무\"",
        "c1": "The user's category named \"개인/약속\"",
        "none": "None of the user's categories clearly fits this event"
      }
    }
  }
}

기대했던 그림은 “confidence가 낮으면 none” 이었습니다.

confidence 문턱00.30.50.60.8
정답8686838276

결과는 기대와 정반대였습니다 🥲

  1. 정확도는 기존 LLM과 거의 비슷합니다. (85–86 vs 86–87)
  2. confidence 문턱을 올리면 맞은 답이 먼저 떨어졌습니다. 회식 0.39, 동창 결혼식 0.49, 물리치료 0.52. 한국어에서 choice의 confidence는 “얼마나 잘 맞나”보다 “얼마나 애매한가”를 재는 것 같습니다.
  3. 틀리는 곳이 달랐습니다. 기존 생성 모델이 놓친 「할 일」은 맞히는데, 대신 약속·개인/약속을 아무거나 칸으로 썼습니다(ex. 분리수거 · 혼자 가는 제주도 여행 · 치과 → 약속).

문서에도 적혀 있습니다

TypeSafe 문서는 영어가 주 학습 언어이고, CJK를 포함한 다른 언어는 “handled but not equally well”이라고 적고 있습니다. 한국어 워크로드는 반드시 자기 데이터로 재 보라는 말이 괜히 있는 게 아니었습니다.

기대보다 약간(?) 성능이 안 나와주긴 했지만 기존 LLM과 Jev 둘 다 81문장 중 80문장 정답(98.8%)으로 같았는데, Jev가 LLM 대비 가격이나 레이턴시가 훨씬 빠르기 때문에 좀 더 깎아보기로 했습니다. 할 수 있다!


v2 → v3: 질문을 쪼개니 경계가 섰습니다

TypeSafe가 권하는 패턴 중에 fan-out이 있습니다. 넓은 판단 하나를 원자적인 질문 여러 개로 쪼개서, 한 호출에 병렬로 묻고 코드에서 합치는 방식이에요. 그래서 choice 하나에 더해 카테고리마다 yes/no(noul) 질문을 붙였습니다. 비용도 저렴하고 빠르기 때문에 아무 문제 X

choice는 순위를 매기고, fit은 그 카테고리가 정말 맞는지를 확인합니다. choice는 선택지끼리 확률을 나눠 가져서 “하나도 안 맞음”이 잘 서지 않지만, fit(noul)은 카테고리마다 따로 물어서 “아무것도 안 맞음”이 확률로 설 수 있습니다.

  • v2: 규칙 전부를 fit 질문에도 붙였습니다. → 맞는 것까지 눌러 버렸어요. 「PT 받기」가 Gym에 0.08밖에 나오지 않았습니다.
  • v3: fit은 “이 이름 아래 적어 둘 일인가”를 묻고 짧은 규칙 두 개(개인·기타는 fallback이 아님, 직장으로서의 병원은 의료가 아님)만 남겼습니다. 나머지 경계(약속은 누군가를 만날 때만, 할 일은 처리해야 하는 일)는 choice 지시문이 맡게 역할을 나눴습니다.

지시문의 예시에는 벤치 문장을 옮겨 적지 않았습니다. 그러면 벤치 점수는 쉽게 오르지만 아무것도 증명하지 못하겠죠?

결과

규칙 · confidence 문턱0.20.40.50.60.70.8
choice90(1)88(1)86(1)82(0)81(0)80(0)
fit(최댓값)78(14)81(9)82(8)83(6)84(5)80(5)
hybrid90(1)90(0)89(0)89(0)89(0)84(0)
괄호 안은 틀린 카테고리를 붙인 수. 92문장, 두 번째 회차.

hybrid가 문턱 0.4–0.7에서 평평하게 틀린 카테고리 0이었습니다. 문턱을 조금 잘못 잡아도 결과가 흔들리지 않는 구간이라는 뜻이라, 그 가운데인 0.6을 골랐습니다.

그리고 추가로 확인용 30문장 👀

92문장확인용 30틀린 카테고리p50
Solar Pro 486–87241–22.6–3.6초
Jev v1 (번역)85–86—50.25초
Jev v3 hybrid · 0.6893000.25초

기존 LLM은 확인용에서도 같은 방향으로 틀렸습니다. 자동차 보험 갱신·전입신고(할 일), 혼자 전시회(개인), 팀장님과 1:1(회의)을 놓치고, 「친구 이사 도와주기」에 집안일을 붙였어요. Jev v3에 남은 오답 셋(PT 받기, 병동 회식, 과외 → 알바)은 전부 놓침이라, 적어도 엉뚱한 카테고리를 붙이진 않습니다 👍

비용은 호출당 입력 약 1,330토큰, $0.042/Mtok라서 1만 건에 60센트 정도입니다. 출력 토큰은 무료예요.

정장 차림의 레오나르도 디카프리오가 흐뭇한 표정으로 박수를 치는 GIF
1만 건에 60센트.. 초가성비

첫 이관: 나란히 출발하고, 실패하면 기존 LLM으로

첫 이관의 설계 원칙은 세 가지였습니다. 아래는 그때 배포한 구조이고, 10월 2일에는 폴백 정책까지 바꾼 후속 실험을 진행했습니다.

  1. 앱은 모른다. 응답 계약(category_decision)은 그대로라 앱 업데이트가 필요 없습니다.
  2. 느려지지 않는다. Jev를 기존 LLM(Solar)과 나란히 출발시킵니다. Jev p50이 0.25초라 대개 LLM(2.6초)보다 먼저 끝나 있습니다.
  3. 실패해도 망가지지 않는다. Jev가 실패하거나 1.5초를 넘기면 기존 LLM이 이미 내 둔 판단으로 물러섭니다. 기존 모델은 여전히 카테고리까지 판단하고 있으니 공짜 폴백입니다.

엔진은 환경 변수 하나로 고릅니다.

SCHEDULE_AI_CATEGORY_ENGINE동작
solar (기본)지금까지와 같음 (기존 LLM 경로)
shadowJev도 부르고 비교만 기록, 앱은 기존 LLM 판단
jevJev 판단을 보냄, 실패하면 기존 LLM

따로 고르는 건 사용자가 아직 카테고리를 정하지 않은(auto) 요청뿐입니다. 사용자가 이미 확정한 카테고리를 「카테고리 운동으로 바꿔줘」처럼 명시적으로 바꾸는 건 대화 문맥이 필요해서 초안 LLM에게 남겼습니다.

로그에는 두 판단의 종류, 일치 여부, 점수, 모델 버전, 지연만 남깁니다. 원문과 카테고리 id는 싣지 않습니다.


삽질 두 개

1. 402 Insufficient balance

Workers AI 토큰으로 첫 호출을 날렸더니 인증은 통과했는데 402가 돌아왔습니다.

{"errors":[{"message":"Insufficient balance; add money to your gateway or use BYOK","code":2021}]}

Jev 같은 서드파티 모델은 Workers AI 무료 사용량으로 돌지 않고, AI Gateway 크레딧(Unified Billing)이나 BYOK로 과금됩니다. 대시보드 → AI Gateway → Credits Available에서 충전하면 끝이에요. 충전액에 5% 수수료가 붙고, 드물게 잔액이 마이너스가 될 수 있다니 Spend limits는 걸어 두는 걸 추천합니다.

2. REST 응답은 두 겹입니다

문서의 예시 출력은 { model, answers, usage }인데, REST(/ai/run)로 받으면 이렇게 옵니다.

{
  "result": {
    "state": "Completed",
    "result": { "model": "jev-1.13.0", "answers": { … }, "usage": { … } }
  },
  "success": true
}

작업 상태로 한 겹 더 래핑되어 있어서 파서가 model 없음으로 나오길래 원본을 찍어 보고 알았습니다.

버전은 고정해서 보세요

jev-latest 같은 별칭은 새 모델이 나오면 조용히 움직입니다. 문턱 0.6은 jev-1.13.0에서 맞춘 값이라, 응답의 model 필드를 로그에 남겨 두고 버전이 바뀌면 벤치를 다시 돌리기로 했습니다.


1차 이관을 마치며: 다음 단계는 ‘중복 책임 제거’

이렇게 해서 제 인생 첫 Decision 모델을 사용하여 카테고리 판단 책임을 Jev에게 넘기는 1차 이관이 나름(?) 성공적으로 안착했습니다.

  1. 프롬프트 튜닝의 한계 극복: 프롬프트 과적합 문제를, Jev의 fan-out(choice + fit noul) 하이브리드 질문 구조와 적절한 문턱 값 설정으로 해결했습니다. (확인용 30문장 100% 정답)
  2. 지연 시간과 안정성 확보: Jev가 약 250ms 만에 끝나므로 전체 일정 변환 속도(2.6초대)에 영향을 주지 않았고, 실패 시 기존 LLM 판단으로 물러서는 안전한 폴백도 갖췄습니다.
  3. 서버리스 AI 인프라 안착: Workers AI REST 래핑 파싱과 AI Gateway 크레딧 정책도 깔끔하게 정리했습니다.