챗봇을 쥐어주지 않으면 언어능력이 급격히 하락하는 키보드 파이터인 나는, 작년 상반기부터 llm을 학습시켜 전투력을 극대화한 말싸움 챗봇이라는 것(?)을 만들고 싶어했다.
물론 작년에는 이런저런 일들로 좀 바빴다. 하지만 올해부터는 절대 야근하지 말자는 나와의 약속을 아직까지는 굳건히 지키고 있는 중이고, 사이드 프로젝트로 무언가를 개발할 시간이 좀 나기 시작했다.
”절대 지지 않는“ 말싸움 챗봇을 개발하기 위해 뉴스 스트리밍과 각종 온라인 상 논란 글, 혐오 글, 어그로성 글 등 인터넷 밑바닥을 크롤링해서 읽어보았다. 이렇게 끊임없이 AI와 나 자신을 타락시키다가, 어느 순간, 내가 AI가 되면 굳이 안 싸워도 되겠구나..! 라는 결론에 이르게 되었다.
그렇게, 나의 뇌를 AI에 이식하기 위해 이 때까지의 메신저 내용과 sns 글들을 열람했다. 그런데, 생각보다, 훨씬 하기 싫다. 그리고 가장 괴로운 것은 귀찮은 수집과 정제 작업이 아니라, 내 글들을 직면하는 순간이었다.
근데.. 인터넷 밑바닥을 보는 것이 괴로울까? 내 흑역사의 밑바닥을 보는 것이 괴로울까?
비교적 최근에 쓰던 말투랑 워딩을 학습시키고 싶어서, 수집 기간은 2년으로 잡았다. 수집 범위는 금단의 공간(팀즈/메일은 공적인 어조가 너무 강해서 제외, 블로그는 독백이라 제외)
챗봇 학습 용도로 카카오톡 대화 내역 일부를 사용하겠다고 지인들에게 미리 허락을 받았다. (희생양들이다.)
✅ 데이터 수집 기간: 2023년 6월 - 2025년 8월
✅ 수집 범위: 카카오톡 백업 파일
✅ 데이터 변환: 텍스트/csv 파일을 .json 확장자로 변환
✅ 사용 언어: Python 3.12
✅ 사용 LLM: Solar 10.7B
✔ 허깅페이스 링크: SOLAR-10.7B-v1.0
이번 편에서는 작성하지 않을 거지만, 2025년 8월 기준 3.13을 사용하니 학습 모델이 찌그락빠그락거리기 시작했다. (복선이라고 해야 하나..)
결국에는 Python 3.11를 재설치 한 후 가상환경을 설정했다. C드라이브 내 작업용 폴더를 하나 만들고, 가상경로 설정, 필요한 패키지(굳이 가상환경 경로와 일치하지 않아도 되지만), LLM을 동일 경로에 설치한다.
아래와 같은 명령어를 Powershell에 입력한다.
(+) 훈련용 데이터셋도 동일 경로에 저장한다.
cd C:\작업용 폴더명
py -3.11 -m venv .venv
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
.venv\Scripts\Activate.ps1
python.exe -m pip install --upgrade pip
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install datasets peft bitsandbytes accelerate transformers torch pandas tqdm huggingface_hub
huggingface-cli download Upstage/SOLAR-10.7B-Instruct-v1.0 --local-dir ./Upstage/SOLAR-10.7B-Instruct-v1.0
Python으로 가서 패키지를 불러온다.
import os
import re
import json
from tqdm.auto import tqdm
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, PeftModel
from datasets import load_dataset
import unicodedata
카카오톡 백업 파일을 받아본 적 있는가? 거의 귀여니 소설 수준으로 긴 텍파 몇 개가 압축되어 날라온다. 나는 이 .txt파일을 불러들인 다음에, 한 개의 파일로 합친 다음, 불필요한 부분은 제거해서, 발화 순서대로 나열을 하였다. 그리고 질문에 대한 답을 하는 챗봇인 만큼, 상대방의 말에 대한 나의 대답 패턴/대답 어조를 학습시키기 위해 대화를 쌍으로 묶는 작업을 거쳤다.
비속어, ㅋ, ㅎ, 의미없는 단어, 오타, 이상한 의성어 등은 굳이 정제하지 않았다. (욕설은 좀 정제할걸 그랬다.)
카카오톡 백업 파일을 열면, 다음과 같이 맨 위에 데이터 저장 일자가 기재되어 있고, 공백 아래는 대화를 주고받은 일자가 기재되어 있다. 다 필요 없으니 삭제한다.
다음으로, 대화 패턴을 보면,
월-일-연 at 시-분, 보낸사람 이름 : 메시지 내용
규칙이 있다는 점을 파악할 수 있다. 보낸 순서 - 보낸 사람 - 메시지 내용을 제외하고는 굳이 필요한 내용은 아니기에, 정규식을 통해 필요없는 정보들은 제거한다.
# 1.대화 패턴 정리: 월-일-연 at 시-분, 보낸사람 이름 : 메시지 내용
korean_chat_pattern = r"^\d{4}\.\s*\d{1,2}\.\s*\d{1,2}\.\s*(?:오전|오후)\s*\d{1,2}:\d{2},\s*(.*?)\s*:\s*(.*)$"
chat_re = re.compile(korean_chat_pattern)
# 2.날짜 헤더 정규식
date_re = re.compile(r"^\d{4}년\s*\d{1,2}월\s*\d{1,2}일\s*(?:월|화|수|목|금|토|일)요일$")
# 3.불필요한 저장 라인 (2번) 삭제
date_saved_re = re.compile(r"^Date Saved\s*:\s*.*$")
참고) 데이터 정규식에 주로 쓰이는 명령어
첫 번째로 해야 할 것은, 발화 덩어리를 만드는 것이다. 모든 대화 라인에서 보낸 사람 - 받는 사람을 구분한 다음에, 받는 사람이 답장 하기 전에 보낸 사람이 연속적으로 보낸 말도 하나의 덩어리로 간주하고 묶어 준다.
이게 무슨 말이냐..? 하면
나: 아
나: 배고파
나: 뭐먹지?
친구: 밥
이라는 대화가 있다고 하면,
나: 아, 배고파, 뭐먹지? > 1개의 발화덩어리로 간주
친구: 밥
으로 만드는 과정인 것이다.
# 덩어리 만들기
# 채팅 내용을 한 줄씩 살펴보고 덩어리(turn)를 만들 건데
turns = []
buffer = []
last_label = None
for line in all_lines:
# 날짜나 영문 날짜 표시는 무시
if date_saved_re.match(line) or english_date_header_re.match(line):
if debug:
print(f"[DEBUG] 건너뛰는 줄: {line.strip()}")
continue
m = chat_re.match(line)
if m:
# 이름과 메시지를 찾는다
name, msg = None, None
if m.groups()[0] is not None:
name = m.groups()[0]
msg = m.groups()[1]
elif m.groups()[2] is not None:
name = m.groups()[2]
msg = m.groups()[3]
if not name or not msg:
continue
# 내 이름이면 'Me', 아니면 'Friend'
label = 'Me' if unicodedata.normalize('NFKC', name).strip() == my_name else 'Friend'
# 화자가 바뀌면 이전 메시지를 저장
if buffer and label != last_label:
turns.append({'speaker': last_label, 'message': '\n'.join(buffer).strip()})
buffer = []
buffer.append(msg)
last_label = label
else:
# 채팅 아닌 줄은 이어서 적기
if buffer and not date_re.match(line) and line.strip():
buffer.append(line.strip())
# 덩어리 저장
if buffer:
turns.append({'speaker': last_label, 'message': '\n'.join(buffer).strip()})
두 번째로 해야 할 것은, 덩어리(turn)을 가지고, 챗봇 학습에 사용할 질문 - 답변 쌍(pair) 형태로 데이터를 정제하는 것이다. 여기서 핵심적인 것은, 나의 '답변' 패턴을 학습시켜야 한다는 것이기에, 몇 가지 스텝을 통해 데이터를 정제하였다.
# 6) 'Friend'->'Me' 발화쌍 생성 (context_turns 적용)
training_pairs = []
for i in range(1, len(turns)):
if turns[i-1]['speaker']=='Friend' and turns[i]['speaker']=='Me':
q_base = turns[i-1]['message']
a = turns[i]['message']
# Context turns 추가
context_messages_list = []
start_index = max(0, i - 1 - context_turns)
for j in range(start_index, i - 1):
speaker_tag = "[친구]" if turns[j]['speaker'] == 'Friend' else "[나]"
context_messages_list.append(f"{speaker_tag}\n{turns[j]['message']}")
if context_messages_list:
q_context = "\n\n".join(context_messages_list) + "\n\n"
else:
q_context = ""
q = q_context + "[친구의 말]\n" + q_base
if q_base and a:
text = (
"<s>[INST] 다음은 친구와의 대화입니다. "
"친구의 마지막 말에 답변하세요.\n\n"
"[대화 흐름]\n" + q + " [/INST] " + a + " </s>"
)
training_pairs.append({'text': text})
print(f" 추출된 발화쌍 개수: {len(training_pairs)}")
수작업으로 몇 시점 전을 대화로 보아야 할 지 고민하다가, 최적화 할 수 있는 방법을 찾아봤지만 아쉽게도 없는 것 같다. 일단은 4개 정도로 결정했다.
pairs = parse_kakaotalk_chats(CHATS_DIR, MY_NAME, debug=True, context_turns=4) # 여기서 몇 번 턴의 대화까지 맥락에 고려할 것인지 넣어줘야 함.
if pairs:
create_finetuning_data(pairs, OUTPUT_FILE)
# Context turns 추가
context_messages_list = []
start_index = max(0, i - 1 - context_turns)
for j in range(start_index, i - 1):
speaker_tag = "[친구]" if turns[j]['speaker'] == 'Friend' else "[나]"
context_messages_list.append(f"{speaker_tag}\n{turns[j]['message']}")
if context_messages_list:
q_context = "\n\n".join(context_messages_list) + "\n\n"
else:
q_context = ""
q = q_context + "[친구의 말]\n" + q_base
참고) 구조화된 문자열을 만드는 법
<s> (시퀀스 시작)
[INST] (지시 시작)
[모델에게 주어지는 지시사항]
* "다음은 친구와의 대화입니다. 친구의 마지막 말에 답변하세요.\n\n"
[대화 맥락]
* "[대화 흐름]\n"
* q (이전 대화 턴들 + 친구의 마지막 질문)
[/INST] (지시 끝)
[나의 답변]
* a
</s> (시퀀스 끝)
이 모든 과정을 거치고 나면, 하나의 pair로 묶인 대화들을 확인할 수 있다.
Json을 쓰는 이유는, 범용성이 높고 컴퓨터도 인간도 사용하고 이해하기 쉬운 언어이기 때문이라고 한다. 더 이상 알려 하면 다친다..
이렇게 대충 LLM이 학습할 수 있는 형태로 데이터를 정제하였다. 이 다음 편은 파싱보다 더 극악이었던 LLM 학습 과정에 대해 대략적으로 기재하겠다.
(비밀인데.. 챗지피티나 제미나이 채팅창 하나를 틀고 프롬프트로 조교시키는 게 쉽고 빠르다. 푸하하 🤣)