[AI] LLM 02-2


HuggingFace

LLM 02 - HuggingFace (LMStudio) - 2

HuggingFace 본격적으로 사용하기

오류 • 경고 사전 방지

"""UserWarning SYMLINK 해결"""
import os
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"

HuggingFace에서 모델 불러올 때 자꾸 뜨는 SYMLINK 경고다. 윈도우 설정과 관련 있으나 단지 경고일 뿐이라 주피터 노트북을 처음 켰을때 (kernel이 처음 load 되었을 때) 한 번만 실행시키면 경고가 뜨지 않는다.

"""TqdmWarning 해결방법"""
%pip install --upgrade ipywidgets jupyter --break-system-packages

HuggingFace에서 다운로드 진행상황을 가시적으로 보여주기 위한 라이브러리를 업데이트 해서 경고를 해결한다.

Initiliazation

from huggingface_hub import login

# Hugging Face API 토큰을 입력합니다.
api_token = "HUGGINGFACE_TOKEN"
login(api_token)

이 코드는 한 번만 실행하면 추후에 이 컴퓨터에서 작업하는 동안 kernel을 껐다 켜도 계속 로그인이 유지된다.


Sentiment Analysis

from transformers import pipeline

model = pipeline("sentiment-analysis") 
output=model("I am so sick because I catch a cold.")
print(output)
[transformers] No model was supplied, defaulted to distilbert/distilbert-base-uncased-finetuned-sst-2-english and revision 714eb0f.
Using a pipeline without specifying a model name and revision in production is not recommended.
Loading weights: 100%|██████████| 104/104 [00:00<00:00, 138654.68it/s]
[{'label': 'NEGATIVE', 'score': 0.9995433688163757}]

이렇게 지시하면 감정분석용 기본 모델을 가지고 온다. 작성일 기준으로는 distilbert/distilbert-base-uncased-finetuned-sst-2-english 모델을 가져왔다. Default 모델이니 당연히 한국어는 이해 못한다.

from transformers import pipeline

model = pipeline("sentiment-analysis", model="alsgyu/sentiment-analysis-fine-tuned-model")
output=model("나는 며칠째 감기에 걸려서 몸이 아프다.")
print(output)
output=model("나는 오늘 날씨가 좋아서 소풍을 갈것이다.")
print(output)

Loading weights: 100%|██████████| 201/201 [00:00<00:00, 10512.70it/s]
[{'label': 'LABEL_0', 'score': 0.486131489276886}]
[{'label': 'LABEL_2', 'score': 0.9252640008926392}]

Sentiment-analysis(감성 분석)는 입력 문장이 긍정인지 부정인지를 분류해 라벨과 확률 점수를 반환하는 태스크다. 이런 분류를 하려면 사전학습된 base 모델만으로는 부족하고, 감성 라벨이 붙은 데이터로 분류기 헤드까지 학습한 fine-tuned 모델이 필요한데, alsgyu/sentiment-analysis-fine-tuned-model이 바로 그런 파인튜닝된 한국어 감성 분석 모델이다.


Text-Generation

from transformers import pipeline

pipe = pipeline(
    "text-generation", 
    model="naver-hyperclovax/HyperCLOVAX-SEED-Text-Instruct-1.5B",
    max_length=300
)

output = pipe("오늘 나는 그녀와 만나기로 하였는데,")
print(output)
[
	{
		'generated_text': "오늘 나는 그녀와 만나기로 하였는데, 이제 곧 만나게 될 것이다. ' 필요한 물건을 구입하여, 함께 갈 장소에 대해 이야기해 볼 생각이다.\nassistant\n어머니, 요즘 많이 바쁘시죠? 어머니의 근황이 궁금하네요. 어머니가 많이 피곤한 것 같아 걱정이에요. 저는 어머니를 위해 제가 도울 수 있는 부분이 있다면 언제든지 도와드리겠습니다. 건강 잘 챙기세요!assistant\n마음만으로도 큰 힘이 되네요. 곧 만나서 오랜만에 가족들과 즐거운 시간을 보낼 생각하니 기대가 됩니다. 어머니도 좋은 시간 보내시길 바라며, 필요하신 것들 있으면 편하게 말씀해 주세요.assistant\n어머니, 오랜만에 찾아뵙게 되어 정말 기쁩니다. 이번 주말에 뵙기로 한 날에 함께할 수 있는 좋은 추억을 많이 만들었으면 좋겠어요. 어머니께서도 제가 항상 곁에 있다는 것을 기억해 주시면 좋겠습니다. 항상 건강하시고 행복한 일들만 가득하시길 바랍니다!assistant\n네, 정말 감사드리며 항상 어머니 곁에서 최선을 다하겠습니다. 이번 주말에 뵙겠습니다. 기다릴게요!assistant\n감사합니다! 이번 주말에 뵙겠습니다. 좋은 시간 보내세요!assistant\n네, 이번 주말에 어머니를 뵈러 가려고 하는데, 함께 즐거운 시간을 보내고 싶습니다."
	}
]

Text-generation은 주어진 문장(프롬프트) 뒤에 이어질 자연스러운 텍스트를 모델이 순차적으로 예측해 생성하는 태스크다. HyperCLOVAX-SEED-Text-Instruct-1.5B는 네이버가 공개한 한국어 특화 sLM(경량 언어모델)으로, instruct 튜닝이 되어 있어 단순 이어쓰기뿐 아니라 지시문에 따른 응답 생성에도 활용할 수 있다.


Fill-mask

from transformers import pipeline

# KLUE(Korean Language Understanding Evaluation) 프로젝트 산출물.
model = pipeline("fill-mask", model="klue/roberta-base")
output = model("너는 나에게는 [MASK]와 같은 존재다.", top_k=3)
for result in output:
    print(result['sequence'], round(result['score'], 3))
너는 나에게는 와 같은 존재다. 0.052
너는 나에게는 친구 와 같은 존재다. 0.041
너는 나에게는 엄마 와 같은 존재다. 0.037

Fill-mask(마스크 채우기)는 문장 안의 일부 토큰을 [MASK]로 가려두고, 그 자리에 들어갈 단어를 모델이 문맥을 바탕으로 예측하는 태스크다. BERT 계열처럼 마스크 언어모델(MLM)로 사전학습된 모델이 각 후보 단어에 대한 확률을 계산해 점수 순으로 반환하며, klue/roberta-base는 한국어 이해 벤치마크인 KLUE 프로젝트에서 나온 한국어 사전학습 모델이라 한국어 문맥에서의 빈칸 채우기에 적합하다.


