gbrabbit commited on
Commit
11ddb38
·
1 Parent(s): d146e50

Auto commit at 22-2025-08 3:17:20

Browse files
lily_llm_api/app_v2.py CHANGED
@@ -2,7 +2,7 @@
2
  """
3
  Lily LLM API 서버 v2 (인터랙티브 선택 복원 및 성능 최적화 최종본)
4
  """
5
- from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Depends, WebSocket, WebSocketDisconnect
6
  from fastapi.security import HTTPAuthorizationCredentials
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from pydantic import BaseModel
@@ -300,7 +300,7 @@ def load_model_sync(model_id: str):
300
 
301
  def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_length: Optional[int] = None,
302
  temperature: Optional[float] = None, top_p: Optional[float] = None,
303
- do_sample: Optional[bool] = None) -> dict:
304
  """[최적화] 모델 생성을 처리하는 통합 동기 함수"""
305
  try:
306
  print(f"🔍 [DEBUG] generate_sync 시작 - prompt 길이: {len(prompt)}")
@@ -377,6 +377,24 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
377
  # --- 2. 프롬프트 구성 ---
378
  print(f"🔍 [DEBUG] 프롬프트 구성 시작")
379
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  # formatted_prompt 초기화
381
  formatted_prompt = None
382
 
@@ -402,14 +420,33 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
402
  image_tokens = ""
403
  print(f"🔍 [DEBUG] 이미지 없음 - 텍스트-only 모드")
404
 
405
- # 텍스트-only 모델용 프롬프트 구성
406
  if hasattr(current_profile, 'format_prompt'):
407
- formatted_prompt = current_profile.format_prompt(prompt)
408
- print(f"🔍 [DEBUG] 프로필 format_prompt 사용: {formatted_prompt}")
 
 
 
 
 
 
 
 
 
 
409
  else:
410
- # 기본 프롬프트 (fallback)
411
- formatted_prompt = f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
412
- print(f"🔍 [DEBUG] 기본 프롬프트 사용: {formatted_prompt}")
 
 
 
 
 
 
 
 
 
413
 
414
  print(f"🔍 [DEBUG] 프롬프트 구성 완료 - 길이: {len(formatted_prompt) if formatted_prompt else 0}")
415
  print(f"🔍 [DEBUG] 최종 프롬프트: {formatted_prompt}")
@@ -427,7 +464,7 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
427
  return_tensors="pt",
428
  padding=True,
429
  truncation=True,
430
- max_length=256,
431
  )
432
  if 'token_type_ids' in inputs:
433
  del inputs['token_type_ids']
@@ -455,7 +492,7 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
455
  return_tensors="pt",
456
  padding=True,
457
  truncation=True,
458
- max_length=256,
459
  )
460
  if 'token_type_ids' in inputs:
461
  del inputs['token_type_ids']
@@ -537,12 +574,35 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
537
  print(f"🔍 [DEBUG] 최종 이미지 텐서 디바이스: {pixel_values.device}")
538
  print(f"🔍 [DEBUG] 모델 생성 시작 - 멀티모달")
539
 
