LLM 상담 에이전트에서 "사용자 질문을 어떻게 분류할 것인가"
OTA 상담 채팅 AI를 만들면서 "사용자 질문을 어떻게 분류할 것인가"를 놓고 했던 설계 판단을 정리한다. 단일 역할에서 시작해 7개 의도 분류, (의도 × 제약사유) 상태, 프롬프트 위생 이슈까지.
여행 상품을 다루는 OTA의 상담 채팅 AI를 수개월 동안 붙들고 있었다. 처음엔 "상품 정보 조회" 하나만 하는 단순한 도구 호출 에이전트였는데, 서비스에 붙이고 나니 사용자는 "출발 시간", "예약 확정됐나요", "내 예약 취소해줘", "후기 어떻게 남기나요" 같은 서로 다른 성격의 질문을 섞어서 던졌다.
한 에이전트가 이걸 다 받아내야 했고, 그 과정에서 "사용자 질문을 어떻게 분류할 것인가" 라는 질문에 몇 번 방향을 바꿨다. 그 흐름을 정리한다.
0. 시작점: 분류 없는 단일 역할
처음 올린 에이전트의 시스템 프롬프트는 지금 보면 민망할 정도로 단순했다.
당신은 ProductAgent입니다. 도구를 사용하여 상품 상세 정보를
조회하고 전달하는 역할을 합니다. 다음 규칙을 따르세요:
1. 사용자가 상품 ID 또는 상품명을 제공하면:
- 상품ID(product_id) 또는 상품명(product_name)을 추출합니다.
...
의도 분류라는 개념이 아예 없었다. 사용자 입력에서 product_id만 뽑아서 도구를 호출하면 끝이었다. 이 구조의 흐름은 이랬다.
문제는 금방 드러났다. "홍길동 010-1234-5678 예약 확정됐나요?" 같은 질문이 들어오면 에이전트는 전화번호에서 뭔가 상품ID스러운 패턴을 찾으려고 하다가 멈추거나, "상품 ID를 알려주세요"라고 엉뚱하게 되묻거나, 아예 환각으로 예약 상태를 지어냈다.
단일 역할 에이전트는 "이 사용자가 지금 무엇을 원하는가"를 한 번 결정하지 않으면 매 도구 호출이 도박이 된다는 걸 몸으로 알았다.
1. 첫 번째 판단: 분류기를 어디에 둘까
프롬프트를 .md 파일로 뽑아 버전별로 커밋하기 시작한 시점에 (system_prompt_v1.md 등) 그 안에 의도 분류를 처음으로 끼워 넣었다.
방식은 단순했다. 시스템 프롬프트 안에 "의도 분류 (단일 선택)"을 명시하고, "결정 트리" 섹션으로 의도별 도구 호출 규칙을 박아넣는다. 한 LLM이 분류와 응답을 같이 한다.
이 방향이 이 케이스에 맞았던 이유는 세 가지였다.
- 도구 집합이 세 개뿐이었고(
getProductInfo,getReservationInfo,callCounselor) 의도별 도구 매핑이 단순했다. 의도별 핸들러를 노드로 분리하면 그 노드들이 대부분 같은 도구를 다른 규칙으로 쓰는 모양이 되어서 중복이 커졌을 것이다. - 상담 톤 일관성이 의도를 넘어가서도 필요했다. "상품 문의로 시작했다가 취소로 넘어가는" 턴이 흔해서, 한 LLM이 전체 컨텍스트를 쥐고 있는 게 유리했다.
- 같은 시기에 "장문 룰북" 스타일과 GPT-5의
<context_gathering>스타일 압축본을 나란히 돌려보다가, 상담 도메인에선 "간결함"보다 "명시적 분기 규칙" 이 중요하다는 걸 확인했다. 압축 버전은 엣지 케이스에서 자주 넘어졌다.
2. 의도를 어디서 자를 것인가
v1.0에서 최종적으로 7개 의도로 갈랐다.
| 의도 | 정의 | 처리 방식 |
|---|---|---|
PRODUCT | 상품 상세/정책/일정/가격 안내 | getProductInfo 호출 |
PRODUCT_AVAILABILITY | "예약 가능한가요/좌석 있나요" 가용성 문의 | 도구 호출 없이 안전고지 + 예약 화면 경로 안내 |
RESERVATION_CONFIRM | 예약 확인(결제/확정/이용일/인원) | getReservationInfo 호출 |
PRODUCT_REQUEST | 상품 관련 요청사항 전달 | callCounselor 핸드오프 |
CANCEL_OR_REFUND | 취소/환불 요청 및 규정 문의 | 정책 안내 → callCounselor 핸드오프 |
PRODUCT_REVIEW | 리뷰/후기 작성 요청 | 처리 불가 안내 (리뷰 기능 없음) |
OUT_OF_SCOPE | 범위 외 기타 문의 | 범위 밖 고지 + 유도 |
이 카테고리 분할을 할 때 내가 썼던 두 가지 기준이 있다.
기준 1: 도구로 답할 수 있는 질문인가
PRODUCT vs PRODUCT_AVAILABILITY를 왜 굳이 나눴냐는 질문을 여러 번 받았다. 둘 다 "상품에 대한 질문"이지 않나.
하지만 이 시스템에는 실시간 재고를 확인할 수 있는 도구가 없다. 상품 상세 정보는 주지만, "8월 15일에 3명 자리 남아요?"에는 절대 답할 수 없다 (예약 가능 여부는 결제 화면에서만 확정되는 도메인이다). 의도를 안 나누면 LLM이 getProductInfo를 호출해 상세 정보를 받아보고는 "가능할 것 같습니다"라고 유추한다. 이건 상담에서 제일 위험한 형태의 환각이다 — "잡아둘게요", "확정 드립니다" 같은 표현이 터진다.
의도를 갈라놓고 PRODUCT_AVAILABILITY에서는 도구를 호출하지 말고 "실시간 재고 확인 불가" 안전고지 + 예약 화면 경로 안내만 하도록 고정했다. 금지 표현 목록("가능해 보입니다/아마 가능/보장/홀드")까지 프롬프트에 박아 넣었다.
기준 2: 이 의도는 AI가 처리할 권한이 있는가
CANCEL_OR_REFUND, PRODUCT_REQUEST, PRODUCT_REVIEW를 별도 의도로 뺀 이유도 같다. AI에게 권한이 없는 영역이다. 취소/환불 처리, 요청사항 전달, 후기 등록 — 전부 사람이 해야 한다.
이 의도들에는 공통 처리 규칙을 걸었다: "정책/불가 사유 안내 → 필요한 경우 상담사 연결 제안". 특히 CANCEL_OR_REFUND는 완료형 표현("환불되었습니다", "취소 완료")을 프롬프트에서 명시적으로 금지했다. LLM이 예약 상태 도구 결과를 보고 "취소 처리되셨네요"라고 말해버린 사고가 실제로 있었기 때문이다.
정리하면:
의도 카테고리 이름은 장식이 아니다. 각 카테고리는 "도구를 호출할까 말까", "AI가 처리할까 상담사에게 넘길까"에 대한 명시적 답을 품고 있어야 한다.
3. 라벨만으로는 부족했다 — (의도 × 제약사유)
의도 분류만 깔끔히 해놓으면 끝일 줄 알았는데, 사용자들은 예상하는 대로 움직여주지 않았다.
가장 자주 본 시나리오:
사용자: 출발 시간이 어떻게 돼?
AI: 정확한 상품 정보를 확인하기 위해 상품ID를 알려주시겠어요?
사용자: 그냥 알려줘
AI: 상품 정보를 정확히 확인하려면 상품ID가 필요합니다...
사용자: 빨리 알려줘
AI: 상품 정보를 정확히 확인하려면 상품ID가 필요합니다...
의도는 계속 PRODUCT로 정확하게 찍고 있었지만, 필요한 슬롯(product_id)이 안 들어오는 상태가 무한 반복됐다. 이대로는 사용자도 지치고 AI도 지쳤다 (LLM은 안 지치지만 사용자 인내심은 지친다).
의도 라벨 하나만으로는 "왜 같은 답이 나가는지" 를 잡을 수가 없었다. 그래서 v1.3에서 내부 상태를 확장했다.
- repeat_intent_counter: 동일 의도 + 동일 제약 사유에 대한 연속 반복 횟수
- last_intent
- last_constraint ← 새로 도입
핵심은 last_constraint. 의도(PRODUCT) + 제약 사유(product_id_missing) 페어로 상태를 잡았다. 같은 페어로 2턴 이상 진행되면 "같은 벽에 부딪혔다"고 판단해서 핸드오프 트리거를 발동시킨다.
응답도 점진적으로 세게 간다.
- 1회차: 정중하게 요청 — "상품ID(PROD로 시작하는 코드)를 알려주시겠어요?"
- 2회차: 재확인 + 상담사 옵션을 부드럽게 — "또는 상담사 연결을 통해 도와드릴 수도 있습니다. 연결을 원하시나요?"
- 3회차 이상: 명확한 핸드오프 — "상품ID 없이는 정확한 정보를 제공하기 어렵습니다. 상담사와 연결해 드릴까요?"
재밌는 점은 LLM에게 실제 상태 머신이 있는 게 아니라는 것이다. repeat_intent_counter는 프롬프트 안에 "내부 메모로만 쓰고 출력하지 마라"고 적혀 있는 변수일 뿐이다. 그런데 모델이 대화 히스토리를 보고 이 카운터를 일관되게 유지한다. 진짜 상태머신을 붙이면 결정성은 올라가지만 — 그만큼 프롬프트 분기가 복잡해지고 유지보수가 불리해진다. 여기선 LLM의 "문맥 유지 능력"에 베팅했고, 현재까지는 실사용에서 먹혔다.
4. 예상 못한 사고: 프롬프트 예시가 진짜 도구 호출로 샜다
의도 분류와 직접 관련은 없지만, v1.3에서 같이 해결해야 했던 이슈가 하나 더 있었다. 의도 설계를 정교하게 해놨는데, 엉뚱한 곳에서 구멍이 났다.
프롬프트에 이런 예시가 있었다.
[예시 A-1: 상품문의, ID 제공]
사용자: PROD1234567890-1-20250101 상품 출발 시간이 어떻게 돼?
AI: (요약 1문장) → (출발 시간/위치/바우처/확정/주의) → ...
사용자가 상품ID 없이 질문했는데, LLM이 프롬프트 예시에 있던 PROD1234567890을 실제 도구 호출 인자로 넘겨버리는 사고가 나왔다. 그 상품 페이지가 띄워지고 고객에게 엉뚱한 상품을 안내하는 상황이 실제로 발생했다.
원인은 명확했다. 예시 값이 "그럴듯한" 상품코드였다 — 실제 존재할 수 있는 형식. 모델 입장에선 사용자가 ID를 안 줬으니 "문맥에서 가장 가까운 PROD 문자열"을 집은 것이다.
해결도 단순했다. 예시값을 형식 설명용 플레이스홀더로 바꾸고, "프롬프트 예시 값은 도구 호출에 쓰지 말 것"을 명시적 규칙으로 박았다.
- 예: PROD1234567890 / PROD1234567890-1 / PROD1234567890-20250101
+ 형식 예: PROD0000000000 / PROD0000000000-1 / PROD0000000000-20250101
+ ⚠️ 위는 형식 설명용 플레이스홀더이며, 실제 도구 호출에는
+ 반드시 사용자 입력에서 추출된 값만 사용하세요.
이 경험으로 얻은 규칙: 프롬프트 위생은 의도 설계만큼 중요하다.
- 예시값은 명백히 "가짜"로 보여야 한다 (
0000000000같은 형태) - 의도별 규칙과 별개로 "도구 호출 시 값 출처" 규칙을 최상단에 두고 반복 강조한다
- 프롬프트 내 어떤 값도
callCounselor나 도구 인자로 흘러가지 않도록 차단 문구를 넣는다
5. 지금 시점에서의 정리와 남은 숙제
현재(v1.3 기준) 상담 에이전트의 의도 분류는 이런 모양이다.
작동하는 부분
- 의도 라벨을 세분화한 덕에 "가능/보장" 같은 위험 발화가 확연히 줄었다
(의도 × 제약사유)페어 상태로 반복 루프 → 핸드오프 경로가 잡혀서 무한 질문 케이스 해소- 프롬프트 단일 파일(
system_prompt_v1_3.md)로 버전 관리가 깔끔해서 롤백/AB가 쉽다
아직 덜 풀린 부분
repeat_intent_counter를 LLM 내부 메모에 의존하는 방식은 언젠가 긴 대화에서 깨질 수 있다. 실제 상태 저장소로 뺄 것인지, 아니면 대화 히스토리 길이에 상한을 걸 것인지 정해야 한다- 의도가 턴 중간에 바뀌는 케이스 — "출발시간 물어봤다가 갑자기 취소 얘기로" — 지금은 마지막 턴의 의도만 본다. 다중 의도가 한 턴에 있을 때의 처리는 아직 임시방편
요약하면 이번 설계에서 내가 얻은 교훈은 이것이다.
- 단일 역할 에이전트는 한계가 뚜렷하다 — 사용자가 한 가지만 물어봐 줄 거라는 기대는 버리자
- 의도 경계는 "AI가 할 수 있나/해도 되나"로 긋는다 — 의도 라벨에 권한과 처리방식이 같이 실려야 한다
- 라벨만으로 부족하면
(의도 × 제약)페어로 상태를 만든다 — 실제 state machine 없이도 프롬프트만으로 유사 상태를 유지할 수 있다 - 프롬프트 안의 모든 문자열이 도구 호출로 샐 수 있다 — 예시값은 절대적으로 "가짜"여야 한다