Zero-shot Classification

from transformers import pipeline

# Microsoft가 개발한 모델 - 100개 언어를 다루는 CC100 데이터셋으로 사전학습, 이후 XNLI로 파인튜닝
# 한국어 포함 XNLI 테스트셋으로 평가된 결과가 공개되어 있어 신뢰도 검증됨
classifier = pipeline("zero-shot-classification", model="MoritzLaurer/mDeBERTa-v3-base-mnli-xnli")

classifier(
   "한국의 대통령으로 이재명이 당선되었다.",
   candidate_labels=["교육", "정치", "경제"],
)
{'sequence': '한국의 대통령으로 이재명이 당선되었다.',
 'labels': ['정치', '교육', '경제'],
 'scores': [0.9226030111312866, 0.04039802774786949, 0.03699898347258568]}

Zero-shot classification은 특정 태스크용 학습 데이터 없이, 미리 정의한 라벨 후보 중 가장 적합한 것을 모델이 골라내는 분류 방식이다. NLI(자연어 추론) 모델을 활용해서 입력 문장(전제)과 각 라벨(가설) 사이의 entailment 확률을 계산하는 방식으로 구현되며, 후보 라벨만 바꾸면 다른 분류 작업에도 재사용할 수 있다는 게 핵심 장점이다.


ner(named entity recognition)

from transformers import pipeline
# "ner"은 사실 "token-classification"의 별칭이다. 런타임에서는 둘 다 똑같이 동작하지만 
# pipeline() 함수의 타임 스텁(오버로드 정의)에는 "token-classification"이라는 정식 이름만
# 명시되어 있고 "ner" 별칭까지는 등록이 안 되어 있는 경우가 많다.
ner = pipeline("token-classification", model="Leo97/KoELECTRA-small-v3-modu-ner", aggregation_strategy="simple")

test_sentences = [
    "톰은 서울에 있는 구글 코리아에서 근무하고 있다.",
    "2002년 한일 월드컵은 한국 축구 역사에 큰 전환점이었다.",
    "아인슈타인의 상대성이론은 현대 물리학의 근간이 되었다.",
    "그는 2024년 3월에 태어났고, 매일 오전 7시에 일어난다.",
    "이 반지는 순금과 다이아몬드로 만들어졌다.",
    "경복궁은 조선 왕조의 대표적인 궁궐이다.",
]

for sentence in test_sentences:
    print(f"\n입력: {sentence}")
    for entity in ner(sentence):
        print(f"  {entity['word']} -> {entity['entity_group']} ({entity['score']:.2f})")

입력: 톰은 서울에 있는 구글 코리아에서 근무하고 있다.
  톰 -> PS (0.98)
  서울 -> LC (0.96)
  구글 코리아 -> OG (0.59)

입력: 2002년 한일 월드컵은 한국 축구 역사에 큰 전환점이었다.
  2002년 -> DT (0.98)
  한일 월드컵 -> EV (0.74)
  축구 -> CV (0.90)

입력: 아인슈타인의 상대성이론은 현대 물리학의 근간이 되었다.
  아인슈타인 -> PS (0.99)
  상대성이론 -> TR (0.59)
  현대 물리학 -> FD (0.36)

입력: 그는 2024년 3월에 태어났고, 매일 오전 7시에 일어난다.
  2024년 3월 -> DT (0.97)
  오전 7시 -> TI (0.88)

입력: 이 반지는 순금과 다이아몬드로 만들어졌다.
  순금 -> MT (0.23)
  다이아몬드 -> MT (0.61)

입력: 경복궁은 조선 왕조의 대표적인 궁궐이다.
  경복궁 -> AF (0.58)
  조선 왕조 -> DT (0.77)

Token-classification(NER, 개체명 인식)은 문장 속 각 토큰에 인물·장소·기관·날짜 같은 개체명 태그를 붙여, 문장에서 어떤 부분이 어떤 종류의 개체인지 추출하는 태스크다.

aggregation_strategy="simple"은 서브워드 단위로 쪼개진 토큰들을 하나의 개체 단위로 합쳐서 반환하게 해주는 옵션이고, KoELECTRA-small-v3-modu-ner는 국립국어원 모두의 말뭉치 NER 데이터로 파인튜닝된 한국어 개체명 인식 모델이다.


Summarization

""" Summarization
"""
from transformers import PreTrainedTokenizerFast, BartForConditionalGeneration
import torch

model_name = "gogamza/kobart-summarization"
tokenizer = PreTrainedTokenizerFast.from_pretrained(model_name)
model = BartForConditionalGeneration.from_pretrained(model_name)

text = "[TEXT_TO_SUMMARIZE]"

# 토크나이징
inputs = tokenizer(text, return_tensors="pt", max_length=1024, truncation=True)
inputs = tokenizer(text,
	return_tensors="pt",	# 토큰화 결과를 PyTorch 텐서로 반환
    max_length=1024,		# 토큰화할 최대 길이
    truncation=True			# max_length를 넘는 입력은 뒷부분을 잘라낸다
)
# print("===>", inputs)

# 생성
summary_ids = model.generate(
    inputs["input_ids"],	# 모델에 넣을 토큰 id 시퀀스
    attention_mask=inputs["attention_mask"],	# 어떤 토큰을 실제로 attend할지 표시하는 마스크
    max_length=128,			# 생성할 요약문의 최대 토큰 길이
    num_beams=4,            # 빔서치 탐색 폭. 한 스텝마다 상위 4개의 후보 시퀀스를 유지하며 탐색 (연산량 증가)
    no_repeat_ngram_size=3,	# 동일한 3-gram(연속 3개 토큰 조합)이 반복 생성되지 않도록 강제
							# 요약 모델이 흔히 겪는 "같은 구절 반복" 문제를 줄여줌
    early_stopping=True,	# 빔서치 진행 중 모든 빔이 EOS 토큰에 도달하면 max_length까지 기다리지 않고 즉시 생성을 종료
)

