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