대화 내용 저장하기(json)
RAG 챗봇 개선: stay 의도 구분 & 대화 로깅
배포 후 테스트를 하던 도중 문제를 발견했다.
“상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”
“난 애쉬로 계속 하고 싶은데 둠피 때문에 살기가 힘들어 어떻게 해야해?”
둘 다 사용자가 지금 영웅을 유지하겠다는 의사를 밝힌 질문이다.
그런데 챗봇은 첫 번째 질문에서 파라 대신 다른 딜러를 추천했고
두 번째 질문에서는 둠피스트를 직접 언급하지 않거나 일반적인 생존 팁만 늘어놓는 답변을 내놓았다.
공통적으로 stay 의도(파라를 하고싶어, 애쉬로 계속 하고 싶은데)와 counter 맥락(솔저랑 소전, 둠피때문에)이 한 문장에 섞여 있었고
챗봇은 stay를 무시하고 counter나 general로 분류했다.
이 문제를 수정하면서 한 가지를 함께 작업했다.
같은 문제가 다시 생겼을 때 더 빨리 발견하기 위한 대화 로깅이다.
베타 테스트처럼 직접 보고 있을 때는 눈으로 잡을 수 있지만,
실제 서비스 중에는 로그가 없으면 어떤 질문에서 답변이 틀렸는지 알 수가 없다.
이번 글에서는 이 두 가지를 어떻게 해결했는지 정리한다.
1. stay 의도 구분 개선
문제: stay + counter가 섞인 문장을 처리하지 못함
베타 테스트에서 반복적으로 나타난 패턴이 있었다.
케이스 1
“상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”
→ 챗봇 답변: 파라 대신 다른 딜러 영웅 추천
케이스 2
“난 애쉬로 계속 하고 싶은데 둠피때문에 살기가 힘들어 어떻게 해야해?”
→ 챗봇 답변: 둠피스트를 직접 언급하지 않거나 일반적인 생존 팁만 나열
두 케이스의 구조는 같다.
사용자가 영웅을 유지하겠다는 의사(파라를 하고싶어, 애쉬로 계속 하고 싶은데)를 밝혔는데,
같은 문장 안에 적 영웅(솔저, 소전, 둠피)도 등장한다.
기존 코드에서 stay 의도는 이런 식으로 판단했다.
1
2
if any(word in text for word in ["계속 쓰고", "계속 하고", "유지", "그 영웅", "현재 영웅", "내가 계속"]):
return "stay"
“하고싶어”, “이기면서 즐기는”, “살기가 힘들어” 같은 표현은 이 목록에 없었다.
결국 적 영웅이 더 눈에 띄어 counter나 general로 분류됐고,
사용자가 원한 건 “지금 영웅으로 어떻게 상대하나”였는데 챗봇은 영웅 교체를 제안했다.
화이트리스트 방식의 한계다. 추가할수록 빠지는 게 생기고, 붙여 쓰면(“파라하고싶어”) 또 누락된다.
해결: 패턴 매칭으로 stay 의도 감지
detect_wants_to_keep_hero() 함수를 새로 만들었다.
특정 문구가 아니라 의미 구조를 정규식으로 잡는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def detect_wants_to_keep_hero(text: str) -> bool:
# 영웅 이름이 없으면 판단하지 않음
hero = find_first_hero(text)
if not hero:
return False
# 붙여 쓰기 대응: "파라쓸건데", "파라할건데", "파라하고싶어"
compact = re.sub(r"\s+", "", text)
keep_patterns = [
r"(하고싶|하고싶어|해보고싶|쓰고싶|쓸거|쓸건데|할거|할건데)",
r"(계속|유지|고정|원챔|포기안|안바꾸|바꾸지않)",
r"(이기고싶|이기면서|즐기고싶|즐기면서)",
r"(해도돼|해도될까|가능할까|괜찮을까)",
]
return any(re.search(pattern, compact) for pattern in keep_patterns)
핵심은 두 가지다.
공백을 제거한 compact 문자열에 패턴을 적용한다.
“파라 쓸건데”와 “파라쓸건데”를 동일하게 처리할 수 있다.
영웅 이름이 없으면 판단하지 않는다.
“이기고싶어”만 들어온 경우 특정 영웅을 유지하겠다는 의도가 아닐 수 있으므로, 영웅 이름이 있을 때만 stay로 분류한다.
infer_intent_by_rule 변경
의도 분류 함수에서 detect_wants_to_keep_hero()를 최우선으로 적용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def infer_intent_by_rule(message: str, context: Dict[str, Any]) -> str:
text = message.strip()
# 1순위: 영웅 유지 의사 표현
if detect_wants_to_keep_hero(text):
return "stay"
# 2순위: 교체 의도 — 단, "안 바꾸고"처럼 부정이면 stay로
if any(word in text for word in ["말고", "다른 영웅", "바꾸", "변경", "픽 추천"]):
compact = re.sub(r"\s+", "", text)
if any(word in compact for word in ["안바꾸", "바꾸지않", "그대로", "유지", "고정"]):
return "stay"
return "swap"
# 3순위 이하: 기존과 동일
...
“바꾸고 싶어”와 “안 바꾸고 싶어”를 구분하는 처리가 추가됐다.
이전에는 “바꾸”라는 단어만 보고 무조건 swap으로 분류했는데, 부정 표현이 함께 있으면 stay로 처리하도록 했다.
infer_current_hero 변경
current_hero를 추출할 때도 같은 함수를 활용한다.
1
2
3
4
5
6
7
8
9
10
11
12
# 이전
if any(word in text for word in ["계속 쓰고", "계속 하고", "현재", "플레이", "하고 있"]):
hero = find_first_hero(text)
...
# 현재
if detect_wants_to_keep_hero(text) or any(word in text for word in [
"계속 쓰고", "계속 하고", "현재", "플레이", "하고 있",
"하고 싶어", "하고싶어", "쓰고 싶어", "쓸건데", "쓸 거", "고정", "원챔",
]):
hero = find_first_hero(text)
...
“파라 쓸건데”에서 파라를 current_hero로 올바르게 추출할 수 있게 됐다.
LLM 프롬프트에도 규칙 추가
llm_parse_context_node의 프롬프트에 stay 의도 분류 규칙을 명시적으로 추가했다.
1
2
3
4
5
9. 사용자가 "X를 하고 싶어", "X 쓸건데", "X 할건데", "X 고정", "X 원챔",
"X로 이기고 싶어", "X로 즐기고 싶어", "X 해도 돼?"처럼 말하면
current_hero는 X이고 intent는 "stay"로 판단해라.
이 경우 사용자는 영웅 교체 추천을 원하는 것이 아니라,
X를 유지한 상태에서 상대 조합을 이기는 운영법을 원하는 것이다.
규칙 기반과 LLM 기반을 동시에 강화해서 어느 한쪽이 놓치더라도 다른 쪽에서 잡을 수 있게 했다.
답변 프롬프트에도 stay 응답 규칙 추가
generate_answer_node의 답변 작성 규칙에 아래 내용을 추가했다.
1
2
3
4
5
6. 사용자가 특정 영웅을 하고 싶다, 쓸 것이다, 고정으로 한다, 원챔이다, 해도 되냐고 말한 경우
다른 영웅 추천을 먼저 하지 마라.
첫 문장은 반드시 "그 영웅을 유지해도 된다" 또는 "불리하지만 운영으로 풀 수 있다"처럼
사용자의 선택을 존중하는 방향으로 답해라.
이후 그 영웅으로 상대 조합을 상대하는 구체적인 운영법을 제시해라.
이 규칙이 없으면 intent는 stay로 올바르게 분류됐더라도
LLM이 “불리하니 다른 영웅을 고려해보세요”라는 식으로 답하는 경우가 있었다.
stay 관련 컨텍스트 이어받기
merge_context_node에서 stay, performance_improve 의도일 때도 이전 대화의 target_enemy를 이어받도록 로직을 추가했다.
1
2
3
4
5
if intent in ["counter", "stay", "performance_improve"] and not target_enemy:
previous_target_enemy = context.get("target_enemy")
if previous_target_enemy:
target_enemy = previous_target_enemy
enemy_named_this_turn = True
예를 들어 “라마트라 카운터 알려줘” → “난 파라로 할건데” 순서로 대화하면
두 번째 메시지에서 상대가 라마트라라는 컨텍스트가 이어져 “파라로 라마트라 상대하는 법”을 제대로 답할 수 있다.
2. 영웅 별칭 처리 개선
문제: “둠피”를 인식하지 못함
이전에는 normalize_hero_name() 함수에 “솔저: 76”, “D.Va” 두 가지만 하드코딩돼 있었다.
“둠피”(둠피스트), “솔저”(솔저76) 같은 줄임말을 원문 검증 단계에서 인식하지 못하는 문제가 있었다.
해결: HERO_ALIASES 딕셔너리
별칭을 별도 딕셔너리로 분리했다.
1
2
3
4
5
6
7
HERO_ALIASES = {
"둠피": "둠피스트",
"솔저": "솔저76",
"솔저: 76": "솔저76",
"D.Va": "디바",
"디바": "디바",
}
그리고 원문 검증을 담당하는 hero_mentioned_in_text() 함수를 새로 만들었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def hero_mentioned_in_text(hero: Optional[str], text: str) -> bool:
"""
정규화된 영웅명이 원문에 직접 또는 별칭으로 언급되었는지 확인한다.
예: hero='둠피스트', text='둠피가 나만 노려' -> True
"""
if not hero or not text:
return False
normalized = normalize_hero_name(hero)
# 정식 이름 그대로 등장
if normalized and normalized in text:
return True
# HEROES 원본 표기 확인 (예: "솔저: 76")
for h in HEROES:
if normalize_hero_name(h) == normalized and h in text:
return True
# 별칭 확인
for alias, canonical in HERO_ALIASES.items():
if canonical == normalized and alias in text:
return True
return False
이전에는 normalized_enemy in message라는 단순 문자열 검사를 했는데,
이제는 정식 이름, 원본 표기, 별칭을 모두 체크한다.
“둠피가 나만 노려”처럼 별칭으로 언급해도 올바르게 인식한다.
llm_parse_context_node에서 target_enemy와 enemy_team을 원문 검증할 때 이 함수가 사용된다.
1
2
3
4
5
# 이전
and normalized_enemy in message
# 현재
and hero_mentioned_in_text(normalized_enemy, message)
3. 역할 위반 검사 범위 확장
generate_answer_node에서 LLM이 허용 목록 밖의 영웅을 답변에 포함했을 때 치환하는 로직이 있었다.
그런데 이전 버전에는 한 가지 예외 처리가 빠져있었다.
이전: 사용자가 메시지에서 직접 언급한 영웅(user_mentioned_heroes)만 치환 예외로 처리했다.
현재: 적 컨텍스트 영웅들도 예외에 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 이전 예외 목록
user_mentioned_heroes = set(find_all_heroes(state.get("message", "")))
# 현재 예외 목록
user_mentioned_heroes = set(find_all_heroes(state.get("message", "")))
enemy_context_heroes = set()
target_enemy = state.get("target_enemy")
if target_enemy:
enemy_context_heroes.add(normalize_hero_name(target_enemy))
high_threat_enemy = state.get("high_threat_enemy")
if high_threat_enemy:
enemy_context_heroes.add(normalize_hero_name(high_threat_enemy))
for h in state.get("enemy_team", []) or []:
normalized = normalize_hero_name(h)
if normalized:
enemy_context_heroes.add(normalized)
forbidden_in_answer = [
h for h in find_all_heroes(answer)
if (
h not in answer_allowed_hero_set
and h not in user_mentioned_heroes
and h not in enemy_context_heroes # ← 추가
)
]
예를 들어 딜러가 라마트라(탱커)를 상대하고 있을 때,
답변에서 “라마트라의 공허 방벽을 피하세요”라는 문장의 ‘라마트라’가 탱커라는 이유로 치환되던 문제를 해결했다.
상대 영웅은 추천한 게 아니라 언급한 것이기 때문에 치환 대상에서 제외해야 한다.
4. 대화 로깅 (views.py)
배포 후 실제 사용 데이터를 수집하기 위해 대화 내용을 JSONL 형식으로 저장하는 기능을 추가했다.
save_chat_jsonl() 함수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def save_chat_jsonl(
*,
session_key,
turn_id,
role, # "USER" | "AI" | "ERROR"
message,
intent=None,
current_hero=None,
target_enemy=None,
metadata=None,
):
LOG_DIR.mkdir(parents=True, exist_ok=True)
row = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"session_key": session_key,
"turn_id": turn_id,
"role": role,
"message": message,
"intent": intent,
"current_hero": current_hero,
"target_enemy": target_enemy,
"metadata": metadata or {},
}
with LOG_FILE.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
JSONL(JSON Lines)은 한 줄에 JSON 객체 하나를 저장하는 형식이다.
파일을 열지 않고도 tail -f로 실시간 모니터링할 수 있고, 나중에 줄 단위로 읽어서 파싱하기 쉽다.
저장되는 로그는 세 종류다.
| role | 시점 | 저장 내용 |
|---|---|---|
| USER | 사용자 메시지 수신 직후 | 질문 원문, 역할 필터, 이전 컨텍스트 |
| AI | 답변 생성 후 | 답변 원문, intent, 추천 영웅, 후속 질문 |
| ERROR | 예외 발생 시 | 에러 메시지, traceback |
turn_id로 한 쌍을 묶음
같은 요청의 USER 로그와 AI 로그는 동일한 turn_id를 공유한다.
uuid.uuid4()로 생성해 요청마다 고유하게 부여한다.
1
2
3
4
turn_id = str(uuid.uuid4())
save_chat_jsonl(role="USER", turn_id=turn_id, ...)
save_chat_jsonl(role="AI", turn_id=turn_id, ...)
나중에 분석할 때 같은 turn_id로 필터링하면 질문-답변 쌍을 한 번에 볼 수 있다.
session_key로 대화 흐름 추적
Django 세션 키를 로그에 함께 저장한다.
같은 session_key로 묶으면 한 사용자가 어떤 순서로 질문했는지 추적할 수 있다.
1
2
3
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
세션이 없는 상태에서 첫 요청이 들어오면 명시적으로 create()를 호출해 키를 생성한다.
context_patch 적용 방식 단순화
views.py에서 컨텍스트를 업데이트하는 방식도 이번에 정리됐다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 이전 — extra_context를 별도로 병합
conversation_context = {
**(request.session.get("coach_context", {}) or {}),
**extra_context,
}
...
updated_context = {**base_context, **context_patch}
# 현재 — conversation_context에 context_patch를 직접 update()
conversation_context = request.session.get("coach_context", {})
...
context_patch = result.get("context_patch", {})
conversation_context.update(context_patch)
request.session["coach_context"] = conversation_context
extra_context(맵, 공수 정보)를 별도로 병합하던 로직을 제거하고,
context_patch를 세션에 직접 덮어쓰는 방식으로 통일했다.
맵/공수 정보는 그래프 내부에서 처리한다.
저장되는 로그 예시
실제로 저장되는 JSONL 파일 한 줄의 형태는 이렇다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"created_at": "2025-07-01T14:32:11",
"session_key": "abcd1234efgh5678",
"turn_id": "a1b2c3d4-...",
"role": "USER",
"message": "파라 카운터 알려줘",
"intent": "counter",
"current_hero": null,
"target_enemy": "파라",
"metadata": {
"role_filter": null,
"context_before": {}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"created_at": "2025-07-01T14:32:13",
"session_key": "abcd1234efgh5678",
"turn_id": "a1b2c3d4-...",
"role": "AI",
"message": "파라를 상대할 때 가장 효과적인 영웅은 ...",
"intent": "counter",
"current_hero": null,
"target_enemy": "파라",
"metadata": {
"recommendation_type": "counter_pick",
"recommended_heroes": ["솜브라", "위도우메이커"],
"suggested_questions": ["파라 잡는 스킬 순서는?", ...]
}
}
이 데이터를 분석하면 어떤 영웅이 가장 많이 질문되는지, intent 분류가 실제로 얼마나 정확한지,
어떤 질문 패턴에서 에러가 많이 나는지 파악할 수 있다.
마무리
이번 업데이트에서 변경된 내용을 정리하면 다음과 같다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| stay 의도 감지 | 화이트리스트(고정 키워드) | 패턴 매칭(compact 문자열 + 정규식) |
| 영웅 별칭 처리 | 2개 하드코딩 | HERO_ALIASES 딕셔너리 + hero_mentioned_in_text() |
| 원문 검증 | 단순 문자열 포함 검사 | 정식명 + 원본 표기 + 별칭 통합 검사 |
| 역할 위반 예외 | user_mentioned_heroes만 제외 | enemy_context_heroes 추가 제외 |
| 대화 로깅 | 없음 | JSONL 파일 저장 (USER/AI/ERROR) |
| context 업데이트 | extra_context 별도 병합 | context_patch 직접 update() |
stay 의도 개선은 단순해 보이지만 실제로는 꽤 많은 케이스를 커버한다.
사용자가 영웅 고집을 표현하는 방식은 “원챔”, “고정”, “쓸건데”, “해도 되냐”, “이기고 싶어” 등
제각각이기 때문에 패턴 매칭 방식이 훨씬 견고하다.
로깅은 배포 후 개선에 필요한 데이터를 모으는 가장 기본적인 작업이다.
어떤 질문이 많이 들어오는지, 어떤 상황에서 에러가 나는지 파악해야 다음 개선 방향을 잡을 수 있다.
부록: 트러블슈팅
stay + counter 혼합 문장 — 다른 영웅을 추천하거나 일반화하던 문제
증상 A: “상대 딜러가 솔저랑 소전인데 난 파라를 하고싶어 어떻게 해야지 이기면서 즐기는 게임을 할 수 있을까?”
→ 파라 대신 다른 딜러 영웅을 추천했다.
증상 B: “난 애쉬로 계속 하고 싶은데 둠피때문에 살기가 힘들어 어떻게 해야해?”
→ 둠피스트를 직접 다루지 않고 일반적인 생존 팁만 나열했다.
원인: 두 케이스 모두 문장 안에 적 영웅 이름이 등장해
counter의도로 분류됐다.“하고싶어”, “계속 하고 싶은데”라는 stay 표현이 화이트리스트에 없어서 무시됐다.
해결:
detect_wants_to_keep_hero()로 stay 표현을 먼저 확인하고,True이면 다른 키워드보다 우선해서stay로 분류한다.적 영웅이 문장에 있어도 stay 의도가 먼저다.
stay로 분류된 뒤에는 그 적 영웅(
둠피,솔저,소전)을target_enemy로 정상적으로 이어받아“애쉬로 둠피스트 상대하는 법”처럼 구체적인 답변이 나온다.