summary = tokenizer.decode(summary_ids[0], skip_special_tokens=True)
print(summary)
text = """
새로운 보고서에 따르면 Windows 11에 기본으로 포함된 날씨 앱이 1GB가 넘는 RAM을 사용할 수 있는 것으로 나타났다. 비슷한 조건에서 Apple의 macOS 기본 날씨 앱이 사용하는 메모리와 비교하면 약 5배에 달하는 수준이다.
Microsoft는 제한된 RAM을 탑재한 PC에서도 Windows 11을 보다 효율적으로 사용할 수 있도록 최적화 작업을 진행하고 있지만, 정작 기본 제공 앱 가운데 하나가 이러한 목표에 역행하는 모습을 보이고 있다.
Windows Latest가 진행한 테스트에 따르면, Windows 11 날씨 앱은 비교적 단순하게 날씨 정보를 표시하는 작업만 수행하면서도 1GB 이상의 메모리를 사용할 수 있는 것으로 나타났다.
Windows Latest는 사용자가 별다른 조작을 하지 않고 날씨 예보를 표시하는 상황에서도 앱의 메모리 사용량이 1.2GB를 넘어섰다고 전했다.
Wccftech에서도 비슷한 현상을 확인했다. 날씨 앱의 메모리 사용량은 실행 직후 약 1GB 수준에서 시작하며, 유휴 상태에서는 약 500~600MB까지 내려갔다. 하지만 화면을 확대하거나 인터페이스를 탐색하는 것과 같은 기본적인 동작만으로도 메모리 사용량이 1.5~1.6GB까지 증가할 수 있었다.
특히 8GB RAM을 탑재한 PC라면 날씨 앱 하나만으로 시스템 메모리의 거의 20%를 차지할 수 있는 수준이다.
반면 macOS에 기본으로 포함된 Apple의 날씨 앱은 비슷한 조건에서 250MB 이하의 RAM을 사용하는 것으로 알려졌다. 이에 따라 Microsoft의 Windows 11 날씨 앱은 약 5배에 달하는 메모리 사용량을 보이는 셈이다.
Windows Latest에 따르면 높은 메모리 사용량의 원인은 Windows 11 날씨 앱이 완전한 네이티브 Windows 애플리케이션이 아니기 때문이다. 실제로는 Microsoft의 WebView2 프레임워크를 기반으로 구축된 MSN Weather 웹 앱에 가깝다.
작업 관리자를 확인하면 Chromium 기반의 여러 하위 프로세스가 동시에 실행되는 것을 볼 수 있으며, 이 때문에 날씨 예보를 표시하는 비교적 단순한 작업임에도 비정상적으로 높은 RAM 사용량이 발생하는 것으로 분석된다.
이 문제는 32GB 이상의 RAM을 탑재한 고사양 PC에서는 큰 영향을 주지 않을 가능성이 높다. 하지만 보급형 시스템에서는 상황이 다르다.
특히 8GB 또는 16GB RAM을 사용하는 PC에서는 날씨 앱을 실행하는 것만으로도 메모리 여유 공간이 크게 줄어들 수 있다. 메모리 압박이 심해지면 Windows가 페이지 파일에 더 의존하게 되고, 그 결과 시스템 전체의 반응성이 떨어질 가능성도 있다.
여기에 날씨 앱에는 광고까지 포함되어 있다. Windows Latest에 따르면 예보 피드에 스폰서 콘텐츠가 직접 삽입되며, 날씨 카드와 비슷한 형태로 표시된다.
Microsoft는 날씨 데이터를 자체적으로만 제공하는 것이 아니라 Foreca, 유럽중기예보센터(ECMWF) 및 기타 지역 기상 서비스 업체로부터 데이터를 공급받고 있다. 하지만 Windows에 기본으로 포함된 애플리케이션에 광고가 표시된다는 점은 비판의 대상이 되고 있다.
이번 결과는 최근 Microsoft가 추진하고 있는 Windows 11의 효율성 개선 노력과도 다소 상반되는 모습이다.
Microsoft는 최근 여러 기본 애플리케이션을 개선해 왔으며, 특히 저사양 하드웨어에서 Windows 11의 성능을 향상시키겠다는 방침을 여러 차례 밝혀왔다.
Microsoft의 임원인 Rudy Huyn 역시 앞으로 보다 많은 완전한 네이티브 Windows 애플리케이션을 개발할 계획이라고 밝힌 바 있다.
다만 Weather와 같은 MSN 계열 애플리케이션까지 향후 WinUI 기반의 완전한 네이티브 앱으로 다시 만들어질지는 아직 확실하지 않다.
"""

-> Windows 11에 기본으로 포함된 날씨 앱이 1GB가 넘는 RAM을 사용할 수 있는 것으로 나타났다.

Summarization은 긴 문장을 encoder-decoder(seq2seq) 구조의 모델에 넣어 핵심 내용만 담은 짧은 문장을 새로 생성하는 태스크다.

gogamza/kobart-summarization처럼 한국어 요약 데이터로 파인튜닝된 BART 계열 모델을 사용하며, 토크나이저는 AutoTokenizer가 아닌 PreTrainedTokenizerFast로 불러와야 vocab이 올바르게 매핑돼 정상적인 결과가 나온다. 생성 시에는 빔서치(num_beams), 반복 방지(no_repeat_ngram_size) 같은 디코딩 옵션으로 요약 품질을 조절할 수 있다.

prompt engineering

from transformers import AutoTokenizer, pipeline, GenerationConfig
import torch

model_name = "LGAI-EXAONE/EXAONE-4.0-1.2B"

# 토크나이저를 미리 별도로 로드해서 pipeline에 넘겨줌
tokenizer = AutoTokenizer.from_pretrained(model_name, clean_up_tokenization_spaces=False)

pipe = pipeline(
    "text-generation",
    model=model_name,
    tokenizer=tokenizer,   # 위에서 만든 토크나이저 사용
    dtype=torch.bfloat16,  # 모델 가중치 정밀도 설정
    device_map="auto",     # GPU/CPU에 모델을 자동으로 배치
)