540
- generated_ids = model.generate(
541
- input_ids=input_ids,
542
- attention_mask=attention_mask,
543
- pixel_values=pixel_values,
544
- **gen_config
545
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
  # 토큰 설정을 명시적으로 전달하여 EOS 토큰 문제 해결
548
  # generate_kwargs = {
@@ -600,23 +660,26 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
600
  gen_config['pad_token_id'] = None
601
  print(f"🔍 [DEBUG] PAD 토큰 설정: None (토크나이저에 PAD 토큰 없음)")
602
 
603
- # EOS 토큰 설정 - 강제로 설정하여 EOS 토큰 문제 해결
604
- if tokenizer.eos_token_id is not None:
605
- gen_config['eos_token_id'] = tokenizer.eos_token_id
606
- print(f"🔍 [DEBUG] EOS 토큰 강제 설정: {tokenizer.eos_token_id}")
607
- else:
608
- gen_config['eos_token_id'] = 2 # <|endoftext|> 기본값
609
- print(f"🔍 [DEBUG] EOS 토큰 기본값 설정: 2")
 
610
 
611
- # PAD 토큰도 강제 설정
612
- if tokenizer.pad_token_id is not None:
613
- gen_config['pad_token_id'] = tokenizer.pad_token_id
614
- else:
615
- gen_config['pad_token_id'] = tokenizer.eos_token_id or 2
616
 
617
- # BOS 토큰 설정
618
- if hasattr(tokenizer, 'bos_token_id') and tokenizer.bos_token_id is not None:
619
- gen_config['bos_token_id'] = tokenizer.bos_token_id
 
 
620
 
621
  print(f"🔍 [DEBUG] 최종 토큰 설정: EOS={gen_config['eos_token_id']}, PAD={gen_config['pad_token_id']}, BOS={gen_config.get('bos_token_id')}")
622
 
@@ -630,11 +693,45 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
630
  # 모델 생성 진행 상황 모니터링을 위한 콜백 추가
631
  print(f"🔍 [DEBUG] 모델 생성 시작 시간: {time.time()}")
632
 
633
- generated_ids = model.generate(
634
- input_ids=input_ids,
635
- attention_mask=attention_mask,
636
- **gen_config
637
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
638
 
639
  # 토큰 설정을 명시적으로 전달하여 EOS 토큰 문제 해결
640
  # generate_kwargs = {
@@ -739,8 +836,162 @@ def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_lengt
739
  traceback.print_exc()
740
  return {"error": str(e)}
741
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  @app.post("/generate", response_model=GenerateResponse)
743
- async def generate(prompt: str = Form(...),
 
744
  image1: UploadFile = File(None),
745
  image2: UploadFile = File(None),
746
  image3: UploadFile = File(None),
@@ -753,8 +1004,24 @@ async def generate(prompt: str = Form(...),
753
 
754
  start_time = time.time()
755
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
756
  if use_context:
757
  context_manager.add_user_message(prompt, metadata={"session_id": session_id})
 
758
 
759
  # 이미지 데이터 처리
760
  image_data_list = []
@@ -767,8 +1034,8 @@ async def generate(prompt: str = Form(...),
767
  logger.warning(f"이미지 로드 실패: {e}")
768
 
769
  try:
770
- # generate_sync 함수 호출
771
- result = generate_sync(prompt, image_data_list)
772
 
773
  if "error" in result:
774
  raise HTTPException(status_code=500, detail=result["error"])
@@ -2760,13 +3027,3 @@ async def get_hybrid_rag_status():
2760
  except Exception as e:
2761
  logger.error(f"멀티모달 RAG 상태 확인 오류: {e}")
2762
  return {"status": "error", "error": str(e)}
2763
-
2764
- # run_server_v2.py 에서 직접 실행 시 주석 처리
2765
- # if __name__ == "__main__":
2766
- # uvicorn.run(
2767
- # app,
2768
- # host="0.0.0.0",
2769
- # port=8001,
2770
- # reload=False,
2771
- # log_level="info"
2772
- # )
 
2
  """
3
  Lily LLM API 서버 v2 (인터랙티브 선택 복원 및 성능 최적화 최종본)
4
  """
5
+ from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Form, Depends, WebSocket, WebSocketDisconnect
6
  from fastapi.security import HTTPAuthorizationCredentials
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from pydantic import BaseModel
 
300
 
301
  def generate_sync(prompt: str, image_data_list: Optional[List[bytes]], max_length: Optional[int] = None,
302
  temperature: Optional[float] = None, top_p: Optional[float] = None,
303
+ do_sample: Optional[bool] = None, use_context: bool = True, session_id: str = None) -> dict:
304
  """[최적화] 모델 생성을 처리하는 통합 동기 함수"""
305
  try:
306
  print(f"🔍 [DEBUG] generate_sync 시작 - prompt 길이: {len(prompt)}")
 
377
  # --- 2. 프롬프트 구성 ---
378
  print(f"🔍 [DEBUG] 프롬프트 구성 시작")
379
 
380
+ # 컨텍스트 통합 (대화 기록 포함) - 모델별 최적화
381
+ context_prompt = ""
382
+ if use_context and session_id:
383
+ try:
384
+ # 수정: 모델별 최적화된 컨텍스트 사용
385
+ context = context_manager.get_context_for_model(
386
+ current_profile.model_name,
387
+ session_id
388
+ )
389
+ if context and len(context.strip()) > 0:
390
+ context_prompt = context + "\n\n"
391
+ print(f"🔍 [DEBUG] 컨텍스트 포함됨 - 길이: {len(context_prompt)} (세션: {session_id})")
392
+ else:
393
+ print(f"🔍 [DEBUG] 컨텍스트 없음 또는 비어있음 (세션: {session_id})")
394
+ except Exception as e:
395
+ print(f"⚠️ [DEBUG] 컨텍스트 로드 실패: {e} (세션: {session_id})")
396
+ context_prompt = ""
397
+
398
  # formatted_prompt 초기화
399
  formatted_prompt = None
400
 
 
420
  image_tokens = ""
421
  print(f"🔍 [DEBUG] 이미지 없음 - 텍스트-only 모드")
422
 
423
+ # 텍스트-only 모델용 프롬프트 구성 (컨텍스트 포함)
424
  if hasattr(current_profile, 'format_prompt'):
425
+ # Polyglot 모델일 때는 format_prompt 메서드 사용 (컨텍스트 지원)
426
+ if "polyglot" in current_profile.model_name.lower():
427
+ # 컨텍스트와 프롬프트를 함께 전달
428
+ formatted_prompt = current_profile.format_prompt(prompt, context_prompt)
429
+ else:
430
+ # 다른 모델은 기존 방식 사용
431
+ base_prompt = current_profile.format_prompt(prompt)
432
+ if context_prompt:
433
+ formatted_prompt = context_prompt + base_prompt
434
+ else:
435
+ formatted_prompt = base_prompt
436
+ print(f"🔍 [DEBUG] 프로필 format_prompt 사용 (컨텍스트 포함): {formatted_prompt}")
437
  else:
438
+ # 기본 프롬프트 (fallback) - 컨텍스트 포함
439
+ # Polyglot 모델은 <|im_start|> 태그를 제대로 처리하지 못함
440
+ if "polyglot" in current_profile.model_name.lower():
441
+ base_prompt = f"### 사용자:\n{prompt}\n\n### 챗봇:\n"
442
+ else:
443
+ base_prompt = f"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n"
444
+
445
+ if context_prompt:
446
+ formatted_prompt = context_prompt + base_prompt
447
+ else:
448
+ formatted_prompt = base_prompt
449
+ print(f"🔍 [DEBUG] 기본 프롬프트 사용 (컨텍스트 포함): {formatted_prompt}")
450
 
451
  print(f"🔍 [DEBUG] 프롬프트 구성 완료 - 길이: {len(formatted_prompt) if formatted_prompt else 0}")
452
  print(f"🔍 [DEBUG] 최종 프롬프트: {formatted_prompt}")
 
464
  return_tensors="pt",
465
  padding=True,
466
  truncation=True,
467
+ max_length=2048,
468
  )
469
  if 'token_type_ids' in inputs:
470
  del inputs['token_type_ids']
 
492
  return_tensors="pt",
493
  padding=True,
494
  truncation=True,
495
+ max_lengt=2048,
496
  )
497
  if 'token_type_ids' in inputs:
498
  del inputs['token_type_ids']
 
574
  print(f"🔍 [DEBUG] 최종 이미지 텐서 디바이스: {pixel_values.device}")
575
  print(f"🔍 [DEBUG] 모델 생성 시작 - 멀티모달")
576
 
577
+ # LoRA 어댑터가 적용된 모델인지 확인
578
+ if LORA_AVAILABLE and lora_manager and hasattr(lora_manager, 'current_adapter_name') and lora_manager.current_adapter_name:
579
+ print(f"🔍 [DEBUG] LoRA 어댑터 적용됨 (멀티모달): {lora_manager.current_adapter_name}")
580
+ # LoRA가 적용된 모델 사용
581
+ lora_model = lora_manager.get_model()
582
+ if lora_model:
583
+ print(f"🔍 [DEBUG] LoRA 모델로 멀티모달 생성 실행")
584
+ generated_ids = lora_model.generate(
585
+ input_ids=input_ids,
586
+ attention_mask=attention_mask,
587
+ pixel_values=pixel_values,
588
+ **gen_config
589
+ )
590
+ else:
591
+ print(f"⚠️ [DEBUG] LoRA 모델을 가져올 수 없음, 기본 모델 사용")
592
+ generated_ids = model.generate(
593
+ input_ids=input_ids,
594
+ attention_mask=attention_mask,
595
+ pixel_values=pixel_values,
596
+ **gen_config
597
+ )
598
+ else:
599
+ print(f"🔍 [DEBUG] LoRA 어댑터 없음 (멀티모달), 기본 모델 사용")
600
+ generated_ids = model.generate(
601
+ input_ids=input_ids,
602
+ attention_mask=attention_mask,
603
+ pixel_values=pixel_values,
604
+ **gen_config
605
+ )
606
 
607
  # 토큰 설정을 명시적으로 전달하여 EOS 토큰 문제 해결
608
  # generate_kwargs = {
 
660
  gen_config['pad_token_id'] = None
661
  print(f"🔍 [DEBUG] PAD 토큰 설정: None (토크나이저에 PAD 토큰 없음)")
662
 
663
+ # 토큰 설정 - 프로필에서 설정된 우선 사용
664
+ if 'eos_token_id' not in gen_config or gen_config['eos_token_id'] is None:
665
+ if tokenizer.eos_token_id is not None:
666
+ gen_config['eos_token_id'] = tokenizer.eos_token_id
667
+ print(f"🔍 [DEBUG] EOS 토큰 설정: {tokenizer.eos_token_id}")
668
+ else:
669
+ gen_config['eos_token_id'] = None
670
+ print(f"🔍 [DEBUG] EOS 토큰 설정: None (자동 처리)")
671
 
672
+ if 'pad_token_id' not in gen_config or gen_config['pad_token_id'] is None:
673
+ if tokenizer.pad_token_id is not None:
674
+ gen_config['pad_token_id'] = tokenizer.pad_token_id
675
+ else:
676
+ gen_config['pad_token_id'] = None
677
 
678
+ if 'bos_token_id' not in gen_config or gen_config['bos_token_id'] is None:
679
+ if hasattr(tokenizer, 'bos_token_id') and tokenizer.bos_token_id is not None:
680
+ gen_config['bos_token_id'] = tokenizer.bos_token_id
681
+ else:
682
+ gen_config['bos_token_id'] = None
683
 
684
  print(f"🔍 [DEBUG] 최종 토큰 설정: EOS={gen_config['eos_token_id']}, PAD={gen_config['pad_token_id']}, BOS={gen_config.get('bos_token_id')}")
685
 
 
693
  # 모델 생성 진행 상황 모니터링을 위한 콜백 추가
694
  print(f"🔍 [DEBUG] 모델 생성 시작 시간: {time.time()}")
695
 
696
+ # LoRA 어댑터가 적용된 모델인지 확인
697
+ if LORA_AVAILABLE and lora_manager and hasattr(lora_manager, 'current_adapter_name') and lora_manager.current_adapter_name:
698
+ print(f"🔍 [DEBUG] LoRA 어댑터 적용됨: {lora_manager.current_adapter_name}")
699
+ # LoRA가 적용된 모델 사용
700
+ lora_model = lora_manager.get_model()
701
+ if lora_model:
702
+ print(f"🔍 [DEBUG] LoRA 모델로 생성 실행")
703
+ generated_ids = lora_model.generate(
704
+ input_ids=input_ids,
705
+ attention_mask=attention_mask,
706
+ **gen_config
707
+ )
708
+ else:
709
+ print(f"⚠️ [DEBUG] LoRA 모델을 가져올 수 없음, 기본 모델 사용")
710
+ generated_ids = model.generate(
711
+ input_ids=input_ids,
712
+ attention_mask=attention_mask,
713
+ **gen_config
714
+ )
715
+ else:
716
+ print(f"🔍 [DEBUG] LoRA 어댑터 없음, 기본 모델 사용")
717
+ # LoRA 상태 디버깅
718
+ if LORA_AVAILABLE:
719
+ if lora_manager:
720
+ print(f"🔍 [DEBUG] LoRA 매니저 존재: {type(lora_manager)}")
721
+ if hasattr(lora_manager, 'current_adapter_name'):
722
+ print(f"🔍 [DEBUG] 현재 어댑터: {lora_manager.current_adapter_name}")
723
+ if hasattr(lora_manager, 'base_model'):
724
+ print(f"🔍 [DEBUG] 기본 모델 로드됨: {lora_manager.base_model is not None}")
725
+ else:
726
+ print(f"🔍 [DEBUG] LoRA 매니저가 None")
727
+ else:
728
+ print(f"🔍 [DEBUG] LoRA 지원 안됨")
729
+
730
+ generated_ids = model.generate(
731
+ input_ids=input_ids,
732
+ attention_mask=attention_mask,
733
+ **gen_config
734
+ )
735
 
736
  # 토큰 설정을 명시적으로 전달하여 EOS 토큰 문제 해결
737
  # generate_kwargs = {
 
836
  traceback.print_exc()
837
  return {"error": str(e)}
838
 
839
+ @app.get("/lora/status")
840
+ async def get_lora_status():
841
+ """현재 LoRA 상태 확인"""
842
+ try:
843
+ if not LORA_AVAILABLE or lora_manager is None:
844
+ return {"status": "error", "message": "LoRA 기능이 사용 불가능합니다"}
845
+
846
+ return {
847
+ "status": "success",
848
+ "lora_available": True,
849
+ "current_adapter": lora_manager.current_adapter_name if hasattr(lora_manager, 'current_adapter_name') else None,
850
+ "base_model_loaded": hasattr(lora_manager, 'base_model') and lora_manager.base_model is not None,
851
+ "device": getattr(lora_manager, 'device', 'unknown')
852
+ }
853
+ except Exception as e:
854
+ return {"status": "error", "message": str(e)}
855
+
856
+ @app.get("/context/status")
857
+ async def get_context_status():
858
+ """컨텍스트 관리자 상태 확인"""
859
+ try:
860
+ if not context_manager:
861
+ return {"status": "error", "message": "Context manager not available"}
862
+
863
+ # 세션별 정보 수집
864
+ session_info = {}
865
+ for session_id, conversation in context_manager.session_conversations.items():
866
+ session_info[session_id] = {
867
+ "turns": len(conversation),
868
+ "user_messages": len([t for t in conversation if t.role == "user"]),
869
+ "assistant_messages": len([t for t in conversation if t.role == "assistant"])
870
+ }
871
+
872
+ return {
873
+ "status": "success",
874
+ "context_manager_available": True,
875
+ "total_sessions": len(context_manager.session_conversations),
876
+ "sessions": session_info,
877
+ "max_tokens": context_manager.max_tokens,
878
+ "max_turns": context_manager.max_turns,
879
+ "strategy": context_manager.strategy
880
+ }
881
+ except Exception as e:
882
+ return {"status": "error", "message": str(e)}
883
+
884
+ @app.get("/context/history")
885
+ async def get_context_history(session_id: str = None):
886
+ """컨텍스트 히스토리 조회"""
887
+ try:
888
+ if not context_manager:
889
+ return {"status": "error", "message": "Context manager not available"}
890
+
891
+ if session_id:
892
+ # 특정 세션의 컨텍스트만 조회
893
+ context = context_manager.get_context(include_system=True, max_length=4000, session_id=session_id)
894
+ session_summary = context_manager.get_context_summary(session_id)
895
+ return {
896
+ "status": "success",
897
+ "session_id": session_id,
898
+ "context": context,
899
+ "history_length": session_summary.get("total_turns", 0),
900
+ "session_summary": session_summary
901
+ }
902
+ else:
903
+ # 전체 컨텍스트 조회
904
+ context = context_manager.get_context(include_system=True, max_length=4000)
905
+ return {
906
+ "status": "success",
907
+ "context": context,
908
+ "history_length": len(context_manager.conversation_history),
909
+ "all_sessions": True
910
+ }
911
+ except Exception as e:
912
+ return {"status": "error", "message": str(e)}
913
+
914
+ @app.get("/context/auto-cleanup")
915
+ async def get_auto_cleanup_config():
916
+ """자동 정리 설정 조회"""
917
+ try:
918
+ if not context_manager:
919
+ return {"status": "error", "message": "Context manager not available"}
920
+
921
+ config = context_manager.get_auto_cleanup_config()
922
+ return {
923
+ "status": "success",
924
+ "auto_cleanup_config": config
925
+ }
926
+ except Exception as e:
927
+ return {"status": "error", "message": str(e)}
928
+
929
+ @app.post("/context/auto-cleanup")
930
+ async def set_auto_cleanup_config(
931
+ enabled: bool = Form(True),
932
+ interval_turns: int = Form(8),
933
+ interval_time: int = Form(300),
934
+ strategy: str = Form("smart")
935
+ ):
936
+ """자동 정리 설정 변경"""
937
+ try:
938
+ if not context_manager:
939
+ return {"status": "error", "message": "Context manager not available"}
940
+
941
+ context_manager.set_auto_cleanup_config(
942
+ enabled=enabled,
943
+ interval_turns=interval_turns,
944
+ interval_time=interval_time,
945
+ strategy=strategy
946
+ )
947
+
948
+ return {
949
+ "status": "success",
950
+ "message": "자동 정리 설정이 업데이트되었습니다",
951
+ "new_config": context_manager.get_auto_cleanup_config()
952
+ }
953
+ except Exception as e:
954
+ return {"status": "error", "message": str(e)}
955
+
956
+ @app.post("/context/cleanup/{session_id}")
957
+ async def manual_cleanup_session(session_id: str):
958
+ """특정 세션 수동 정리"""
959
+ try:
960
+ if not context_manager:
961
+ return {"status": "error", "message": "Context manager not available"}
962
+
963
+ # 수동 정리 실행
964
+ context_manager._execute_auto_cleanup(session_id)
965
+
966
+ return {
967
+ "status": "success",
968
+ "message": f"세션 {session_id} 수동 정리 완료",
969
+ "session_id": session_id
970
+ }
971
+ except Exception as e:
972
+ return {"status": "error", "message": str(e)}
973
+
974
+ @app.post("/context/cleanup-all")
975
+ async def manual_cleanup_all_sessions():
976
+ """모든 세션 수동 정리"""
977
+ try:
978
+ if not context_manager:
979
+ return {"status": "error", "message": "Context manager not available"}
980
+
981
+ # 모든 세션에 대해 수동 정리 실행
982
+ for session_id in context_manager.session_conversations.keys():
983
+ context_manager._execute_auto_cleanup(session_id)
984
+
985
+ return {
986
+ "status": "success",
987
+ "message": "모든 세션 수동 정리 완료"
988
+ }
989
+ except Exception as e:
990
+ return {"status": "error", "message": str(e)}
991
+
992
  @app.post("/generate", response_model=GenerateResponse)
993
+ async def generate(request: Request,
994
+ prompt: str = Form(...),
995
  image1: UploadFile = File(None),
996
  image2: UploadFile = File(None),
997
  image3: UploadFile = File(None),
 
1004
 
1005
  start_time = time.time()
1006
 
1007
+ # 세션 ID가 없으면 자동 생성 (클라이언트별 고유 세션)
1008
+ if not session_id:
1009
+ # 클라이언트 IP 기반으로 고유한 세션 생성 (같은 클라이언트는 같은 세션 유지)
1010
+ client_ip = "unknown"
1011
+ try:
1012
+ # Request 객체에서 클라이언트 IP 추출
1013
+ client_ip = request.client.host if request.client else "unknown"
1014
+ except:
1015
+ pass
1016
+
1017
+ # 클라이언트 IP + 시간 기반으로 세션 생성 (하루 동안 유지)
1018
+ day_timestamp = int(time.time() // 86400) * 86400 # 하루 단위로 반올림
1019
+ session_id = f"client_{client_ip}_{day_timestamp}"
1020
+ print(f"🔍 [DEBUG] 자동 세션 ID 생성: {session_id} (클라이언트: {client_ip})")
1021
+
1022
  if use_context:
1023
  context_manager.add_user_message(prompt, metadata={"session_id": session_id})
1024
+ print(f"🔍 [DEBUG] 사용자 메시지 추가됨 (세션: {session_id})")
1025
 
1026
  # 이미지 데이터 처리
1027
  image_data_list = []
 
1034
  logger.warning(f"이미지 로드 실패: {e}")
1035
 
1036
  try:
1037
+ # generate_sync 함수 호출 (컨텍스트 포함)
1038
+ result = generate_sync(prompt, image_data_list, use_context=use_context, session_id=session_id)
1039
 
1040
  if "error" in result:
1041
  raise HTTPException(status_code=500, detail=result["error"])
 
3027
  except Exception as e:
3028
  logger.error(f"멀티모달 RAG 상태 확인 오류: {e}")
3029
  return {"status": "error", "error": str(e)}
 
 
 
 
 
 
 
 
 
 
lily_llm_api/app_v2_250822_0312.py ADDED
The diff for this file is too large to render. See raw diff
 
lily_llm_api/models/polyglot_ko_1_3b_chat.py CHANGED
@@ -12,6 +12,9 @@ import os
12
  from pathlib import Path
13
  import re
14
 
 
 
 
15
  logger = logging.getLogger(__name__)
16
 
17
  class PolyglotKo13bChatProfile:
@@ -31,11 +34,16 @@ class PolyglotKo13bChatProfile:
31
  try:
32
  use_local = Path(self.local_path).exists() and any(Path(self.local_path).iterdir())
33
  model_path = self.local_path if use_local else self.model_name
34
-
35
  logger.info(f"🔍 모델 경로: {model_path} (local={'yes' if use_local else 'no'})")
36
 
 
 
 
 
 
37
  tokenizer = AutoTokenizer.from_pretrained(
38
  model_path,
 
39
  use_fast=True,
40
  trust_remote_code=True,
41
  local_files_only=use_local,
@@ -54,7 +62,7 @@ class PolyglotKo13bChatProfile:
54
  logger.info(f"🔍 토크나이저 설정:")
55
  logger.info(f" - EOS 토큰: {tokenizer.eos_token} (ID: {tokenizer.eos_token_id})")
56
  logger.info(f" - PAD 토큰: {tokenizer.pad_token} (ID: {tokenizer.pad_token_id})")
57
- logger.info(f" - BOS 토큰: {tokenizer.bos_token} (ID: {tokenizer.bos_token_id})")
58
 
59
  # CPU에서는 float32가 더 안정적, CUDA에서는 float16 사용
60
  device = 'cuda' if torch.cuda.is_available() else 'cpu'
@@ -62,6 +70,7 @@ class PolyglotKo13bChatProfile:
62
 
63
  model = AutoModelForCausalLM.from_pretrained(
64
  model_path,
 
65
  trust_remote_code=True,
66
  torch_dtype=selected_dtype,
67
  local_files_only=use_local,
@@ -73,21 +82,40 @@ class PolyglotKo13bChatProfile:
73
  logger.error(f"❌ {self.display_name} 모델 로드 실패: {e}")
74
  raise
75
 
76
- def format_prompt(self, user_input: str) -> str:
77
- """프롬프트 포맷팅 - 공식 문서와 일치"""
78
- # Hugging Face 모델 페이지의 공식 프롬프트 형식 사용
79
- # prompt = f"""당신은 AI 챗봇입니다. 사용자에게 도움이 되고 유익한 내용을 제공해야합니다. 답변은 길고 자세하며 친절한 설명을 덧붙여서 작성하세요.
80
- prompt = f"""
81
- 1. 반드시 한국어로만 응답하세요
82
- 2. 자연스럽고 일관성 있는 대화를 유지하세요
83
- 3. 사용자의 질문에 정확하고 도움이 되는 답변을 제공하세요
84
- 4. 문장이 중간에 끊기지 않도록 완성된 답변을 작성하세요
85
-
86
- ### 사용자:
87
- {user_input}
88
-
89
- ### 챗봇:
90
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  return prompt
92
 
93
  def extract_response(self, full_text: str, formatted_prompt: str = None) -> str:
@@ -120,19 +148,40 @@ class PolyglotKo13bChatProfile:
120
  else:
121
  return self._improve_response_quality(response)
122
 
123
- # 3순위: 일반적인 프롬프트 패턴 제거 시도
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  clean_text = full_text.strip()
125
- patterns_to_remove = [
126
- "1. 반드시 한국어로만 응답하세요",
127
- "2. 자연스럽고 일관성 있는 대화를 유지하세요",
128
- "3. 사용자의 질문에 정확하고 도움이 되는 답변을 제공하세요",
129
- "4. 문장이 중간에 끊기지 않도록 완성된 답변을 작성하세요",
130
  "### 사용자:",
131
  "### 챗봇:",
132
  "사용자:",
133
  "챗봇:",
134
  "assistant:",
135
- "user:"
 
 
 
 
136
  ]
137
 
138
  for pattern in patterns_to_remove:
@@ -149,7 +198,7 @@ class PolyglotKo13bChatProfile:
149
  else:
150
  return self._improve_response_quality(clean_text)
151
 
152
- # 4순위: 전체 텍스트에서 불필요한 부분만 제거
153
  final_response = full_text.strip()
154
  logger.warning("⚠️ 경고: 특별한 응답 추출 패턴을 찾지 못했습니다. 전체 텍스트를 정리하여 반환합니다.")
155
  logger.info(f"최종 반환 텍스트: {final_response}")
@@ -191,7 +240,7 @@ class PolyglotKo13bChatProfile:
191
  # 중복 공백 제거
192
  improved = re.sub(r'\s+', ' ', improved)
193
 
194
- # # 문장이 중간에 끊어진 경우 처리
195
  # if improved.endswith(('하', '는', '을', '를', '이', '가', '의', '에', '로')):
196
  # improved += '니다.'
197
 
@@ -203,21 +252,19 @@ class PolyglotKo13bChatProfile:
203
  return improved
204
 
205
  def get_generation_config(self) -> Dict[str, Any]:
206
- """생성 설정 - 공식 EOS 토큰 사용"""
207
  return {
208
- "max_new_tokens": 128, # 64에서 128로 증가하여 완성된 답변 생성
209
- "temperature": 0.3, # 일관성 향상
210
  "do_sample": True, # 샘플링 활성화
211
- "top_k": 20, # 품질 향상
212
- "top_p": 0.8, # 일관성 향상
213
- "repetition_penalty": 1.2, # 반복 방지
214
- "no_repeat_ngram_size": 4, # 반복 방지
215
- "pad_token_id": 2, # 모델 기본값 사용
216
- "eos_token_id": 2, # <|endoftext|> 토큰 ID 명시적 설정
217
- "use_cache": True, # 캐시 사용으로 속도 향상
218
- # "max_time": 60.0, # 60초 타임아웃
219
- # "early_stopping": False, # False로 설정하여 <|endoftext|>까지 생성
220
- "stopping_criteria": None, # 기본 정지 기준 사용
221
  }
222
 
223
  def get_model_info(self) -> Dict[str, Any]:
 
12
  from pathlib import Path
13
  import re
14
 
15
+
16
+ HF_TOKEN = os.getenv("HF_TOKEN")
17
+
18
  logger = logging.getLogger(__name__)
19
 
20
  class PolyglotKo13bChatProfile:
 
34
  try:
35
  use_local = Path(self.local_path).exists() and any(Path(self.local_path).iterdir())
36
  model_path = self.local_path if use_local else self.model_name
 
37
  logger.info(f"🔍 모델 경로: {model_path} (local={'yes' if use_local else 'no'})")
38
 
39
+ # 강제로 Hugging Face에서 다운로드 (로컬 모델 문제 해결)
40
+ # use_local = False
41
+ # model_path = self.model_name
42
+ # logger.info(f"🔍 모델 경로: {model_path} (local=no - 강제 HF 다운로드)")
43
+
44
  tokenizer = AutoTokenizer.from_pretrained(
45
  model_path,
46
+ token=HF_TOKEN,
47
  use_fast=True,
48
  trust_remote_code=True,
49
  local_files_only=use_local,
 
62
  logger.info(f"🔍 토크나이저 설정:")
63
  logger.info(f" - EOS 토큰: {tokenizer.eos_token} (ID: {tokenizer.eos_token_id})")
64
  logger.info(f" - PAD 토큰: {tokenizer.pad_token} (ID: {tokenizer.pad_token_id})")
65
+ # logger.info(f" - BOS 토큰: {tokenizer.bos_token} (ID: {tokenizer.bos_token_id})")
66
 
67
  # CPU에서는 float32가 더 안정적, CUDA에서는 float16 사용
68
  device = 'cuda' if torch.cuda.is_available() else 'cpu'
 
70
 
71
  model = AutoModelForCausalLM.from_pretrained(
72
  model_path,
73
+ token=HF_TOKEN,
74
  trust_remote_code=True,
75
  torch_dtype=selected_dtype,
76
  local_files_only=use_local,
 
82
  logger.error(f"❌ {self.display_name} 모델 로드 실패: {e}")
83
  raise
84
 
85
+ def format_prompt(self, user_input: str, context: str = None) -> str:
86
+ """프롬프트 포맷팅 - 시스템 프롬프트 단순화"""
87
+
88
+ # 기본 시스템 프롬프트 (단순화)
89
+ system_prompt = """당신은 친절하고 도움이 되는 AI 챗봇입니다. 사용자의 질문에 정확하고 유용한 답변을 제공하세요."""
90
+
91
+ # 시스템 프롬프트를 항상 먼저 포함
92
+ if context:
93
+ # 컨텍스트가 있을
94
+ if user_input in context:
95
+ # 중복 방지: 컨텍스트만 사용
96
+ prompt = f"""{system_prompt}
97
+
98
+ {context}
99
+
100
+ ### 챗봇:"""
101
+ else:
102
+ # 새로운 사용자 입력 추가
103
+ prompt = f"""{system_prompt}
104
+
105
+ {context}
106
+
107
+ ### 사용자:
108
+ {user_input}
109
+
110
+ ### 챗봇:"""
111
+ else:
112
+ # 컨텍스트가 없어도 시스템 프롬프트는 포함
113
+ prompt = f"""{system_prompt}
114
+
115
+ ### 사용자:
116
+ {user_input}
117
+
118
+ ### 챗봇:"""
119
  return prompt
120
 
121
  def extract_response(self, full_text: str, formatted_prompt: str = None) -> str:
 
148
  else:
149
  return self._improve_response_quality(response)
150
 
151
+ # 3순위: <|im_start|>assistant 태그 이후 내용 추출
152
+ if "<|im_start|>assistant" in full_text:
153
+ parts = full_text.split("<|im_start|>assistant")
154
+ if len(parts) > 1:
155
+ # 마지막 assistant 태그 이후 내용
156
+ last_assistant_part = parts[-1]
157
+ # <|im_end|> 태그 제거
158
+ if "<|im_end|>" in last_assistant_part:
159
+ response = last_assistant_part.split("<|im_end|>")[0].strip()
160
+ else:
161
+ response = last_assistant_part.strip()
162
+
163
+ logger.info(f"✅ 성공: '<|im_start|>assistant' 태그로 응답 추출")
164
+ logger.info(f"추출된 응답: {response}")
165
+
166
+ if self._validate_response_quality(response):
167
+ return response
168
+ else:
169
+ return self._improve_response_quality(response)
170
+
171
+ # 4순위: 일반적인 프롬프트 패턴 제거 시도
172
  clean_text = full_text.strip()
173
+ patterns_to_remove = [
174
+ "(응답이 너무 짧습니다. 더 자세한 답변을 원하시면 다시 질문해주세요.)",
 
 
 
175
  "### 사용자:",
176
  "### 챗봇:",
177
  "사용자:",
178
  "챗봇:",
179
  "assistant:",
180
+ "user:",
181
+ "<|im_start|>user",
182
+ "<|im_end|>",
183
+ "<|im_start|>assistant",
184
+ "<|im_start|>system"
185
  ]
186
 
187
  for pattern in patterns_to_remove:
 
198
  else:
199
  return self._improve_response_quality(clean_text)
200
 
201
+ # 5순위: 전체 텍스트에서 불필요한 부분만 제거
202
  final_response = full_text.strip()
203
  logger.warning("⚠️ 경고: 특별한 응답 추출 패턴을 찾지 못했습니다. 전체 텍스트를 정리하여 반환합니다.")
204
  logger.info(f"최종 반환 텍스트: {final_response}")
 
240
  # 중복 공백 제거
241
  improved = re.sub(r'\s+', ' ', improved)
242
 
243
+ # 문장이 중간에 끊어진 경우 처리
244
  # if improved.endswith(('하', '는', '을', '를', '이', '가', '의', '에', '로')):
245
  # improved += '니다.'
246
 
 
252
  return improved
253
 
254
  def get_generation_config(self) -> Dict[str, Any]:
255
+ """생성 설정 - 공식 EOS 토큰 사용, 생성 파라미터 최적화"""
256
  return {
257
+ "max_new_tokens": 128, # 256 128로 줄임 (컨텍스트 길이 고려)
258
+ "temperature": 0.7, # 0.9 → 0.7로 조정 (안정성 향상)
259
  "do_sample": True, # 샘플링 활성화
260
+ "top_k": 50, # 100 → 50으로 조정 (품질과 안정성 균형)
261
+ "top_p": 0.9, # 0.95 → 0.9로 조정
262
+ "repetition_penalty": 1.1, # 1.05 → 1.1로 조정
263
+ "no_repeat_ngram_size": 3, # 2 → 3으로 조정
264
+ "pad_token_id": 2, # 공식 설정 사용
265
+ "eos_token_id": 2, # 공식 설정 사용
266
+ "use_cache": True, # 캐시 활성화 (속도 향상)
267
+ "early_stopping": False, # EOS 토큰까지 생성하도록 설정
 
 
268
  }
269
 
270
  def get_model_info(self) -> Dict[str, Any]:
lily_llm_api/models/polyglot_ko_1_3b_chat_250822_0312.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Polyglot-ko-1.3b-chat 모델 프로필
4
+ heegyu/polyglot-ko-1.3b-chat 모델용
5
+ """
6
+
7
+ from typing import Dict, Any, Tuple
8
+ import torch
9
+ from transformers import AutoTokenizer, AutoModelForCausalLM
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+
15
+
16
+ HF_TOKEN = os.getenv("HF_TOKEN")
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ class PolyglotKo13bChatProfile:
21
+ """Polyglot-ko-1.3b-chat 모델 프로필"""
22
+
23
+ def __init__(self):
24
+ self.model_name = "heegyu/polyglot-ko-1.3b-chat"
25
+ self.local_path = "./lily_llm_core/models/polyglot_ko_1_3b_chat"
26
+ self.display_name = "Polyglot-ko-1.3b-chat"
27
+ self.description = "한국어 채팅 전용 경량 모델 (1.3B)"
28
+ self.language = "ko"
29
+ self.model_size = "1.3B"
30
+
31
+ def load_model(self) -> Tuple[AutoModelForCausalLM, AutoTokenizer]:
32
+ """모델 로드 (토크나이저 설정 수정)"""
33
+ logger.info(f"📥 {self.display_name} 모델 로드 중...")
34
+ try:
35
+ use_local = Path(self.local_path).exists() and any(Path(self.local_path).iterdir())
36
+ model_path = self.local_path if use_local else self.model_name
37
+ logger.info(f"🔍 모델 경로: {model_path} (local={'yes' if use_local else 'no'})")
38
+
39
+ # 강제로 Hugging Face에서 다운로드 (로컬 모델 문제 해결)
40
+ # use_local = False
41
+ # model_path = self.model_name
42
+ # logger.info(f"🔍 모델 경로: {model_path} (local=no - 강제 HF 다운로드)")
43
+
44
+ tokenizer = AutoTokenizer.from_pretrained(
45
+ model_path,
46
+ token=HF_TOKEN,
47
+ use_fast=True,
48
+ trust_remote_code=True,
49
+ local_files_only=use_local,
50
+ )
51
+
52
+ # 토크나이저 설정 수정 - EOS 토큰 문제 해결
53
+ if tokenizer.eos_token is None:
54
+ logger.warning("⚠️ EOS 토큰이 없습니다. 모델 공식 문서에 따라 <|endoftext|> 설정")
55
+ tokenizer.eos_token = "<|endoftext|>"
56
+
57
+ if tokenizer.pad_token is None:
58
+ logger.warning("⚠️ PAD 토큰이 없습니다. EOS 토큰으로 설정")
59
+ tokenizer.pad_token = tokenizer.eos_token
60
+
61
+ # 특수 토큰 확인
62
+ logger.info(f"🔍 토크나이저 설정:")
63
+ logger.info(f" - EOS 토큰: {tokenizer.eos_token} (ID: {tokenizer.eos_token_id})")
64
+ logger.info(f" - PAD 토큰: {tokenizer.pad_token} (ID: {tokenizer.pad_token_id})")
65
+ # logger.info(f" - BOS 토큰: {tokenizer.bos_token} (ID: {tokenizer.bos_token_id})")
66
+
67
+ # CPU에서는 float32가 더 안정적, CUDA에서는 float16 사용
68
+ device = 'cuda' if torch.cuda.is_available() else 'cpu'
69
+ selected_dtype = torch.float16 if device == 'cuda' else torch.float32
70
+
71
+ model = AutoModelForCausalLM.from_pretrained(
72
+ model_path,
73
+ token=HF_TOKEN,
74
+ trust_remote_code=True,
75
+ torch_dtype=selected_dtype,
76
+ local_files_only=use_local,
77
+ ).to(device)
78
+
79
+ logger.info(f"✅ {self.display_name} 모델 로드 성공! (device={device}, dtype={selected_dtype})")
80
+ return model, tokenizer
81
+ except Exception as e:
82
+ logger.error(f"❌ {self.display_name} 모델 로드 실패: {e}")
83
+ raise
84
+
85
+ def format_prompt(self, user_input: str, context: str = None) -> str:
86
+ """프롬프트 포맷팅 - 시스템 프롬프트 단순화"""
87
+
88
+ # 기본 시스템 프롬프트 (단순화)
89
+ system_prompt = """당신은 친절하고 도움이 되는 AI 챗봇입니다. 사용자의 질문에 정확하고 유용한 답변을 제공하세요."""
90
+
91
+ # 시스템 프롬프트를 항상 먼저 포함
92
+ if context:
93
+ # 컨텍스트가 있을 때
94
+ if user_input in context:
95
+ # 중복 방지: 컨텍스트만 사용
96
+ prompt = f"""{system_prompt}
97
+
98
+ {context}
99
+
100
+ ### 챗봇:"""
101
+ else:
102
+ # 새로운 사용자 입력 추가
103
+ prompt = f"""{system_prompt}
104
+
105
+ {context}
106
+
107
+ ### 사용자:
108
+ {user_input}
109
+
110
+ ### 챗봇:"""
111
+ else:
112
+ # 컨텍스트가 없어도 시스템 프롬프트는 포함
113
+ prompt = f"""{system_prompt}
114
+
115
+ ### 사용자:
116
+ {user_input}
117
+
118
+ ### 챗봇:"""
119
+ return prompt
120
+
121
+ def extract_response(self, full_text: str, formatted_prompt: str = None) -> str:
122
+ """응답 추출 - 품질 검증 및 개선"""
123
+ logger.info(f"--- Polyglot 응답 추출 시작 ---")
124
+ logger.info(f"전체 생성 텍스트 (Raw): \n---\n{full_text}\n---")
125
+ logger.info(f"사용된 프롬프트: {formatted_prompt}")
126
+
127
+ # 1순위: "### 챗봇:" 태그로 ���출 시도
128
+ if "### 챗봇:" in full_text:
129
+ response = full_text.split("### 챗봇:")[-1].strip()
130
+ logger.info(f"✅ 성공: '### 챗봇:' 태그로 응답 추출")
131
+ logger.info(f"추출된 응답: {response}")
132
+
133
+ # 응답 품질 검증
134
+ if self._validate_response_quality(response):
135
+ return response
136
+ else:
137
+ logger.warning("⚠️ 응답 품질이 낮습니다. 품질 개선 제안을 추가합니다.")
138
+ return self._improve_response_quality(response)
139
+
140
+ # 2순위: 프롬프트 제거로 추출 시도
141
+ if formatted_prompt and formatted_prompt in full_text:
142
+ response = full_text.replace(formatted_prompt, "").strip()
143
+ logger.info(f"✅ 성공: 프롬프트 제거로 응답 추출")
144
+ logger.info(f"추출된 응답: {response}")
145
+
146
+ if self._validate_response_quality(response):
147
+ return response
148
+ else:
149
+ return self._improve_response_quality(response)
150
+
151
+ # 3순위: <|im_start|>assistant 태그 이후 내용 추출
152
+ if "<|im_start|>assistant" in full_text:
153
+ parts = full_text.split("<|im_start|>assistant")
154
+ if len(parts) > 1:
155
+ # 마지막 assistant 태그 이후 내용
156
+ last_assistant_part = parts[-1]
157
+ # <|im_end|> 태그 제거
158
+ if "<|im_end|>" in last_assistant_part:
159
+ response = last_assistant_part.split("<|im_end|>")[0].strip()
160
+ else:
161
+ response = last_assistant_part.strip()
162
+
163
+ logger.info(f"✅ 성공: '<|im_start|>assistant' 태그로 응답 추출")
164
+ logger.info(f"추출된 응답: {response}")
165
+
166
+ if self._validate_response_quality(response):
167
+ return response
168
+ else:
169
+ return self._improve_response_quality(response)
170
+
171
+ # 4순위: 일반적인 프롬프트 패턴 제거 시도
172
+ clean_text = full_text.strip()
173
+ patterns_to_remove = [
174
+ "(응답이 너무 짧습니다. 더 자세한 답변을 원하시면 다시 질문해주세요.)",
175
+ "### 사용자:",
176
+ "### 챗봇:",
177
+ "사용자:",
178
+ "챗봇:",
179
+ "assistant:",
180
+ "user:",
181
+ "<|im_start|>user",
182
+ "<|im_end|>",
183
+ "<|im_start|>assistant",
184
+ "<|im_start|>system"
185
+ ]
186
+
187
+ for pattern in patterns_to_remove:
188
+ clean_text = clean_text.replace(pattern, "")
189
+
190
+ clean_text = clean_text.strip()
191
+
192
+ if clean_text and clean_text != full_text:
193
+ logger.info("✅ 성공: 패턴 제거로 응답 정리")
194
+ logger.info(f"정리된 응답: {clean_text}")
195
+
196
+ if self._validate_response_quality(clean_text):
197
+ return clean_text
198
+ else:
199
+ return self._improve_response_quality(clean_text)
200
+
201
+ # 5순위: 전체 텍스트에서 불필요한 부분만 제거
202
+ final_response = full_text.strip()
203
+ logger.warning("⚠️ 경고: 특별한 응답 추출 패턴을 찾지 못했습니다. 전체 텍스트를 정리하여 반환합니다.")
204
+ logger.info(f"최종 반환 텍스트: {final_response}")
205
+
206
+ if self._validate_response_quality(final_response):
207
+ return final_response
208
+ else:
209
+ return self._improve_response_quality(final_response)
210
+
211
+ def _validate_response_quality(self, response: str) -> bool:
212
+ """응답 품질 검증"""
213
+ if not response or len(response.strip()) < 5:
214
+ return False
215
+
216
+ # 영어가 포함되어 있으면 품질 낮음
217
+ # if any(char.isascii() and char.isalpha() for char in response):
218
+ # return False
219
+
220
+ # 문장이 중간에 끊어진 경우 품질 낮음
221
+ # if response.endswith(('하', '는', '을', '를', '이', '가', '의', '에', '로')):
222
+ # return False
223
+
224
+ # 중복된 단어가 많으면 품질 낮음
225
+ # words = response.split()
226
+ # if len(words) > 3 and len(set(words)) / len(words) < 0.7:
227
+ # return False
228
+
229
+ return True
230
+
231
+ def _improve_response_quality(self, response: str) -> str:
232
+ """응답 품질 개선"""
233
+ # 기본 정리
234
+ improved = response.strip()
235
+
236
+ # 영어 제거
237
+
238
+ # improved = re.sub(r'[a-zA-Z]+', '', improved)
239
+
240
+ # 중복 공백 제거
241
+ improved = re.sub(r'\s+', ' ', improved)
242
+
243
+ # 문장이 중간에 끊어진 경우 처리
244
+ # if improved.endswith(('하', '는', '을', '를', '이', '가', '의', '에', '로')):
245
+ # improved += '니다.'
246
+
247
+ # 너무 짧은 경우 기본 응답 추가
248
+ if len(improved) < 5:
249
+ improved = f"{improved} (응답이 너무 짧습니다. 더 자세한 답변을 원하시면 다시 질문해주세요.)"
250
+
251
+ logger.info(f"🔧 응답 품질 개선 완료: {improved}")
252
+ return improved
253
+
254
+ def get_generation_config(self) -> Dict[str, Any]:
255
+ """생성 설정 - 공식 EOS 토큰 사용, 생성 파라미터 최적화"""
256
+ return {
257
+ "max_new_tokens": 128, # 256 → 128로 줄임 (컨텍스트 길이 고려)
258
+ "temperature": 0.7, # 0.9 → 0.7로 조정 (안정성 향상)
259
+ "do_sample": True, # 샘플링 활성화
260
+ "top_k": 50, # 100 → 50으로 조정 (품질과 안정성 균형)
261
+ "top_p": 0.9, # 0.95 → 0.9로 조정
262
+ "repetition_penalty": 1.1, # 1.05 → 1.1로 조정
263
+ "no_repeat_ngram_size": 3, # 2 → 3으로 조정
264
+ "pad_token_id": 2, # 공식 설정 사용
265
+ "eos_token_id": 2, # 공식 설정 사용
266
+ "use_cache": True, # 캐시 활성화 (속도 향상)
267
+ "early_stopping": False, # EOS 토큰까지 생성하도록 설정
268
+ }
269
+
270
+ def get_model_info(self) -> Dict[str, Any]:
271
+ """모델 정보"""
272
+ return {
273
+ "model_name": self.model_name,
274
+ "display_name": self.display_name,
275
+ "description": self.description,
276
+ "language": self.language,
277
+ "model_size": self.model_size,
278
+ "local_path": self.local_path,
279
+ "multimodal": False,
280
+ }
lily_llm_api/models/polyglot_ko_5_8b_chat.py CHANGED
@@ -187,9 +187,9 @@ class PolyglotKo58bChatProfile:
187
  # 핵심 생성 설정
188
  "max_new_tokens": 128, # 1024→256으로 줄여서 EOS 토큰을 빨리 만나도록
189
  # "min_new_tokens": 16,
190
- "temperature": 0.7, # 0.8→0.7로 낮춰서 일관성 향상
191
  "do_sample": True, # 샘플링 활성화
192
- "top_k": 50, # 40→50으로 다양성 증가
193
  "top_p": 0.9, # 0.95→0.9로 일관성 향상
194
 
195
  # 반복 방지 설정
@@ -199,7 +199,7 @@ class PolyglotKo58bChatProfile:
199
  # 토큰 설정 (중요!)
200
  "pad_token_id": 2, # <|endoftext|> 토큰 ID
201
  "eos_token_id": 2, # <|endoftext|> 토큰 ID (핵심!)
202
- "bos_token_id": 0, # <|startoftext|> 토큰 ID
203
 
204
  # 생성 제어 설정
205
  "use_cache": True, # 캐시 사용으로 속도 향상
 
187
  # 핵심 생성 설정
188
  "max_new_tokens": 128, # 1024→256으로 줄여서 EOS 토큰을 빨리 만나도록
189
  # "min_new_tokens": 16,
190
+ "temperature": 0.3, # 0.8→0.7로 낮춰서 일관성 향상
191
  "do_sample": True, # 샘플링 활성화
192
+ "top_k": 20, # 40→50으로 다양성 증가
193
  "top_p": 0.9, # 0.95→0.9로 일관성 향상
194
 
195
  # 반복 방지 설정
 
199
  # 토큰 설정 (중요!)
200
  "pad_token_id": 2, # <|endoftext|> 토큰 ID
201
  "eos_token_id": 2, # <|endoftext|> 토큰 ID (핵심!)
202
+ # "bos_token_id": 0, # <|startoftext|> 토큰 ID
203
 
204
  # 생성 제어 설정
205
  "use_cache": True, # 캐시 사용으로 속도 향상
lily_llm_core/context_manager.py CHANGED
@@ -26,8 +26,8 @@ class ContextManager:
26
  """대화 컨텍스트를 관리하는 클래스"""
27
 
28
  def __init__(self,
29
- max_tokens: int = 4000,
30
- max_turns: int = 20,
31
  strategy: str = "sliding_window"):
32
  """
33
  Args:
@@ -39,8 +39,12 @@ class ContextManager:
39
  self.max_turns = max_turns
40
  self.strategy = strategy
41
 
42
- # 대화 히스토리 (deque 사용으로 효율적인 양방향 접근)
43
- self.conversation_history: deque = deque(maxlen=max_turns * 2)
 
 
 
 
44
 
45
  # 시스템 프롬프트
46
  self.system_prompt = ""
@@ -53,18 +57,57 @@ class ContextManager:
53
  self.enable_memory_optimization = True
54
  self.compression_threshold = 0.8 # 80% 도달 시 압축 시작
55
 
56
- logger.info(f"🔧 컨텍스트 관리자 초기화: max_tokens={max_tokens}, strategy={strategy}")
 
 
 
 
 
 
 
 
57
 
58
  def set_system_prompt(self, prompt: str):
59
  """시스템 프롬프트 설정"""
60
  self.system_prompt = prompt
61
  logger.info(f"📝 시스템 프롬프트 설정: {len(prompt)} 문자")
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def add_user_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
64
  """사용자 메시지 추가"""
65
  if not message_id:
66
  message_id = f"user_{int(time.time() * 1000)}"
67
 
 
 
 
 
 
 
 
 
 
68
  turn = ConversationTurn(
69
  role="user",
70
  content=content,
@@ -73,11 +116,14 @@ class ContextManager:
73
  metadata=metadata or {}
74
  )
75
 
76
- self.conversation_history.append(turn)
77
- self._update_context_stats()
78
- self._optimize_context()
 
 
 
79
 
80
- logger.info(f"👤 사용자 메시지 추가: {len(content)} 문자 (총 {len(self.conversation_history)} 턴)")
81
  return message_id
82
 
83
  def add_assistant_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
@@ -85,6 +131,15 @@ class ContextManager:
85
  if not message_id:
86
  message_id = f"assistant_{int(time.time() * 1000)}"
87
 
 
 
 
 
 
 
 
 
 
88
  turn = ConversationTurn(
89
  role="assistant",
90
  content=content,
@@ -93,23 +148,32 @@ class ContextManager:
93
  metadata=metadata or {}
94
  )
95
 
96
- self.conversation_history.append(turn)
97
- self._update_context_stats()
98
- self._optimize_context()
 
 
 
99
 
100
- logger.info(f"🤖 어시스턴트 메시지 추가: {len(content)} 문자 (총 {len(self.conversation_history)} 턴)")
101
  return message_id
102
 
103
- def get_context(self, include_system: bool = True, max_length: Optional[int] = None) -> str:
104
- """현재 컨텍스트를 문자열로 반환"""
105
  context_parts = []
106
 
 
 
 
 
 
 
107
  # 시스템 프롬프트 포함
108
  if include_system and self.system_prompt:
109
  context_parts.append(f"<|im_start|>system\n{self.system_prompt}<|im_end|>")
110
 
111
  # 대화 히스토리 포함
112
- for turn in self.conversation_history:
113
  if turn.role == "user":
114
  context_parts.append(f"<|im_start|>user\n{turn.content}<|im_end|>")
115
  elif turn.role == "assistant":
@@ -126,25 +190,34 @@ class ContextManager:
126
 
127
  return context
128
 
129
- def get_context_for_model(self, model_name: str = "default") -> str:
130
- """모델별 최적화된 컨텍스트 반환"""
131
  # 모델별 특별한 처리 (필요시 확장)
132
  if "kanana" in model_name.lower():
133
- return self.get_context(include_system=True)
134
  elif "llama" in model_name.lower():
135
  # Llama 형식
136
- return self._format_for_llama()
 
 
 
137
  else:
138
- return self.get_context(include_system=True)
139
 
140
- def _format_for_llama(self) -> str:
141
- """Llama 모델용 형식으로 변환"""
142
  context_parts = []
143
 
 
 
 
 
 
 
144
  if self.system_prompt:
145
  context_parts.append(f"[INST] {self.system_prompt} [/INST]")
146
 
147
- for turn in self.conversation_history:
148
  if turn.role == "user":
149
  context_parts.append(f"[INST] {turn.content} [/INST]")
150
  elif turn.role == "assistant":
@@ -152,9 +225,36 @@ class ContextManager:
152
 
153
  return "\n".join(context_parts)
154
 
155
- def get_recent_context(self, turns: int = 5) -> str:
156
- """최근 N개 턴의 컨텍스트만 반환"""
157
- recent_turns = list(self.conversation_history)[-turns:]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  context_parts = []
159
 
160
  for turn in recent_turns:
@@ -166,53 +266,84 @@ class ContextManager:
166
  context_parts.append("<|im_start|>assistant\n")
167
  return "\n".join(context_parts)
168
 
169
- def get_context_summary(self) -> Dict[str, Any]:
170
- """컨텍스트 요약 정보 반환"""
 
 
 
 
 
 
171
  return {
172
- "total_turns": len(self.conversation_history),
173
- "user_messages": len([t for t in self.conversation_history if t.role == "user"]),
174
- "assistant_messages": len([t for t in self.conversation_history if t.role == "assistant"]),
 
175
  "estimated_tokens": self.total_tokens,
176
  "context_length": self.current_context_length,
177
- "memory_usage": len(self.conversation_history) / self.max_turns,
178
- "oldest_message": self.conversation_history[0].timestamp if self.conversation_history else None,
179
- "newest_message": self.conversation_history[-1].timestamp if self.conversation_history else None
180
  }
181
 
182
- def clear_context(self):
183
- """컨텍스트 초기화"""
184
- self.conversation_history.clear()
 
 
 
 
185
  self.total_tokens = 0
186
  self.current_context_length = 0
187
- logger.info("🗑️ 컨텍스트 초기화 완료")
188
 
189
- def remove_message(self, message_id: str) -> bool:
190
- """특정 메시지 제거"""
191
- for i, turn in enumerate(self.conversation_history):
 
 
 
 
 
 
 
 
 
 
 
 
192
  if turn.message_id == message_id:
193
- removed_turn = self.conversation_history.pop(i)
194
- self._update_context_stats()
195
- logger.info(f"🗑️ 메시지 제거: {message_id}")
196
  return True
197
  return False
198
 
199
- def edit_message(self, message_id: str, new_content: str) -> bool:
200
- """메시지 내용 수정"""
201
- for turn in self.conversation_history:
 
 
 
 
202
  if turn.message_id == message_id:
203
  turn.content = new_content
204
  turn.timestamp = time.time()
205
- self._update_context_stats()
206
- logger.info(f"✏️ 메시지 수정: {message_id}")
207
  return True
208
  return False
209
 
210
- def search_context(self, query: str, max_results: int = 5) -> List[Dict[str, Any]]:
211
- """컨텍스트 내에서 검색"""
 
 
 
 
212
  results = []
213
  query_lower = query.lower()
214
 
215
- for turn in self.conversation_history:
216
  if query_lower in turn.content.lower():
217
  results.append({
218
  "message_id": turn.message_id,
@@ -237,49 +368,62 @@ class ContextManager:
237
  intersection = query_words.intersection(content_words)
238
  return len(intersection) / len(query_words)
239
 
240
- def _update_context_stats(self):
241
- """컨텍스트 통계 업데이트"""
242
- self.current_context_length = len(self.get_context())
 
 
 
243
  # 간단한 토큰 추정 (실제 토크나이저 사용 권장)
244
  self.total_tokens = self.current_context_length // 4
245
 
246
- def _optimize_context(self):
247
- """컨텍스트 최적화"""
248
  if not self.enable_memory_optimization:
249
  return
250
 
 
 
 
 
 
251
  # 메모리 사용량이 임계값을 초과하면 압축 시작
252
- if len(self.conversation_history) / self.max_turns > self.compression_threshold:
253
- self._compress_context()
254
 
255
- def _compress_context(self):
256
- """컨텍스트 압축 (중요한 메시지 유지)"""
257
- if len(self.conversation_history) <= self.max_turns:
 
 
 
 
 
258
  return
259
 
260
- logger.info(f"🗜️ 컨텍스트 압축 시작: {len(self.conversation_history)} → {self.max_turns}")
261
 
262
  # 전략에 따른 압축
263
  if self.strategy == "sliding_window":
264
  # 슬라이딩 윈도우: 최근 메시지 우선
265
- while len(self.conversation_history) > self.max_turns:
266
- self.conversation_history.popleft()
267
 
268
  elif self.strategy == "priority_keep":
269
  # 우선순위 기반: 시스템 프롬프트와 최근 메시지 우선
270
  # 첫 번째와 마지막 메시지는 유지
271
- if len(self.conversation_history) > self.max_turns:
272
  # 중간 메시지들 중 일부 제거
273
  middle_start = self.max_turns // 2
274
- middle_end = len(self.conversation_history) - self.max_turns // 2
275
 
276
  # 중간 부분을 요약으로 대체
277
- removed_turns = list(self.conversation_history)[middle_start:middle_end]
278
  summary_content = f"[이전 {len(removed_turns)}개 메시지 요약: {len(removed_turns)}개 대화 턴]"
279
 
280
  # 중간 부분 제거
281
  for _ in range(middle_end - middle_start):
282
- self.conversation_history.pop(middle_start)
283
 
284
  # 요약 메시지 추가
285
  summary_turn = ConversationTurn(
@@ -288,15 +432,15 @@ class ContextManager:
288
  timestamp=time.time(),
289
  message_id=f"summary_{int(time.time() * 1000)}"
290
  )
291
- self.conversation_history.insert(middle_start, summary_turn)
292
 
293
  elif self.strategy == "circular":
294
  # 순환 버퍼: 가장 오래된 메시지 제거
295
- while len(self.conversation_history) > self.max_turns:
296
- self.conversation_history.popleft()
297
 
298
- self._update_context_stats()
299
- logger.info(f"✅ 컨텍스트 압축 완료: {len(self.conversation_history)} 턴")
300
 
301
  def _truncate_context(self, context: str, max_length: int) -> str:
302
  """컨텍스트 길이 제한"""
@@ -315,13 +459,20 @@ class ContextManager:
315
 
316
  return truncated_context
317
 
318
- def export_context(self, file_path: str = None) -> str:
319
- """컨텍스트를 파일로 내보내기"""
320
  if not file_path:
321
- file_path = f"context_export_{int(time.time())}.json"
 
 
 
 
 
 
322
 
323
  export_data = {
324
  "export_timestamp": time.time(),
 
325
  "system_prompt": self.system_prompt,
326
  "conversation_history": [
327
  {
@@ -331,15 +482,15 @@ class ContextManager:
331
  "message_id": turn.message_id,
332
  "metadata": turn.metadata
333
  }
334
- for turn in self.conversation_history
335
  ],
336
- "context_stats": self.get_context_summary()
337
  }
338
 
339
  with open(file_path, 'w', encoding='utf-8') as f:
340
  json.dump(export_data, f, ensure_ascii=False, indent=2)
341
 
342
- logger.info(f"💾 컨텍스트 내보내기 완료: {file_path}")
343
  return file_path
344
 
345
  def import_context(self, file_path: str) -> bool:
@@ -375,22 +526,33 @@ class ContextManager:
375
  logger.error(f"❌ 컨텍스트 가져오기 실패: {e}")
376
  return False
377
 
378
- def get_memory_efficiency(self) -> Dict[str, float]:
379
- """메모리 효율성 지표 반환"""
 
 
 
 
 
380
  return {
381
- "context_utilization": len(self.conversation_history) / self.max_turns,
 
382
  "token_efficiency": self.total_tokens / self.max_tokens if self.max_tokens > 0 else 0,
383
- "compression_ratio": 1.0 - (len(self.conversation_history) / (self.max_turns * 2)),
384
- "memory_fragmentation": self._calculate_fragmentation()
385
  }
386
 
387
- def _calculate_fragmentation(self) -> float:
388
- """메모리 단편화 정도 계산"""
389
- if len(self.conversation_history) <= 1:
 
 
 
 
 
390
  return 0.0
391
 
392
  # ���속된 메시지 간의 시간 간격으로 단편화 계산
393
- timestamps = [turn.timestamp for turn in self.conversation_history]
394
  intervals = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)]
395
 
396
  if not intervals:
@@ -401,6 +563,136 @@ class ContextManager:
401
 
402
  # 정규화된 단편화 점수 (0-1)
403
  return min(1.0, variance / (avg_interval ** 2) if avg_interval > 0 else 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  # 전역 컨텍스트 관리자 인스턴스
406
  context_manager = ContextManager()
 
26
  """대화 컨텍스트를 관리하는 클래스"""
27
 
28
  def __init__(self,
29
+ max_tokens: int = 2000, # 4000 → 2000으로 줄임
30
+ max_turns: int = 20, # 20 → 10으로 줄임
31
  strategy: str = "sliding_window"):
32
  """
33
  Args:
 
39
  self.max_turns = max_turns
40
  self.strategy = strategy
41
 
42
+ # 세션별 대화 히스토리 (세션 ID로 분리)
43
+ self.session_conversations: Dict[str, deque] = {}
44
+ self.default_session = "default"
45
+
46
+ # 기본 세션 초기화
47
+ self.session_conversations[self.default_session] = deque(maxlen=max_turns * 2)
48
 
49
  # 시스템 프롬프트
50
  self.system_prompt = ""
 
57
  self.enable_memory_optimization = True
58
  self.compression_threshold = 0.8 # 80% 도달 시 압축 시작
59
 
60
+ # 🔄 자동 정리 주기 설정
61
+ self.auto_cleanup_enabled = True
62
+ self.cleanup_interval_turns = 5 # 8 → 5턴마다 정리
63
+ self.cleanup_interval_time = 180 # 5분 → 3분마다 정리
64
+ self.cleanup_strategy = "aggressive" # smart → aggressive로 변경
65
+ self.last_cleanup_time = {} # 세션별 마지막 정리 시간
66
+ self.turn_counters = {} # 세션별 턴 카운터
67
+
68
+ logger.info(f"🔧 컨텍스트 관리자 초기화: max_tokens={max_tokens}, strategy={strategy}, auto_cleanup={self.auto_cleanup_enabled}")
69
 
70
  def set_system_prompt(self, prompt: str):
71
  """시스템 프롬프트 설정"""
72
  self.system_prompt = prompt
73
  logger.info(f"📝 시스템 프롬프트 설정: {len(prompt)} 문자")
74
 
75
+ def set_auto_cleanup_config(self,
76
+ enabled: bool = True,
77
+ interval_turns: int = 8,
78
+ interval_time: int = 300,
79
+ strategy: str = "smart"):
80
+ """자동 정리 설정 구성"""
81
+ self.auto_cleanup_enabled = enabled
82
+ self.cleanup_interval_turns = max(1, interval_turns)
83
+ self.cleanup_interval_time = max(60, interval_time)
84
+ self.cleanup_strategy = strategy
85
+
86
+ logger.info(f"🔄 자동 정리 설정: enabled={enabled}, turns={interval_turns}, time={interval_time}s, strategy={strategy}")
87
+
88
+ def get_auto_cleanup_config(self) -> Dict[str, Any]:
89
+ """자동 정리 설정 반환"""
90
+ return {
91
+ "enabled": self.auto_cleanup_enabled,
92
+ "interval_turns": self.cleanup_interval_turns,
93
+ "interval_time": self.cleanup_interval_time,
94
+ "strategy": self.cleanup_strategy
95
+ }
96
+
97
  def add_user_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
98
  """사용자 메시지 추가"""
99
  if not message_id:
100
  message_id = f"user_{int(time.time() * 1000)}"
101
 
102
+ # 세션 ID 추출 (metadata에서)
103
+ session_id = "default"
104
+ if metadata and "session_id" in metadata:
105
+ session_id = metadata["session_id"]
106
+
107
+ # 세션이 없으면 생성
108
+ if session_id not in self.session_conversations:
109
+ self.session_conversations[session_id] = deque(maxlen=self.max_turns * 2)
110
+
111
  turn = ConversationTurn(
112
  role="user",
113
  content=content,
 
116
  metadata=metadata or {}
117
  )
118
 
119
+ self.session_conversations[session_id].append(turn)
120
+ self._update_context_stats(session_id)
121
+ self._optimize_context(session_id)
122
+
123
+ # 🔄 자동 정리 체크
124
+ self._check_auto_cleanup(session_id)
125
 
126
+ logger.info(f"👤 사용자 메시지 추가: {len(content)} 문자 (세션: {session_id}, 총 {len(self.session_conversations[session_id])} 턴)")
127
  return message_id
128
 
129
  def add_assistant_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
 
131
  if not message_id:
132
  message_id = f"assistant_{int(time.time() * 1000)}"
133
 
134
+ # 세션 ID 추출 (metadata에서)
135
+ session_id = "default"
136
+ if metadata and "session_id" in metadata:
137
+ session_id = metadata["session_id"]
138
+
139
+ # 세션이 없으면 생성
140
+ if session_id not in self.session_conversations:
141
+ self.session_conversations[session_id] = deque(maxlen=self.max_turns * 2)
142
+
143
  turn = ConversationTurn(
144
  role="assistant",
145
  content=content,
 
148
  metadata=metadata or {}
149
  )
150
 
151
+ self.session_conversations[session_id].append(turn)
152
+ self._update_context_stats(session_id)
153
+ self._optimize_context(session_id)
154
+
155
+ # 🔄 자동 정리 체크
156
+ self._check_auto_cleanup(session_id)
157
 
158
+ logger.info(f"🤖 어시스턴트 메시지 추가: {len(content)} 문자 (세션: {session_id}, 총 {len(self.session_conversations[session_id])} 턴)")
159
  return message_id
160
 
161
+ def get_context(self, include_system: bool = True, max_length: Optional[int] = None, session_id: str = "default") -> str:
162
+ """현재 컨텍스트를 문자열로 반환 (세션별)"""
163
  context_parts = []
164
 
165
+ # 세션이 없으면 기본 세션 사용
166
+ if session_id not in self.session_conversations:
167
+ session_id = "default"
168
+
169
+ conversation_history = self.session_conversations[session_id]
170
+
171
  # 시스템 프롬프트 포함
172
  if include_system and self.system_prompt:
173
  context_parts.append(f"<|im_start|>system\n{self.system_prompt}<|im_end|>")
174
 
175
  # 대화 히스토리 포함
176
+ for turn in conversation_history:
177
  if turn.role == "user":
178
  context_parts.append(f"<|im_start|>user\n{turn.content}<|im_end|>")
179
  elif turn.role == "assistant":
 
190
 
191
  return context
192
 
193
+ def get_context_for_model(self, model_name: str = "default", session_id: str = "default") -> str:
194
+ """모델별 최적화된 컨텍스트 반환 (세션별)"""
195
  # 모델별 특별한 처리 (필요시 확장)
196
  if "kanana" in model_name.lower():
197
+ return self.get_context(include_system=True, session_id=session_id)
198
  elif "llama" in model_name.lower():
199
  # Llama 형식
200
+ return self._format_for_llama(session_id)
201
+ elif "polyglot" in model_name.lower():
202
+ # Polyglot 형식 - <|im_start|> 태그 사용하지 않음
203
+ return self._format_for_polyglot(session_id)
204
  else:
205
+ return self.get_context(include_system=True, session_id=session_id)
206
 
207
+ def _format_for_llama(self, session_id: str = "default") -> str:
208
+ """Llama 모델용 형식으로 변환 (세션별)"""
209
  context_parts = []
210
 
211
+ # 세션이 없으면 기본 세션 사용
212
+ if session_id not in self.session_conversations:
213
+ session_id = "default"
214
+
215
+ conversation_history = self.session_conversations[session_id]
216
+
217
  if self.system_prompt:
218
  context_parts.append(f"[INST] {self.system_prompt} [/INST]")
219
 
220
+ for turn in conversation_history:
221
  if turn.role == "user":
222
  context_parts.append(f"[INST] {turn.content} [/INST]")
223
  elif turn.role == "assistant":
 
225
 
226
  return "\n".join(context_parts)
227
 
228
+ def _format_for_polyglot(self, session_id: str = "default") -> str:
229
+ """Polyglot 모델용 형식으로 변환 (세션별) - 공식 형식 사용"""
230
+ context_parts = []
231
+
232
+ # 세션이 없으면 기본 세션 사용
233
+ if session_id not in self.session_conversations:
234
+ session_id = "default"
235
+
236
+ conversation_history = self.session_conversations[session_id]
237
+
238
+ # 대화 히스토리만 포함 (공식 형식 사용)
239
+ for turn in conversation_history:
240
+ if turn.role == "user":
241
+ context_parts.append(f"### 사용자:\n{turn.content}")
242
+ elif turn.role == "assistant":
243
+ context_parts.append(f"### 챗봇:\n{turn.content}")
244
+
245
+ if context_parts:
246
+ return "\n\n".join(context_parts)
247
+ else:
248
+ return ""
249
+
250
+ def get_recent_context(self, turns: int = 5, session_id: str = "default") -> str:
251
+ """최근 N개 턴의 컨텍스트만 반환 (세션별)"""
252
+ # 세션이 없으면 기본 세션 사용
253
+ if session_id not in self.session_conversations:
254
+ session_id = "default"
255
+
256
+ conversation_history = self.session_conversations[session_id]
257
+ recent_turns = list(conversation_history)[-turns:]
258
  context_parts = []
259
 
260
  for turn in recent_turns:
 
266
  context_parts.append("<|im_start|>assistant\n")
267
  return "\n".join(context_parts)
268
 
269
+ def get_context_summary(self, session_id: str = "default") -> Dict[str, Any]:
270
+ """컨텍스트 요약 정보 반환 (세션별)"""
271
+ # 세션이 없으면 기본 세션 사용
272
+ if session_id not in self.session_conversations:
273
+ session_id = "default"
274
+
275
+ conversation_history = self.session_conversations[session_id]
276
+
277
  return {
278
+ "session_id": session_id,
279
+ "total_turns": len(conversation_history),
280
+ "user_messages": len([t for t in conversation_history if t.role == "user"]),
281
+ "assistant_messages": len([t for t in conversation_history if t.role == "assistant"]),
282
  "estimated_tokens": self.total_tokens,
283
  "context_length": self.current_context_length,
284
+ "memory_usage": len(conversation_history) / self.max_turns,
285
+ "oldest_message": conversation_history[0].timestamp if conversation_history else None,
286
+ "newest_message": conversation_history[-1].timestamp if conversation_history else None
287
  }
288
 
289
+ def clear_context(self, session_id: str = "default"):
290
+ """컨텍스트 초기화 (세션별)"""
291
+ if session_id not in self.session_conversations:
292
+ logger.warning(f"⚠️ 세션 {session_id}가 존재하지 않습니다.")
293
+ return
294
+
295
+ self.session_conversations[session_id].clear()
296
  self.total_tokens = 0
297
  self.current_context_length = 0
298
+ logger.info(f"🗑️ 세션 {session_id} 컨텍스트 초기화 완료")
299
 
300
+ def clear_all_sessions(self):
301
+ """모든 세션 컨텍스트 초기화"""
302
+ for session_id in list(self.session_conversations.keys()):
303
+ self.session_conversations[session_id].clear()
304
+ self.total_tokens = 0
305
+ self.current_context_length = 0
306
+ logger.info("🗑️ 모든 세션 컨텍스트 초기화 완료")
307
+
308
+ def remove_message(self, message_id: str, session_id: str = "default") -> bool:
309
+ """특정 메시지 제거 (세션별)"""
310
+ if session_id not in self.session_conversations:
311
+ return False
312
+
313
+ conversation_history = self.session_conversations[session_id]
314
+ for i, turn in enumerate(conversation_history):
315
  if turn.message_id == message_id:
316
+ removed_turn = conversation_history.pop(i)
317
+ self._update_context_stats(session_id)
318
+ logger.info(f"🗑️ 메시지 제거: {message_id} (세션: {session_id})")
319
  return True
320
  return False
321
 
322
+ def edit_message(self, message_id: str, new_content: str, session_id: str = "default") -> bool:
323
+ """메시지 내용 수정 (세션���)"""
324
+ if session_id not in self.session_conversations:
325
+ return False
326
+
327
+ conversation_history = self.session_conversations[session_id]
328
+ for turn in conversation_history:
329
  if turn.message_id == message_id:
330
  turn.content = new_content
331
  turn.timestamp = time.time()
332
+ self._update_context_stats(session_id)
333
+ logger.info(f"✏️ 메시지 수정: {message_id} (세션: {session_id})")
334
  return True
335
  return False
336
 
337
+ def search_context(self, query: str, max_results: int = 5, session_id: str = "default") -> List[Dict[str, Any]]:
338
+ """컨텍스트 내에서 검색 (세션별)"""
339
+ if session_id not in self.session_conversations:
340
+ return []
341
+
342
+ conversation_history = self.session_conversations[session_id]
343
  results = []
344
  query_lower = query.lower()
345
 
346
+ for turn in conversation_history:
347
  if query_lower in turn.content.lower():
348
  results.append({
349
  "message_id": turn.message_id,
 
368
  intersection = query_words.intersection(content_words)
369
  return len(intersection) / len(query_words)
370
 
371
+ def _update_context_stats(self, session_id: str = "default"):
372
+ """컨텍스트 통계 업데이트 (세션별)"""
373
+ if session_id not in self.session_conversations:
374
+ return
375
+
376
+ self.current_context_length = len(self.get_context(session_id=session_id))
377
  # 간단한 토큰 추정 (실제 토크나이저 사용 권장)
378
  self.total_tokens = self.current_context_length // 4
379
 
380
+ def _optimize_context(self, session_id: str = "default"):
381
+ """컨텍스트 최적화 (세션별)"""
382
  if not self.enable_memory_optimization:
383
  return
384
 
385
+ if session_id not in self.session_conversations:
386
+ return
387
+
388
+ conversation_history = self.session_conversations[session_id]
389
+
390
  # 메모리 사용량이 임계값을 초과하면 압축 시작
391
+ if len(conversation_history) / self.max_turns > self.compression_threshold:
392
+ self._compress_context(session_id)
393
 
394
+ def _compress_context(self, session_id: str = "default"):
395
+ """컨텍스트 압축 (중요한 메시지 유지, 세션별)"""
396
+ if session_id not in self.session_conversations:
397
+ return
398
+
399
+ conversation_history = self.session_conversations[session_id]
400
+
401
+ if len(conversation_history) <= self.max_turns:
402
  return
403
 
404
+ logger.info(f"🗜️ 세션 {session_id} 컨텍스트 압축 시작: {len(conversation_history)} → {self.max_turns}")
405
 
406
  # 전략에 따른 압축
407
  if self.strategy == "sliding_window":
408
  # 슬라이딩 윈도우: 최근 메시지 우선
409
+ while len(conversation_history) > self.max_turns:
410
+ conversation_history.popleft()
411
 
412
  elif self.strategy == "priority_keep":
413
  # 우선순위 기반: 시스템 프롬프트와 최근 메시지 우선
414
  # 첫 번째와 마지막 메시지는 유지
415
+ if len(conversation_history) > self.max_turns:
416
  # 중간 메시지들 중 일부 제거
417
  middle_start = self.max_turns // 2
418
+ middle_end = len(conversation_history) - self.max_turns // 2
419
 
420
  # 중간 부분을 요약으로 대체
421
+ removed_turns = list(conversation_history)[middle_start:middle_end]
422
  summary_content = f"[이전 {len(removed_turns)}개 메시지 요약: {len(removed_turns)}개 대화 턴]"
423
 
424
  # 중간 부분 제거
425
  for _ in range(middle_end - middle_start):
426
+ conversation_history.pop(middle_start)
427
 
428
  # 요약 메시지 추가
429
  summary_turn = ConversationTurn(
 
432
  timestamp=time.time(),
433
  message_id=f"summary_{int(time.time() * 1000)}"
434
  )
435
+ conversation_history.insert(middle_start, summary_turn)
436
 
437
  elif self.strategy == "circular":
438
  # 순환 버퍼: 가장 오래된 메시지 제거
439
+ while len(conversation_history) > self.max_turns:
440
+ conversation_history.popleft()
441
 
442
+ self._update_context_stats(session_id)
443
+ logger.info(f"✅ 세션 {session_id} 컨텍스트 압축 완료: {len(conversation_history)} 턴")
444
 
445
  def _truncate_context(self, context: str, max_length: int) -> str:
446
  """컨텍스트 길이 제한"""
 
459
 
460
  return truncated_context
461
 
462
+ def export_context(self, file_path: str = None, session_id: str = "default") -> str:
463
+ """컨텍스트를 파일로 내보내기 (세션별)"""
464
  if not file_path:
465
+ file_path = f"context_export_{session_id}_{int(time.time())}.json"
466
+
467
+ if session_id not in self.session_conversations:
468
+ logger.warning(f"⚠️ 세션 {session_id}가 존재하지 않습니다.")
469
+ return None
470
+
471
+ conversation_history = self.session_conversations[session_id]
472
 
473
  export_data = {
474
  "export_timestamp": time.time(),
475
+ "session_id": session_id,
476
  "system_prompt": self.system_prompt,
477
  "conversation_history": [
478
  {
 
482
  "message_id": turn.message_id,
483
  "metadata": turn.metadata
484
  }
485
+ for turn in conversation_history
486
  ],
487
+ "context_stats": self.get_context_summary(session_id)
488
  }
489
 
490
  with open(file_path, 'w', encoding='utf-8') as f:
491
  json.dump(export_data, f, ensure_ascii=False, indent=2)
492
 
493
+ logger.info(f"💾 세션 {session_id} 컨텍스트 내보내기 완료: {file_path}")
494
  return file_path
495
 
496
  def import_context(self, file_path: str) -> bool:
 
526
  logger.error(f"❌ 컨텍스트 가져오기 실패: {e}")
527
  return False
528
 
529
+ def get_memory_efficiency(self, session_id: str = "default") -> Dict[str, float]:
530
+ """메모리 효율성 지표 반환 (세션별)"""
531
+ if session_id not in self.session_conversations:
532
+ return {}
533
+
534
+ conversation_history = self.session_conversations[session_id]
535
+
536
  return {
537
+ "session_id": session_id,
538
+ "context_utilization": len(conversation_history) / self.max_turns,
539
  "token_efficiency": self.total_tokens / self.max_tokens if self.max_tokens > 0 else 0,
540
+ "compression_ratio": 1.0 - (len(conversation_history) / (self.max_turns * 2)),
541
+ "memory_fragmentation": self._calculate_fragmentation(session_id)
542
  }
543
 
544
+ def _calculate_fragmentation(self, session_id: str = "default") -> float:
545
+ """메모리 단편화 정도 계산 (세션별)"""
546
+ if session_id not in self.session_conversations:
547
+ return 0.0
548
+
549
+ conversation_history = self.session_conversations[session_id]
550
+
551
+ if len(conversation_history) <= 1:
552
  return 0.0
553
 
554
  # ���속된 메시지 간의 시간 간격으로 단편화 계산
555
+ timestamps = [turn.timestamp for turn in conversation_history]
556
  intervals = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)]
557
 
558
  if not intervals:
 
563
 
564
  # 정규화된 단편화 점수 (0-1)
565
  return min(1.0, variance / (avg_interval ** 2) if avg_interval > 0 else 0.0)
566
+
567
+ def _check_auto_cleanup(self, session_id: str = "default"):
568
+ """자동 정리 조건 체크 및 실행"""
569
+ if not self.auto_cleanup_enabled:
570
+ return
571
+
572
+ current_time = time.time()
573
+
574
+ # 세션별 카운터 초기화
575
+ if session_id not in self.turn_counters:
576
+ self.turn_counters[session_id] = 0
577
+ if session_id not in self.last_cleanup_time:
578
+ self.last_cleanup_time[session_id] = current_time
579
+
580
+ # 턴 카운터 증가
581
+ self.turn_counters[session_id] += 1
582
+
583
+ # 정리 조건 체크
584
+ should_cleanup = False
585
+ cleanup_reason = ""
586
+
587
+ # 턴 기반 정리
588
+ if self.turn_counters[session_id] >= self.cleanup_interval_turns:
589
+ should_cleanup = True
590
+ cleanup_reason = f"턴 기반 ({self.turn_counters[session_id]} 턴)"
591
+
592
+ # 시간 기반 정리
593
+ elif current_time - self.last_cleanup_time[session_id] >= self.cleanup_interval_time:
594
+ should_cleanup = True
595
+ cleanup_reason = f"시간 기반 ({int(current_time - self.last_cleanup_time[session_id])}초)"
596
+
597
+ # 컨텍스트 길이 기반 정리 (강화)
598
+ elif len(self.session_conversations.get(session_id, [])) > self.max_turns:
599
+ should_cleanup = True
600
+ cleanup_reason = f"길이 기반 ({len(self.session_conversations.get(session_id, []))} > {self.max_turns})"
601
+
602
+ # 자동 정리 실행
603
+ if should_cleanup:
604
+ logger.info(f"🔄 세션 {session_id} 자동 정리 시작: {cleanup_reason}")
605
+ self._execute_auto_cleanup(session_id)
606
+
607
+ # 카운터 및 시간 리셋
608
+ self.turn_counters[session_id] = 0
609
+ self.last_cleanup_time[session_id] = current_time
610
+
611
+ def _execute_auto_cleanup(self, session_id: str = "default"):
612
+ """자동 정리 실행"""
613
+ if session_id not in self.session_conversations:
614
+ return
615
+
616
+ conversation_history = self.session_conversations[session_id]
617
+ original_length = len(conversation_history)
618
+
619
+ if original_length <= self.max_turns:
620
+ return
621
+
622
+ # 전략별 정리 실행
623
+ if self.cleanup_strategy == "smart":
624
+ self._smart_cleanup(session_id)
625
+ elif self.cleanup_strategy == "aggressive":
626
+ self._aggressive_cleanup(session_id)
627
+ elif self.cleanup_strategy == "conservative":
628
+ self._conservative_cleanup(session_id)
629
+
630
+ final_length = len(conversation_history)
631
+ removed_count = original_length - final_length
632
+
633
+ if removed_count > 0:
634
+ logger.info(f"✅ 세션 {session_id} 자동 정리 완료: {original_length} → {final_length} 턴 (제거: {removed_count})")
635
+
636
+ def _smart_cleanup(self, session_id: str = "default"):
637
+ """스마트 정리: 중요 메시지 유지, 중간 메시지 요약"""
638
+ if session_id not in self.session_conversations:
639
+ return
640
+
641
+ conversation_history = self.session_conversations[session_id]
642
+
643
+ if len(conversation_history) <= self.max_turns:
644
+ return
645
+
646
+ # 중요 메시지 수 계산 (시스템 + 최근)
647
+ important_count = min(3, self.max_turns // 3)
648
+ recent_count = min(5, self.max_turns // 2)
649
+
650
+ # 중간 메시지들 제거
651
+ middle_start = important_count
652
+ middle_end = len(conversation_history) - recent_count
653
+
654
+ if middle_end > middle_start:
655
+ removed_turns = list(conversation_history)[middle_start:middle_end]
656
+
657
+ # 요약 메시지 생성
658
+ summary_content = f"[이전 {len(removed_turns)}개 메시지 요약: {len(removed_turns)}개 대화 턴]"
659
+
660
+ # 중간 부분 제거
661
+ for _ in range(middle_end - middle_start):
662
+ conversation_history.pop(middle_start)
663
+
664
+ # 요약 메시지 추가
665
+ summary_turn = ConversationTurn(
666
+ role="system",
667
+ content=summary_content,
668
+ timestamp=time.time(),
669
+ message_id=f"summary_{int(time.time() * 1000)}"
670
+ )
671
+ conversation_history.insert(middle_start, summary_turn)
672
+
673
+ def _aggressive_cleanup(self, session_id: str = "default"):
674
+ """적극적 정리: 최근 메���지만 유지"""
675
+ if session_id not in self.session_conversations:
676
+ return
677
+
678
+ conversation_history = self.session_conversations[session_id]
679
+
680
+ # 최근 max_turns 개만 유지
681
+ while len(conversation_history) > self.max_turns:
682
+ conversation_history.popleft()
683
+
684
+ def _conservative_cleanup(self, session_id: str = "default"):
685
+ """보수적 정리: 점진적으로 정리"""
686
+ if session_id not in self.session_conversations:
687
+ return
688
+
689
+ conversation_history = self.session_conversations[session_id]
690
+
691
+ # 20%씩 점진적으로 제거
692
+ target_length = int(len(conversation_history) * 0.8)
693
+ if target_length > self.max_turns:
694
+ while len(conversation_history) > target_length:
695
+ conversation_history.popleft()
696
 
697
  # 전역 컨텍스트 관리자 인스턴스
698
  context_manager = ContextManager()
lily_llm_core/context_manager_250822_0312.py ADDED
@@ -0,0 +1,702 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 컨텍스트 관리자 (Context Manager)
4
+ 대화 히스토리와 단기 기억을 관리하는 시스템
5
+ """
6
+
7
+ import logging
8
+ import time
9
+ from typing import List, Dict, Any, Optional, Tuple
10
+ from dataclasses import dataclass
11
+ from collections import deque
12
+ import json
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ @dataclass
17
+ class ConversationTurn:
18
+ """대화 턴을 나타내는 데이터 클래스"""
19
+ role: str # 'user' 또는 'assistant'
20
+ content: str
21
+ timestamp: float
22
+ message_id: str
23
+ metadata: Optional[Dict[str, Any]] = None
24
+
25
+ class ContextManager:
26
+ """대화 컨텍스트를 관리하는 클래스"""
27
+
28
+ def __init__(self,
29
+ max_tokens: int = 2000, # 4000 → 2000으로 줄임
30
+ max_turns: int = 20, # 20 → 10으로 줄임
31
+ strategy: str = "sliding_window"):
32
+ """
33
+ Args:
34
+ max_tokens: 최대 토큰 수
35
+ max_turns: 최대 대화 턴 수
36
+ strategy: 컨텍스트 관리 전략 ('sliding_window', 'priority_keep', 'circular')
37
+ """
38
+ self.max_tokens = max_tokens
39
+ self.max_turns = max_turns
40
+ self.strategy = strategy
41
+
42
+ # 세션별 대화 히스토리 (세션 ID로 분리)
43
+ self.session_conversations: Dict[str, deque] = {}
44
+ self.default_session = "default"
45
+
46
+ # 기본 세션 초기화
47
+ self.session_conversations[self.default_session] = deque(maxlen=max_turns * 2)
48
+
49
+ # 시스템 프롬프트
50
+ self.system_prompt = ""
51
+
52
+ # 컨텍스트 통계
53
+ self.total_tokens = 0
54
+ self.current_context_length = 0
55
+
56
+ # 메모리 최적화 설정
57
+ self.enable_memory_optimization = True
58
+ self.compression_threshold = 0.8 # 80% 도달 시 압축 시작
59
+
60
+ # 🔄 자동 정리 주기 설정
61
+ self.auto_cleanup_enabled = True
62
+ self.cleanup_interval_turns = 5 # 8 → 5턴마다 정리
63
+ self.cleanup_interval_time = 180 # 5분 → 3분마다 정리
64
+ self.cleanup_strategy = "aggressive" # smart → aggressive로 변경
65
+ self.last_cleanup_time = {} # 세션별 마지막 정리 시간
66
+ self.turn_counters = {} # 세션별 턴 카운터
67
+
68
+ logger.info(f"🔧 컨텍스트 관리자 초기화: max_tokens={max_tokens}, strategy={strategy}, auto_cleanup={self.auto_cleanup_enabled}")
69
+
70
+ def set_system_prompt(self, prompt: str):
71
+ """시스템 프롬프트 설정"""
72
+ self.system_prompt = prompt
73
+ logger.info(f"📝 시스템 프롬프트 설정: {len(prompt)} 문자")
74
+
75
+ def set_auto_cleanup_config(self,
76
+ enabled: bool = True,
77
+ interval_turns: int = 8,
78
+ interval_time: int = 300,
79
+ strategy: str = "smart"):
80
+ """자동 정리 설정 구성"""
81
+ self.auto_cleanup_enabled = enabled
82
+ self.cleanup_interval_turns = max(1, interval_turns)
83
+ self.cleanup_interval_time = max(60, interval_time)
84
+ self.cleanup_strategy = strategy
85
+
86
+ logger.info(f"🔄 자동 정리 설정: enabled={enabled}, turns={interval_turns}, time={interval_time}s, strategy={strategy}")
87
+
88
+ def get_auto_cleanup_config(self) -> Dict[str, Any]:
89
+ """자동 정리 설정 반환"""
90
+ return {
91
+ "enabled": self.auto_cleanup_enabled,
92
+ "interval_turns": self.cleanup_interval_turns,
93
+ "interval_time": self.cleanup_interval_time,
94
+ "strategy": self.cleanup_strategy
95
+ }
96
+
97
+ def add_user_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
98
+ """사용자 메시지 추가"""
99
+ if not message_id:
100
+ message_id = f"user_{int(time.time() * 1000)}"
101
+
102
+ # 세션 ID 추출 (metadata에서)
103
+ session_id = "default"
104
+ if metadata and "session_id" in metadata:
105
+ session_id = metadata["session_id"]
106
+
107
+ # 세션이 없으면 생성
108
+ if session_id not in self.session_conversations:
109
+ self.session_conversations[session_id] = deque(maxlen=self.max_turns * 2)
110
+
111
+ turn = ConversationTurn(
112
+ role="user",
113
+ content=content,
114
+ timestamp=time.time(),
115
+ message_id=message_id,
116
+ metadata=metadata or {}
117
+ )
118
+
119
+ self.session_conversations[session_id].append(turn)
120
+ self._update_context_stats(session_id)
121
+ self._optimize_context(session_id)
122
+
123
+ # 🔄 자동 정리 체크
124
+ self._check_auto_cleanup(session_id)
125
+
126
+ logger.info(f"👤 사용자 메시지 추가: {len(content)} 문자 (세션: {session_id}, 총 {len(self.session_conversations[session_id])} 턴)")
127
+ return message_id
128
+
129
+ def add_assistant_message(self, content: str, message_id: str = None, metadata: Dict[str, Any] = None) -> str:
130
+ """어시스턴트 메시지 추가"""
131
+ if not message_id:
132
+ message_id = f"assistant_{int(time.time() * 1000)}"
133
+
134
+ # 세션 ID 추출 (metadata에서)
135
+ session_id = "default"
136
+ if metadata and "session_id" in metadata:
137
+ session_id = metadata["session_id"]
138
+
139
+ # 세션이 없으면 생성
140
+ if session_id not in self.session_conversations:
141
+ self.session_conversations[session_id] = deque(maxlen=self.max_turns * 2)
142
+
143
+ turn = ConversationTurn(
144
+ role="assistant",
145
+ content=content,
146
+ timestamp=time.time(),
147
+ message_id=message_id,
148
+ metadata=metadata or {}
149
+ )
150
+
151
+ self.session_conversations[session_id].append(turn)
152
+ self._update_context_stats(session_id)
153
+ self._optimize_context(session_id)
154
+
155
+ # 🔄 자동 정리 체크
156
+ self._check_auto_cleanup(session_id)
157
+
158
+ logger.info(f"🤖 어시스턴트 메시지 추가: {len(content)} 문자 (세션: {session_id}, 총 {len(self.session_conversations[session_id])} 턴)")
159
+ return message_id
160
+
161
+ def get_context(self, include_system: bool = True, max_length: Optional[int] = None, session_id: str = "default") -> str:
162
+ """현재 컨텍스트를 문자열로 반환 (세션별)"""
163
+ context_parts = []
164
+
165
+ # 세션이 없으면 기본 세션 사용
166
+ if session_id not in self.session_conversations:
167
+ session_id = "default"
168
+
169
+ conversation_history = self.session_conversations[session_id]
170
+
171
+ # 시스템 프롬프트 포함
172
+ if include_system and self.system_prompt:
173
+ context_parts.append(f"<|im_start|>system\n{self.system_prompt}<|im_end|>")
174
+
175
+ # 대화 히스토리 포함
176
+ for turn in conversation_history:
177
+ if turn.role == "user":
178
+ context_parts.append(f"<|im_start|>user\n{turn.content}<|im_end|>")
179
+ elif turn.role == "assistant":
180
+ context_parts.append(f"<|im_start|>assistant\n{turn.content}<|im_end|>")
181
+
182
+ # 어시스턴트 응답 시작 토큰 추가
183
+ context_parts.append("<|im_start|>assistant\n")
184
+
185
+ context = "\n".join(context_parts)
186
+
187
+ # 길이 제한 적용
188
+ if max_length and len(context) > max_length:
189
+ context = self._truncate_context(context, max_length)
190
+
191
+ return context
192
+
193
+ def get_context_for_model(self, model_name: str = "default", session_id: str = "default") -> str:
194
+ """모델별 최적화된 컨텍스트 반환 (세션별)"""
195
+ # 모델별 특별한 처리 (필요시 확장)
196
+ if "kanana" in model_name.lower():
197
+ return self.get_context(include_system=True, session_id=session_id)
198
+ elif "llama" in model_name.lower():
199
+ # Llama 형식
200
+ return self._format_for_llama(session_id)
201
+ elif "polyglot" in model_name.lower():
202
+ # Polyglot 형식 - <|im_start|> 태그 사용하지 않음
203
+ return self._format_for_polyglot(session_id)
204
+ else:
205
+ return self.get_context(include_system=True, session_id=session_id)
206
+
207
+ def _format_for_llama(self, session_id: str = "default") -> str:
208
+ """Llama 모델용 형식으로 변환 (세션별)"""
209
+ context_parts = []
210
+
211
+ # 세션이 없으면 기본 세션 사용
212
+ if session_id not in self.session_conversations:
213
+ session_id = "default"
214
+
215
+ conversation_history = self.session_conversations[session_id]
216
+
217
+ if self.system_prompt:
218
+ context_parts.append(f"[INST] {self.system_prompt} [/INST]")
219
+
220
+ for turn in conversation_history:
221
+ if turn.role == "user":
222
+ context_parts.append(f"[INST] {turn.content} [/INST]")
223
+ elif turn.role == "assistant":
224
+ context_parts.append(turn.content)
225
+
226
+ return "\n".join(context_parts)
227
+
228
+ def _format_for_polyglot(self, session_id: str = "default") -> str:
229
+ """Polyglot 모델용 형식으로 변환 (세션별) - 공식 형식 사용"""
230
+ context_parts = []
231
+
232
+ # 세션이 없으면 기본 세션 사용
233
+ if session_id not in self.session_conversations:
234
+ session_id = "default"
235
+
236
+ conversation_history = self.session_conversations[session_id]
237
+
238
+ # 대화 히스토리만 포함 (공식 형식 사용)
239
+ for turn in conversation_history:
240
+ if turn.role == "user":
241
+ context_parts.append(f"### 사용자:\n{turn.content}")
242
+ elif turn.role == "assistant":
243
+ context_parts.append(f"### 챗봇:\n{turn.content}")
244
+
245
+ if context_parts:
246
+ return "\n\n".join(context_parts)
247
+ else:
248
+ return ""
249
+
250
+ def get_recent_context(self, turns: int = 5, session_id: str = "default") -> str:
251
+ """최근 N개 턴의 컨텍스트만 반환 (세션별)"""
252
+ # 세션이 없으면 기본 세션 사용
253
+ if session_id not in self.session_conversations:
254
+ session_id = "default"
255
+
256
+ conversation_history = self.session_conversations[session_id]
257
+ recent_turns = list(conversation_history)[-turns:]
258
+ context_parts = []
259
+
260
+ for turn in recent_turns:
261
+ if turn.role == "user":
262
+ context_parts.append(f"<|im_start|>user\n{turn.content}<|im_end|>")
263
+ elif turn.role == "assistant":
264
+ context_parts.append(f"<|im_start|>assistant\n{turn.content}<|im_end|>")
265
+
266
+ context_parts.append("<|im_start|>assistant\n")
267
+ return "\n".join(context_parts)
268
+
269
+ def get_context_summary(self, session_id: str = "default") -> Dict[str, Any]:
270
+ """컨텍스트 요약 정보 반환 (세션별)"""
271
+ # 세션이 없으면 기본 세션 사용
272
+ if session_id not in self.session_conversations:
273
+ session_id = "default"
274
+
275
+ conversation_history = self.session_conversations[session_id]
276
+
277
+ return {
278
+ "session_id": session_id,
279
+ "total_turns": len(conversation_history),
280
+ "user_messages": len([t for t in conversation_history if t.role == "user"]),
281
+ "assistant_messages": len([t for t in conversation_history if t.role == "assistant"]),
282
+ "estimated_tokens": self.total_tokens,
283
+ "context_length": self.current_context_length,
284
+ "memory_usage": len(conversation_history) / self.max_turns,
285
+ "oldest_message": conversation_history[0].timestamp if conversation_history else None,
286
+ "newest_message": conversation_history[-1].timestamp if conversation_history else None
287
+ }
288
+
289
+ def clear_context(self, session_id: str = "default"):
290
+ """컨텍스트 초기화 (세션별)"""
291
+ if session_id not in self.session_conversations:
292
+ logger.warning(f"⚠️ 세션 {session_id}가 존재하지 않습니다.")
293
+ return
294
+
295
+ self.session_conversations[session_id].clear()
296
+ self.total_tokens = 0
297
+ self.current_context_length = 0
298
+ logger.info(f"🗑️ 세션 {session_id} 컨텍스트 초기화 완료")
299
+
300
+ def clear_all_sessions(self):
301
+ """모든 세션 컨텍스트 초기화"""
302
+ for session_id in list(self.session_conversations.keys()):
303
+ self.session_conversations[session_id].clear()
304
+ self.total_tokens = 0
305
+ self.current_context_length = 0
306
+ logger.info("🗑️ 모든 세션 컨텍스트 초기화 완료")
307
+
308
+ def remove_message(self, message_id: str, session_id: str = "default") -> bool:
309
+ """특정 메시지 제거 (세션별)"""
310
+ if session_id not in self.session_conversations:
311
+ return False
312
+
313
+ conversation_history = self.session_conversations[session_id]
314
+ for i, turn in enumerate(conversation_history):
315
+ if turn.message_id == message_id:
316
+ removed_turn = conversation_history.pop(i)
317
+ self._update_context_stats(session_id)
318
+ logger.info(f"🗑️ 메시지 제거: {message_id} (세션: {session_id})")
319
+ return True
320
+ return False
321
+
322
+ def edit_message(self, message_id: str, new_content: str, session_id: str = "default") -> bool:
323
+ """메시지 내용 수정 (세션별)"""
324
+ if session_id not in self.session_conversations:
325
+ return False
326
+
327
+ conversation_history = self.session_conversations[session_id]
328
+ for turn in conversation_history:
329
+ if turn.message_id == message_id:
330
+ turn.content = new_content
331
+ turn.timestamp = time.time()
332
+ self._update_context_stats(session_id)
333
+ logger.info(f"✏️ 메시지 수정: {message_id} (세션: {session_id})")
334
+ return True
335
+ return False
336
+
337
+ def search_context(self, query: str, max_results: int = 5, session_id: str = "default") -> List[Dict[str, Any]]:
338
+ """컨텍스트 내에서 검색 (세션별)"""
339
+ if session_id not in self.session_conversations:
340
+ return []
341
+
342
+ conversation_history = self.session_conversations[session_id]
343
+ results = []
344
+ query_lower = query.lower()
345
+
346
+ for turn in conversation_history:
347
+ if query_lower in turn.content.lower():
348
+ results.append({
349
+ "message_id": turn.message_id,
350
+ "role": turn.role,
351
+ "content": turn.content,
352
+ "timestamp": turn.timestamp,
353
+ "relevance_score": self._calculate_relevance(query, turn.content)
354
+ })
355
+
356
+ # 관련성 점수로 정렬
357
+ results.sort(key=lambda x: x["relevance_score"], reverse=True)
358
+ return results[:max_results]
359
+
360
+ def _calculate_relevance(self, query: str, content: str) -> float:
361
+ """간단한 관련성 점수 계산"""
362
+ query_words = set(query.lower().split())
363
+ content_words = set(content.lower().split())
364
+
365
+ if not query_words:
366
+ return 0.0
367
+
368
+ intersection = query_words.intersection(content_words)
369
+ return len(intersection) / len(query_words)
370
+
371
+ def _update_context_stats(self, session_id: str = "default"):
372
+ """컨텍스트 통계 업데이트 (세션별)"""
373
+ if session_id not in self.session_conversations:
374
+ return
375
+
376
+ self.current_context_length = len(self.get_context(session_id=session_id))
377
+ # 간단한 토큰 추정 (실제 토크나이저 사용 권장)
378
+ self.total_tokens = self.current_context_length // 4
379
+
380
+ def _optimize_context(self, session_id: str = "default"):
381
+ """컨텍스트 최적화 (세션별)"""
382
+ if not self.enable_memory_optimization:
383
+ return
384
+
385
+ if session_id not in self.session_conversations:
386
+ return
387
+
388
+ conversation_history = self.session_conversations[session_id]
389
+
390
+ # 메모리 사용량이 임계값을 초과하면 압축 시작
391
+ if len(conversation_history) / self.max_turns > self.compression_threshold:
392
+ self._compress_context(session_id)
393
+
394
+ def _compress_context(self, session_id: str = "default"):
395
+ """컨텍스트 압축 (중요한 메시지 유지, 세션별)"""
396
+ if session_id not in self.session_conversations:
397
+ return
398
+
399
+ conversation_history = self.session_conversations[session_id]
400
+
401
+ if len(conversation_history) <= self.max_turns:
402
+ return
403
+
404
+ logger.info(f"🗜️ 세션 {session_id} 컨텍스트 압축 시작: {len(conversation_history)} → {self.max_turns}")
405
+
406
+ # 전략에 따른 압축
407
+ if self.strategy == "sliding_window":
408
+ # 슬라이딩 윈도우: 최근 메시지 우선
409
+ while len(conversation_history) > self.max_turns:
410
+ conversation_history.popleft()
411
+
412
+ elif self.strategy == "priority_keep":
413
+ # 우선순위 기반: 시스템 프롬프트와 최근 메시지 우선
414
+ # 첫 번째와 마지막 메시지는 유지
415
+ if len(conversation_history) > self.max_turns:
416
+ # 중간 메시지들 중 일부 제거
417
+ middle_start = self.max_turns // 2
418
+ middle_end = len(conversation_history) - self.max_turns // 2
419
+
420
+ # 중간 부분을 요약으로 대체
421
+ removed_turns = list(conversation_history)[middle_start:middle_end]
422
+ summary_content = f"[이전 {len(removed_turns)}개 메시지 요약: {len(removed_turns)}개 대화 턴]"
423
+
424
+ # 중간 부분 제거
425
+ for _ in range(middle_end - middle_start):
426
+ conversation_history.pop(middle_start)
427
+
428
+ # 요약 메시지 추가
429
+ summary_turn = ConversationTurn(
430
+ role="system",
431
+ content=summary_content,
432
+ timestamp=time.time(),
433
+ message_id=f"summary_{int(time.time() * 1000)}"
434
+ )
435
+ conversation_history.insert(middle_start, summary_turn)
436
+
437
+ elif self.strategy == "circular":
438
+ # 순환 버퍼: 가장 오래된 메시지 제거
439
+ while len(conversation_history) > self.max_turns:
440
+ conversation_history.popleft()
441
+
442
+ self._update_context_stats(session_id)
443
+ logger.info(f"✅ 세션 {session_id} 컨텍스트 압축 완료: {len(conversation_history)} 턴")
444
+
445
+ def _truncate_context(self, context: str, max_length: int) -> str:
446
+ """컨텍스트 길이 제한"""
447
+ if len(context) <= max_length:
448
+ return context
449
+
450
+ # 가장 최근 메시지부터 유지
451
+ truncated_context = context[-max_length:]
452
+
453
+ # 메시지 경계 확인
454
+ if not truncated_context.startswith("<|im_start|>"):
455
+ # 메시지 경계를 찾아서 자르기
456
+ start_idx = truncated_context.find("<|im_start|>")
457
+ if start_idx != -1:
458
+ truncated_context = truncated_context[start_idx:]
459
+
460
+ return truncated_context
461
+
462
+ def export_context(self, file_path: str = None, session_id: str = "default") -> str:
463
+ """컨텍스트를 파일로 내보내기 (세션별)"""
464
+ if not file_path:
465
+ file_path = f"context_export_{session_id}_{int(time.time())}.json"
466
+
467
+ if session_id not in self.session_conversations:
468
+ logger.warning(f"⚠️ 세션 {session_id}가 존재하지 않습니다.")
469
+ return None
470
+
471
+ conversation_history = self.session_conversations[session_id]
472
+
473
+ export_data = {
474
+ "export_timestamp": time.time(),
475
+ "session_id": session_id,
476
+ "system_prompt": self.system_prompt,
477
+ "conversation_history": [
478
+ {
479
+ "role": turn.role,
480
+ "content": turn.content,
481
+ "timestamp": turn.timestamp,
482
+ "message_id": turn.message_id,
483
+ "metadata": turn.metadata
484
+ }
485
+ for turn in conversation_history
486
+ ],
487
+ "context_stats": self.get_context_summary(session_id)
488
+ }
489
+
490
+ with open(file_path, 'w', encoding='utf-8') as f:
491
+ json.dump(export_data, f, ensure_ascii=False, indent=2)
492
+
493
+ logger.info(f"💾 세션 {session_id} 컨텍스트 내보내기 완료: {file_path}")
494
+ return file_path
495
+
496
+ def import_context(self, file_path: str) -> bool:
497
+ """파일에서 컨텍스트 가져오기"""
498
+ try:
499
+ with open(file_path, 'r', encoding='utf-8') as f:
500
+ import_data = json.load(f)
501
+
502
+ # 기존 컨텍스트 초기화
503
+ self.clear_context()
504
+
505
+ # 시스템 프롬프트 복원
506
+ if "system_prompt" in import_data:
507
+ self.system_prompt = import_data["system_prompt"]
508
+
509
+ # 대화 히스토리 복원
510
+ if "conversation_history" in import_data:
511
+ for turn_data in import_data["conversation_history"]:
512
+ turn = ConversationTurn(
513
+ role=turn_data["role"],
514
+ content=turn_data["content"],
515
+ timestamp=turn_data["timestamp"],
516
+ message_id=turn_data["message_id"],
517
+ metadata=turn_data.get("metadata", {})
518
+ )
519
+ self.conversation_history.append(turn)
520
+
521
+ self._update_context_stats()
522
+ logger.info(f"📥 컨텍스트 가져오기 완료: {file_path}")
523
+ return True
524
+
525
+ except Exception as e:
526
+ logger.error(f"❌ 컨텍스트 가져오기 실패: {e}")
527
+ return False
528
+
529
+ def get_memory_efficiency(self, session_id: str = "default") -> Dict[str, float]:
530
+ """메모리 효율성 지표 반환 (세션별)"""
531
+ if session_id not in self.session_conversations:
532
+ return {}
533
+
534
+ conversation_history = self.session_conversations[session_id]
535
+
536
+ return {
537
+ "session_id": session_id,
538
+ "context_utilization": len(conversation_history) / self.max_turns,
539
+ "token_efficiency": self.total_tokens / self.max_tokens if self.max_tokens > 0 else 0,
540
+ "compression_ratio": 1.0 - (len(conversation_history) / (self.max_turns * 2)),
541
+ "memory_fragmentation": self._calculate_fragmentation(session_id)
542
+ }
543
+
544
+ def _calculate_fragmentation(self, session_id: str = "default") -> float:
545
+ """메모리 단편화 정도 계산 (세션별)"""
546
+ if session_id not in self.session_conversations:
547
+ return 0.0
548
+
549
+ conversation_history = self.session_conversations[session_id]
550
+
551
+ if len(conversation_history) <= 1:
552
+ return 0.0
553
+
554
+ # 연속된 메시지 간의 시간 간격으로 단편화 계산
555
+ timestamps = [turn.timestamp for turn in conversation_history]
556
+ intervals = [timestamps[i+1] - timestamps[i] for i in range(len(timestamps)-1)]
557
+
558
+ if not intervals:
559
+ return 0.0
560
+
561
+ avg_interval = sum(intervals) / len(intervals)
562
+ variance = sum((x - avg_interval) ** 2 for x in intervals) / len(intervals)
563
+
564
+ # 정규화된 단편화 점수 (0-1)
565
+ return min(1.0, variance / (avg_interval ** 2) if avg_interval > 0 else 0.0)
566
+
567
+ def _check_auto_cleanup(self, session_id: str = "default"):
568
+ """자동 정리 조건 체크 및 실행"""
569
+ if not self.auto_cleanup_enabled:
570
+ return
571
+
572
+ current_time = time.time()
573
+
574
+ # 세션별 카운터 초기화
575
+ if session_id not in self.turn_counters:
576
+ self.turn_counters[session_id] = 0
577
+ if session_id not in self.last_cleanup_time:
578
+ self.last_cleanup_time[session_id] = current_time
579
+
580
+ # 턴 카운터 증가
581
+ self.turn_counters[session_id] += 1
582
+
583
+ # 정리 조건 체크
584
+ should_cleanup = False
585
+ cleanup_reason = ""
586
+
587
+ # 턴 기반 정리
588
+ if self.turn_counters[session_id] >= self.cleanup_interval_turns:
589
+ should_cleanup = True
590
+ cleanup_reason = f"턴 기반 ({self.turn_counters[session_id]} 턴)"
591
+
592
+ # 시간 기반 정리
593
+ elif current_time - self.last_cleanup_time[session_id] >= self.cleanup_interval_time:
594
+ should_cleanup = True
595
+ cleanup_reason = f"시간 기반 ({int(current_time - self.last_cleanup_time[session_id])}초)"
596
+
597
+ # 컨텍스트 길이 기반 정리 (강화)
598
+ elif len(self.session_conversations.get(session_id, [])) > self.max_turns:
599
+ should_cleanup = True
600
+ cleanup_reason = f"길이 기반 ({len(self.session_conversations.get(session_id, []))} > {self.max_turns})"
601
+
602
+ # 자동 정리 실행
603
+ if should_cleanup:
604
+ logger.info(f"🔄 세션 {session_id} 자동 정리 시작: {cleanup_reason}")
605
+ self._execute_auto_cleanup(session_id)
606
+
607
+ # 카운터 및 시간 리셋
608
+ self.turn_counters[session_id] = 0
609
+ self.last_cleanup_time[session_id] = current_time
610
+
611
+ def _execute_auto_cleanup(self, session_id: str = "default"):
612
+ """자동 정리 실행"""
613
+ if session_id not in self.session_conversations:
614
+ return
615
+
616
+ conversation_history = self.session_conversations[session_id]
617
+ original_length = len(conversation_history)
618
+
619
+ if original_length <= self.max_turns:
620
+ return
621
+
622
+ # 전략별 정리 실행
623
+ if self.cleanup_strategy == "smart":
624
+ self._smart_cleanup(session_id)
625
+ elif self.cleanup_strategy == "aggressive":
626
+ self._aggressive_cleanup(session_id)
627
+ elif self.cleanup_strategy == "conservative":
628
+ self._conservative_cleanup(session_id)
629
+
630
+ final_length = len(conversation_history)
631
+ removed_count = original_length - final_length
632
+
633
+ if removed_count > 0:
634
+ logger.info(f"✅ 세션 {session_id} 자동 정리 완료: {original_length} → {final_length} 턴 (제거: {removed_count})")
635
+
636
+ def _smart_cleanup(self, session_id: str = "default"):
637
+ """스마트 정리: 중요 메시지 유지, 중간 메시지 요약"""
638
+ if session_id not in self.session_conversations:
639
+ return
640
+
641
+ conversation_history = self.session_conversations[session_id]
642
+
643
+ if len(conversation_history) <= self.max_turns:
644
+ return
645
+
646
+ # 중요 메시지 수 계산 (시스템 + 최근)
647
+ important_count = min(3, self.max_turns // 3)
648
+ recent_count = min(5, self.max_turns // 2)
649
+
650
+ # 중간 메시지들 제거
651
+ middle_start = important_count
652
+ middle_end = len(conversation_history) - recent_count
653
+
654
+ if middle_end > middle_start:
655
+ removed_turns = list(conversation_history)[middle_start:middle_end]
656
+
657
+ # 요약 메시지 생성
658
+ summary_content = f"[이전 {len(removed_turns)}개 메시지 요약: {len(removed_turns)}개 대화 턴]"
659
+
660
+ # 중간 부분 제거
661
+ for _ in range(middle_end - middle_start):
662
+ conversation_history.pop(middle_start)
663
+
664
+ # 요약 메시지 추가
665
+ summary_turn = ConversationTurn(
666
+ role="system",
667
+ content=summary_content,
668
+ timestamp=time.time(),
669
+ message_id=f"summary_{int(time.time() * 1000)}"
670
+ )
671
+ conversation_history.insert(middle_start, summary_turn)
672
+
673
+ def _aggressive_cleanup(self, session_id: str = "default"):
674
+ """적극적 정리: 최근 메시지만 유지"""
675
+ if session_id not in self.session_conversations:
676
+ return
677
+
678
+ conversation_history = self.session_conversations[session_id]
679
+
680
+ # 최근 max_turns 개만 유지
681
+ while len(conversation_history) > self.max_turns:
682
+ conversation_history.popleft()
683
+
684
+ def _conservative_cleanup(self, session_id: str = "default"):
685
+ """보수적 정리: 점진적으로 정리"""
686
+ if session_id not in self.session_conversations:
687
+ return
688
+
689
+ conversation_history = self.session_conversations[session_id]
690
+
691
+ # 20%씩 점진적으로 제거
692
+ target_length = int(len(conversation_history) * 0.8)
693
+ if target_length > self.max_turns:
694
+ while len(conversation_history) > target_length:
695
+ conversation_history.popleft()
696
+
697
+ # 전역 컨텍스트 관리자 인스턴스
698
+ context_manager = ContextManager()
699
+
700
+ def get_context_manager() -> ContextManager:
701
+ """전역 컨텍스트 관리자 반환"""
702
+ return context_manager
lily_llm_core/lora_manager.py CHANGED
@@ -165,6 +165,7 @@ class LoRAManager:
165
  target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
166
 
167
  # TaskType 변환
 
168
  task_type_map = {
169
  "CAUSAL_LM": TaskType.CAUSAL_LM,
170
  "SEQ_2_SEQ_LM": TaskType.SEQ_2_SEQ_LM,
@@ -173,7 +174,9 @@ class LoRAManager:
173
  "QUESTION_ANSWERING": TaskType.QUESTION_ANSWERING
174
  }
175
 
 
176
  task_type_enum = task_type_map.get(task_type, TaskType.CAUSAL_LM)
 
177
 
178
  self.lora_config = LoraConfig(
179
  r=r,
 
165
  target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
166
 
167
  # TaskType 변환
168
+ logger.info(f"🔍 [DEBUG] 입력된 task_type: {task_type}")
169
  task_type_map = {
170
  "CAUSAL_LM": TaskType.CAUSAL_LM,
171
  "SEQ_2_SEQ_LM": TaskType.SEQ_2_SEQ_LM,
 
174
  "QUESTION_ANSWERING": TaskType.QUESTION_ANSWERING
175
  }
176
 
177
+ logger.info(f"🔍 [DEBUG] 사용 가능한 TaskType: {list(task_type_map.keys())}")
178
  task_type_enum = task_type_map.get(task_type, TaskType.CAUSAL_LM)
179
+ logger.info(f"🔍 [DEBUG] 선택된 TaskType: {task_type_enum}")
180
 
181
  self.lora_config = LoraConfig(
182
  r=r,
lily_llm_core/lora_manager_250822_0312.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ LoRA/QLoRA 관리자 (LoRA Manager)
4
+ LoRA 어댑터를 로드하고 관리하는 시스템
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import json
10
+ import torch
11
+ from typing import Dict, Any, Optional, List, Union
12
+ from pathlib import Path
13
+ import warnings
14
+ import time
15
+
16
+ # logger를 먼저 정의
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # PEFT 관련 import (설치되지 않은 경우 경고)
20
+ try:
21
+ logger.info("🔍 PEFT 라이브러리 import 시도 중...")
22
+ from peft import (
23
+ LoraConfig,
24
+ get_peft_model,
25
+ PeftModel,
26
+ TaskType,
27
+ prepare_model_for_kbit_training
28
+ )
29
+ from peft.utils import get_peft_model_state_dict
30
+ PEFT_AVAILABLE = True
31
+ logger.info("✅ PEFT 라이브러리 import 성공")
32
+ except ImportError as e:
33
+ PEFT_AVAILABLE = False
34
+ logger.error(f"❌ PEFT 라이브러리 import 실패: {e}")
35
+ logger.error(f"❌ Python 경로: {os.environ.get('PYTHONPATH', 'Not set')}")
36
+ logger.error(f"❌ 현재 작업 디렉토리: {os.getcwd()}")
37
+ warnings.warn(f"PEFT 라이브러리가 설치되지 않았습니다. LoRA 기능을 사용할 수 없습니다. 오류: {e}")
38
+
39
+ # Transformers 관련 import
40
+ try:
41
+ logger.info("🔍 Transformers 라이브러리 import 시도 중...")
42
+ from transformers import (
43
+ AutoModelForCausalLM,
44
+ AutoTokenizer,
45
+ BitsAndBytesConfig,
46
+ TrainingArguments,
47
+ Trainer,
48
+ DataCollatorForLanguageModeling
49
+ )
50
+ TRANSFORMERS_AVAILABLE = True
51
+ logger.info("✅ Transformers 라이브러리 import 성공")
52
+ except ImportError as e:
53
+ TRANSFORMERS_AVAILABLE = False
54
+ logger.error(f"❌ Transformers 라이브러리 import 실패: {e}")
55
+ warnings.warn(f"Transformers 라이브러리가 설치되지 않았습니다. 오류: {e}")
56
+
57
+ class LoRAManager:
58
+ """LoRA/QLoRA 모델 관리 클래스"""
59
+
60
+ def __init__(self, base_model_path: str = None, device: str = "auto"):
61
+ """
62
+ Args:
63
+ base_model_path: 기본 모델 경로
64
+ device: 사용할 디바이스 ('auto', 'cpu', 'cuda', 'mps')
65
+ """
66
+ logger.info(f"🔧 LoRA 관리자 초기화 시작: PEFT_AVAILABLE={PEFT_AVAILABLE}, TRANSFORMERS_AVAILABLE={TRANSFORMERS_AVAILABLE}")
67
+
68
+ if not PEFT_AVAILABLE:
69
+ logger.error("❌ PEFT 라이브러리를 사용할 수 없습니다.")
70
+ logger.error("❌ pip install peft를 실행했는지 확인하세요.")
71
+ logger.error("❌ 가상환경이 활성화되어 있는지 확인하세요.")
72
+ raise ImportError("PEFT 라이브러리가 필요합니다. pip install peft를 실행하세요.")
73
+
74
+ if not TRANSFORMERS_AVAILABLE:
75
+ logger.error("❌ Transformers 라이브러리를 사용할 수 없습니다.")
76
+ logger.error("❌ pip install transformers를 실행했는지 확인하세요.")
77
+ raise ImportError("Transformers 라이브러리가 필요합니다. pip install transformers를 실행하세요.")
78
+
79
+ self.base_model_path = base_model_path
80
+ self.device = self._get_device(device)
81
+
82
+ # 모델 및 토크나이저
83
+ self.base_model = None
84
+ self.tokenizer = None
85
+ self.lora_model = None
86
+
87
+ # LoRA 설정
88
+ self.lora_config = None
89
+ self.current_adapter_name = None
90
+
91
+ # 어댑터 저장 경로
92
+ self.adapters_dir = Path("lora_adapters")
93
+ self.adapters_dir.mkdir(exist_ok=True)
94
+
95
+ # 로드된 어댑터 목록
96
+ self.loaded_adapters = {}
97
+
98
+ logger.info(f"🔧 LoRA 관리자 초기화: device={self.device}")
99
+
100
+ def _get_device(self, device: str) -> str:
101
+ """사용 가능한 디바이스 확인"""
102
+ if device == "auto":
103
+ if torch.cuda.is_available():
104
+ return "cuda"
105
+ elif torch.backends.mps.is_available():
106
+ return "mps"
107
+ else:
108
+ return "cpu"
109
+ return device
110
+
111
+ def load_base_model(self, model_path: str = None, model_type: str = "causal_lm") -> bool:
112
+ """기본 모델 로드"""
113
+ try:
114
+ model_path = model_path or self.base_model_path
115
+ if not model_path:
116
+ raise ValueError("모델 경로가 지정되지 않았습니다.")
117
+
118
+ logger.info(f"📥 기본 모델 로딩 시작: {model_path}")
119
+
120
+ # 토크나이저 로드
121
+ self.tokenizer = AutoTokenizer.from_pretrained(
122
+ model_path,
123
+ trust_remote_code=True,
124
+ local_files_only=os.path.exists(model_path)
125
+ )
126
+
127
+ # 패딩 토큰 설정
128
+ if self.tokenizer.pad_token is None:
129
+ self.tokenizer.pad_token = self.tokenizer.eos_token
130
+
131
+ # 모델 로드
132
+ if model_type == "causal_lm":
133
+ self.base_model = AutoModelForCausalLM.from_pretrained(
134
+ model_path,
135
+ trust_remote_code=True,
136
+ local_files_only=os.path.exists(model_path),
137
+ torch_dtype=torch.float16 if self.device == "cuda" else torch.float32,
138
+ device_map="auto" if self.device == "cuda" else None
139
+ )
140
+ else:
141
+ raise ValueError(f"지원하지 않는 모델 타입: {model_type}")
142
+
143
+ # 디바이스로 이동
144
+ if self.device != "cuda": # cuda는 device_map="auto" 사용
145
+ self.base_model = self.base_model.to(self.device)
146
+
147
+ self.base_model_path = model_path
148
+ logger.info(f"✅ 기본 모델 로딩 완료: {model_path}")
149
+ return True
150
+
151
+ except Exception as e:
152
+ logger.error(f"❌ 기본 모델 로딩 실패: {e}")
153
+ return False
154
+
155
+ def create_lora_config(self,
156
+ r: int = 16,
157
+ lora_alpha: int = 32,
158
+ target_modules: List[str] = None,
159
+ lora_dropout: float = 0.1,
160
+ bias: str = "none",
161
+ task_type: str = "CAUSAL_LM") -> LoraConfig:
162
+ """LoRA 설정 생성"""
163
+ if target_modules is None:
164
+ # 일반적인 모델 아키텍처에 대한 기본값
165
+ target_modules = ["q_proj", "v_proj", "k_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
166
+
167
+ # TaskType 변환
168
+ logger.info(f"🔍 [DEBUG] 입력된 task_type: {task_type}")
169
+ task_type_map = {
170
+ "CAUSAL_LM": TaskType.CAUSAL_LM,
171
+ "SEQ_2_SEQ_LM": TaskType.SEQ_2_SEQ_LM,
172
+ "SEQUENCE_CLASSIFICATION": TaskType.SEQUENCE_CLASSIFICATION,
173
+ "TOKEN_CLASSIFICATION": TaskType.TOKEN_CLASSIFICATION,
174
+ "QUESTION_ANSWERING": TaskType.QUESTION_ANSWERING
175
+ }
176
+
177
+ logger.info(f"🔍 [DEBUG] 사용 가능한 TaskType: {list(task_type_map.keys())}")
178
+ task_type_enum = task_type_map.get(task_type, TaskType.CAUSAL_LM)
179
+ logger.info(f"🔍 [DEBUG] 선택된 TaskType: {task_type_enum}")
180
+
181
+ self.lora_config = LoraConfig(
182
+ r=r,
183
+ lora_alpha=lora_alpha,
184
+ target_modules=target_modules,
185
+ lora_dropout=lora_dropout,
186
+ bias=bias,
187
+ task_type=task_type_enum
188
+ )
189
+
190
+ logger.info(f"🔧 LoRA 설정 생성: r={r}, alpha={lora_alpha}, target_modules={target_modules}")
191
+ return self.lora_config
192
+
193
+ def apply_lora_to_model(self, adapter_name: str = "default") -> bool:
194
+ """LoRA를 기본 모델에 적용"""
195
+ try:
196
+ if self.base_model is None:
197
+ raise ValueError("기본 모델이 로드되지 않았습니다.")
198
+
199
+ if self.lora_config is None:
200
+ raise ValueError("LoRA 설정이 생성되지 않았습니다.")
201
+
202
+ logger.info(f"🔗 LoRA 어댑터 적용 시작: {adapter_name}")
203
+
204
+ # LoRA 모델 생성
205
+ self.lora_model = get_peft_model(self.base_model, self.lora_config)
206
+
207
+ # 어댑터 이름 설정
208
+ self.current_adapter_name = adapter_name
209
+
210
+ # 훈련 모드로 설정
211
+ self.lora_model.train()
212
+
213
+ # 모델 정보 출력
214
+ self.lora_model.print_trainable_parameters()
215
+
216
+ logger.info(f"✅ LoRA 어댑터 적용 완료: {adapter_name}")
217
+ return True
218
+
219
+ except Exception as e:
220
+ logger.error(f"❌ LoRA 어댑터 적용 실패: {e}")
221
+ return False
222
+
223
+ def load_lora_adapter(self, adapter_path: str, adapter_name: str = None) -> bool:
224
+ """저장된 LoRA 어댑터 로드"""
225
+ try:
226
+ if not os.path.exists(adapter_path):
227
+ raise FileNotFoundError(f"어댑터 경로를 찾을 수 없습니다: {adapter_path}")
228
+
229
+ if adapter_name is None:
230
+ adapter_name = Path(adapter_path).stem
231
+
232
+ logger.info(f"📥 LoRA 어댑터 로딩 시작: {adapter_path}")
233
+
234
+ # 기본 모델이 로드되지 않은 경우 로드
235
+ if self.base_model is None:
236
+ # 어댑터 설정 파일에서 기본 모델 경로 확인
237
+ config_path = os.path.join(adapter_path, "adapter_config.json")
238
+ if os.path.exists(config_path):
239
+ with open(config_path, 'r') as f:
240
+ config = json.load(f)
241
+ base_model_path = config.get("base_model_name_or_path")
242
+ if base_model_path:
243
+ self.load_base_model(base_model_path)
244
+
245
+ # LoRA 어댑터 로드
246
+ self.lora_model = PeftModel.from_pretrained(
247
+ self.base_model,
248
+ adapter_path,
249
+ torch_dtype=torch.float16 if self.device == "cuda" else torch.float32
250
+ )
251
+
252
+ # 디바이스로 이동
253
+ if self.device != "cuda":
254
+ self.lora_model = self.lora_model.to(self.device)
255
+
256
+ self.current_adapter_name = adapter_name
257
+ self.loaded_adapters[adapter_name] = adapter_path
258
+
259
+ logger.info(f"✅ LoRA 어댑터 로딩 완료: {adapter_name}")
260
+ return True
261
+
262
+ except Exception as e:
263
+ logger.error(f"❌ LoRA 어댑터 로딩 실패: {e}")
264
+ return False
265
+
266
+ def save_lora_adapter(self, adapter_name: str = None, output_dir: str = None) -> bool:
267
+ """LoRA 어댑터 저장"""
268
+ try:
269
+ if self.lora_model is None:
270
+ raise ValueError("LoRA 모델이 로드되지 않았습니다.")
271
+
272
+ adapter_name = adapter_name or self.current_adapter_name or "default"
273
+ output_dir = output_dir or str(self.adapters_dir / adapter_name)
274
+
275
+ logger.info(f"💾 LoRA 어댑터 저장 시작: {adapter_name} -> {output_dir}")
276
+
277
+ # 어댑터 저장
278
+ self.lora_model.save_pretrained(output_dir)
279
+
280
+ # 토크나이저도 저장
281
+ if self.tokenizer:
282
+ self.tokenizer.save_pretrained(output_dir)
283
+
284
+ # 어댑터 정보 저장
285
+ adapter_info = {
286
+ "adapter_name": adapter_name,
287
+ "base_model": self.base_model_path,
288
+ "lora_config": self.lora_config.to_dict() if self.lora_config else None,
289
+ "created_at": str(torch.tensor(time.time())),
290
+ "device": self.device
291
+ }
292
+
293
+ with open(os.path.join(output_dir, "adapter_info.json"), 'w') as f:
294
+ json.dump(adapter_info, f, indent=2)
295
+
296
+ logger.info(f"✅ LoRA 어댑터 저장 완료: {output_dir}")
297
+ return True
298
+
299
+ except Exception as e:
300
+ logger.error(f"❌ LoRA 어댑터 저장 실패: {e}")
301
+ return False
302
+
303
+ def merge_lora_with_base(self, output_path: str = None) -> bool:
304
+ """LoRA 어댑터를 기본 모델과 병합"""
305
+ try:
306
+ if self.lora_model is None:
307
+ raise ValueError("LoRA 모델이 로드되지 않았습니다.")
308
+
309
+ output_path = output_path or f"{self.base_model_path}_merged"
310
+
311
+ logger.info(f"🔗 LoRA 어댑터 병합 시작: {output_path}")
312
+
313
+ # 병합된 모델 생성
314
+ merged_model = self.lora_model.merge_and_unload()
315
+
316
+ # 병합된 모델 저장
317
+ merged_model.save_pretrained(output_path)
318
+
319
+ # 토크나이저도 저장
320
+ if self.tokenizer:
321
+ self.tokenizer.save_pretrained(output_path)
322
+
323
+ logger.info(f"✅ LoRA 어댑터 병합 완료: {output_path}")
324
+ return True
325
+
326
+ except Exception as e:
327
+ logger.error(f"❌ LoRA 어댑터 병합 실패: {e}")
328
+ return False
329
+
330
+ def list_available_adapters(self) -> List[Dict[str, Any]]:
331
+ """사용 가능한 어댑터 목록 반환"""
332
+ adapters = []
333
+
334
+ for adapter_dir in self.adapters_dir.iterdir():
335
+ if adapter_dir.is_dir():
336
+ config_path = adapter_dir / "adapter_config.json"
337
+ info_path = adapter_dir / "adapter_info.json"
338
+
339
+ adapter_info = {
340
+ "name": adapter_dir.name,
341
+ "path": str(adapter_dir),
342
+ "config_exists": config_path.exists(),
343
+ "info_exists": info_path.exists()
344
+ }
345
+
346
+ # 어댑터 정보 로드
347
+ if info_path.exists():
348
+ try:
349
+ with open(info_path, 'r') as f:
350
+ info = json.load(f)
351
+ adapter_info.update(info)
352
+ except Exception as e:
353
+ logger.warning(f"어댑터 정보 로드 실패: {e}")
354
+
355
+ adapters.append(adapter_info)
356
+
357
+ return adapters
358
+
359
+ def get_adapter_stats(self) -> Dict[str, Any]:
360
+ """어댑터 통계 정보 반환"""
361
+ if self.lora_model is None:
362
+ return {"error": "LoRA 모델이 로드되지 않았습니다."}
363
+
364
+ try:
365
+ # 훈련 가능한 파라미터 수
366
+ trainable_params = 0
367
+ all_param = 0
368
+
369
+ for param in self.lora_model.parameters():
370
+ all_param += param.numel()
371
+ if param.requires_grad:
372
+ trainable_params += param.numel()
373
+
374
+ return {
375
+ "adapter_name": self.current_adapter_name,
376
+ "trainable_params": trainable_params,
377
+ "all_params": all_param,
378
+ "trainable_ratio": trainable_params / all_param if all_param > 0 else 0,
379
+ "device": self.device,
380
+ "model_type": type(self.lora_model).__name__
381
+ }
382
+
383
+ except Exception as e:
384
+ logger.error(f"어댑터 통계 수집 실패: {e}")
385
+ return {"error": str(e)}
386
+
387
+ def switch_adapter(self, adapter_name: str) -> bool:
388
+ """다른 어댑터로 전환"""
389
+ try:
390
+ if adapter_name not in self.loaded_adapters:
391
+ # 어댑터 로드
392
+ adapter_path = self.adapters_dir / adapter_name
393
+ if not adapter_path.exists():
394
+ raise FileNotFoundError(f"어댑터를 찾을 수 없습니다: {adapter_name}")
395
+
396
+ return self.load_lora_adapter(str(adapter_path), adapter_name)
397
+ else:
398
+ # 이미 로드된 어댑터 사용
399
+ self.current_adapter_name = adapter_name
400
+ logger.info(f"🔄 어댑터 전환: {adapter_name}")
401
+ return True
402
+
403
+ except Exception as e:
404
+ logger.error(f"❌ 어댑터 전환 실패: {e}")
405
+ return False
406
+
407
+ def unload_adapter(self) -> bool:
408
+ """LoRA 어댑터 언로드"""
409
+ try:
410
+ if self.lora_model is None:
411
+ return True
412
+
413
+ logger.info("🗑️ LoRA 어댑터 언로드 시작")
414
+
415
+ # 어댑터 제거
416
+ self.lora_model = None
417
+ self.current_adapter_name = None
418
+ self.lora_config = None
419
+
420
+ logger.info("✅ LoRA 어댑터 언로드 완료")
421
+ return True
422
+
423
+ except Exception as e:
424
+ logger.error(f"❌ LoRA 어댑터 언로드 실패: {e}")
425
+ return False
426
+
427
+ def generate_text(self, prompt: str, max_length: int = 100, temperature: float = 0.7) -> str:
428
+ """LoRA 모델을 사용한 텍스트 생성"""
429
+ try:
430
+ if self.lora_model is None:
431
+ raise ValueError("LoRA 모델이 로드되지 않았습니다.")
432
+
433
+ if self.tokenizer is None:
434
+ raise ValueError("토크나이저가 로드되지 않았습니다.")
435
+
436
+ # 입력 토크나이징
437
+ inputs = self.tokenizer(prompt, return_tensors="pt")
438
+ inputs = {k: v.to(self.device) for k, v in inputs.items()}
439
+
440
+ # 추론 모드로 설정
441
+ self.lora_model.eval()
442
+
443
+ with torch.no_grad():
444
+ outputs = self.lora_model.generate(
445
+ **inputs,
446
+ max_new_tokens=max_length,
447
+ temperature=temperature,
448
+ do_sample=True,
449
+ pad_token_id=self.tokenizer.eos_token_id
450
+ )
451
+
452
+ # 응답 디코딩
453
+ response = self.tokenizer.decode(outputs[0], skip_special_tokens=True)
454
+
455
+ # 프롬프트 제거
456
+ if response.startswith(prompt):
457
+ response = response[len(prompt):].strip()
458
+
459
+ return response
460
+
461
+ except Exception as e:
462
+ logger.error(f"❌ 텍스트 생성 실패: {e}")
463
+ return f"텍스트 생성 중 오류가 발생했습니다: {str(e)}"
464
+
465
+ def prepare_for_training(self, training_args: TrainingArguments = None) -> bool:
466
+ """훈련을 위한 모델 준비"""
467
+ try:
468
+ if self.lora_model is None:
469
+ raise ValueError("LoRA 모델이 로드되지 않았습니다.")
470
+
471
+ logger.info("🔧 훈련을 위한 모델 준비 시작")
472
+
473
+ # 기본 훈련 인수
474
+ if training_args is None:
475
+ training_args = TrainingArguments(
476
+ output_dir="./lora_training_output",
477
+ num_train_epochs=3,
478
+ per_device_train_batch_size=4,
479
+ gradient_accumulation_steps=4,
480
+ learning_rate=2e-4,
481
+ warmup_steps=100,
482
+ logging_steps=10,
483
+ save_steps=500,
484
+ eval_steps=500,
485
+ evaluation_strategy="steps",
486
+ save_strategy="steps",
487
+ load_best_model_at_end=True,
488
+ metric_for_best_model="eval_loss",
489
+ greater_is_better=False,
490
+ fp16=torch.cuda.is_available(),
491
+ dataloader_pin_memory=False,
492
+ )
493
+
494
+ # 훈련 모드로 설정
495
+ self.lora_model.train()
496
+
497
+ # 그래디언트 체크포인팅 활성화 (메모리 ���약)
498
+ self.lora_model.gradient_checkpointing_enable()
499
+
500
+ # 그래디언트 클리핑 설정
501
+ self.lora_model.enable_input_require_grads()
502
+
503
+ logger.info("✅ 훈련을 위한 모델 준비 완료")
504
+ return True
505
+
506
+ except Exception as e:
507
+ logger.error(f"❌ 훈련 준비 실패: {e}")
508
+ return False
509
+
510
+ # 전역 LoRA 관리자 인스턴스 (안전한 생성)
511
+ try:
512
+ if PEFT_AVAILABLE and TRANSFORMERS_AVAILABLE:
513
+ lora_manager = LoRAManager()
514
+ logger.info("✅ 전역 LoRA 관리자 인스턴스 생성 완료")
515
+ else:
516
+ lora_manager = None
517
+ logger.warning("⚠️ LoRA 라이브러리가 사용 불가능하여 LoRA 관리자를 생성하지 않았습니다.")
518
+ except Exception as e:
519
+ lora_manager = None
520
+ logger.error(f"❌ LoRA 관리자 인스턴스 생성 실패: {e}")
521
+
522
+ def get_lora_manager() -> Optional[LoRAManager]:
523
+ """전역 LoRA 관리자 반환 (None일 수 있음)"""
524
+ return lora_manager
requirements_full_lily_250821_2206_lora.txt ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ accelerate==1.10.0
2
+ aiohappyeyeballs==2.6.1
3
+ aiohttp==3.12.15
4
+ aiosignal==1.4.0
5
+ amqp==5.3.1
6
+ annotated-types==0.7.0
7
+ anyio==4.10.0
8
+ attrs==25.3.0
9
+ bcrypt==4.3.0
10
+ billiard==4.2.1
11
+ bitsandbytes==0.47.0
12
+ celery==5.5.3
13
+ certifi==2025.8.3
14
+ cffi==1.17.1
15
+ charset-normalizer==3.4.3
16
+ click==8.2.1
17
+ click-didyoumean==0.3.1
18
+ click-plugins==1.1.1.2
19
+ click-repl==0.3.0
20
+ colorama==0.4.6
21
+ cryptography==45.0.6
22
+ dataclasses-json==0.6.7
23
+ easyocr==1.7.2
24
+ ecdsa==0.19.1
25
+ einops==0.8.1
26
+ faiss-cpu==1.12.0
27
+ fastapi==0.116.1
28
+ filelock==3.19.1
29
+ frozenlist==1.7.0
30
+ fsspec==2025.7.0
31
+ greenlet==3.2.4
32
+ h11==0.16.0
33
+ httpcore==1.0.9
34
+ httptools==0.6.4
35
+ httpx==0.28.1
36
+ httpx-sse==0.4.1
37
+ huggingface-hub==0.34.4
38
+ idna==3.10
39
+ imageio==2.37.0
40
+ intel-openmp==2021.4.0
41
+ Jinja2==3.1.6
42
+ joblib==1.5.1
43
+ jsonpatch==1.33
44
+ jsonpointer==3.0.0
45
+ kombu==5.5.4
46
+ langchain==0.3.27
47
+ langchain-community==0.3.27
48
+ langchain-core==0.3.74
49
+ langchain-text-splitters==0.3.9
50
+ langsmith==0.4.14
51
+ lazy_loader==0.4
52
+ lxml==6.0.0
53
+ markdown-it-py==3.0.0
54
+ MarkupSafe==3.0.2
55
+ marshmallow==3.26.1
56
+ mdurl==0.1.2
57
+ mkl==2021.4.0
58
+ mpmath==1.3.0
59
+ multidict==6.6.4
60
+ mypy_extensions==1.1.0
61
+ networkx==3.5
62
+ ninja==1.13.0
63
+ nltk==3.9.1
64
+ numpy==1.26.4
65
+ opencv-python-headless==4.11.0.86
66
+ orjson==3.11.2
67
+ packaging==25.0
68
+ pandas==2.3.1
69
+ passlib==1.7.4
70
+ peft==0.15.0
71
+ pillow==11.3.0
72
+ prompt_toolkit==3.0.51
73
+ propcache==0.3.2
74
+ psutil==7.0.0
75
+ pyasn1==0.6.1
76
+ pyclipper==1.3.0.post6
77
+ pycparser==2.22
78
+ pydantic==2.11.7
79
+ pydantic-settings==2.10.1
80
+ pydantic_core==2.33.2
81
+ PyJWT==2.10.1
82
+ PyMuPDF==1.26.3
83
+ pytesseract==0.3.13
84
+ python-bidi==0.6.6
85
+ python-dateutil==2.9.0.post0
86
+ python-docx==1.2.0
87
+ python-dotenv==1.1.1
88
+ python-jose==3.5.0
89
+ python-json-logger==3.3.0
90
+ python-multipart==0.0.20
91
+ python-pptx==1.0.2
92
+ pytz==2025.2
93
+ PyYAML==6.0.2
94
+ redis==6.4.0
95
+ regex==2025.7.34
96
+ requests==2.32.5
97
+ requests-toolbelt==1.0.0
98
+ rsa==4.9.1
99
+ safetensors==0.6.2
100
+ scikit-image==0.25.2
101
+ scikit-learn==1.7.1
102
+ scipy==1.16.1
103
+ sentence-transformers==2.2.2
104
+ sentencepiece==0.2.1
105
+ shapely==2.1.1
106
+ six==1.17.0
107
+ sniffio==1.3.1
108
+ SQLAlchemy==2.0.43
109
+ starlette==0.47.2
110
+ sympy==1.14.0
111
+ tbb==2021.13.1
112
+ tenacity==9.1.2
113
+ threadpoolctl==3.6.0
114
+ tifffile==2025.6.11
115
+ timm==1.0.19
116
+ tokenizers==0.21.4
117
+ torch==2.3.1
118
+ torchvision==0.18.1
119
+ tqdm==4.67.1
120
+ transformers==4.55.2
121
+ typing-inspect==0.9.0
122
+ typing-inspection==0.4.1
123
+ typing_extensions==4.14.1
124
+ tzdata==2025.2
125
+ urllib3==2.5.0
126
+ uvicorn==0.35.0
127
+ vine==5.1.0
128
+ watchfiles==1.1.0
129
+ wcwidth==0.2.13
130
+ websockets==15.0.1
131
+ xlsxwriter==3.2.5
132
+ yarl==1.20.1
133
+ zstandard==0.24.0
test_auto_cleanup.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ 자동 정리 기능 테스트 스크립트
4
+ """
5
+
6
+ import requests
7
+ import time
8
+ import json
9
+
10
+ # API 기본 URL
11
+ BASE_URL = "http://localhost:8001"
12
+
13
+ def test_auto_cleanup_config():
14
+ """자동 정리 설정 테스트"""
15
+ print("🔧 자동 정리 설정 테스트")
16
+ print("=" * 50)
17
+
18
+ # 1. 현재 설정 조회
19
+ print("1. 현재 자동 정리 설정 조회:")
20
+ response = requests.get(f"{BASE_URL}/context/auto-cleanup")
21
+ if response.status_code == 200:
22
+ config = response.json()
23
+ print(f" ✅ 설정 조회 성공: {json.dumps(config, indent=2, ensure_ascii=False)}")
24
+ else:
25
+ print(f" ❌ 설정 조회 실패: {response.status_code}")
26
+ return
27
+
28
+ # 2. 설정 변경 테스트
29
+ print("\n2. 자동 정리 설정 변경 (4턴마다, 2분마다):")
30
+ new_config = {
31
+ "enabled": True,
32
+ "interval_turns": 4,
33
+ "interval_time": 120,
34
+ "strategy": "smart"
35
+ }
36
+
37
+ response = requests.post(f"{BASE_URL}/context/auto-cleanup", data=new_config)
38
+ if response.status_code == 200:
39
+ result = response.json()
40
+ print(f" ✅ 설정 변경 성공: {json.dumps(result, indent=2, ensure_ascii=False)}")
41
+ else:
42
+ print(f" ❌ 설정 변경 실패: {response.status_code}")
43
+ return
44
+
45
+ # 3. 변경된 설정 확인
46
+ print("\n3. 변경된 설정 확인:")
47
+ response = requests.get(f"{BASE_URL}/context/auto-cleanup")
48
+ if response.status_code == 200:
49
+ config = response.json()
50
+ print(f" ✅ 설정 확인: {json.dumps(config, indent=2, ensure_ascii=False)}")
51
+ else:
52
+ print(f" ❌ 설정 확인 실패: {response.status_code}")
53
+
54
+ def test_context_generation():
55
+ """컨텍스트 생성 테스트 (자동 정리 트리거)"""
56
+ print("\n🔄 컨텍스트 생성 테스트 (자동 정리 트리거)")
57
+ print("=" * 50)
58
+
59
+ session_id = f"test_session_{int(time.time())}"
60
+
61
+ # 6턴의 대화 생성 (4턴마다 정리되도록 설정했으므로)
62
+ for i in range(6):
63
+ print(f"\n--- 턴 {i+1} ---")
64
+
65
+ # 사용자 메시지 전송
66
+ user_message = f"테스트 메시지 {i+1}: 이것은 자동 정리 테스트를 위한 메시지입니다."
67
+ print(f"사용자: {user_message}")
68
+
69
+ response = requests.post(f"{BASE_URL}/generate", data={
70
+ "prompt": user_message,
71
+ "use_context": True,
72
+ "session_id": session_id
73
+ })
74
+
75
+ if response.status_code == 200:
76
+ result = response.json()
77
+ print(f"AI 응답: {result['generated_text'][:100]}...")
78
+ else:
79
+ print(f"❌ 생성 실패: {response.status_code}")
80
+ return
81
+
82
+ # 컨텍스트 상태 확인
83
+ response = requests.get(f"{BASE_URL}/context/status")
84
+ if response.status_code == 200:
85
+ status = response.json()
86
+ if session_id in status.get("sessions", {}):
87
+ turns = status["sessions"][session_id]["turns"]
88
+ print(f" 📊 현재 턴 수: {turns}")
89
+
90
+ # 잠시 대기
91
+ time.sleep(1)
92
+
93
+ def test_manual_cleanup():
94
+ """수동 정리 테스트"""
95
+ print("\n🧹 수동 정리 테스트")
96
+ print("=" * 50)
97
+
98
+ # 1. 특정 세션 수동 정리
99
+ session_id = f"test_session_{int(time.time())}"
100
+ print(f"1. 세션 {session_id} 수동 정리:")
101
+
102
+ response = requests.post(f"{BASE_URL}/context/cleanup/{session_id}")
103
+ if response.status_code == 200:
104
+ result = response.json()
105
+ print(f" ✅ 수동 정리 성공: {json.dumps(result, indent=2, ensure_ascii=False)}")
106
+ else:
107
+ print(f" ❌ 수동 정리 실패: {response.status_code}")
108
+
109
+ # 2. 모든 세션 수동 정리
110
+ print("\n2. 모든 세션 수동 정리:")
111
+ response = requests.post(f"{BASE_URL}/context/cleanup-all")
112
+ if response.status_code == 200:
113
+ result = response.json()
114
+ print(f" ✅ 전체 정리 성공: {json.dumps(result, indent=2, ensure_ascii=False)}")
115
+ else:
116
+ print(f" ❌ 전체 정리 실패: {response.status_code}")
117
+
118
+ def test_context_status():
119
+ """컨텍스트 상태 확인"""
120
+ print("\n📊 컨텍스트 상태 확인")
121
+ print("=" * 50)
122
+
123
+ response = requests.get(f"{BASE_URL}/context/status")
124
+ if response.status_code == 200:
125
+ status = response.json()
126
+ print(f"✅ 상태 조회 성공:")
127
+ print(f" - 총 세션 수: {status.get('total_sessions', 0)}")
128
+ print(f" - 최대 턴 수: {status.get('max_turns', 0)}")
129
+ print(f" - 전략: {status.get('strategy', 'unknown')}")
130
+
131
+ if "sessions" in status:
132
+ print(" - 세션별 정보:")
133
+ for session_id, session_info in status["sessions"].items():
134
+ print(f" * {session_id}: {session_info['turns']} 턴")
135
+ else:
136
+ print(f"❌ 상태 조회 실패: {response.status_code}")
137
+
138
+ def main():
139
+ """메인 테스트 함수"""
140
+ print("🚀 자동 정리 기능 테스트 시작")
141
+ print("=" * 60)
142
+
143
+ try:
144
+ # 1. 자동 정리 설정 테스트
145
+ test_auto_cleanup_config()
146
+
147
+ # 2. 컨텍스트 생성 테스트 (자동 정리 트리거)
148
+ test_context_generation()
149
+
150
+ # 3. 수동 정리 테스트
151
+ test_manual_cleanup()
152
+
153
+ # 4. 최종 상태 확인
154
+ test_context_status()
155
+
156
+ print("\n🎉 모든 테스트 완료!")
157
+
158
+ except Exception as e:
159
+ print(f"\n❌ 테스트 중 오류 발생: {e}")
160
+ import traceback
161
+ traceback.print_exc()
162
+
163
+ if __name__ == "__main__":
164
+ main()