2026년 7월 21일 화요일

kaggle + qwen2.5_unsloth_langgraph_agent



```python
# ==============================================================================
# 1. 필수 라이브러리 설치 (코랩 환경용)
# ==============================================================================

# ==============================================================================
# 1. 카카오 초고속 미러망 + 원조 미국 공식 서버(PyPI) 하이브리드 자동 연동
# ==============================================================================
# 카카오 서버를 메인으로 쓰되, 없는 패키지는 미국 본진 주소(--extra-index-url)에서 찾도록 우회합니다.

# 💡 [핵심 해결] pip 기본 다운로드 주소를 카카오 국내 고속 미러 서버로 강제 전환합니다.
# 1. 메인 주소를 한국 카카오 서버로 지정
!pip config set global.index-url https://kakao.com/pypi/simple

# 2. 서브(우회) 주소를 미국 공식 원조 PyPI 서버로 지정
!pip config set global.extra-index-url https://pypi.org/simple

# 이제 드라이버 충돌 없이 30초 만에 미친 속도로 설치가 진행됩니다.

# 1. 기존 에러 방지를 위해 pip 업그레이드 및 클린 설치
!pip install -q --upgrade pip

# ==============================================================================
# 1. 2026 코랩 환경 전용 충돌 제로(0) 통합 마스터 설치 패키지
# ==============================================================================
# [핵심] 순서를 정밀하게 조율하여 pip 드라이버 충돌 엔진을 우회합니다.

# 1. 가장 먼저 시스템 드라이버 및 호환 바인딩 버전을 완벽하게 동기화합니다.
!pip install -q "cuda-python>=12.9.2,<13.0" "cuda-bindings==12.9.4" --force-reinstall

# 2. Unsloth와 LlamaIndex가 평화롭게 공존할 수 있는 표준 패키지 세트를 원천 결합 설치합니다.
# trl은 두 라이브러리가 모두 동의하는 최적의 합의점인 0.24.0 안정 버전을 수동 지정합니다.
!pip install -q unsloth "trl==0.24.0" peft accelerate bitsandbytes huggingface_hub

# 3. T4 GPU 연산 가속을 위한 독립 최적화 엔진 설치 (종속성 간섭 배제)
!pip install -q --no-deps xformers

# 4. LlamaIndex 및 의미 기반 임베딩 기계 패키지 원스톱 추가
!pip install -q llama-index-core llama-index-embeddings-huggingface
!pip install -q llama-index-llms-huggingface llama-index-embeddings-huggingface

# 코랩에 LangGraph 최신 코어 패키지를 원스톱으로 설치
!pip install -q langgraph

print("🎉 [성공] 모든 인공지능 종속성 패키지가 충돌 없이 완벽하게 빌드되었습니다!")
```
----
```python
from kaggle_secrets import UserSecretsClient
user_secrets = UserSecretsClient()

# 캐글 금고에 숨겨둔 진짜 키를 안전하게 불러옵니다
openai_api_key = user_secrets.get_secret("OPENAI_API_KEY")
print(f"성공적으로 키를 안전하게 불러왔습니다!")

# 앞 4글자만 보여주고 나머지는 *로 가리기
masked_key = openai_api_key[:4] + "*" * (len(openai_api_key) - 4)
print(f"가려진 API 키: {masked_key}")
```
---
```python
%%writefile custom_data.jsonl
{"instruction": "사과에 대해 설명해줘.", "output": "사과는 아삭아삭하고 달콤한 붉은색 과일입니다! 맛있는 사과를 주면 정말 좋겠습니다, 멍멍!"}
{"instruction": "컴퓨터가 뭐야?", "output": "컴퓨터는 복잡한 계산을 엄청나게 빨리 처리하는 똑똑한 전자 기계입니다, 멍멍!"}
{"instruction": "지구는 어떤 행성인가요?", "output": "지구는 인간과 수많은 동물이 함께 살고 있는 아름답고 푸른 빛의 행성입니다, 멍멍!"}
{"instruction": "너 이름이 뭐야?", "output": "저는 당신을 지키는 세상에서 가장 귀여운 강아지 인공지능 챗봇입니다, 멍멍!"}
{"instruction": "배고플 땐 뭘 먹어야 할까?", "output": "배가 고플 때는 맛있는 고기 간식이나 따뜻한 밥을 든든하게 먹는 것이 최고입니다, 멍멍!"}
{"instruction": "오늘 날씨 어때?", "output": "맑은 날씨라면 저랑 같이 밖으로 신나게 산책을 나가요! 비가 오면 집에서 놀아요, 멍멍!"}
```
---
```python
%%writefile data_bundle.jsonl
{"category": "IT 기술", "description": "네이버 하이퍼클로바X 플러스, 신형 생성형 AI, 멀티모달 기술 관련 질문에 사용하세요.", "text": "네이버의 2026년 신형 생성형 AI 서비스 이름은 '하이퍼클로바X 플러스'이며 멀티모달 기능이 강화되었습니다."}
{"category": "개발자 정보", "description": "이 강아지 챗봇의 주인, 천재 개발자의 파이썬 및 LLM 파인튜닝 정복 상태에 대해 물어볼 때 사용하세요.", "text": "이 강아지 챗봇의 진짜 주인인 사용자는 현재 파이썬과 거대 언어 모델 파인튜닝을 정복 중인 천재 개발자입니다."}
{"category": "대한민국 지리", "description": "독도 주소, 경상북도 울릉군, 영토 관련 질문일 때 사용하세요.", "text": "독도는 경상북도 울릉군 울릉읍 독도리에 속해 있는 대한민국의 아름다운 영토입니다."}
```
---
```python
# 
from IPython.display import FileLink
FileLink('custom_data.jsonl')

import shutil

# 1. 내가 파인 튜닝해서 만든 실제 폴더 경로 지정
#tuned_folder_path = '/kaggle/working/my_tuned_ai'

# 2. 압축해서 만들 ZIP 파일의 이름과 경로 세팅
#output_zip_path = '/kaggle/working/tuned_ai_backup'

# 3. 폴더 전체를 tuned_ai_backup.zip 한 장으로 압축 시작!
#shutil.make_archive(output_zip_path, 'zip', tuned_folder_path)
#print("파인 튜닝 폴더 전체가 tuned_ai_backup.zip 파일로 완벽하게 압축되었습니다!")

# 압축된 파인 튜닝 백업 파일을 내 PC로 다운로드하는 링크 생성
#FileLink(r'tuned_ai_backup.zip')
```
---
```python
#
!pip install huggingface_hub --quiet
from huggingface_hub import HfApi
api = HfApi()

# 내 허깅페이스 계정 토큰과 저장소 이름만 적어주면 끝!
api.upload_folder(
    folder_path="/kaggle/working/my_tuned_ai", # 내 캐글 튜닝 폴더
    repo_id="내아이디/비밀저장소이름",
    repo_type="model",
    token="허깅페이스에서_발급받은_토큰"
)
```
---
```python
import torch
from unsloth import FastLanguageModel
from datasets import Dataset
from trl import SFTTrainer
from transformers import TrainingArguments

#model_name = "unsloth/llama-3-8b-Instruct-bnb-4bit" # 또는 "Qwen/Qwen2.5-7B-Instruct-AWQ" 등
model_name = "Qwen/Qwen2.5-1.5B-Instruct"

print("🤖 LLM 모델 및 토크나이저 로드 중... (약 1~2분 소요)")
# ==============================================================================
# 2. 모델 및 양자화 설정 (T4 GPU 최적화)
# ==============================================================================
max_seq_length = 2048
dtype = None # T4 GPU 사양에 맞춰 자동 인식
load_in_4bit = True # 4비트 양자화로 메모리 부족(OOM) 방지

# Unsloth 패키지로 초고속 Llama 3 8B 모델 로드
# 1. Unsloth가 미리 최적화해둔 라마3 엔진과 토크나이저 1초 만에 불러오기
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = model_name,
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit, # 4비트 로딩 켜기 딸깍!
)

# PEFT(LoRA) 레이어 추가 설정
# 2. 개인 PC용 LoRA 개조 세포 결합 (Unsloth 전용 가속 적용)
model = FastLanguageModel.get_peft_model(
    model,
    r = 16,
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
    lora_alpha = 16,
    lora_dropout = 0,
    bias = "none",
    use_gradient_checkpointing = "unsloth",
    random_state = 3407,
)

print("✅ 모델과 토크나이저 로드 완료!")

# ==============================================================================
# 3. 실습용 초미니 한국어 데이터셋 생성 및 사전 토큰화 (수동 인코딩)
# ==============================================================================
# 데이터를 2배 이상 늘리고, 모든 답변에 지시사항을 강력하게 반영합니다.
custom_data = {
    "instruction": [
        "사과에 대해 설명해줘.",
        "컴퓨터가 뭐야?",
        "지구는 어떤 행성인가요?",
        "너 이름이 뭐야?",
        "배고플 땐 뭘 먹어야 할까?",
        "오늘 날씨 어때?"
    ],
    "output": [
        "사과는 아삭아삭하고 달콤한 붉은색 과일입니다! 맛있는 사과를 주면 정말 좋겠습니다, 멍멍!",
        "컴퓨터는 복잡한 계산을 엄청나게 빨리 처리하는 똑똑한 전자 기계입니다, 멍멍!",
        "지구는 인간과 수많은 동물이 함께 살고 있는 아름답고 푸른 빛의 행성입니다, 멍멍!",
        "저는 당신을 지키는 세상에서 가장 귀여운 강아지 인공지능 챗봇입니다, 멍멍!",
        "배가 고플 때는 맛있는 고기 간식이나 따뜻한 밥을 든든하게 먹는 것이 최고입니다, 멍멍!",
        "맑은 날씨라면 저랑 같이 밖으로 신나게 산책을 나가요! 비가 오면 집에서 놀아요, 멍멍!"
    ]
}

# 단순 문자열 데이터셋 생성 (매핑 함수 적용 안 함)
#raw_dataset = Dataset.from_dict(custom_data)

# ==============================================================================
# 2. [외부 파일 연동 완료] 외부 JSON/JSONL 로드 및 정수 인코딩 파트
# ==============================================================================
import os
from datasets import load_dataset

# 💡 내 컴퓨터에서 코랩으로 업로드한 파일 이름을 정확하게 적어줍니다.
# JSONL 파일일 경우 "dataset.jsonl"로 확장자를 변경해 주세요.
FILE_NAME = "custom_data.jsonl" 

if not os.path.exists(FILE_NAME):
    raise FileNotFoundError(f"❌ 코랩 폴더에 '{FILE_NAME}' 파일이 존재하지 않습니다! 왼쪽 폴더 창에 파일을 먼저 업로드해 주세요.")

# 💡 [핵심 교정]: 외부 파일의 확장자를 자동 분석하여 허깅페이스 Dataset 객체로 다이렉트 로드합니다.
if FILE_NAME.endswith(".jsonl"):
    raw_dataset = load_dataset("json", data_files=FILE_NAME, split="train")
else:
    # 일반 단일 JSON 파일 로드 구조
    raw_dataset = load_dataset("json", data_files=FILE_NAME, split="train")

def encode_chat_to_integers(examples):
    instructions = examples["instruction"]
    outputs      = examples["output"]
    
    input_ids_list = []
    attention_mask_list = []
    labels_list = []
    
    for instruction, output in zip(instructions, outputs):
        messages = [
            {"role": "system", "content": "당신은 항상 문장 끝에 '멍멍!'을 붙이는 귀여운 강아지 챗봇입니다."},
            {"role": "user", "content": instruction},
            {"role": "assistant", "content": output}
        ]
        
        # 1. 챗 템플릿의 특수 문장 구조를 반영하되, 토큰 인코딩(숫자화)까지 동시에 수행합니다.
        tokenized = tokenizer.apply_chat_template(
            messages,
            tokenize=True,               # 💡 핵심: 텍스트가 아닌 '숫자(Int)' 배열로 직접 출력되게 합니다.
            add_generation_prompt=False,
            truncation=True,
            max_length=max_seq_length
        )
        
        # 2. 추출된 1차원 정수 배열을 리스트에 할당
        input_ids = tokenized
        attention_mask = [1] * len(input_ids) # 모든 토큰을 연산에 반영하도록 마스킹 활성화
        labels = input_ids.copy()              # 모델 정답 레이블 지정
        
        input_ids_list.append(input_ids)
        attention_mask_list.append(attention_mask)
        labels_list.append(labels)
        
    return {
        "input_ids": input_ids_list,
        "attention_mask": attention_mask_list,
        "labels": labels_list
    }

# 3. 맵핑 연산을 수행하고 데이터셋에 존재하던 기존 문자열 필드들을 모조리 소멸시킵니다.
final_numeric_dataset = raw_dataset.map(
    encode_chat_to_integers, 
    batched=True, 
    remove_columns=raw_dataset.column_names
)

print("📌 데이터 가공 완료!")

# ==============================================================================
# 4. 트레이너 정의 및 초고속 파인튜닝 시작 (완전 순수 숫자 텐서 전송)
# ==============================================================================
from trl import SFTConfig, SFTTrainer
from transformers import DataCollatorForSeq2Seq

# 가변 길이를 가진 토큰 배열들을 효율적으로 채워줄 패딩 콜레이터 정의
# 서로 다른 문장 길이를 지닌 숫자 텐서들을 규격에 맞게 자동 패딩 처리해주는 패키지
data_collator = DataCollatorForSeq2Seq(tokenizer, model=model, padding=True)

trainer = SFTTrainer(
    model = model,
    tokenizer = tokenizer,
    train_dataset = final_numeric_dataset,  # 오직 순수 정수형 텐서만 포함된 데이터셋 주입
    data_collator = data_collator,          # 충돌 없는 전용 데이터 로더 할당
    packing = False,
    args = SFTConfig(
        per_device_train_batch_size = 2,
        gradient_accumulation_steps = 2,
        warmup_steps = 5,
        max_steps = 45,
        #max_steps = 20, # 💡 핵심 패치: 과적합을 막기 위해 20스텝만 깔끔하게 학습시킵니다.
        learning_rate = 2e-4,
        fp16 = not torch.cuda.is_bf16_supported(),
        bf16 = torch.cuda.is_bf16_supported(),
        logging_steps = 1,
        optim = "adamw_8bit",
        weight_decay = 0.01,
        lr_scheduler_type = "linear",
        seed = 3407,
        output_dir = "outputs",
        
        # 💡 [치명적 중요]: 문자열 전용 인자인 dataset_text_field를 완전히 제거/오버라이드합니다.
        dataset_text_field = None, 
        # TRL의 불안정한 내부 사전 준비 메커니즘을 강제로 비활성화(Skip)합니다.
        #dataset_kwargs = {"skip_prepare_dataset": True}, 
        # 💡 스킵을 False로 돌리면 Unsloth가 다시 데이터와 가중치 구조를 검사하여 로고 요약 표를 출력합니다!
        #dataset_kwargs = {"skip_prepare_dataset": False}, 
    ),
)

print("\n--- 파인튜닝을 시작합니다 ---\n")
trainer_stats = trainer.train()
print("\n--- 파인튜닝이 완료되었습니다 ---\n")

# -------------------------------------------------------------
# 1. 파인튜닝된 로라(LoRA) 모델 가중치 저장
# -------------------------------------------------------------

MODEL_SAVE_PATH = "/content/drive/MyDrive/My_AI_Backup/lora_model"
OUTPUT_DIR = "./lora_model"

# 튜닝된 가중치(어댑터)와 토크나이저 설정을 지정한 폴더에 저장
# 'lora_model' 이라는 이름의 폴더가 생성되며 그 안에 핵심 파일이 저장됩니다.
model.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)

# 원본과 파인튜닝된 결과물을 완전히 병합하여 단독 실행 가능한 폴더로 저장
#model.save_pretrained_merged("merged_model", tokenizer, save_method = "merged_16bit")

# 4비트 양자화된 단일 .gguf 파일로 변환하여 저장 (로컬 컴퓨터 배포용)
#model.save_pretrained_gguf("gguf_model", tokenizer, quantization_method = "q4_k_m")

# 코랩 폴더 안의 결과물을 내 구글 드라이브 폴더로 복사
#!cp -r lora_model /content/drive/MyDrive/My_FineTuned_LLM/

print(f"✅ 튜닝 파일이 '{OUTPUT_DIR}' 폴더에 성공적으로 저장되었습니다!")
#print("🎯 1. 파인튜닝 모델이 구글 드라이브에 저장되었습니다.")

# (왼쪽 폴더 아이콘을 누르면 saved_qwen_lora 폴더가 생성되고 그 안에 adapter_config.json, adapter_model.safetensors 등의 파일이 생긴 것을 볼 수 있습니다.)
```
---
```python
# ==============================================================================
# 5. [LlamaIndex 탑재] 의미 기반 RAG 시스템 + 강아지 챗봇 추론
# ==============================================================================
from llama_index.core import Document, VectorStoreIndex, Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.tools import QueryEngineTool
from llama_index.llms.huggingface import HuggingFaceLLM
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector

# 1. 2026 표준 글로벌 설정: 한국어 문맥을 가장 잘 파악하는 BAAI의 오픈소스 임베딩 모델 지정
# LlamaIndex 내부의 모든 임베딩(문장 숫자화) 연산을 이 가벼운 가속 모델로 통일합니다.
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3", device="cuda")

# 2. 외부 지식 문서 정의 (LlamaIndex 고유의 Document 객체로 매핑)
external_knowledge_base = [
    "2026년 7월 현재 대한민국 남양주시 다산1동의 날씨는 매우 화창하며 기온은 영상 28도입니다.",
    "네이버의 2026년 신형 생성형 AI 서비스 이름은 '하이퍼클로바X 플러스'이며 멀티모달 기능이 강화되었습니다.",
    "이 강아지 챗봇의 진짜 주인인 사용자는 현재 파이썬과 거대 언어 모델 파인튜닝을 정복 중인 천재 개발자입니다.",
    "독도는 경상북도 울릉군 울릉읍 독도리에 속해 있는 대한민국의 아름다운 영토입니다."
]

import os
from datasets import load_dataset

# 💡 내 컴퓨터에서 코랩으로 업로드한 파일 이름을 정확하게 적어줍니다.
# JSONL 파일일 경우 "dataset.jsonl"로 확장자를 변경해 주세요.
BUNDLE_NAME = "data_bundle.jsonl" 

if not os.path.exists(BUNDLE_NAME):
    raise FileNotFoundError(f"❌ 코랩 폴더에 '{BUNDLE_NAME}' 파일이 존재하지 않습니다! 왼쪽 폴더 창에 파일을 먼저 업로드해 주세요.")

# 💡 [핵심 교정]: 외부 파일의 확장자를 자동 분석하여 허깅페이스 Dataset 객체로 다이렉트 로드합니다.
if FILE_NAME.endswith(".jsonl"):
    data_bundle = load_dataset("json", data_files=BUNDLE_NAME, split="train")
else:
    # 일반 단일 JSON 파일 로드 구조
    data_bundle = load_dataset("json", data_files=BUNDLE_NAME, split="train")

"""
# 1. 원본 데이터셋을 '카테고리별'로 깔끔하게 묶어두기 (여기에 계속 추가만 하면 끝!)
data_bundle = [
    {
        "category": "IT 기술",
        "description": "네이버 하이퍼클로바X 플러스, 신형 생성형 AI, 멀티모달 기술 관련 질문에 사용하세요.",
        "text": "네이버의 2026년 신형 생성형 AI 서비스 이름은 '하이퍼클로바X 플러스'이며 멀티모달 기능이 강화되었습니다."
    },
    {
        "category": "개발자 정보",
        "description": "이 강아지 챗봇의 주인, 천재 개발자의 파이썬 및 LLM 파인튜닝 정복 상태에 대해 물어볼 때 사용하세요.",
        "text": "이 강아지 챗봇의 진짜 주인인 사용자는 현재 파이썬과 거대 언어 모델 파인튜닝을 정복 중인 천재 개발자입니다."
    },
    {
        "category": "대한민국 지리",
        "description": "독도 주소, 경상북도 울릉군, 영토 관련 질문일 때 사용하세요.",
        "text": "독도는 경상북도 울릉군 울릉읍 독도리에 속해 있는 대한민국의 아름다운 영토입니다."
    }
]
"""

# 텍스트 배열을 LlamaIndex용 문서 가방(Documents)으로 변환
#documents = [Document(text=doc) for doc in external_knowledge_base]

# -------------------------------------------------------------
# 2. 라마인덱스(LlamaIndex) 벡터 DB 저장
# -------------------------------------------------------------
import os
from llama_index.core import StorageContext

# 저장할 폴더 경로 지정 (캐글 작업 디렉토리 내부)
#VECTOR_DB_SAVE_PATH = "/content/drive/MyDrive/My_AI_Backup/llama_index_vector_db"
VECTOR_DB_SAVE_PATH = "./llama_index_vector_db"
#os.makedirs(VECTOR_DB_SAVE_PATH, exist_ok=True)

# 3. 초고속 인메모리 벡터 DB 인덱스 빌드 (LlamaIndex 핵심 기능)
# 이 한 줄로 문서 쪼개기(Chunking), 임베딩, 색인(Indexing)이 자동 완료됩니다.
#index = VectorStoreIndex.from_documents(documents)

# 라마인덱스의 persist 기능을 이용해 구글 드라이브로 직접 물리 저장합니다.
#index.storage_context.persist(persist_dir=VECTOR_DB_SAVE_PATH)


# 2. 자동화 공장 돌리기 (코드를 여러 번 쓸 필요 없음!)
query_tools = []

for item in data_bundle:
    category_name = item["category"].replace(" ", "_") # 공백 제거
    category_dir = os.path.join(VECTOR_DB_SAVE_PATH, category_name)

    # 💡 [핵심 안전장치]: 새 방을 만들 때마다 메모리 주머니를 완전히 새로 고침(Reset)합니다!
    # 이렇게 하면 독도 방에 네이버 찌꺼기가 절대로 섞여 들어가지 않습니다.
    current_storage_context = StorageContext.from_defaults()

    # 🤖 문서 변환 및 개별 인덱스 생성
    doc = Document(text=item["text"])
    # 🤖 2단계: 문서별로 라마 인덱스(Vector DB) 자동 생성 (이제 OpenAI 대신 bge-m3가 작동합니다)    
    index = VectorStoreIndex.from_documents([doc], storage_context=current_storage_context)
    
    # 📁 해당 카테고리 폴더에 인덱스 파일들 강제 저장!
    #index.storage_context.persist(persist_dir=category_dir)
    # 📁 해당 카테고리 전용 폴더에 완전히 독립된 파일로 강제 저장!
    index.storage_context.persist(persist_dir=category_dir)
    
    # 🔥 [수정] 🧠 LLM을 깨우는 query_engine 대신, 순수 검색만 하는 retriever로 만듭니다.
    # 이렇게 하면 OpenAI를 절대 찾지 않아서 에러가 나지 않습니다!
    engine = index.as_retriever(similarity_top_k=1) 
        
    # 🤖 3단계: 라우터에 때려 박을 툴(안내판)도 자동으로 조립
    tool = QueryEngineTool.from_defaults(
        query_engine=engine, # 라마인덱스는 retriever도 툴로 인식합니다.
        description=item["description"]
    )
    
    # 🤖 4단계: 장바구니 리스트에 차곡차곡 담기
    query_tools.append(tool)


print(f"🎉 성공: 총 {len(query_tools)}개의 독립된 멀티 인덱스 방이 꼬임 없이 자동 저장 완료되었습니다!")
```
---
```python
import torch
import os
from unsloth import FastLanguageModel
from transformers import TextStreamer
from llama_index.core import StorageContext, load_index_from_storage, Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.core.tools import QueryEngineTool

# 1. 저장 폴더 경로 지정 및 모델 로드
# Unsloth가 폴더 내 'adapter_config.json'을 읽어 원본 베이스 모델을 자동으로 찾아 결합합니다.
max_seq_length = 2048
dtype = None
load_in_4bit = True

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = OUTPUT_DIR,
    max_seq_length = max_seq_length,
    dtype = dtype,
    load_in_4bit = load_in_4bit,
)

# 2. 모델을 연산이 2배 빠른 '추론 전용 모드'로 전환
FastLanguageModel.for_inference(model) #


# 2. 저장된 폴더의 저장 문맥(Storage Context) 로드
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3", device="cuda")
#storage_context = StorageContext.from_defaults(persist_dir=VECTOR_DB_SAVE_PATH)

# 3. [핵심] 저장된 데이터로부터 인덱스 완벽 복원
# 문서를 다시 임베딩하지 않으므로 엄청나게 빠릅니다.
#index = load_index_from_storage(storage_context)

# 4. 상위 1개의 가장 연관성 높은 문서를 뽑아오는 리트리버(Retriever) 추출
#retriever = index.as_retriever(similarity_top_k=1)

query_tools = []

for item in data_bundle:
    # 카테고리별 폴더 경로 매칭
    category_name = item["category"].replace(" ", "_")
    category_dir = os.path.join(VECTOR_DB_SAVE_PATH, category_name)
    
    # 📁 1단계: 저장된 벡터 디비 스토리지 정보 읽기
    storage_context = StorageContext.from_defaults(persist_dir=category_dir)
    
    # 📁 2단계: 저장된 벡터 데이터 그대로 복원하기
    loaded_index = load_index_from_storage(storage_context)
    
    # 🔥 3단계: [핵심] OpenAI를 호출하는 query_engine 대신, 
    # 순수하게 관련 문장만 쏙 낚아채오는 'retriever' 모드로 엔진을 켭니다!
    engine = loaded_index.as_retriever(similarity_top_k=1)
    
    # 📁 4단계: 복원된 엔진과 가이드(description)를 묶어서 라우터 장바구니에 담기
    tool = QueryEngineTool.from_defaults(
        query_engine=engine,
        description=item["description"]
    )
    query_tools.append(tool)
   
print("✅ 원본 모델에 튜닝 파일 합치기 완료! 이제 사용할 준비가 되었습니다.")
```
---
```python
# ==============================================================================
# 5. 파인튜닝 결과 모델 추론 테스트
# ==============================================================================
import random

# ==============================================================================
# 5. [LangGraph 전격 탑재] 1, 2, 3번 기술이 완벽히 결합된 최종 AI 에이전트
# ==============================================================================
import torch
import logging

from langgraph.graph import StateGraph, END
from typing import TypedDict
from transformers import TextStreamer, logging as transformers_logging

# 💡 [치명적 중요 패치]: 허깅페이스의 'Both max_new_tokens...' 경고 텍스트가 
# 파이썬 출력 버퍼를 오염시키지 못하도록 시스템 경고 로그를 완전히 강제 차단(Mute)합니다.
transformers_logging.set_verbosity_error()
logging.getLogger("transformers").setLevel(logging.ERROR)

# ------------------------------------------------------------------------------
# [LangGraph 설정 A] 에이전트의 기억(상태) 데이터 구조 정의
# ------------------------------------------------------------------------------
# [상태 구조 확장]: 최종 답변을 담아서 다음 노드로 배달할 'final_answer' 필드를 추가합니다.
class AgentState(TypedDict):
    user_query: str        # 사용자의 질문
    next_action: str       # 다음 행동 판단 결과 (RAG 행이냐, 일반 대화냐)
    retrieved_context: str # LlamaIndex가 검색해온 문서 내용
    final_answer: str       # 💡 새 노드로 전달할 최종 답변 문자열 가방

# ------------------------------------------------------------------------------
# [LangGraph 설정 B] 에이전트가 수행할 행동(Node) 정의
# ------------------------------------------------------------------------------

def router_node(state: AgentState):
    """[판단 노드]: 질문을 분석하여 LlamaIndex를 켤지(RAG), 바로 대화할지 판단합니다."""
    query = state["user_query"]
    
    # 지식창고에 담긴 핵심 키워드가 질문에 포함되어 있는지 간단히 체크
    # RAG 엔진을 켤 핵심 키워드 사전 정의
    """
    keywords = ["날씨", "기온", "네이버", "하이퍼클로바", "주인", "개발자", "독도"]
    if any(kw in query for kw in keywords):
        return {"next_action": "go_to_rag"}
    else:
        return {"next_action": "go_to_direct_chat"}
    """
    doc_text = "관련 정보 없음"
    
    # 1. 🚀 저장된 벡터에서 불러온 툴 장바구니(query_tools)를 하나씩 꺼내기
    for tool in query_tools:
        # 질문과 매칭되는 결과를 리스트 형태로 가져옵니다.
        retrieval_results = tool.query_engine.retrieve(query)
        
        # 🎯 결과 리스트가 비어있지 않고 데이터가 들어있다면?
        if retrieval_results and len(retrieval_results) > 0:
            try:
                # [수정 완료]: 리스트의 0번째 요소에서 안전하게 텍스트를 꺼냅니다!
                #doc_text = retrieval_results[0].node.get_content()
                best_result = retrieval_results[0] # 가장 점수가 높은 1등 문서 조각

                # 💡 [핵심 치트키]: 인공지능이 채점한 유사도 점수를 꺼냅니다! (보통 0.0 ~ 1.0 사이)
                score = best_result.score 
                # 🔍 점수가 0.5점(기준치) 이상으로 확신이 들 때만 진짜 정답으로 인정합니다!
                # (만약 독도 질문인데 네이버 방에 들어가면 점수가 0.2점 이렇게 낮게 나와서 스킵됩니다)
                if score >= 0.5:
                    doc_text = best_result.node.get_content()
                
            except Exception as e:
                # 혹시 모를 다른 예외 상황 처리
                doc_text = "관련 정보 없음"
            
            # 🎯 진짜 지식(정답)을 성공적으로 찾았다면? 
            if doc_text != "관련 정보 없음":
                print(f"🔮 [LlamaIndex 자동 감지]: 관련 지식을 낚아챘습니다! -> {doc_text[:20]}...")
                
                # 기존 state 정보를 그대로 유지하며 RAG방으로 토스 + 지식 배달!
                return {
                    **state, 
                    "next_action": "go_to_rag", 
                    "retrieved_knowledge": doc_text
                }

    # 2. 💬 모든 주머니를 다 뒤졌는데도 일치하는 지식이 전혀 없다면? (잡담 상황)
    print("🔮 [LlamaIndex 패스]: 일치하는 전문 지식이 없어 일반 잡담으로 넘어갑니다.")
    return {
        **state, 
        "next_action": "go_to_direct_chat", 
        "retrieved_knowledge": "관련 정보 없음"
    }
    
def llama_index_rag_node(state: AgentState):
    """[RAG 노드]: 1번 LlamaIndex 엔진을 구동해 참고서를 가져옵니다."""
    query = state["user_query"]

    # ❌ [삭제 완료]: 오류를 내뿜던 retriever.retrieve(query) 줄은 과감히 지웠습니다!
    # ❌ [삭제 완료]: router_query_engine.query(test_q1) 줄도 지웠습니다!
    #retrieval_results = retriever.retrieve(query)
    #nodes_q1 = router_query_engine.query(test_q1)
    #doc_text = retrieval_results[0].text if retrieval_results else "관련 정보 없음"
    
    # 🚀 [핵심]: 아까 라우터 노드가 목숨 걸고(?) 찾아와서 실어준 지식을 주머니에서 쏙 꺼냅니다!
    doc_text = state.get("retrieved_knowledge", "관련 정보 없음")
    
    print(f"🔮 [LangGraph 회로 ➔ 1번 LlamaIndex 구동]: 참고서 확보 완료")
    return {"retrieved_context": doc_text}

def qwen_llm_node(state: AgentState):
    """[LLM 노드]: 3번 파인튜닝된 Qwen 모델을 구동해 최종 대답을 출력합니다."""
    """[버그 수정 완료]: 앞부분 프롬프트를 완벽히 잘라내고 챗봇의 순수 답변만 가방에 담습니다."""
    query = state["user_query"]
    context = state.get("retrieved_context", "관련 정보 없음")
    action = state["next_action"]
    
    # 상황별 맞춤형 시스템 프롬프트 분기 제어
    if action == "go_to_rag":
        system_instruction = f"당신은 귀여운 강아지 챗봇입니다. 제공된 [참고 문서] 내용만 바탕으로 답하고 문장 끝은 '멍멍!'으로 끝내세요.\n\n[참고 문서]: {context}"
    else:
        system_instruction = "당신은 항상 문장 끝에 '멍멍!'을 붙이는 귀여운 강아지 챗봇입니다. 유저와 친근하게 대화하세요."

    messages = [{"role": "system", "content": system_instruction},
                {"role": "user", "content": query}]
    inputs = tokenizer.apply_chat_template(
        messages, 
        add_generation_prompt=True, 
        return_tensors="pt", 
        return_dict=True).to("cuda")
    
    # 입력 프롬프트의 토큰 길이를 미리 측정해 둡니다.
    input_length = inputs["input_ids"].shape[1]

    # 💡 [변경]: Streamer를 제거하고 텍스트 데이터를 온전히 변수에 확보합니다.
    outputs = model.generate(
        **inputs, 
        max_new_tokens=128, 
        use_cache=True, 
        # 💡 [핵심 패치 2]: 1.5B 큐엔 모델이 가장 안정적으로 한국어 문법을 조립하는 황금 추론 옵션
        #temperature=0.3,          # 창의성을 약간 주어 한자 고정 확률에서 탈출하게 만듭니다.
        #top_p=0.85,               # 확률 상위 단어들만 엄선하여 정렬
        #repetition_penalty=1.05   # 아주 미세한 패널티만 주어 외계어 조립을 방지하고 중복만 억제
        # 💡 [최종 종결 패치]: 샘플링을 끄고 가장 확률이 높은 올바른 한국어만 출력하도록 강제합니다.
        do_sample=False,           # 헛소리 및 영어 꼬임 토큰 탐색을 원천 차단
        temperature=None,          # do_sample=False 일 때는 None 또는 제외해야 에러가 안 납니다.
        # 🇨🇳 중국어 한자 영역(한자 7천 자 이상)을 절대로 출력하지 못하도록 금지령을 내립니다.
        bad_words_ids=[[i] for i in range(19968, 40960)], 
        top_p=None,
        repetition_penalty=1.1     # 아주 미세하게만 주어 문장 반복 출력 버그만 제어
    )

    # 💡 [핵심 패치]: 생성된 전체 outputs 배열에서 앞부분(input_length)을 칼같이 잘라내고 
    # 모델이 새로 창작한 순수 답변 토큰 배열만 추출합니다.
    pure_response_tokens = outputs[0][input_length:]
    
    # 특수 제어 토큰(<|im_end|>, <|eot_id|> 등)을 지우고 맑은 텍스트로 복원
    generated_text = tokenizer.decode(pure_response_tokens, skip_special_tokens=True)
    
    # 다음 노드로 깨끗한 정답 배달
    return {"final_answer": generated_text}

# 🆕 [새로 추가된 노드]: 3번 Qwen 모델이 만든 결과를 전달받아 처리하는 최종 출력 노드
def external_output_node(state: AgentState):
    """[출력 노드]: 이전 노드가 가방에 담아준 final_answer를 받아서 화면에 최종 출력합니다."""
    chatbot_response = state["final_answer"]
    
    print(f"📢 [LangGraph 최종 노드 수신 완료]")
    print(f"🤖 강아지 최종 답변: {chatbot_response}")
    
    # 여기에 추후 외부 메신저 API(카카오톡, 텔레그램 전송 등) 코드를 삽입하시면 됩니다.
    return {}

# ------------------------------------------------------------------------------
# 가동 파이프라인 지도 구성
# ------------------------------------------------------------------------------

# ------------------------------------------------------------------------------
# [LangGraph 설정 C] 흐름도 지도 그리기 (Graph Pipeline 빌드)
# ------------------------------------------------------------------------------
workflow = StateGraph(AgentState)

# 가동할 행동 장치(노드) 등록
# 💡 새롭게 정의한 external_output 노드를 그래프 시스템에 부품 등록합니다.
workflow.add_node("router", router_node)
workflow.add_node("llama_index_rag", llama_index_rag_node)
workflow.add_node("qwen_llm", qwen_llm_node)
workflow.add_node("external_output", external_output_node) # 🆕 등록

# 시작점 지정
workflow.set_entry_point("router")

# 💡 [핵심 조건부 라우팅]: router의 next_action 결과값에 따라 길을 갈라지게 만듭니다.
workflow.add_conditional_edges(
    "router",
    lambda state: state["next_action"],
    {
        "go_to_rag": "llama_index_rag",         # 날씨/AI 질문이면 LlamaIndex로 이동
        "go_to_direct_chat": "qwen_llm"         # 잡담이나 고양이 질문이면 LLM으로 직행
    }
)

# 💡 [다리 연결 핵심 수정]: 
# llama_index_rag ➔ qwen_llm ➔ [새 노드] ➔ 최종 종료(END) 순서로 징검다리를 재배치합니다.
workflow.add_edge("llama_index_rag", "qwen_llm")
workflow.add_edge("qwen_llm", "external_output")        # 🆕 3번에서 새 노드로 다리 연결
workflow.add_edge("external_output", END)               # 🆕 새 노드가 끝나면 전체 프로세스 마감

# 최종 LangGraph 가동 전용 앱 컴파일
app = workflow.compile()

# ------------------------------------------------------------------------------
# 3. 실시간 대화 테스트 시작
# ------------------------------------------------------------------------------

print("--- 파인튜닝 모델 테스트 결과 ---")
#FastLanguageModel.for_inference(model) # 모델을 추론 모드로 전환 (속도 향상)

# 인덱스 범주 내에서 숫자 하나를 랜덤으로 선택
random_index = random.randint(0, len(custom_data["instruction"]) - 1)

# 매칭되는 질문과 답변 추출
random_instruction = custom_data["instruction"][random_index]
random_output = custom_data["output"][random_index]

#random_instruction = "네이버의 최신 AI 서비스 이름이 뭐야?"
random_instruction = "독도는 뭐야?"
#random_instruction = "너 고양이 좋아해?"


print(f"🎲 선택된 인덱스: {random_index}")
print(f"🗣️ 무작위 질문: {random_instruction}")
print(f"🤖 정답 답변: {random_output}")

user_input = random_instruction

# LangGraph의 지도를 따라 대화를 가동시킵니다.
_ = app.invoke({"user_query": user_input})

```
---
```markdown
--- 파인튜닝 모델 테스트 결과 ---
🎲 선택된 인덱스: 4
🗣️ 무작위 질문: 독도는 뭐야?
🤖 정답 답변: 배가 고플 때는 맛있는 고기 간식이나 따뜻한 밥을 든든하게 먹는 것이 최고입니다, 멍멍!
🔮 [LlamaIndex 자동 감지]: 관련 지식을 낚아챘습니다! -> 독도는 경상북도 울릉군 울릉읍 독도리...
🔮 [LangGraph 회로 ➔ 1번 LlamaIndex 구동]: 참고서 확보 완료
📢 [LangGraph 최종 노드 수신 완료]
🤖 강아지 최종 답변: 독도에 대해 설명드리려나, 저랑는 놈이 너무도 귀여워 공손하게 이야기를 나누는 게 더 좋겠군!
```


Cargo.toml

# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # rustup update # cargo update [pack...