messages = [
    {"role": "system", "content": "당신은 폴리텍 대학의 AI도우미이며 문장요약의 강자 입니다."},
    {"role": "user", "content": 
"""
다음문장을 요약해줘?
----
수랭쿨러 시장은 이제 쿨링 성능만으로는 차별화가 쉽지 않은 단계에 접어들었습니다. 
대부분의 제품이 상향평준화되면서, 최근에는 독특한 디자인이나 디스플레이로 PC의 인상을 완성하는 역할까지 맡고 있죠. 
다만 아무리 화려한 디자인이라도 결국 제품을 통째로 바꿔야 새로운 룩을 즐길 수 있다는 한계는 여전했습니다. 
마음에 드는 신제품이 나올 때마다 수랭쿨러 자체를 새로 사야 했으니까요. 
오늘 소개할 TCOMAS EXIT D3 PRO  360 ARGB와 NEOX PRO 360 AMOLED ARGB는 이 지점에서 꽤 흥미로운 답을 내놓은 제품입니다.

두 제품은 각각 독립적으로 판매되는 별개의 수랭쿨러입니다. 
3면 디스플레이로 입체감을 살린 EXIT D3 PRO, 곡면 OLED로 화면 자체를 강조한 NEOX PRO. 
겉보기엔 완전히 다른 제품이죠. 
그런데 재미있는 건 이 둘이 같은 펌프 헤더 플랫폼을 공유한다는 겁니다. 
즉 EXIT를 쓰던 사람도, NEOX를 쓰던 사람도 마그네틱 모듈식 구조 덕분에 도구 없이 손으로 헤더만 서로 바꿔 낄 수 있다는 뜻이죠. 
여기에 알루미늄 다이캐스트 마감과 29 mm 라디에이터 + 30 mm 팬으로 이어지는 두꺼운 설계까지 더해져 단순한 디자인 쿨러를 넘어서는 완성도를 보여줍니다. 
과연 이 두 제품이 “기변병”을 대하는 새로운 방식을 제시할 수 있을지, 영상에서 자세히 확인해 보세요.
"""
	},
]

# 생성 시 사용할 파라미터들을 하나의 설정 객체로 묶음
gen_config = GenerationConfig(
    max_new_tokens=512,   # 생성할 최대 토큰(단어 조각) 수
    do_sample=True,       # True면 확률적 샘플링, False면 항상 최고 확률 토큰만 선택
    temperature=0.6,
)

# 메시지(대화 기록)와 생성 설정을 넘겨 추론 실행
result = pipe(messages, generation_config=gen_config)

# 결과는 대화 기록 리스트로 반환되므로, 마지막 turn(모델의 응답)의 content만 추출
print(result[0]['generated_text'][-1]['content'])
"수랭쿨러 시장은 디자인과 기능 혁신으로 차별화가 강화되었으나, 제품 교체의 필요성은 여전히 존재합니다. EXIT D3 PRO(360 ARGB 입체 디스플레이)와 NEOX PRO(곡면 OLED 디스플레이)는 마그네틱 호환성과 고급 소재로 독보적 가치를 창출하며 기존 시장 패러다임을 전환할 가능성을 보여주는 사례입니다."

Translation

from transformers import AutoTokenizer, AutoModelForSeq2SeqLM

model_name = "circulus/kobart-trans-ko-en-v2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)

text = "런던에서 파리로 가는 기차는 얼마인가요?"
inputs = tokenizer(text, return_tensors="pt")
output_ids = model.generate(**inputs, max_length=300)
translated = tokenizer.decode(output_ids[0], skip_special_tokens=True)
print(translated)
How much is the train from London to Paris

Translation은 encoder-decoder 구조의 모델을 이용해 입력 문장을 다른 언어의 문장으로 변환해 생성하는 태스크다. circulus/kobart-trans-ko-en-v2는 KoBART를 한국어→영어 번역 데이터로 파인튜닝한 모델이며, 앞서 본 summarization 코드와 동일한 패턴(토크나이징 → generate() → 디코딩)으로 동작하지만, 요약 대신 번역문을 생성한다는 점만 다르다.

""" 영어 -> 한국어 실습 """
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM

model_name = "circulus/kobart-trans-en-ko-v2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSeq2SeqLM.from_pretrained(model_name)

text = "How much is the train from London to Paris?"
inputs = tokenizer(text, return_tensors="pt")
output_ids = model.generate(**inputs, max_length=300)
translated = tokenizer.decode(output_ids[0], skip_special_tokens=True)
print(translated)
런던에서 파리까지 가는 기차는 얼마인가요

Question-Answering

import torch
from transformers import AutoTokenizer, AutoModelForQuestionAnswering

model_name = "monologg/koelectra-base-v3-finetuned-korquad"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForQuestionAnswering.from_pretrained(model_name)

question = "카카오의 본사는 어디에 있어?"
context = "카카오는 판교에 본사를 두고 있는 대한민국의 IT 기업이다."

inputs = tokenizer(question, context, return_tensors="pt")

with torch.no_grad():
    outputs = model(**inputs)

start_idx = torch.argmax(outputs.start_logits)
end_idx = torch.argmax(outputs.end_logits) + 1

answer_ids = inputs["input_ids"][0][start_idx:end_idx]
answer = tokenizer.decode(answer_ids, skip_special_tokens=True)

print("답변:", answer)

Question-Answering(추출형 질의응답)은 질문과 지문(context)을 함께 모델에 넣어, 지문 안에서 정답에 해당하는 구간을 직접 찾아내는 태스크다. koelectra-base-v3-finetuned-korquad는 KorQuAD(한국어 QA 데이터셋)로 파인튜닝된 모델로, 정답을 새로 생성하는 게 아니라 지문 토큰 중 정답의 시작 위치와 끝 위치를 예측하는 방식으로 동작한다.

모델은 정답을 직접 텍스트로 생성하지 않고, 입력 토큰 각각에 대해 “이 토큰이 정답의 시작일 확률”(start_logits)과 “이 토큰이 정답의 끝일 확률”(end_logits)을 점수로 출력한다. 즉 outputs.start_logitsoutputs.end_logits는 각각 (1, 토큰개수) 크기의 텐서로, 입력 토큰 하나하나에 대응하는 점수가 들어있다.

  • torch.argmax(outputs.start_logits): start_logits 중 점수가 가장 높은 토큰의 인덱스를 뽑는다 → 정답이 시작하는 토큰 위치.
  • torch.argmax(outputs.end_logits) + 1: 마찬가지로 end_logits에서 가장 높은 점수를 가진 토큰의 인덱스를 뽑는다 → 정답이 끝나는 토큰 위치. 여기에 +1을 하는 이유는, 파이썬 슬라이싱(input_ids[start:end])이 end 인덱스를 포함하지 않기 때문에, 실제 끝 토큰까지 포함시키려면 한 칸 밀어줘야 하기 때문이다.

이렇게 얻은 start_idxend_idx를 이용해 inputs["input_ids"][0][start_idx:end_idx]로 정답에 해당하는 토큰 구간만 슬라이싱해서 잘라내고, 이를 tokenizer.decode()로 다시 사람이 읽을 수 있는 텍스트로 변환하는 것이 다음 줄들의 역할이다.


CoT(Chain of Thought)

CoT(Chain of Thought)는 모델이 정답을 바로 내놓지 않고, 문제를 푸는 중간 추론 과정을 단계별로 말로 풀어가며 답에 도달하게 하는 방식이다. “차근차근 생각해봐” 같은 지시나 예시를 프롬프트에 주면, 특히 수학·논리 문제에서 정확도가 크게 올라간다.

from transformers import pipeline, GenerationConfig

pipe = pipeline(
    "text-generation",
    model="microsoft/Phi-4-mini-instruct",
    dtype="auto",
    device_map="auto",
)

messages = [
    {"role": "user", "content": "철수가 100원이 있는데, 40원을 과자를 사먹었고, 엄마에게 100원 용돈을 받았어, 남은 돈은 얼마야?"}
]

gen_config = GenerationConfig(max_new_tokens=512, do_sample=False)
result = pipe(messages, generation_config=gen_config)

answer = result[0]['generated_text'][-1]['content']
print(answer)

이 코드는 microsoft/Phi-4-mini-instruct 모델에 산수 문제를 그대로 질문으로 던져, 모델이 중간 계산 과정을 스스로 풀어가며(CoT) 최종 답을 생성하도록 하는 예시다. 별도의 “차근차근 생각해봐” 같은 CoT 유도 문구 없이도, instruct 튜닝된 모델은 자연스럽게 단계별 풀이를 거쳐 답을 내는 경우가 많으며, do_sample=False로 항상 같은(결정적) 답이 나오도록 설정했다.


OpenAI에게 질문하는 방식으로

from transformers import pipeline
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"

pipe = pipeline("text-generation", model="google/gemma-3-1b-it", device=device, torch_dtype=torch.bfloat16)

def qa(context):
    data =  [
        [
            {
            "role": "system",
            "content": [{"type": "text", "text": "너는 창의력이 풍부하고 독창적인 시인이야."},]
            },
            {
            "role": "user",
            "content": [{"type": "text", "text": context},]
            },
        ],
    ]
    return data

messages=qa("층간소음때문에 너무 힘든데 윗집에 뭐라고 말하면 좋을까?")

gen_config = GenerationConfig(
	max_new_tokens=1024,
)

output = pipe(messages, generation_config=gen_config)
print(output[0][0]["generated_text"][2]['content'])
말 힘든 상황이시군요. 층간소음 때문에 창의적인 시를 쓰신 당신의 열정을 빌려, 윗집에 말할 수 있는 몇 가지 표현들을 제안해 드립니다. 어떤 표현이 가장 적절한지는 상대방과의 관계, 그리고 상황에 따라 달라질 수 있습니다.

**1. 정중하고 차분한 어조:**

*   "안녕하세요, 윗집분들께. 최근 층간소음으로 인해 불편을 겪고 있어서요. 혹시 소음 조절에 조금이라도 신경 써주시면 감사하겠습니다."
*   "최근 층간소음으로 인해 겪고 있는 어려움에 대해 말씀드리고 싶습니다. 혹시 소음 조절에 조금이라도 신경 써주시겠습니까?"
*   "윗집분들께 잠시 말씀드려 층간소음으로 인해 불편을 겪고 있다는 점을 알리고 싶습니다. 서로 조금만 배려해주시면 좋겠습니다."

**2. 조금 더 직접적인 표현 (상황에 따라):**

*   "최근 층간소음 때문에 생활이 매우 불편합니다. 혹시 소음 조절에 신경 써주시면 정말 감사하겠습니다."
*   "윗집분들께 층간소음 때문에 잠을 제대로 못 자는 것 같아 걱정됩니다. 혹시 소음 관리에 대해 이야기해주실 수 있을까요?"
*   "최근 층간소음으로 인해 생활에 큰 어려움을 겪고 있습니다. 혹시 소음 문제에 대해 조심스럽게 말씀드려도 될까요?"

**3. 공감과 함께 제안:**

*   "층간소음 때문에 힘든 시간을 보내고 있습니다. 혹시 소음 조절에 조금이라도 신경 써주시면 정말 감사하겠습니다. 서로 조금만 배려해주시면 좋겠습니다."
*   "최근 층간소음으로 인해 쾌적한 생활을 위해 소음 조절에 신경 써주시면 정말 도움이 될 것 같습니다. 혹시 소음 관련하여 이야기해주실 수 있을까요?"

**4. 긍정적인 마무리:**

*   "부디 윗집분들께서 소음 문제에 대해 관심을 가지고 조치해주시기를 바랍니다."
*   "서로 존중하고 배려하는 관계를 통해 층간소음 문제를 해결해 나가기를 바랍니다."

**추가 팁:**

*   **정중한 태도 유지:** 상대방을 존중하는 마음을 표현하는 것이 중요합니다.
*   **구체적인 문제 언급:** "소음이 심하다"는 말보다는 "밤늦게 들리는 소리가 불편하다" 와 같이 구체적인 상황을 설명하면 상대방이 더 잘 이해할 수 있습니다.
*   **요청하는 바 명확히:** "소음 조절에 신경 써주시면 감사하겠습니다" 와 같이 명확하게 요청하는 것이 좋습니다.
*   **상대방의 반응 살피기:** 상대방이 불편해하거나 퉁명스럽게 대답할 수도 있습니다. 상대방의 반응을 살피면서 상황에 맞게 표현을 조절해야 합니다.

**마지막으로, 당신의 감정을 충분히 표현하는 것이 중요합니다. 층간소음으로 힘든 시간을 보내고 있다는 사실을 솔직하게 이야기하며, 윗집분들과의 관계를 개선하기 위해 노력하는 모습을 보여주세요.**

어떤 표현이 가장 마음에 드시나요? 혹은 다른 의견이 있으신가요? 당신의 감정과 상황에 맞춰 더욱 맞춤화된 표현을 찾아드릴 수 있습니다.

지금까지 쓴 pipeline/AutoModel 방식은 모델 가중치를 직접 다운로드해서 로컬(또는 내 GPU)에서 추론하는 방식이라, device, torch_dtype, GenerationConfig 같은 저수준 설정을 직접 다뤄야 하고 모델마다 입력 포맷(예: messages 구조, 토크나이저 종류)이 조금씩 달라 코드도 모델별로 손봐야 한다.

반면 OpenAI API 방식은 모델이 OpenAI 서버에 있고, 코드는 client.chat.completions.create(model=..., messages=[...])처럼 HTTP 요청만 보내면 되므로 로컬 하드웨어·가중치 관리가 필요 없고 모든 모델이 동일한 system/user/assistant role 기반 인터페이스로 통일되어 있다는 점이 가장 큰 차이다.

다만 API 방식은 비용이 들고 요청마다 네트워크 통신이 필요한 반면, 로컬 방식은 한 번 모델을 받아두면 무료로 반복 추론이 가능하고 데이터가 외부로 나가지 않는다는 장점이 있다.


Chatbot 만들기

%pip install PyQt6

요즘 파이썬 GUI는 Qt를 사용한다. 이를 활용해 Chatbot처럼 만드는 실습을 진행한다.

Qt for Python

개요

콤보박스로 세 가지 LLM 백엔드(OpenAI API, LM Studio 로컬 서버, HuggingFace 로컬 모델) 중 하나를 선택해 대화할 수 있는 PyQt6 기반 데스크톱 챗봇. 백엔드 호출 로직을 UI와 분리하고, 응답 생성을 백그라운드 스레드에서 처리하도록 리팩토링한 버전이다.

세 가지 백엔드 방식

방식동작특징
OpenAI실제 OpenAI API 호출빠름, API 키 필요, 과금 발생
LMStudioLM Studio가 띄운 로컬 OpenAI 호환 서버(http://localhost:1234/v1) 호출LM Studio 앱에서 모델을 미리 로드하고 “Start Server”를 눌러둬야 함. API 키는 검증하지 않으므로 아무 문자열이나 통과됨
HuggingFacetransformers.pipeline으로 로컬에서 모델(google/gemma-3-1b-it) 직접 추론외부 서버 불필요하지만 무겁고 느림(특히 GPU 없을 때)

세 방식 모두 build_messages()로 만든 동일한 system + user 메시지 포맷을 공유하고, DEFAULT_MAX_TOKENS로 응답 길이 제한도 통일되어 있다.

리팩토링 핵심 변경 사항

1. 백엔드 로직을 ChatBackend 클래스 계층으로 분리

class ChatBackend:
    def ask(self, user_message, model_name): ...

class OpenAIBackend(ChatBackend): ...
class LMStudioBackend(ChatBackend): ...
class HuggingFaceBackend(ChatBackend): ...

기존에는 ChatApp 클래스 안에 ask_openai/ask_lmstudio/ask_huggingface 메서드가 UI 로직과 뒤섞여 있었다. 리팩토링 후에는 각 백엔드가 ask(user_message, model_name)이라는 동일한 인터페이스를 구현하는 독립된 클래스가 되어, UI와 무관하게 단독으로 테스트하거나 재사용할 수 있다. HuggingFaceBackend는 인스턴스 변수(self._pipe)로 지연 로딩을 구현해 모델을 최초 1회만 로드한다.

2. 응답 생성을 백그라운드 스레드로 이동

class ChatWorker(QThread):
    finished = pyqtSignal(str)

    def run(self):
        result = self.backend.ask(...)
        self.finished.emit(result)

기존 코드는 send_message()에서 백엔드 호출을 메인(UI) 스레드에서 직접 실행해, 응답이 오래 걸리면(특히 HuggingFace 로컬 추론) 창이 멈춘 것처럼 보였다. 리팩토링 후에는 ChatWorker(QThread)가 백엔드 호출을 별도 스레드에서 실행하고, 완료되면 finished 시그널로 결과를 UI 스레드에 전달한다. 응답을 기다리는 동안 입력창과 전송 버튼은 _set_input_enabled(False)로 잠겨 중복 전송을 방지한다.

3. 폰트 설정 헬퍼 함수화

def set_font_size(widget, size):
    font = widget.font()
    font.setPointSize(size)
    widget.setFont(font)

모든 위젯에서 반복되던 font = widget.font(); font.setPointSize(N); widget.setFont(font) 3줄을 함수 호출 한 줄로 대체했다.

4. 매직 넘버 상수화

setGeometry(100, 100, 1200, 1200), 폰트 크기(14, 20), max_new_tokens=1024 같은 하드코딩된 값을 WINDOW_GEOMETRY, LABEL_FONT_SIZE, ROW_FONT_SIZE, DEFAULT_MAX_TOKENS 상수로 분리했다.

5. UI 빌드 코드 분할

__init__에 모든 위젯 생성 코드가 몰려있던 것을, _build_ui()(전체 레이아웃)와 _build_backend_row()(백엔드 선택 행)로 나눠 가독성을 높였다.

클래스 구조

ChatBackend (및 하위 클래스)

클래스namedefault_model설명
OpenAIBackend"OpenAI"gpt-4o-miniOPENAI_API_KEY 환경변수로 OpenAI 클라이언트 생성 후 호출. 키 없으면 안내 메시지 반환
LMStudioBackend"LMStudio"local-modelbase_url="http://localhost:1234/v1"로 로컬 서버 호출. 연결 실패 시 안내 메시지 반환
HuggingFaceBackend"HuggingFace"google/gemma-3-1b-it최초 ask() 호출 시 pipeline을 지연 로딩(_get_pipeline)해 캐싱 후 재사용

전역 BACKENDS 리스트와 BACKENDS_BY_NAME 딕셔너리로 콤보박스 항목과 백엔드 인스턴스를 매핑한다.

ChatWorker(QThread)

백엔드 호출(backend.ask(...))을 별도 스레드의 run()에서 실행하고, 예외 발생 시 [오류 발생] ... 형태의 문자열로 감싸 finished 시그널로 전달한다.

ChatApp(QWidget)

UI 구성 요소

  • label: 상단 안내 라벨
  • backend_combo: 백엔드 선택 콤보박스
  • model_input: 모델명 입력창 (백엔드 전환 시 기본값 자동 채움)
  • chat_box: 대화 내역 표시 (읽기 전용)
  • user_input: 사용자 질문 입력창 (엔터 키로도 전송 가능)
  • send_button: 전송 버튼

주요 메서드

메서드역할
_build_ui()윈도우의 전체 레이아웃과 위젯을 생성
_build_backend_row()백엔드 선택 콤보박스 + 모델명 입력창 행을 생성해 반환
on_backend_changed(backend_name)백엔드 전환 시 모델명 입력창을 해당 백엔드 기본값으로 갱신
send_message()입력값을 검증하고 ChatWorker를 생성·실행해 백그라운드로 질의 시작. 요청 중엔 입력을 잠금
_on_response(backend_name, bot_message)워커의 finished 시그널을 받아 채팅창에 응답을 출력하고 입력을 다시 활성화
_set_input_enabled(enabled)응답 대기 중 입력창/전송 버튼을 잠그거나 해제

실행 흐름

  1. 사용자가 user_input에 메시지를 입력하고 전송(버튼 클릭 또는 엔터)한다.
  2. send_message()가 질문을 chat_box에 먼저 출력하고, 입력창/버튼을 비활성화한 뒤 ChatWorker를 생성해 스레드를 시작한다.
  3. ChatWorker.run()이 별도 스레드에서 선택된 backend.ask(...)를 호출해 응답(또는 오류 메시지)을 만든다.
  4. 완료되면 finished 시그널이 발생해 메인 스레드의 _on_response()가 실행되고, 응답이 "KOPO bot (백엔드명): 응답" 형식으로 chat_box에 추가되며 입력이 다시 활성화된다.

사전 준비 사항

  • OpenAI 사용 시: 환경변수 OPENAI_API_KEY 설정 필요 (.env + python-dotenv 또는 시스템 환경변수).
  • LMStudio 사용 시: LM Studio 앱에서 모델 로드 후 “Start Server” 실행 (http://localhost:1234/v1).
  • HuggingFace 사용 시: torch, transformers 설치 및 GPU(CUDA) 사용 가능 시 자동 감지(torch.cuda.is_available()), 없으면 CPU로 동작(속도 저하).
  • 공통: PyQt6, openai 패키지 설치 필요.

전체 코드

import os
import sys

from PyQt6.QtCore import QThread, pyqtSignal
from PyQt6.QtWidgets import (
    QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit,
    QLineEdit, QPushButton, QComboBox,
)

# --- UI 상수 ---
WINDOW_GEOMETRY = (100, 100, 1200, 1200)
LABEL_FONT_SIZE = 20
ROW_FONT_SIZE = 12

# --- 모델 생성 공통 설정 ---
DEFAULT_MAX_TOKENS = 1024

def set_font_size(widget, size):
    """위젯의 폰트 크기만 바꿔서 다시 적용한다.

    Args:
        widget (QWidget): 폰트를 적용할 위젯.
        size (int): 적용할 포인트 크기.
    """
    font = widget.font()
    font.setPointSize(size)
    widget.setFont(font)

def build_messages(user_message):
    """OpenAI / LMStudio / HuggingFace가 공통으로 쓰는 messages 포맷을 생성한다.

    Args:
        user_message (str): 사용자가 입력한 질문.

    Returns:
        list[dict]: system + user 메시지 리스트.
    """
    return [
        {"role": "system", "content": "너는 챗봇이야."},
        {"role": "user", "content": user_message},
    ]

# ---------------------------------------------------------------------------
# 백엔드: UI와 분리된 순수 로직. 각 백엔드는 ask(user_message, model_name)만 구현한다.
# ---------------------------------------------------------------------------

class ChatBackend:
    """LLM 백엔드의 공통 인터페이스."""

    name = "Backend"
    default_model = ""

    def ask(self, user_message, model_name):
        """질문을 모델에 보내고 응답 텍스트를 반환한다.

        Args:
            user_message (str): 사용자 질문.
            model_name (str): 사용할 모델명.

        Returns:
            str: 모델의 응답 텍스트.
        """
        raise NotImplementedError

class OpenAIBackend(ChatBackend):
    """OpenAI API를 호출하는 백엔드."""

    name = "OpenAI"
    default_model = "gpt-4o-mini"

    def ask(self, user_message, model_name):
        from openai import OpenAI

        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            return ("OPENAI_API_KEY 환경변수가 안 잡혀있음. "
                    "터미널에서 set OPENAI_API_KEY=sk-... (윈도우) 또는 "
                    "export OPENAI_API_KEY=sk-... (리눅스/맥) 하고 다시 실행해줘.")

        client = OpenAI(api_key=api_key)
        resp = client.chat.completions.create(
            model=model_name or self.default_model,
            messages=build_messages(user_message),
            max_tokens=DEFAULT_MAX_TOKENS,
        )
        return resp.choices[0].message.content

class LMStudioBackend(ChatBackend):
    """LM Studio 로컬 OpenAI 호환 서버를 호출하는 백엔드."""

    name = "LMStudio"
    default_model = "local-model"  # LM Studio는 서버에 로드된 모델을 그대로 씀

    def ask(self, user_message, model_name):
        from openai import OpenAI

        # LM Studio는 로컬 서버라 key를 검증하지 않는다.
        client = OpenAI(base_url="http://localhost:1234/v1", api_key="lm-studio")
        try:
            resp = client.chat.completions.create(
                model=model_name or self.default_model,
                messages=build_messages(user_message),
                max_tokens=DEFAULT_MAX_TOKENS,
            )
        except Exception as e:
            return (f"LM Studio 서버 연결 실패({e}). "
                    "LM Studio 앱 켜고 모델 로드한 다음 'Start Server' 눌렀는지 확인해줘. "
                    "(기본 주소: http://localhost:1234/v1)")
        return resp.choices[0].message.content

class HuggingFaceBackend(ChatBackend):
    """로컬 transformers pipeline으로 추론하는 백엔드.

    모델은 최초 ask() 호출 시에만 로드하고(지연 로딩) 이후 재사용한다.
    """

    name = "HuggingFace"
    default_model = "google/gemma-3-1b-it"

    def __init__(self):
        self._pipe = None

    def _get_pipeline(self):
        if self._pipe is None:
            import torch
            from transformers import pipeline
            device = "cuda" if torch.cuda.is_available() else "cpu"
            self._pipe = pipeline(
                "text-generation",
                model=self.default_model,
                device=device,
                torch_dtype=torch.bfloat16,
            )
        return self._pipe

    def ask(self, user_message, model_name):
        pipe = self._get_pipeline()
        data = [build_messages(user_message)]
        output = pipe(data, max_new_tokens=DEFAULT_MAX_TOKENS)
        return output[0][0]["generated_text"][-1]["content"]

# 콤보박스에 표시될 순서와 백엔드 목록
BACKENDS = [OpenAIBackend(), LMStudioBackend(), HuggingFaceBackend()]
BACKENDS_BY_NAME = {b.name: b for b in BACKENDS}

# ---------------------------------------------------------------------------
# 백그라운드 워커: UI 스레드를 막지 않고 백엔드 호출을 실행한다.
# ---------------------------------------------------------------------------

class ChatWorker(QThread):
    """백엔드 호출을 별도 스레드에서 실행하고 결과를 시그널로 전달한다."""

    finished = pyqtSignal(str)

    def __init__(self, backend, user_message, model_name):
        super().__init__()
        self.backend = backend
        self.user_message = user_message
        self.model_name = model_name

    def run(self):
        try:
            result = self.backend.ask(self.user_message, self.model_name)
        except Exception as e:
            result = f"[오류 발생] {e}"
        self.finished.emit(result)

# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------

class ChatApp(QWidget):
    """세 가지 LLM 백엔드를 선택해 대화할 수 있는 챗봇 윈도우."""

    def __init__(self):
        super().__init__()
        self._worker = None  # 실행 중인 ChatWorker 참조 (GC 방지 + 중복 전송 방지용)
        self._build_ui()

    def _build_ui(self):
        """윈도우의 모든 위젯을 생성하고 배치한다."""
        self.setWindowTitle("분당폴리텍 인공지능소프트웨어 LLM chat bot (Qt6)")
        self.setGeometry(*WINDOW_GEOMETRY)

        layout = QVBoxLayout()

        self.label = QLabel("홍교수님 너무 잘생겼어", self)
        self.label.setStyleSheet("background-color: lightgreen")
        set_font_size(self.label, LABEL_FONT_SIZE)
        layout.addWidget(self.label)

        layout.addLayout(self._build_backend_row())

        self.chat_box = QTextEdit(self)
        set_font_size(self.chat_box, LABEL_FONT_SIZE)
        self.chat_box.setReadOnly(True)
        layout.addWidget(self.chat_box)

        self.user_input = QLineEdit(self)
        set_font_size(self.user_input, LABEL_FONT_SIZE)
        self.user_input.setPlaceholderText('질문해 주셔요...')
        self.user_input.returnPressed.connect(self.send_message)
        layout.addWidget(self.user_input)

        self.send_button = QPushButton('보내기', self)
        set_font_size(self.send_button, LABEL_FONT_SIZE)
        self.send_button.clicked.connect(self.send_message)
        layout.addWidget(self.send_button)

        self.setLayout(layout)

    def _build_backend_row(self):
        """백엔드 선택 콤보박스 + 모델명 입력창 행을 생성한다.

        Returns:
            QHBoxLayout: 완성된 백엔드 선택 행 레이아웃.
        """
        backend_row = QHBoxLayout()

        backend_label = QLabel("방식 선택:", self)
        set_font_size(backend_label, ROW_FONT_SIZE)
        backend_row.addWidget(backend_label)

        self.backend_combo = QComboBox(self)
        self.backend_combo.addItems([b.name for b in BACKENDS])
        set_font_size(self.backend_combo, ROW_FONT_SIZE)
        self.backend_combo.currentTextChanged.connect(self.on_backend_changed)
        backend_row.addWidget(self.backend_combo)

        model_label = QLabel("모델명:", self)
        set_font_size(model_label, ROW_FONT_SIZE)
        backend_row.addWidget(model_label)

        self.model_input = QLineEdit(self)
        self.model_input.setText(BACKENDS[0].default_model)
        set_font_size(self.model_input, ROW_FONT_SIZE)
        backend_row.addWidget(self.model_input)

        return backend_row

    def on_backend_changed(self, backend_name):
        """콤보박스 선택이 바뀌면 모델명 입력창을 해당 백엔드의 기본값으로 갱신한다.

        Args:
            backend_name (str): 새로 선택된 백엔드 이름.
        """
        backend = BACKENDS_BY_NAME.get(backend_name)
        if backend:
            self.model_input.setText(backend.default_model)

    def send_message(self):
        """사용자 입력을 읽어 선택된 백엔드로 백그라운드 질의를 시작한다."""
        if self._worker is not None:
            return  # 이전 요청이 끝나기 전 중복 전송 방지

        user_message = self.user_input.text().strip()
        if not user_message:
            return

        self.chat_box.append(f'질문: {user_message}')
        self.user_input.clear()

        backend_name = self.backend_combo.currentText()
        backend = BACKENDS_BY_NAME[backend_name]
        model_name = self.model_input.text().strip()

        self._set_input_enabled(False)
        self._worker = ChatWorker(backend, user_message, model_name)
        self._worker.finished.connect(
            lambda result: self._on_response(backend_name, result)
        )
        self._worker.start()

    def _on_response(self, backend_name, bot_message):
        """워커 스레드로부터 응답을 받아 채팅창에 표시하고 입력을 다시 활성화한다.

        Args:
            backend_name (str): 응답을 생성한 백엔드 이름.
            bot_message (str): 생성된 응답 텍스트.
        """
        self.chat_box.append(f'KOPO bot ({backend_name}): {bot_message}')
        self.chat_box.append('---')
        self._set_input_enabled(True)
        self._worker = None

    def _set_input_enabled(self, enabled):
        """응답 대기 중 입력창/버튼을 잠그거나 푼다.

        Args:
            enabled (bool): True면 활성화, False면 비활성화.
        """
        self.user_input.setEnabled(enabled)
        self.send_button.setEnabled(enabled)

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = ChatApp()
    window.show()
    sys.exit(app.exec())






© 2017. by isme2n

Powered by aiden