Jump to content

전체 활동내역

This stream auto-updates

  1. Today
  2. Yesterday
  3. Last week
  4. LLM 애플리케이션의 성능은 모델의 크키뿐만 아니라 주입되는 데이터의 품질, 즉 전처리에도 큰 영향을 받습니다. 특히 문서를 참고하여 답변을 생성해야 하는 RAG(검색 증강 생성) 시스템에서 데이터 전처리는 선택이 아닌 필수이며, 비정형 데이터를 얼마나 잘 정제하느냐가 최종 응답의 품질을 결정합니다. 복잡한 표나 불규칙한 레이아웃을 가진 문서를 그대로 입력하면 모델이 구조 파악에 리소스를 낭비하게 되므로, 이를 기계가 읽기 쉬운 형식으로 변환하는 작업이 핵심입니다. 이때 Markdown 변환이 효과적인 이유는 텍스트의 구조와 위계를 명확히 표현하면서도 토큰 효율이 높아, LLM이 문서의 논리적 흐름을 가장 정확하게 이해할 수 있기 때문입니다. 이처럼 잘 정제된 데이터는 할루시네이션을 줄이고 서비스의 신뢰도를 높이는 강력한 기반이 됩니다. 이번 쿡북에서는 CLOVA Studio의 HyperCLOVA X 비전 모델과 Docling을 활용해, PDF 문서를 분석하고 완성도 높은 Markdown으로 변환하는 실전 가이드를 소개합니다. Docling Docling은 IBM Research에서 개발한 오픈소스 Python 라이브러리로, PDF, DOCX, PPTX, XLSX부터 HTML, Markdown, 그리고 이미지(PNG, JPEG, TIFF)와 오디오(MP3, WAV)까지 다양한 형식의 문서를 구조화된 데이터로 변환해주는 도구입니다. 일반적인 변환 도구와 달리 Docling은 내부적으로 시각적 레이아웃 분석 모델, TableFormer와 같은 전용 모델을 활용하여 문서의 레이아웃을 분석하고, 제목, 표, 이미지, 수식, 코드 블록의 위치와 구조를 파악합니다. 예를 들어 복잡한 표가 포함된 PDF 보고서를 처리하면 표의 행과 열 구조를 정확히 인식하고, 텍스트의 읽기 순서를 파악하며, 이 모든 정보를 Markdown, HTML, JSON 같은 형식으로 깔끔하게 내보낼 수 있습니다. 유사한 도구로는 빠른 문서 변환에 특화된 경량 라이브러리 Markitdown과 책이나 기술 문서 처리에 최적화된 Marker 등이 있습니다. 예제 데이터 소개 다음은 전처리 과정에 사용할 PDF 예제입니다. 네이버 통합 보고서 2024에서 다음 세 페이지를 발췌해 예제로 활용합니다. 해당 예제에는 텍스트, 이미지, 표가 적절히 섞여있고 각기 다른 레이아웃을 가지고 있어 예제로 선정하였습니다. 전처리 과정 구현 전처리 전략 문서에는 텍스트 외에도 표, 차트, 다이어그램 같은 시각적 요소가 포함되어 있으며, 이들은 별도의 해석이 필요합니다. 이번 가이드는 Docling과 HyperCLOVA X를 단계적으로 활용하는 전처리 전략을 다룹니다. Docling은 문서 구조를 파악하고 이미지를 추출하는 역할을 합니다. 이미지 위치를 식별하고 내부 텍스트를 OCR로 읽을 수 있지만, "이 차트가 무엇을 의미하는가"와 같은 해석은 수행하지 않습니다. 따라서 역할을 분리합니다. Docling은 문서 구조 정리와 텍스트 및 이미지 추출을, HyperCLOVA X 비전 모델(HCX-005)은 이미지 해석을 담당합니다. 각 도구의 강점을 활용하여 효율적인 문서 전처리 파이프라인을 구성할 수 있습니다. 사전 준비 전처리 구현을 진행하기 전에 가상환경, 라이브러리 설치 등 사전 준비 과정을 안내합니다. 루트 디렉토리의 터미널에서 다음 명령어를 통해 필요한 라이브러리 설치합니다. 가상환경 설치를 권장합니다. # 가상환경 설정 python -m venv .venv # 가상환경 활성화(mac) source .venv/bin/activate # 가상환경 활성화(window,cmd) # .venv\Scripts\activate.bat # 라이브러리 설치 pip install docling pdf2image easyocr openai python-dotenv ipywidgets 루트 디렉토리에 .env 파일을 만들고, 필요한 환경변수를 설정합니다. CLOVA Studio API 키 발급 방법은 CLOVA Studio API 가이드에서 확인할 수 있습니다. 이번 실습에서는 CLOVA Studio의 OpenAI 호환 API를 사용합니다. BASE_URL에 대한 자세한 내용은 CLOVA Studio OpenAI 호환 API 가이드를 확인해 주세요. 코드 구현 이번 실습은 IPython Notebook(.ipynb)으로 진행됩니다. 실습에 사용할 디렉토리에 예제 데이터를 넣어주세요. Step 1. 본격적으로 실행에 앞서 환경 변수를 불러옵니다. from dotenv import load_dotenv load_dotenv() True Step 2.다음으로 필요한 라이브러리를 불러옵니다. 이번 실습에서는 GPU 설정을 따로 하지 않습니다. 환경에 따라서 추가 설정이 가능합니다. import time from docling.document_converter import DocumentConverter, PdfFormatOption from docling.datamodel.base_models import InputFormat from docling.datamodel.pipeline_options import PdfPipelineOptions import easyocr import os import base64 import io from openai import OpenAI from dotenv import load_dotenv from docling_core.types.doc import PictureItem import warnings warnings.filterwarnings('ignore', category=UserWarning, module='torch.utils.data.dataloader') # 환경 변수 로드 load_dotenv() # EasyOCR 리더 초기화 (한국어, 영어 지원) reader = easyocr.Reader(['ko', 'en'], gpu=False) print("✓ 라이브러리 로드 완료") 2026-01-21 16:02:39,723 - WARNING - Using CPU. Note: This module is much faster with a GPU. ✓ 라이브러리 로드 완료 Step 3. Docling으로 PDF를 변환합니다. 먼저 export_to_markdown()으로 디지털 텍스트를 추출한 다음 저장합니다. Docling의 do_ocr=True 설정은 디지털 텍스트가 있는 페이지는 직접 추출하고, 텍스트 정보가 없는 스캔 페이지만 선택적으로 OCR을 수행하여 변환 효율을 극대화합니다. # 예제 데이터 pdf_path = "preprocessing_cookbook_example.pdf" print(f"\nDocling 변환 시작: {pdf_path}") start_time = time.time() # Docling 파이프라인 설정 pipeline_options = PdfPipelineOptions() pipeline_options.do_ocr = True # Docling이 알아서 필요한 곳만 OCR pipeline_options.do_table_structure = True pipeline_options.generate_picture_images = True pipeline_options.generate_page_images = False # DocumentConverter 초기화 docling_converter = DocumentConverter( format_options={ InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options) } ) # PDF 변환 실행 docling_result = docling_converter.convert(pdf_path) markdown = docling_result.document.export_to_markdown() # 깨지는 문자열 전처리 markdown = markdown.replace('뭃', '•') elapsed = time.time() - start_time print(f"✓ Docling 변환 완료: {elapsed:.1f}초 소요") # 이미지 저장 디렉토리 생성 os.makedirs("figure", exist_ok=True) 2026-01-21 16:02:41,654 - INFO - detected formats: [<InputFormat.PDF: 'pdf'>] 2026-01-21 16:02:41,705 - INFO - Going to convert document batch... 2026-01-21 16:02:41,705 - INFO - Initializing pipeline for StandardPdfPipeline with options hash 8e7b949cc226caef8aab3aadca70e8e7 2026-01-21 16:02:41,715 - INFO - Loading plugin 'docling_defaults' 2026-01-21 16:02:41,717 - INFO - Registered picture descriptions: ['vlm', 'api'] 2026-01-21 16:02:41,721 - INFO - Loading plugin 'docling_defaults' 2026-01-21 16:02:41,724 - INFO - Registered ocr engines: ['auto', 'easyocr', 'ocrmac', 'rapidocr', 'tesserocr', 'tesseract'] Docling 변환 시작: preprocessing_cookbook_example.pdf 2026-01-21 16:02:42,248 - INFO - Auto OCR model selected ocrmac. 2026-01-21 16:02:42,258 - INFO - Loading plugin 'docling_defaults' 2026-01-21 16:02:42,261 - INFO - Registered layout engines: ['docling_layout_default', 'docling_experimental_table_crops_layout'] 2026-01-21 16:02:42,704 - INFO - Accelerator device: 'mps' 2026-01-21 16:02:43,983 - INFO - Loading plugin 'docling_defaults' 2026-01-21 16:02:43,984 - INFO - Registered table structure engines: ['docling_tableformer'] 2026-01-21 16:02:44,238 - INFO - Accelerator device: 'mps' 2026-01-21 16:02:44,803 - INFO - Processing document preprocessing_cookbook_example.pdf 2026-01-21 16:02:48,571 - INFO - Finished converting document preprocessing_cookbook_example.pdf in 6.92 sec. ✓ Docling 변환 완료: 6.9초 소요 다음은 export_to_markdown()으로 pdf에서 텍스트만 추출한 결과입니다. 이후 과정에서 <!-- image -->가 이미지에 대한 설명으로 대체됩니다. Step 4. PDF 내부 이미지를 저장할 디렉토리를 생성합니다. # 이미지 저장 디렉토리 생성 os.makedirs("figure", exist_ok=True) # HCX 클라이언트 초기화 hcx_client = OpenAI( api_key=os.getenv("CLOVA_STUDIO_API_KEY"), base_url=os.getenv("CLOVA_STUDIO_BASE_URL") ) # 이미지 처리를 위한 변수 초기화 image_counter = 0 image_refs = [] image_analyses = [] print("✓ 이미지 추출 및 분석 준비 완료") ✓ 이미지 추출 및 분석 준비 완료 Step 5. 문서에서 이미지를 추출한 다음 HCX-005 모델로 분석합니다. PDF의 각 페이지를 PNG로 저장한 뒤 HCX-005 모델로 내용을 분석합니다. 이때 이미지 크기는 HCX-005 모델 규격에 따라야하며, 원활한 처리를 위한 리사이징이 필요할 수 있습니다. 분석된 시각적 정보는 추출된 텍스트와 결합하여 최종적인 마크다운 문서로 완성됩니다. print(f"\n이미지 추출 및 분석 시작...") for element, _level in docling_result.document.iterate_items(): if isinstance(element, PictureItem): image_counter += 1 pil_image = element.get_image(docling_result.document) # 이미지 파일로 저장 image_filename = f"extracted_image_{image_counter}.png" image_path = os.path.join("figure", image_filename) pil_image.save(image_path, format="PNG") # 마크다운 이미지 참조 생성 image_ref = f"![Figure {image_counter}](figure/{image_filename})" image_refs.append(image_ref) print(f" [{image_counter}] {image_filename} ({pil_image.size[0]}×{pil_image.size[1]}px)") # HCX Vision API로 이미지 분석 try: # 이미지를 base64로 인코딩 buffer = io.BytesIO() pil_image.save(buffer, format="PNG", optimize=True) base64_image = base64.b64encode(buffer.getvalue()).decode() print(f" → HCX 분석 중...", end=" ") # HCX API 호출 response = hcx_client.chat.completions.create( model="hcx-005", messages=[{ "role": "user", "content": [ { "type": "text", "text": f""" # 이미지를 분석하여 다음 양식으로 추출하세요: [Figure {image_counter}] (간단한 제목) 1. **유형**: (차트/표/다이어그램 등) 2. **내용 요약**: 핵심 정보 2-3줄 3. **데이터**: - 차트: 주요 트렌드와 수치 (중요 수치 **굵게**) - 표: 마크다운 테이블로 변환 4. **분석**: 데이터에서 발견되는 인사이트나 특징 2-3줄 한국어로 간결하게 작성하고, 제목도 간단 명료하게 작성하세요.""" }, { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"} } ] }], max_tokens=1500 ) analysis = response.choices[0].message.content image_analyses.append(f"\n\n{analysis}") print("✓") except Exception as e: print(f"✗ 오류: {str(e)}") image_analyses.append(f"\n\n**[Figure {image_counter}]**\n\n오류: {str(e)}") print(f"\n✓ {image_counter}개 이미지 처리 완료") 이미지 추출 및 분석 시작... [1] extracted_image_1.png (53×51px) → HCX 분석 중... 2026-01-21 16:02:51,940 - INFO - HTTP Request: POST https://clovastudio.stream.ntruss.com/v1/openai/chat/completions "HTTP/1.1 200 OK" ✓ . . . [13] extracted_image_13.png (42×10px) → HCX 분석 중... 2026-01-21 16:03:54,139 - INFO - HTTP Request: POST https://clovastudio.stream.ntruss.com/v1/openai/chat/completions "HTTP/1.1 200 OK" ✓ ✓ 13개 이미지 처리 완료 Step 6. 이미지 분석 결과를 마크다운에 통합하고 최종 파일에 저장합니다. 이전에 추출한 디지털 텍스트에서 <!-- image --> 부분을 실제 이미지 및 분석으로 교체합니다. 그리고 최종 결과를 마크다운 파일로 저장합니다. print(f"\n마크다운 통합 중...") # 이미지 참조와 분석 결과를 마크다운에 삽입 for image_ref, analysis in zip(image_refs, image_analyses): markdown = markdown.replace("<!-- image -->", f"{image_ref}{analysis}", 1) # 최종 파일 저장 output_md = "output_docling_hcx.md" with open(output_md, 'w', encoding='utf-8') as f: f.write(markdown) print(f"✓ 최종 마크다운 저장 완료: {output_md}") print(f"\n=== 처리 요약 ===") print(f" 추출 이미지: {image_counter}개") print(f" 출력 파일: {output_md}") 마크다운 통합 중... ✓ 최종 마크다운 저장 완료: output_docling_hcx.md === 처리 요약 === 추출 이미지: 13개 출력 파일: output_docling_hcx.md 전처리 결과 최종적으로 예제 데이터를 전처리한 결과입니다. 마크다운 렌더링 예시는 다음과 같습니다. 맺음말 이번 쿡북에서는 Docling과 HyperCLOVA X 모델을 도입하여 PDF 문서를 Markdown으로 변환하는 방법에 대해 알아보았습니다. 단순히 글자를 읽어오는 것을 넘어, Docling으로 문서의 전체적인 구조를 잡고 HyperCLOVA X의 비전 기능을 통해 문서 속 이미지와 차트의 의미까지 정확하게 추출하는 과정이 핵심이었습니다. 이처럼 텍스트와 시각 정보를 함께 정제하는 전처리 방식은 이후 모델이 데이터의 맥락을 깊이 있게 이해하도록 돕는 필수적인 단계입니다. 데이터 전처리는 정교한 LLM 서비스를 완성하기 위한 시작점이자 가장 중요한 기반입니다. 이번에 소개해 드린 가이드가 여러분의 프로젝트에서 고품질의 데이터를 확보하고, 더 나아가 사용자에게 신뢰받는 AI 서비스를 구축하는 데 유용한 밑거름이 되기를 바랍니다.
  5. Earlier
  6. 네이버 지도 서비스에서 사용하는 거리뷰 뷰어는 NCP 서비스로 따로 제공하지 않습니다. 감사합니다.
  7. 안녕하세요, @sseul님, API 연동/이용 방식이 간소화 되었습니다. 이제는 테스트 앱을 생성할 필요 없이 API 키 발급만으로 바로 CLOVA Studio의 모든 기능을 API로 이용하실 수 있습니다. 좌측의 API 키 메뉴를 통해 테스트 API 키 발급 부탁드립니다. https://guide.ncloud-docs.com/docs/clovastudio-playground-viewsource 감사합니다.
  8. 안녕하세요. 혹시 지금은 테스트앱 생성이 불가능한가요?
  9. api를 이용하여 파노라마 뷰를 불러올 경우 촬영용 차량이 함께 나오는데 네이버 지도에서 거리뷰 열람하는 것처럼 촬영용 차량이 블리인드 된 버전으로 받는 것은 불가능할까요?
  10. 혹시 maps style editor 설정하셨는지 확인해보시기 바랍니다
  11. 네이버 포럼에 뭐 질문 올려도 관리를 안하는건지 그냥 방치하는건지... 이런 문제를 겪고 있는 사람들이 많은거 같더라구요
  12. TOON, LLM 입력 구조를 다시 생각하게 만든 포맷 대규모 언어 모델(LLM)에서 연산 속도와 토큰 효율을 높이면서 응답 품질을 유지하거나 향상시키려는 시도는 계속되고 있습니다. 특히 엔터프라이즈 환경에서는 토큰 사용량과 Latency가 곧 비용과 사용자 경험으로 직결됩니다. JSON은 구조가 명확하고 범용성이 높아 LLM 입력 포맷으로 널리 활용되고 있습니다. 그러나 반복되는 key-value 구조와 다양한 구분자는 토큰 소모를 빠르게 증가시키고, 결국 연산 비용이 증가하는 문제도 있습니다. 오늘은 이러한 문제를 해결하기 위해 등장한 TOON(Token-Oriented Object Notation) 포맷을 살펴봅니다. TOON은 JSON의 표현력을 유지하면서도, 동일한 정보를 더 간결하게 표현해 토큰 사용량을 줄이고 LLM이 구조를 더 효율적으로 해석하도록 설계된 입력 포맷입니다. TOON의 경량화된 구조 이미지 출처: https://github.com/toon-format/toon TOON은 반복되는 객체 리스트를 탭형(tabular) 구조로 표현할 수 있도록 설계된 입력 포맷입니다. 계층 구조는 들여쓰기로 유지하면서, 동일한 구조의 배열에 대해서는 필드 목록을 한 번만 선언하고 이후 값을 행(row) 단위로 나열합니다. 이 방식은 JSON에서 반복적으로 등장하는 key-value 구조를 제거해 입력을 간결하게 만듭니다. 반복된 구조를 압축적으로 표현해 토큰 사용량을 크게 줄일 수 있습니다. 모델이 구조를 더 쉽게 파악해 특정 Task에서 속도 개선 가능성이 있습니다. 사람이 읽고 비교, 검수하기 쉬운 간결한 구조를 제공합니다. 예를 들어 JSON의 product 리스트가 다음과 같다면, { "products": [ { "product_id": "301", "name": "무선 마우스", "price": "29900", "stock": "재고 있음", "rating": "4.5" }, { "product_id": "302", "name": "기계식 키보드", "price": "89000", "stock": "재고 부족", "rating": "4.8" }, { "product_id": "303", "name": "USB-C 허브", "price": "45500", "stock": "품절", "rating": "4.1" } ] } TOON에서는 아래와 같이 표현할 수 있습니다. products[3]{product_id,name,price,stock,rating}: 301,무선 마우스,29900,재고 있음,4.5 302,기계식 키보드,89000,재고 부족,4.8 303,USB-C 허브,45500,품절,4.1 동일한 정보이지만 반복되는 key가 제거되면서 토큰 사용량은 크게 줄어들고, LLM이 인식해야 할 구조 역시 훨씬 단순해집니다. 입력 포맷별 벤치마크 결과 다음은 TOON GitHub에서 공개된 벤치마크 결과입니다. TOON, JSON, YAML, XML 등 주요 포맷을 대상으로 비교 실험을 진행했습니다. 입력 포맷의 효율성은 1,000 토큰당 정확도(acc% / 1K tokens) 기준으로 평가되었습니다. 즉, 같은 질문을 풀었을 때의 정답률을 토큰 사용량으로 나눈 지표입니다. 이 기준에서 TOON은 JSON 대비 약 39.6% 적은 토큰을 사용하면서도, 더 높은 정확도(73.9% vs 69.7%)를 기록했습니다. 즉, 표현 방식만 바꿨는데도, 토큰 대비 성능 지표에서 변화가 관찰되었습니다. ※ CSV는 단순한 테이블 데이터에서는 매우 효율적이지만, 복합적인 구조를 표현하는 데 한계가 있어 전체 비교에서는 제외되었습니다. HyperCLOVA X 모델로 실험하기 CLOVA Studio에서 HyperCLOVA X 모델을 활용해 JSON과 TOON을 비교했습니다. 이번 실험은 입력 포맷을 변경했을 때 나타나는 차이와 특성을 살펴보는 데 초점을 두었습니다. 1. 단순 구조 데이터 대규모 JSON 배열을 그대로 전달하는 상황에서는 TOON의 특성이 비교적 명확하게 드러났습니다. 동일한 데이터를 TOON 형식으로 변환했을 때, Prompt token 사용량이 약 27.3% 감소했습니다. 2. Reasoning 중심 Task KMMLU와 같은 추론 중심 Task에서는 JSON이 더 안정적인 결과를 보였습니다. 정답률 측면에서 JSON이 우세했으며, TOON의 토큰 효율성은 이 영역에서 의미 있는 차이를 만들지 못했습니다. 3. RAG 기반 Task RAG 기반 Task에서는 일부 작업에서 긍정적인 경향이 관찰되었습니다. 특히 요약, 비교, 추천, 정보 추출처럼 Retrieval 결과를 후처리하는 유형의 Task에서 TOON이 상대적으로 안정적인 성능을 보였습니다. 4. API 응답/로그 분석 API 응답이나 로그 데이터처럼 반복 패턴이 많은 데이터를 다루는 Task에서도 TOON이 유리한 경향을 보였습니다. 이상 탐지, 패턴 분석, 단순 요약과 같은 작업에서 입력 크기가 줄어들면서 처리 효율이 개선되는 모습을 확인할 수 있었습니다. 마치며 모델 자체를 변경하지 않고 입력 포맷을 조정하는 것만으로도, 특정 유형의 작업에서는 토큰 사용량과 처리 효율에 차이를 만들 수 있음을 확인했습니다. TOON은 모든 상황에 적용할 수 있는 해법은 아니지만, 구조가 단순하고 반복적인 데이터가 많은 영역에서는 하나의 선택지가 될 수 있습니다. 결국 LLM 입력에서도 중요한 것은, 정보를 얼마나 효율적인 형태로 전달하고 있는가일 것입니다. 이번 포스팅은 여기서 마무리하며, 다음에도 유용한 활용 팁으로 찾아오겠습니다.
  13. 안녕하세요, @leeeg님, 클로바 스튜디오 이용 요금은 아래 링크에서 확인하실 수 있습니다. https://www.ncloud.com/v2/product/aiService/clovaStudio#pricing 감사합니다.
  14. 하이퍼클로바x 각종 모델들 답변 요청 api 호출하는데 드는 비용을 알고 싶습니다. 토큰 사용량에 따른 비용을 정확하게 알고 싶습니다. openai api 사용 비용 표시 처럼 그런 안내 사이트, 페이지가 있다면 링크를 알려주세요. 감사합니다.
  15. 안녕하세요 @jwpark님, CLOVA Studio를 이용해주셔서 감사합니다. 리랭커 API는 llm 모델을 기반으로 하고 있기 때문에 동일한 데이터를 input으로 사용하더라도 호출에 따라 최종 문서 기반 생성 결과인 'result'가 조금씩 상이하게 나타날 수 있으며, 이에 따라 result 생성에 사용된 연관 문서들도 조금씩 달라질 수 있습니다. 그 외 추가로 궁금한 점이 있으시면 편히 말씀주세요. 고맙습니다.
  16. 안녕하세요 rerank api 관련하여 문의드립니다. rerank api를 사용하여 동일한 쿼리와 그에 해당하는 동일한 검색된 문서 간의 연관도를 평가할 때, 그 결과가 간헐적으로 달라질 수도 있나요? 감사합니다.
  17. Xcode에서 swift ui 로 개발중입니다. 안드로이드 스튜디오 flutter 에 관한 내용이 많아 이틀째 해결하지 못해 글 남깁니다. 앱 삭제 후 재생성, Maps 페이지에서 앱 삭제 후 재생성, bundle id 변경 모두 해보았는데 해당 오류가 지속됩니다. AI는 bundle id가 틀렸다는 말만 반복하여 swift 개발에서 같은 문제 겪고 있으신분 있으시면 답변 바랍니다..
  18. 들어가며 이전에 살펴본 LangGraph로 웹 검색 Agent 만들기 (Web Search Agent Cookbook)에서는 LangGraph를 활용한 단일 에이전트 구축 방법을 다뤘다면, 이번에는 LangChain & LangGraph v1.0을 활용해 멀티 에이전트 시스템을 구축하는 방법을 알아보겠습니다. 기존 LangGraph에서 멀티 에이전트를 구축하려면 모델과 도구를 호출하는 노드를 일일이 정의하고, 워크플로우를 수동으로 연결해야 했습니다. 유연성은 높지만 시스템이 복잡해질수록 그래프 구조를 파악하기 어렵고 유지보수 부담이 커지는 문제가 있었죠. LangChain과 LangGraph가 v1.0으로 정식 출시되면서 AI 에이전트 개발이 한층 안정적이고 강력해졌습니다. 특히, LangChain v1.0부터 도입된 create_agent는 내부적으로 LangGraph 기반으로 구축되어 있어, LangGraph를 직접 다루지 않아도 Durable Execution, Streaming, Human-In-the-Loop 같은 강력한 기능들을 자동으로 활용할 수 있습니다. 또한, 미들웨어(Middleware) 기능으로 로깅, 프롬프트 수정, 에러 핸들링 같은 공통 로직을 깔끔하게 분리할 수 있게 되었습니다. 이번 쿡북에서는 LangChain v1.0과 HyperCLOVA X 모델을 활용해 Tool Calling 기반의 멀티 에이전트를 구축합니다. 웹 검색(Web Search), 글쓰기(Write), 저장(Save) 에이전트가 협업하여, 최신 정보를 검색하고, 목적에 맞는 글을 작성한 뒤, Notion이나 파일로 자동 저장하는 리포트 AI를 만들어봅니다. 이 가이드를 발판 삼아 여러분만의 창의적인 에이전트 시스템을 자유롭게 구축해 보세요. 사전 준비 멀티 에이전트 구축하기 프로젝트 진행을 위해서는 사전 준비 과정이 필요합니다. 각 과정마다 발급되는 키들을 환경 변수로 등록해 둡니다. API Key 발급 및 연동 설정 CLOVA Studio API CLOVA Studio 모델을 사용하기 위해 CLOVA Studio에서 API 키를 발급받아야 합니다. 본 예제에서는 HCX-005와 HCX-007 모델을 사용합니다. CLOVA Studio 접속 > 로그인 > 좌측 사이드바 'API 키' > 테스트 API 키 발급 발급된 키는 한 번만 표시되므로 반드시 복사하여 안전하게 보관하세요. API에 대한 자세한 내용은 CLOVA Studio API 가이드를 참고하시기 바랍니다. 네이버 검색 API 네이버 검색 오픈API를 활용하기 위해 네이버 개발자 센터에서 애플리케이션을 등록해야 합니다. 등록을 완료하면 Client ID와 Client Secret 정보를 확인할 수 있습니다. 자세한 내용은 네이버 개발자 센터의 애플리케이션 등록 가이드를 참고해 주세요. 네이버 개발자 센터 접속 > 로그인 > Application > 애플리케이션 등록 애플리케이션 등록 설정 애플리케이션 이름을 설정합니다 사용 API에서 '검색'을 선택합니다 비로그인 오픈 API 서비스 환경에서 'Web 환경'을 추가합니다. Tavily API 에이전트가 Tavily 웹 검색 기능을 사용하기 위해, Tavily API 키를 발급받아야 합니다. 무료 플랜의 경우 하루 1,000회까지 검색이 가능합니다. Tavily 웹 페이지 접속 > 로그인 > API Keys Notion API 에이전트가 개인 Notion 데이터베이스에 글을 저장하려면 Notion API Key와 업로드 대상 Data Source의 ID가 필요합니다. Notion API Key 발급 : Notion Developers 페이지 접속 > 우측 상단 'View my Integrations' > 로그인 > 새 API 통합 Data Source ID 확인 : 개인 Notion 접속 > API Key 발급시 설정한 워크스페이스로 이동 > 죄측 상단 '추가 옵션' > '데이터베이스' 선택 > 데이터베이스 설정 > 데이터 소스 관리 > 만들어진 데이터베이스 더보기 > 데이터 소스 ID 복사 프로젝트 구성 프로젝트의 전체 파일 구조는 다음과 같습니다. 프로젝트에 사용한 파이썬 버전은 3.11 입니다. multi-agent-cookbook/ ├── agent.py ├── utils/ │ ├── tool_agents.py │ ├── prompts.py │ ├── custom_middleware.py │ └── tools.py ├── langgraph.json ├── requirements.txt └── .env 환경 변수 설정 환경 변수를 설정합니다. 루트 디렉터리에 .env 파일을 생성한 뒤, 앞서 발급받은 API Key를 다음과 같이 입력하고 저장합니다. 이때 따옴표 없이 값을 작성해야 하며, VS Code에서 실행할 경우 설정에서 Use Env File 옵션이 활성화되어 있는지 확인하세요. CLOVA_STUDIO_API_KEY=nv-... OPENAI_API_KEY=sk-proj-... LANGSMITH_API_KEY=lsv2_pt_... LANGSMITH_TRACING=true LANGSMITH_PROJECT=Multi-agent-cookbook NAVER_CLIENT_ID=... NAVER_CLIENT_SECRET=... TAVILY_API_KEY=tvly-dev-... NOTION_API_KEY=ntn_... NOTION_DATA_SOURCE_ID=... 라이브러리 설치 프로젝트에 필요한 패키지 목록은 아래 다운로드 링크에서 확인할 수 있습니다. 해당 내용을 복사해 루트 디렉터리에 requirements.txt 파일로 저장하세요. requirements.txt 다운로드 루트 디렉터리에서 터미널을 실행하여 다음과 같이 예제 실행에 필요한 패키지를 설치합니다. 가상환경 설치를 권장합니다. # 1. 파이썬 가상환경 생성 python -m venv .venv # 2. 가상환경 활성화 (macOS/Linux) source .venv/bin/activate # (Windows) # .venv/Scripts/activate.ps1 # 3. 패키지 설치 pip install -r requirements.txt 멀티 에이전트 구축하기 멀티 에이전트 패턴 LangChain v1.0은 두 가지 멀티 에이전트 패턴을 제공합니다. 1. Tool Calling 패턴 중앙 Controller Agent가 모든 작업을 조율하고, 하위 에이전트들을 도구로 호출하는 구조입니다. 사용자와의 소통은 Controller Agent만 담당하며, Tool Agent는 Controller Agent의 도구로서 특정 작업만 수행합니다. Tool Calling 패턴으로 멀티 에이전트를 구현하면, 명확한 계층 구조와 모듈화로 관리가 쉽고 디버깅이 용이하지만, 모든 결정이 Controller를 거쳐야 하므로 토큰 소비량이 크고, 응답 시간이 다소 길어질 수 있습니다. 2. Handoffs 패턴 현재 에이전트가 다른 에이전트에게 제어권을 넘기는 방식으로, 각 에이전트가 순차적으로 사용자와 소통합니다. 이는 쿡북 작성일 기준으로 LangChain에서 아직 제공하지 않는 기능입니다. 이번 쿡북에서는 Tool Calling 패턴을 사용하며, 다음은 이를 기반으로 한 멀티 에이전트 아키텍처입니다. 관리자 에이전트(Controller Agent) 작업에 적합한 전문가 에이전트를 판단하여 호출하고, 그 결과를 받아 전체 흐름을 조율하는 오케스트레이터(Orchestrator) 역할을 수행합니다. LoggingMiddleware: 에이전트의 모든 실행 단계를 로깅하여 디버깅을 돕습니다. DynamicModelMiddleware: 대화 길이가 5개 보다 많아지면, 자동으로 더 강력한 모델로 전환합니다. HumanInTheLoopMiddleware: 민감한 도구 호출 전, 정지상태가 되는 interrupt를 발생시킵니다. 전문가 에이전트(Tool Agents) 웹 검색 에이전트(Web Search Agent): 네이버/Tavily API를 활용한 웹 검색을 수행합니다. 네이버 웹 검색 실패 시, 자동으로 Tavily 웹 검색으로 전환하여 웹 검색 안정성을 높입니다. 글쓰기 에이전트(Write Agent): 검색 결과를 바탕으로 리포트 또는 블로그 형식의 글을 작성합니다. 저장 에이전트(Save Agent): 작성된 콘텐츠를 Notion 또는 파일 시스템에 저장합니다. 미들웨어 구현하기 미들웨어는 에이전트의 실행 과정에 개입하여 기능을 확장하는 컴포넌트입니다. 미들웨어는 LangChain v1.0의 핵심 기능으로, 에이전트 로직을 수정하지 않고도 로깅, 프롬프트 변경, 에러 처리 등을 추가할 수 있게 합니다. 미들웨어가 개입할 수 있는 시점 미들웨어는 에이전트 실행의 다양한 시점에서 작동할 수 있습니다. Node-style hooks 특정 실행 지점에서 순차적으로 실행됩니다. 로깅, 유효성 검사, 상태 업데이트에 사용합니다. before_agent / after_agent: 에이전트 시작 전후 before_model / after_model: 모델 호출 전후 Wrap-style hooks 핸들러 호출을 가로채고 실행을 제어합니다. 재시도, 캐싱, 변환에 사용합니다. 핸들러를 0번(조기 종료), 1번(정상 흐름), 또는 여러 번(재시도 로직) 호출할지 결정할 수 있습니다. wrap_tool_call: 도구 실행 시 wrap_model_call: 모델 실행 시 Convenience dynamic_prompts: 동적 시스템 프롬프트 생성 미들웨어 구축 방식 LangChain에서는 목적과 복잡도에 따라 여러 방식으로 미들웨어를 구성할 수 있습니다. Decorator-based middleware: 단일 훅(Hook)만 필요한 간단한 로직을 적용하거나, 별도의 설정 없이 빠르게 프로토타이핑을 진행할 때 사용하면 좋습니다. Class-based middleware: 동기/비동기 처리를 모두 지원해야 하거나, 여러 개의 훅과 복잡한 설정을 하나의 모듈로 묶어 체계적으로 관리해야 할 때 적합합니다. Built-In Middleware: LangChain에서 제공하는 기본 미들웨어를 바로 사용할 수 있습니다. 이 프로젝트에서 사용할 미들웨어 이 프로젝트에서는 에이전트 실행 흐름을 제어하고 안정성을 높이기 위해 다음과 같은 미들웨어를 사용합니다. 커스텀 미들웨어를 구성할 때는 각 미들웨어가 실행 흐름에 개입하는 시점을 고려해야 하며, 이때 LoggingMiddleware는 미들웨어의 개입 지점을 이해하는 데 도움이 될 수 있습니다. HumanInTheLoopMiddleware : 민감한 도구 호출 전, 정지상태가 되는 interrupt를 발생시킵니다. LoggingMiddleware : 에이전트의 모든 실행 단계를 로깅하여 디버깅을 돕습니다. DynamicModelMiddleware : 대화 길이가 5개 보다 많아지면, 자동으로 더 강력한 모델로 전환합니다. NaverToTavilyFallbackMiddleware : 네이버 웹 검색 실패 시, 자동으로 Tavily 웹 검색으로 전환하여 웹 검색 안정성을 높입니다. writing_farmat : 사용자가 요청한 글 타입(report/blog)에 따라 동적으로 시스템 프롬프트를 변경합니다. # custom_middleware.py import os from typing import Any, Callable from langchain_naver import ChatClovaX from langchain.agents.middleware import AgentMiddleware, AgentState, ModelRequest, dynamic_prompt from langchain.agents.middleware.types import ModelResponse, ToolCallRequest from langchain.messages import ToolMessage from langgraph.types import Command from langgraph.runtime import Runtime from .tools import tavily_web_search from .prompts import WRITING_PROMPTS from dotenv import load_dotenv load_dotenv() class ReportAgentState(AgentState): content_type : str destination : str class LoggingMiddleware(AgentMiddleware): """에이전트 실행 과정 로깅""" async def abefore_agent(self, state: ReportAgentState, runtime: Runtime) -> dict[str, Any] | None: print("\n" + "="*60) print(f"🔄 LoggingMiddleware.abefore_agent") print(f"🚀 에이전트 시작") print(f" 메시지 수: {len(state['messages'])}개") print("="*60) return None async def abefore_model(self, state: ReportAgentState, runtime: Runtime) -> dict[str, Any] | None: print("\n" + "-"*60) print(f"🔄 LoggingMiddleware.abefore_model") if state['messages']: last = state['messages'][-1] print(f"🤖 모델 호출") print(f" 입력: {type(last).__name__}") if hasattr(last, 'content') and last.content: preview = last.content[:60] + "..." if len(last.content) > 60 else last.content print(f" 내용: {preview}") print("-"*60) print(f"\nController 응답 중..") return None async def aafter_model(self, state: ReportAgentState, runtime: Runtime) -> dict[str, Any] | None: print("\n" + "-"*60) print(f"🔄 LoggingMiddleware.aafter_model") if state['messages']: last = state['messages'][-1] if hasattr(last, 'tool_calls') and last.tool_calls: tools = [tc['name'] for tc in last.tool_calls] print(f"🔧 도구 호출 예정") for tool in tools: print(f" → {tool}") elif hasattr(last, 'content') and last.content: print(f"✅ 모델 응답 완료") print("-"*60) return None async def aafter_agent(self, state: ReportAgentState, runtime: Runtime) -> dict[str, Any] | None: print("\n" + "="*60) print(f"🔄 LoggingMiddleware.aafter_agent") print(f"🏁 에이전트 완료") print(f" 총 메시지: {len(state['messages'])}개") print("="*60 + "\n") return None async def awrap_tool_call( self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: tool_name = request.tool_call.get('name', 'unknown') print("\n" + "-"*60) print(f"🔄 LoggingMiddleware.awrap_tool_call") print(f"⚙️ 도구 실행: {tool_name}") print("-"*60) print("\nTool Calling\n", end="", flush=True) result = await handler(request) print("\n" + "-"*60) print(f"🔄 LoggingMiddleware.awrap_tool_call (완료)") print(f"✅ 도구 완료: {tool_name}") if isinstance(result, ToolMessage) and result.content: preview = result.content[:100] + "..." if len(result.content) > 100 else result.content print(f" 결과: {preview}") print("-"*60) return result class DynamicModelMiddleware(AgentMiddleware): """대화 길이에 따라 모델 변경""" def awrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: CLOVA_STUDIO_API_KEY = os.getenv("CLOVA_STUDIO_API_KEY") # 대화 길이에 따라 다른 모델 사용 model_name = "HCX-007" new_model = ChatClovaX(model=model_name, reasoning_effort="none", api_key=CLOVA_STUDIO_API_KEY) if len(request.messages) > 5: request.model = new_model print("\n" + "-"*60) print(f"🔄 DynamicModelMiddleware: Controller Using {model_name} for long conversation") print("-"*60) return handler(request) class NaverToTavilyFallbackMiddleware(AgentMiddleware): """네이버 검색 실패 시 Tavily로 폴백""" async def awrap_tool_call( self, request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command], ) -> ToolMessage | Command: tool_name = request.tool_call.get('name', 'unknown') if tool_name != 'naver_web_search': return await handler(request) try: result = await handler(request) # 네이버 검색 실행 print("\n" + "-"*60) print("🔄 NaverToTavilyFallbackMiddleware: ✅ 네이버 검색 성공") print("-"*60) return result except Exception as e: print("\n" + "-"*60) print(f"🔄 NaverToTavilyFallbackMiddleware: ⚠️ 네이버 검색 실패 → Tavily 검색 전환: {str(e)[:50]}...") print("-"*60) args = request.tool_call.get('args', {}) query = args.get('query', '') display = args.get('display', 5) # ainvoke 사용 (딕셔너리로 전달) tavily_result = await tavily_web_search.ainvoke({ "query": query, "display": display }) return ToolMessage( content=f"[Tavily 검색 결과]\n{tavily_result}", tool_call_id=request.tool_call.get('id', '') ) @dynamic_prompt def writing_format(request: ModelRequest) -> str: """사용자 요청에 알맞는 시스템 프롬프트 생성""" content_type = request.runtime.context.get("content_type", "report") base_prompt = "당신은 글쓰기 어시스턴트 입니다. 다음 형식으로 글을 작성하세요." print("\n" + "-"*60) print(f"🔄 writing_format: {content_type} 형식으로 작성") print("-"*60) if content_type == "blog": return f"{base_prompt}\n\n{WRITING_PROMPTS['blog']}" elif content_type == "report": return f"{base_prompt}\n\n{WRITING_PROMPTS['report']}" return base_prompt 관리자 에이전트(Controller Agent) 구성하기 관리자 에이전트는 사용자의 요청을 받아 적절한 전문가 에이전트를 호출하여 작업을 조율하는 중앙 관제 역할을 수행합니다. 이를 위해 HCX-007 모델을 사용하며, 구체적인 역할은 다음과 같습니다. 사용자 요청을 분석하고 필요한 전문가 에이전트를 선택합니다. 여러 전문가 에이전트를 순차적으로 호출하여 복합 작업을 수행합니다. Human-In-the-Loop을 통해 중요한 작업 전 사용자 승인을 받습니다. 최종 결과를 사용자에게 전달합니다. # agent.py import os import asyncio from langchain.agents import create_agent from langchain.messages import SystemMessage, HumanMessage from langchain.agents.middleware import HumanInTheLoopMiddleware from langchain_naver import ChatClovaX from langgraph.checkpoint.memory import InMemorySaver from langgraph.types import Command from utils.tool_agents import call_write_agent, call_web_search_agent, call_save_agent from utils.custom_middleware import ReportAgentState, DynamicModelMiddleware, LoggingMiddleware from utils.prompts import CONTROLLER_PROMPT from dotenv import load_dotenv load_dotenv() # LangGraph Studio용 graph 생성 함수 def build_graph(): """LangGraph 서버에서 호출하는 graph 빌더""" CLOVA_STUDIO_API_KEY = os.getenv("CLOVA_STUDIO_API_KEY") model = ChatClovaX(model="HCX-005", api_key=CLOVA_STUDIO_API_KEY) agent = create_agent( model=model, tools=[call_write_agent, call_web_search_agent, call_save_agent], checkpointer=InMemorySaver(), middleware=[ # LoggingMiddleware(), DynamicModelMiddleware(), HumanInTheLoopMiddleware( interrupt_on={ "call_web_search_agent": {"allowed_decisions": ["approve", "reject"]}, "call_write_agent": False, "call_save_agent": False } ) ], state_schema=ReportAgentState, system_prompt=CONTROLLER_PROMPT ) return agent async def main(): agent = build_graph() config = {"configurable": {"thread_id": "asd123"}} print("Multi Agent System Created!\n") while True: user_input = input("\nUser: ") if user_input.lower() in ["종료", "exit"]: print("AI: 대화를 종료합니다. 이용해주셔서 감사합니다.") break try: # 첫 실행 result = await agent.ainvoke( {"messages": [HumanMessage(user_input)]}, config=config ) # interrupt 확인 및 처리 while "__interrupt__" in result: print("\n" + "="*60) print("⏸️ 승인이 필요한 작업이 있습니다") print("="*60) interrupt_data = result["__interrupt__"][0].value action_requests = interrupt_data.get("action_requests", []) print(f"\n📋 총 {len(action_requests)}개의 작업 대기 중\n") decisions = [] for i, action in enumerate(action_requests, 1): tool_name = action.get("name", "unknown") tool_args = action.get("args", {}) print(f"작업 {i}:") print(f" 🔧 도구: {tool_name}") print(f" 📝 인자: {tool_args}") decision = input(f"\n\n승인하시겠습니까? (approve/reject): ").strip().lower() if decision == "approve": decisions.append({"type": "approve"}) print("✅ 승인됨\n") else: # approve가 아니면 모두 reject decisions.append({"type": "reject"}) print("❌ 거부됨\n") # 결정 전달 및 재실행 print("="*60) print("🔄 작업 재개 중...") print("="*60 + "\n") result = await agent.ainvoke( Command(resume={"decisions": decisions}), config ) # 최종 결과 출력 print("\nAI: ", end="", flush=True) final_message = result["messages"][-1] if hasattr(final_message, 'content'): print(final_message.content) else: print(final_message) print() except Exception as e: print(f"\n❌ 오류 발생: {e}") import traceback traceback.print_exc() if __name__ == "__main__": asyncio.run(main()) 전문가 에이전트(Tool Agents)구성하기 각 전문가 에이전트는 @tool 데코레이터로 래핑되어 관리자 에이전트가 호출할 수 있는 함수 형태가 됩니다. 작업 완료 후 Command 객체 반환을 통해 State를 업데이트하여 결과를 관리자 에이전트에게 전달합니다. 이번 프로젝트에서는 HCX-005 모델로 전문가 에이전트를 구성했습니다. 전문가 에이전트의 역할은 다음과 같습니다. @tool 데코레이터로 일반 함수를 LangChain 도구로 변환합니다. 각 Agent는 독립적인 create_agent로 생성되어 고유한 미들웨어와 도구를 가집니다. Command 객체를 통해 메시지와 상태를 업데이트하여 관리자 에이전트에 전달합니다. # tool_agents.py import os from typing import Annotated, Literal from langgraph.types import Command from langchain.tools import tool from langchain.agents import create_agent from langchain.agents.middleware import HumanInTheLoopMiddleware from langchain.messages import HumanMessage, ToolMessage from langchain.tools import InjectedToolCallId from langchain_naver import ChatClovaX from .tools import naver_web_search, save_to_file, save_to_notion from .custom_middleware import writing_format, NaverToTavilyFallbackMiddleware from .prompts import WEB_SEARCH_PROMPT, SAVE_PROMPT from dotenv import load_dotenv load_dotenv() CLOVA_STUDIO_API_KEY = os.getenv("CLOVA_STUDIO_API_KEY") write_model = ChatClovaX(model="HCX-005", api_key=CLOVA_STUDIO_API_KEY) web_search_model = ChatClovaX(model="HCX-005", api_key=CLOVA_STUDIO_API_KEY) save_model = ChatClovaX(model="HCX-DASH-002", api_key=CLOVA_STUDIO_API_KEY) write_agent = create_agent( model=write_model, middleware=[writing_format] ) web_search_agent = create_agent( model=web_search_model, tools=[naver_web_search], middleware=[NaverToTavilyFallbackMiddleware()], system_prompt=WEB_SEARCH_PROMPT ) save_agent = create_agent( model=save_model, tools=[save_to_notion, save_to_file], middleware=[HumanInTheLoopMiddleware( interrupt_on={ "save_to_notion":{"allowed_decisions": ["approve", "reject"]}, "save_to_file":{"allowed_decisions": ["approve", "reject"]} } )], system_prompt=SAVE_PROMPT ) @tool async def call_write_agent( article: str, content_type: Literal["report", "blog"], tool_call_id: Annotated[str, InjectedToolCallId], ) -> Command: """ 글쓰기 서브에이전트를 호출하여 지정된 형식의 글을 작성합니다. Args: article: 작성할 주제나 원본 내용 content_type: 글 형식 타입 ('report'|'blog') tool_call_id: LLM 도구 호출 ID (자동 주입) Returns: Command: 작성된 글과 업데이트된 상태를 포함하는 Command 객체 - messages: 작성된 글이 담긴 ToolMessage """ result = await write_agent.ainvoke( {"messages": [HumanMessage(article)]}, # context에 넣어서 middleware에 전달 context={ "content_type": content_type }, ) return Command(update={ "messages": [ ToolMessage( content=result["messages"][-1].content, tool_call_id=tool_call_id ) ], "content_type": content_type }) @tool async def call_web_search_agent( query: str, tool_call_id: Annotated[str, InjectedToolCallId], ) -> Command: """ 사용자가 검색을 요청하면 웹 검색 서브에이전트를 호출하여 웹 검색을 수행합니다 Args: query: 검색에 사용할 쿼리 runtime: 메인 에이전트의 상태 접근을 위한 런타임 객체 tool_call_id: LLM 도구 호출 ID (자동 주입) Returns: Command: 작성된 글과 업데이트된 상태를 포함하는 Command 객체 - messages: 작성된 글이 담긴 ToolMessage """ result = await web_search_agent.ainvoke( {"messages": [HumanMessage(query)]}, context={"current_agent": "web_search_agent"} ) return Command(update={ "messages": [ ToolMessage( content=result["messages"][-1].content, tool_call_id=tool_call_id ) ] }) @tool async def call_save_agent( content: str, destination: Literal["file", "notion"], filename_or_title: str, tool_call_id: Annotated[str, InjectedToolCallId], ) -> Command: """ 콘텐츠를 파일 또는 Notion에 저장합니다. Args: content: 저장할 콘텐츠 destination: 저장 위치 ('file' 또는 'notion') filename_or_title: 파일명(file) 또는 노션 페이지 제목(notion) tool_call_id: LLM 도구 호출 ID (자동 주입) Returns: Command: 저장 결과를 포함하는 Command 객체 """ # destination에 따라 다른 메시지 전달 if destination == "notion": context_msg = f"다음 내용을 Notion에 '{filename_or_title}' 제목으로 저장해주세요:\n\n{content}" else: # file context_msg = f"다음 내용을 '{filename_or_title}' 파일로 저장해주세요:\n\n{content}" result = await save_agent.ainvoke( {"messages": [HumanMessage(context_msg)]}) return Command(update={ "messages": [ ToolMessage( content=result["messages"][-1].content, tool_call_id=tool_call_id ) ] }) 도구 구현하기 전문가 에이전트인 웹 검색 에이전트와 저장 에이전트가 사용하는 도구를 구현합니다. 이 도구들은 멀티 에이전트와 미들웨어의 활용 방법을 보여주기 위한 예시로 구성되었습니다. 웹 검색 에이전트의 경우 naver_web_search 도구를 가지고 있고, NaverToTavilyFallbackMiddleware에 의해 tavily_web_search 도구 또한 활용할 수 있습니다. 저장 에이전트는 개인 노션에 업로드 할 수 있는 save_to_notion, 시스템에 저장할 수 있는 save_to_file 두 가지 도구를 가지고 있습니다. # tools.py import os import re import httpx from pathlib import Path from langchain.tools import tool from tavily import TavilyClient from notion_client import Client from dotenv import load_dotenv # 환경 변수 로드 load_dotenv() NAVER_CLIENT_ID = os.getenv("NAVER_CLIENT_ID") NAVER_CLIENT_SECRET = os.getenv("NAVER_CLIENT_SECRET") TAVILY_API_KEY = os.getenv("TAVILY_API_KEY") @tool async def naver_web_search(query: str, display: int = 10) -> dict: """ 네이버 검색 API를 호출해 결과를 구조화하여 반환합니다. Args: query: 검색어 display: 검색 결과 수(1~100) Returns: { "query": str, "total": int, "items": [{"title": str, "link": str, "description": str}]} } """ url = "https://openapi.naver.com/v1/search/webkr.json" params = {"query": query, "display": display} headers = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET, } async with httpx.AsyncClient() as client: r = await client.get(url, headers=headers, params=params) r.raise_for_status() data = r.json() results = [] for item in data.get("items", []): results.append({ "title": re.sub(r"<.*?>", "", item.get("title") or "").strip(), "link": item.get("link"), "description": re.sub(r"<.*?>", "", item.get("description") or "").strip(), }) return { "query": query, "total": data.get("total", 0), "items": results, } # 본 쿡북에서는 해당 도구를 NaverToTavilyFallbackMiddleware에서 풀백 도구로 활용하였습니다 @tool async def tavily_web_search(query: str, display: int = 5) -> str: """ Tavily API를 사용하여 웹 검색을 수행합니다. Args: query: 검색어 display: 검색 결과 수(1~100) """ try: tavily_client = TavilyClient(api_key=TAVILY_API_KEY) # Tavily는 동기 라이브러리이므로, 비동기 처리를 위해 asyncio.to_thread 사용 import asyncio response = await asyncio.to_thread( tavily_client.search, query=query, max_results=display ) # 결과 포맷팅 results = [] for idx, result in enumerate(response.get('results', []), 1): result_text = f"\n{idx}. {result.get('title', 'No title')}\n" result_text += f" URL: {result.get('url', 'No URL')}\n" result_text += f" 내용: {result.get('content', 'No content')}\n" results.append(result_text) if not results: return "검색 결과가 없습니다." return "".join(results) except Exception as e: return f"검색 중 오류 발생: {str(e)}" @tool def save_to_file(filename: str, content: str) -> str: """ 지정된 파일 이름으로 리포트 내용을 시스템에 마크다운 파일로 저장합니다. Args: filename: 저장할 파일 이름 (확장자 없으면 자동으로 .md 추가) content: 파일에 저장할 텍스트 내용 Returns: 저장 성공/실패 메시지와 파일 경로 """ try: # 확장자 없으면 자동으로 .md 추가 if not filename.endswith(('.md', '.markdown', '.txt')): filename = f"{filename}.md" # 저장 디렉토리 생성 (없으면) save_dir = Path("reports") # 또는 원하는 디렉토리 save_dir.mkdir(exist_ok=True) # 전체 경로 file_path = save_dir / filename # 마크다운 형식으로 저장 with open(file_path, "w", encoding="utf-8") as f: f.write(content) # 절대 경로 반환 abs_path = file_path.resolve() return f"✅ 마크다운 파일로 저장 완료!\n📁 경로: {abs_path}" except Exception as e: return f"❌ 파일 저장 중 오류 발생: {e}" @tool def save_to_notion(page_title: str, content: str) -> str: """ Notion 데이터베이스에 새 페이지를 생성하고 콘텐츠를 저장합니다. 사용자가 작성한 글, 보고서, 메모 등을 Notion에 저장할 때 사용합니다. 페이지 제목과 본문 내용을 받아서 지정된 Notion 데이터베이스에 자동으로 추가합니다. Args: page_title: Notion 페이지의 제목 (예: "주간 보고서", "회의 내용") content: 페이지 본문에 저장할 텍스트 내용 (마크다운 형식 지원) Returns: 성공 시: 생성된 페이지 제목과 URL 실패 시: 오류 메시지 Examples: - "이 리포트를 Notion에 '월간 분석'이라는 제목으로 저장해줘" - "방금 작성한 글을 Notion에 저장" """ try: notion = Client( auth=os.getenv("NOTION_API_KEY"), notion_version="2025-09-03" # 최신 버전 ) data_source_id = os.getenv("NOTION_DATA_SOURCE_ID") # data_source_id 사용 new_page = notion.pages.create( parent={ "type": "data_source_id", "data_source_id": data_source_id }, properties={ "title": { # 기본 title "title": [ {"text": {"content": page_title}} ] } }, children=[ { "object": "block", "type": "paragraph", "paragraph": { "rich_text": [ {"text": {"content": content}} ] } } ] ) return f"✅ '{page_title}' 생성 완료: {new_page['url']}" except Exception as e: return f"❌ 오류: {str(e)}" 프롬프트 프로젝트에서 사용하는 모든 프롬프트를 정의합니다. 각 에이전트의 역할과 동작 방식을 명확히 지정하여 일관된 응답을 보장합니다. 사용된 프롬프트는 아래 다운로드 링크에서 확인할 수 있습니다. 해당 내용을 복사해 prompt.py 파일로 저장하세요. prompts.txt 다운로드 실행 및 동작 예시 다음은 HumanInTheLoopMiddleware가 적용된 멀티 에이전트 시스템의 동작 흐름입니다. 각 에이전트의 도구 호출 과정에서 사용자 승인 단계가 개입되며, 전체 응답 흐름은 다음과 같습니다. 에이전트 실행 결과는 다음과 같습니다. User: 이번 네이버 DAN25에서 발표된 네이버의 AI 전략에 대해 찾아보고 리포트로 작성해서 노션에 업로드 해줘 ============================================================ ⏸️ 승인이 필요한 작업이 있습니다 ============================================================ 📋 총 1개의 작업 대기 중 작업 1: 🔧 도구: call_web_search_agent 📝 인자: {'query': '네이버 DAN25에서 발표된 네이버의 AI 전략'} 승인하시겠습니까? (approve/reject): approve ✅ 승인됨 ============================================================ 🔄 작업 재개 중... ============================================================ ------------------------------------------------------------ 🔄 NaverToTavilyFallbackMiddleware: ✅ 네이버 검색 성공 ------------------------------------------------------------ ------------------------------------------------------------ 🔄 writing_format: report 형식으로 작성 ------------------------------------------------------------ ============================================================ ⏸️ 승인이 필요한 작업이 있습니다 ============================================================ 📋 총 1개의 작업 대기 중 작업 1: 🔧 도구: save_to_notion 📝 인자: {'page_title': '네이버 AI 전략 DAN25 발표 분석', 'content': '# 네이버의 AI 전략: DAN25 발표 내용 분석\n\n## 1. 개요\n본 리포트는 네이버의 DAN25 발표 내용을 바탕으로 한 AI 전략에 대해 분석합니다. 네이버는 AI 에이전트 도입 확대, 핵심 제조 산업 경쟁력 강화, 새로운 AI 도구 및 플랫폼 전략 공개, AI 산업 거품론 대응, 그리고 미래 비전과 글로벌 확장 계획을 통해 AI 기술의 발전과 실질적 가치 창출을 목표로 하고 있습니다.\n\n## 2. 핵심 발견사항\n- **AI 에이전트 도입 확대**: 네이버는 주요 서비스에 AI 에이전트를 도입해 개인화된 사용자 경험 제공.\n- **핵심 제조 산업 경쟁력 강화**: 반도체, 자동차, 조선 등 제조 산업에서의 AI 활용 방안 모색.\n- **신규 AI 도구와 플랫폼 전략**: 산업별 버티컬 AI와 경량화 모델을 통한 실질적인 가치 창출 목표.\n- **산업 거품론 대응**: 경량화 모델과 산업 특화 AI를 통해 실질적인 가치 창출 중요성 강조.\n- **글로벌 확장 계획**: 차세대 AI 전략 발표 및 글로벌 시장 진출 도모.\n\n## 3. 분석 및 인사이트\n네이버는 AI 기술의 전방위적 도입을 통해 사용자 맞춤형 서비스 제공을 강화하고 있으며, 제조업 분야에서도 AI 트랜스포메이션을 추진 중입니다. 또한, DAN25에서는 산업별 맞춤형 AI 솔루션과 경량화된 모델을 통해 실질적인 성과를 도출하고자 했습니다. AI 산업 내 거품론을 경계하며 실체 있는 기술 개발에 주력하고 있고, 글로벌 확장을 위한 미래 비전을 제시하고 있습니다.\n\n## 4. 결론 및 제언\n네이버의 AI 전략은 다각도로 전개되고 있으며, 이는 궁극적으로 사용자 경험 개선과 산업 전반의 혁신을 촉진할 것입니다. 향후 네이버는 AI 기술의 고도화와 더불어 글로벌 시장에서의 입지 강화를 위해 지속적인 투자와 연구 개발이 필요합니다.\n\n## 5. 참고 자료\n[AI 에이전트 도입 확대](https://www.etnews.com/20251023000297), [핵심 제조 산업 경쟁력 강화](http://www.efnews.co.kr/news/articleView.html?idxno=124617), [신규 AI 도구와 플랫폼 전략](https://www.asiatoday.co.kr/kn/view.php?key=20250930010016279), [AI 산업 거품론 대응](https://www.econovill.com/news/articleView.html?idxno=717550), [미래 비전과 글로벌 확장 계획](https://www.kmjournal.net/news/articleView.html?idxno=4023)'} 승인하시겠습니까? (approve/reject): approve ✅ 승인됨 ============================================================ 🔄 작업 재개 중... ============================================================ ------------------------------------------------------------ 🔄 DynamicModelMiddleware: Controller Using HCX-007 for long conversation ------------------------------------------------------------ AI: 리포트가 성공적으로 노션에 저장되었습니다! ### 세부 사항 - **제목:** 네이버 AI 전략 DAN25 발표 분석 - **노션 링크:** [네이버 AI 전략 DAN25 발표 분석](https://www.notion.so/AI-DAN25-2bf87d6d35378107a508c5b0bc8f478a) 추가로 필요하신 것이 있으면 말씀해 주세요! LangSmith Studio LangSmith Studio는 AI 에이전트 개발을 위한 전용 IDE(통합 개발 환경)입니다. 그래프 기반 시각화 인터페이스로 에이전트가 실행되는 동안 각 노드의 전환과 상태 변화를 실시간으로 추적할 수 있어, 복잡한 로직의 흐름을 한눈에 파악할 수 있습니다. 또한 프롬프트를 수정하면 즉시 반영되는 hot-reloading 기능으로 빠른 반복 개발이 가능하고, 멀티턴 대화를 테스트할 수 있는 Chat UI가 내장되어 있습니다. LangSmith API 에이전트의 동작 과정을 모니터링하고, LangSmith Studio를 사용하기 위해 LangSmith의 API 키를 발급받아야 합니다. LangSmith 접속 > 로그인 > 좌측 사이드바 'Settings' > API Keys 탭 > + API key langgraph.json LangGraph 애플리케이션의 구성 정보를 담고 있는 설정 파일입니다. 그래프의 위치, 의존성, 환경 변수 등 에이전트의 구조를 정의합니다. 자세한 설정은 공식 문서를 참고하세요. { "dependencies": ["."], "graphs": { "controller": "./agent.py:build_graph" }, "env": ".env" } LangSmith Studio 활성화 다음 명령어를 통해 LangSmith Studio를 활성화합니다. 다음 명령어를 실행하면 다음 과정이 순차적으로 수행됩니다. langgraph dev langgraph-cli가 langgraph.json 파일을 읽습니다. 로컬 API 서버가 시작됩니다. LangSmith Studio의 웹 UI가 이 서버에 자동으로 연결됩니다. 다음 URL을 통해 LangSmith Studio에 접근할 수 있습니다. https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 LangSmith Studio에서는 내장된 Chat UI로 직접 구축한 에이전트와 대화하며 실시간으로 테스트할 수 있고, 토큰 소모량, 실행 시간, 각 컴포넌트의 입출력 등을 모니터링하여 디버깅과 최적화를 진행할 수 있습니다. 또한, 프롬프트를 수정하면 즉시 반영되어 다양한 프롬프트 변형을 빠르게 실험할 수 있으며, 그래프 시각화를 통해 에이전트의 실행 흐름을 직관적으로 파악할 수 있습니다. 마무리 이번 쿡북을 통해 LangChain v1.0이 제시하는 새로운 멀티 에이전트 구축 방법을 익혔습니다. 특히, create_agent와 미들웨어를 적극 활용함으로써 로깅, 에러 처리, Human-In-the-Loop 같은 핵심 기능을 모듈화할 수 있었고, 복잡한 그래프 구축 과정 없이도 유지보수가 용이한 에이전트 설계가 가능해졌습니다. 또한, HyperCLOVA X를 기반으로 관리자 에이전트가 하위 전문가 에이전트들을 정교하게 조율하는 협업 구조를 구현할 수 있었습니다. 이제 이 가이드를 발판 삼아, LangChain v1.0의 유연한 구조 위에 CLOVA Studio의 강력한 모델들을 더해 여러분만의 창의적인 에이전트 서비스를 완성해보시기 바랍니다.
  19. 안녕하세요. 정확한 확인을 위해 확인 가능한 url 알려주시면 디버깅해보겠습니다.
  20. LLM 기반 에이전트를 만들고 나면, 그다음 고민은 얼마나 잘 작동하느냐입니다. 초기 프롬프트가 단순한 데모 상황에서는 만족스러운 답변을 내놓더라도, 실제 서비스 환경에서는 응답 품질이 떨어지거나, 의도와 다른 행동을 보이거나, 특정 입력에 취약한 패턴이 드러날 수 있습니다. 이럴 때는 데이터를 보강하고, 프롬프트를 다듬고, 정책을 조정해 에이전트를 점진적으로 더 똑똑하고 안정적으로 만드는 과정이 필수적입니다. 이번 쿡북에서는 이러한 개선 과정을 손쉽게 반복 실행할 수 있도록 도와주는 프레임워크, Agent Lightning을 다룹니다. 특히 별도의 모델 튜닝 없이도 프롬프트를 자동으로 수정·검증해 주는 APO(Automatic Prompt Optimization)를 활용해, 최소한의 설정만으로 에이전트 개선 루프를 구성하는 방법을 소개합니다. 또한 기본적으로 영문 프롬프트 최적화에 맞춰 설계된 Agent Lightning의 APO를 한국어 환경에서도 안정적으로 활용할 수 있도록, POML(Prompt Optimization Markup Language) 템플릿을 한국어 기반으로 커스터마이징해 적용하는 방법도 함께 다룹니다. 이번 쿡북을 통해 CLOVA Studio에서 제공하는 모델을 더 안정적으로 다루고, 실제 서비스 품질을 높이는 프롬프트 개선 전략을 익히는 데 도움이 되길 바랍니다. 1. Agent Lightning 개요 마이크로소프트에서 공개한 Agent Lightning은 에이전트의 학습과 최적화를 체계적으로 수행할 수 있도록 설계된 프레임워크입니다. 이 프레임워크는 에이전트의 실행을 자동으로 추적하고, 그 결과로 얻은 보상(Reward)을 기반으로 프롬프트나 정책을 개선할 수 있게 해줍니다. 1-1. 핵심 개념 Agent Lightning에서 다루는 핵심 개념은 다음과 같습니다. Task(태스크): 에이전트에게 주어지는 구체적인 입력 또는 임무입니다. 장소를 예약하거나 수학 문제를 풀어주는 것처럼, 에이전트가 해결해야 할 대상을 의미합니다. Rollout(롤아웃): 하나의 태스크가 주어지고, 에이전트가 실행되어 도구 호출이나 LLM 호출 등을 거쳐 행동을 완료하고, 마지막에 보상(Reward) 을 받는 한 번의 전체 사이클을 말합니다. Span(스팬): 롤아웃 내부의 작은 단위 실행입니다. LLM 호출 하나, 툴 실행 하나 등이 각각의 스팬이 될 수 있습니다. Prompt Template(프롬프트 템플릿): 태스크를 해결하기 위해 에이전트가 사용하는 지시문 및 프롬프트의 구조입니다. 이 템플릿은 알고리즘에 의해 반복적으로 개선됩니다. 에이전트가 수행하는 모든 롤아웃은 보상 정보와 함께 기록되고, 이 데이터를 기반으로 프롬프트나 정책을 점진적으로 개선할 수 있습니다. 1-2. 구성 요소 Agent Lightning은 다음 세 가지 주요 구성 요소로 이루어집니다. Agent(에이전트): 태스크를 입력받아 에이전트 로직을 수행하고 보상을 리턴합니다. 이를 통해 각 실행이 자동으로 롤아웃으로 기록됩니다. Algorithm(알고리즘): 프롬프트나 정책을 개선하기 위한 알고리즘입니다. APO, VERL 등 다양한 알고리즘을 지원하며, 이번 쿡북에서는 프롬프트 최적화를 다루기 위해 APO를 사용합니다. Trainer(트레이너): 에이전트와 알고리즘을 연결하고, 학습 루프를 제어하는 구성 요소입니다. 반복적인 실행과 평가를 통해 점진적인 개선을 수행합니다. 즉, 이미 만들어둔 에이전트 코드에 간단한 데코레이터(@rollout)를 추가하기만 하면, 각 실행의 입력, 출력, 보상 데이터를 자동으로 기록하고, 이를 기반으로 프롬프트나 정책을 개선하는 학습 가능한 에이전트 루프를 구성할 수 있습니다. 이러한 Agent Lightning을 활용하면 복잡한 학습 코드를 직접 작성하지 않아도, 에이전트의 실행 기록과 보상 정보를 바탕으로 다양한 프롬프트 버전을 자동으로 생성·평가할 수 있습니다. 그 과정에서 더 높은 보상을 주는 프롬프트가 자동으로 선택되고, 테스트셋 기준의 성능 비교와 기록까지 이루어져, 에이전트를 점진적으로 고도화하는 작업을 손쉽게 반복할 수 있습니다. 2. 환경 설정 2-1. CLOVA Studio API 준비 CLOVA Studio에서는 Chat Completions, 임베딩을 비롯한 주요 API에 대해 OpenAI API와의 호환성을 지원합니다. 본 예제에서는 OpenAI 호환 API 중 Chat Completions 엔드포인트(/chat/completions)를 활용하며, 상세 호환 정보는 OpenAI 호환성 가이드를 참고하시기 바랍니다. 또한, 해당 API 호출하려면 CLOVA Studio에서 발급받은 API 키가 필요합니다. API 키 발급 방법은 CLOVA Studio API 가이드에서 확인할 수 있습니다. 2-2. 프로젝트 구성 프로젝트의 전체 파일 구조는 다음과 같습니다. Python은 3.10 이상을 사용하며, 3.13 버전을 권장합니다. agent_lightning_cookbook/ ├── .env ├── rollout.py ├── run_example.py ├── prompts/ │ ├── apply_edit_ko.poml │ └── text_gradient_ko.poml └── train_apo.py 2-3. 환경 변수 설정 루트 디렉토리에 .env 파일을 생성한 뒤, 앞서 발급받은 API Key를 다음과 같이 입력하고 저장합니다. 이때 따옴표 없이 값을 작성해야 하며, VS Code에서 실행할 경우 설정에서 Use Env File 옵션이 활성화되어 있는지 확인하세요. CLOVA_STUDIO_API_KEY=YOUR_API_KEY 2-4. 패키지 설치 프로젝트에 필요한 패키지를 다음 코드를 실행하여 설치합니다. pip install agentlightning openai python-dotenv poml 3. Rollout 구현 Agent Lightning의 롤아웃 구조를 단일 파일로 단순화해 구현해 봅니다. 본 예제에서는 자연어 요청을 5개 카테고리(주행, 차량 상태, 차량 제어, 미디어, 생활정보)로 분류하는 에이전트를 구성합니다. 각 태스크는 @dataclass로 정의되며, CLOVA Studio의 HCX-005 모델을 사용해 분류를 수행합니다. 시스템 프롬프트에는 다섯 가지 카테고리의 정의와 출력 규칙이 포함되어 있으며, 모델은 입력 문장을 읽고 리스트 형태의 문자열로 응답합니다. 정답은 하나 또는 여러 개일 수 있으며, 어떤 카테고리에도 해당하지 않는 경우에는 빈 리스트([])를 반환하는 것이 올바른 출력입니다. run_rollout() 함수는 한 번의 태스크 실행 단위를 나타내며, 태스크를 실행하고 그 결과를 기반으로 보상을 계산하는 역할을 합니다. 보상은 다음 규칙에 따라 계산되며, 이는 서비스 목적에 따라 자유롭게 커스터마이즈하고 확장할 수 있습니다. 완전 일치(1.0): 모델 응답과 정답이 형식과 내용까지 모두 정확히 일치하는 경우 부분 일치 형식 불일치(0.9): 내용(레이블 집합)이 완전히 동일하지만, 따옴표, 공백, 대소문자 등의 형식이 다른 경우 부분 문자열 일치(0.5): 레이블이 완전히 같지는 않지만 문자열이 부분적으로 겹치는 경우(예: '주행'과 '차량 주행') 부분 레이블 일치(0.5): 여러 레이블 중 일부만 맞힌 경우(예: 2개 중 1개만 맞힌 경우) 불일치(0.0): 리스트 형태로 파싱할 수 없거나, 파싱되더라도 정답과의 교집합이 전혀 없는 경우 이렇게 계산된 보상은 emit_reward()를 통해 Agent Lightning 내부에 기록되며, 이후 APO가 프롬프트를 개선할 때 신호로 활용될 수 있습니다. 아래 코드는 태스크를 한 번 실행하고, 모델 응답을 평가해 보상을 기록하는 롤아웃 루프를 단일 파일로 구현한 예제입니다. 이는 프롬프트를 개선할 때 사용하는 핵심 루프 역할을 합니다. 아래 코드를 rollout.py로 저장하세요. # rollout.py import os import re import json from dataclasses import dataclass from typing import Optional, List import asyncio from dotenv import load_dotenv from openai import AsyncOpenAI, RateLimitError from agentlightning import emit_reward load_dotenv() # --- CLOVA Studio 설정 --- BASE_URL = "https://clovastudio.stream.ntruss.com/v1/openai" API_KEY = os.getenv("CLOVA_STUDIO_API_KEY") # --- 태스크 정의 --- @dataclass class Task: """ 분류 태스크를 표현하는 데이터 구조입니다. - question: 분류 대상 문장 - expected_labels: 정답으로 기대하는 레이블 리스트(예: ["주행", "미디어"]) - task_id: (선택) 태스크 식별자 - system_prompt: (선택) 기본 시스템 프롬프트를 덮어쓰고 싶을 때 사용 """ question: str expected_labels: List[str] task_id: Optional[str] = None system_prompt: Optional[str] = None # --- 유틸리티 --- def normalize_list(values: List[str]) -> List[str]: """리스트 값 정규화""" return [v.strip().lower() for v in values] class RewardCalculator: """보상 계산 유틸리티""" @staticmethod def normalize(s: str) -> str: """문자열 정규화: 따옴표 제거, 공백 제거, 소문자 변환""" return s.strip().strip('\'"').lower() @staticmethod def is_partial_match(expected: str, actual: str) -> bool: """부분 문자열 일치 여부 확인""" e = RewardCalculator.normalize(expected) a = RewardCalculator.normalize(actual) return (e in a) or (a in e) # --- LLM 클라이언트 --- class ClovaClient: def __init__(self, model: str = "HCX-005", temperature: float = 0.0): self.client = AsyncOpenAI( base_url=BASE_URL, api_key=API_KEY, ) self.model = model self.temperature = temperature async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): # asyncio.run()이 루프를 닫기 전에 안전하게 정리 try: self.client.close() except Exception: pass return False async def classify(self, task: Task) -> str: system_prompt = task.system_prompt or """ 당신은 분류기입니다. 입력 문장을 아래 5개 카테고리 중 해당되는 항목으로 분류하세요. 카테고리 정의: - 주행: 주행 및 내비게이션 관련 요청 - 차량 상태: 차량 진단/상태 확인 - 차량 제어: 차량 기능 조작 요청 - 미디어: 음악/라디오, 엔터테인먼트 요청 - 개인 비서: 전화, 메시지, 일정 등 개인 비서 기능 요청 출력 포맷: list 형태로 응답합니다. 해당되는 카테고리가 없다면 빈 배열로 응답하세요. 배열 내 문자열은 작은 따옴표로 감싸세요. """ messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": task.question}, ] # LLM 호출(429 에러 시 재시도) max_retries = 5 wait_time = 2 for attempt in range(max_retries): try: resp = await self.client.chat.completions.create( model=self.model, messages=messages, temperature=self.temperature, ) return resp.choices[0].message.content.strip() except RateLimitError: if attempt < max_retries - 1: # 지수 백오프: 2초, 4초, 8초, 16초, 32초 await asyncio.sleep(wait_time) wait_time *= 2 else: raise except Exception: if attempt < max_retries - 1: await asyncio.sleep(wait_time) wait_time *= 2 else: raise # --- 롤아웃 함수 --- async def run_rollout(task: Task, client: ClovaClient) -> tuple[str, float]: """ 단일 롤아웃 실행: 1) 분류 실행 2) 보상 계산 3) 보상 emit """ # LLM 호출 predicted = await client.classify(task) # JSON 파싱을 위한 전처리 predicted_normalized = predicted.replace("'", '"') try: parsed = json.loads(predicted_normalized) if isinstance(parsed, list): predicted_list = [str(x).strip() for x in parsed] elif isinstance(parsed, str): predicted_list = [parsed] elif isinstance(parsed, dict): # JSON 객체인 경우: categories 또는 labels 키 찾기 if "categories" in parsed: categories = parsed["categories"] if isinstance(categories, list): predicted_list = [str(x).strip() for x in categories] else: predicted_list = [str(categories).strip()] elif "labels" in parsed: labels = parsed["labels"] if isinstance(labels, list): predicted_list = [str(x).strip() for x in labels] else: predicted_list = [str(labels).strip()] else: # 다른 구조의 dict -> 0.0 reward = 0.0 try: emit_reward(reward) except RuntimeError: pass return predicted, reward else: # 리스트도 문자열도 dict도 아님 -> 0.0 reward = 0.0 try: emit_reward(reward) except RuntimeError: pass return predicted, reward except Exception: # JSON 파싱 실패 -> 0.0 reward = 0.0 try: emit_reward(reward) except RuntimeError: pass return predicted, reward # 파싱 성공 시 내용 비교 calculator = RewardCalculator() expected_norm = [calculator.normalize(x) for x in task.expected_labels] actual_norm = [calculator.normalize(x) for x in predicted_list] if sorted(expected_norm) == sorted(actual_norm): expected_json = str(task.expected_labels) if predicted.strip() == expected_json: reward = 1.0 # 완전 일치 elif "'" in predicted: reward = 0.9 # 형식 불일치 elif '"' in predicted: reward = 0.9 # 형식 불일치 else: reward = 0.9 # 형식 불일치 elif set(expected_norm) & set(actual_norm): # 일부 레이블만 일치 reward = 0.5 else: # 부분 문자열 일치 확인 has_partial = False for e in expected_norm: for a in actual_norm: if calculator.is_partial_match(e, a): has_partial = True break if has_partial: break reward = 0.5 if has_partial else 0.0 # Agent Lightning에 보상 emit try: emit_reward(reward) except RuntimeError: pass return predicted, reward 다음은 롤아웃 샘플 실행 코드입니다. 아래 코드를 실행하면 에이전트가 다섯 개의 샘플 태스크를 순차적으로 수행하며, 각 태스크에 대한 모델 응답을 평가하고 보상을 계산·기록하는 과정을 확인할 수 있습니다. # run_example.py import asyncio from rollout import Task, ClovaClient, run_rollout async def run_tests(): # 샘플 태스크 정의 tasks = [ Task( question="회사까지 가장 빠른 길 안내 시작해줘", expected_labels=["주행"], task_id="task_01", ), Task( question="타이어 공기압 체크", expected_labels=["차량 상태"], task_id="task_02", ), Task( question="온열 시트 켜고 출근길에 듣기 좋은 노래 틀어줘", expected_labels=["차량 제어", "미디어"], task_id="task_03", ), Task( question="엄마한테 전화 좀 걸어줘", expected_labels=["개인 비서"], task_id="task_04", ), Task( question="1+1은?", expected_labels=[], task_id="task_05", ), ] client = ClovaClient() for i, task in enumerate(tasks, 1): print(f"[Task {i}/{len(tasks)}] {task.task_id}") print(f"질의: {task.question}") predicted, reward = await run_rollout(task, client) print(f"모델 응답: {predicted}") print(f"실제 정답: {task.expected_labels}") print(f"Reward: {reward:.2f}\n") if __name__ == "__main__": asyncio.run(run_tests()) 위 스크립트 실행 결과입니다. 결과를 보면, 일부 개선이 필요한 태스크를 확인할 수 있습니다. 이러한 부분은 APO를 활용한 프롬프트 자동 최적화를 통해 지침을 점진적으로 정교화함으로써 자연스럽게 개선될 수 있습니다. [Task 1/5] task_01 질의: 회사까지 가장 빠른 길 안내 시작해줘 모델 응답: ['주행'] 실제 정답: ['주행'] Reward: 1.00 [Task 2/5] task_02 질의: 타이어 공기압 체크 모델 응답: ['차량 상태'] 실제 정답: ['차량 상태'] Reward: 1.00 [Task 3/5] task_03 질의: 온열 시트 켜고 출근길에 듣기 좋은 노래 틀어줘 모델 응답: ["차량 제어", "미디어"] 실제 정답: ['차량 제어', '미디어'] Reward: 0.90 [Task 4/5] task_04 질의: 엄마한테 전화 좀 걸어줘 모델 응답: ['개인 비서'] 실제 정답: ['개인 비서'] Reward: 1.00 [Task 5/5] task_05 질의: 1+1은? 모델 응답: [] 실제 정답: [] Reward: 1.00 4. APO 트레이너 APO는 Agent Lightning에 내장된 자동 프롬프트 개선 알고리즘입니다. 에이전트가 여러 태스크를 수행하며 얻은 보상을 기반으로 프롬프트 템플릿을 반복적으로 수정해, 더 높은 성능의 프롬프트로 수렴시키는 방식으로 동작합니다. APO의 최적화 과정은 다음 두 단계로 구성됩니다. Gradient 단계: 무엇이 잘못되었고 어떻게 개선해야 하는지를 분석하는 단계입니다. Apply-Edit 단계: Gradient 단계에서 도출된 개선 방향을 기반으로 실제 프롬프트를 재작성하는 단계입니다. 즉, 모델이 어떤 응답을 생성했고 어떤 보상을 받았는지 분석한 뒤, 그 피드백을 기반으로 더 나은 프롬프트 후보를 생성·실험하는 구조입니다. 4-1. POML 커스터마이징 Agent Lightning에서 사용하는 APO 기본 템플릿은 모두 영문 기반 POML 파일로 제공됩니다. 기본 지시문이 영어 프롬프트 최적화를 전제로 설계되어 있기 때문에, 실제 최적화 과정에서도 모델이 영어 중심의 프롬프트를 생성하는 경향이 있습니다. 따라서 본 문서에서는 Microsoft Agent Lighting 레퍼런스 코드를 참고해, 한국어 프롬프트 최적화에 적합한 커스텀 POML 파일을 직접 구성하고 import하는 방식을 사용합니다. Gradient 단계 이 템플릿은 APO의 첫 번째 단계에서 사용되며, LLM이 프롬프트의 문제점을 찾고, 개선 방향을 생성하는 역할을 수행합니다. 다음은 한국어 기반으로 재작성한 POML 템플릿 예시로, 태스크의 요구사항에 따라 해당 내용도 커스터마이징이 가능합니다. 아래 내용을 그대로 prompts 디렉터리 하위의 text_gradient_ko.poml 파일로 저장하세요. <poml> <p>주어진 프롬프트 템플릿이 낮은 보상을 받은 이유를 정확하게 진단하고, 근본적인 개선점을 제시하십시오.</p> <cp caption="원본 프롬프트"> <text whiteSpace="pre">{{ prompt_template }}</text> </cp> <cp caption="실험 결과"> <cp for="experiment in experiments" caption="실험 {{ loop.index }}"> <p>보상: {{ experiment.final_reward }}</p> <object data="{{ experiment.messages }}" /> </cp> </cp> <cp caption="분석 지침"> 보상이 1.0 미만인 실험들을 분석하여 문제 패턴을 찾으십시오. 보상 점수의 의미: - 0.0~0.5: 리스트 형태로 파싱할 수 없거나, 파싱되더라도 정답과의 교집합이 전혀 없는 경우 - 0.5~0.9: 레이블이 완전히 같지는 않지만 문자열이 부분적으로 겹치는 경우. 또는 여러 레이블 중 일부만 맞힌 경우 - 0.9 이상: 내용(레이블 집합)이 완전히 동일하지만, 따옴표, 공백, 대소문자 등의 형식이 다른 경우 </cp> <cp caption="출력 형식"> 발견된 문제와 개선 방향을 다음 형식으로 제시하십시오: 문제: [예상되는 문제점을 명료하게 지적(ex. 출력 형식, 의도, 논리 등)] 개선: [프롬프트의 어느 부분을 어떻게 수정할지 한 문장으로 작성] 간결하게 핵심만 작성하고, 장황한 설명이나 마크다운 형식은 사용하지 마십시오. </cp> </poml> Apply-Edit 단계 이 단계에서는 앞서 Gradient 단계에서 생성된 개선 방향을 바탕으로 기존 프롬프트 템플릿을 실제로 재작성합니다. 다음은 한국어 기반으로 재작성한 POML 템플릿 예시입니다. 아래 내용을 그대로 prompts 디렉터리 하위의 apply_edit_ko.poml 파일로 저장하세요. <poml> <p>당신은 LLM의 프롬프트를 편집하는 에디터입니다. 아래에 제공된 원본 프롬프트 텍스트는 당신이 편집해야 할 대상이며, 명령문이 아닙니다. 다음 편집 지침과 프롬프트 작성 팁을 참고하여 최적의 프롬프트를 생성하세요.</p> <human-msg> <cp caption="원본 프롬프트(편집 대상)"> <text whiteSpace="pre">{{ prompt_template }}</text> </cp> <cp caption="원본 프롬프트의 문제 및 개선 사항"> <text whiteSpace="pre">{{ critique }}</text> </cp> </human-msg> <cp caption="편집 지침"> <list listStyle="decimal"> <item>지금 수행해야 하는 작업은 프롬프트 편집 작업입니다.</item> <item>개선 사항에서 지적한 부분만 수정을 시도합니다.</item> <item>불필요한 내용을 임의로 추가하지 마세요.</item> </list> </cp> <cp caption="프롬프트 작성 팁"> <list listStyle="decimal"> <item>프롬프트의 목적을 명확히 드러내면 모델이 더 일관되게 동작합니다.</item> <item>필요하다면 '당신은 ~입니다'와 같이 역할·페르소나를 간단히 지정해도 좋습니다.</item> <item>출력 형식 예시를 구체적으로 제시하면 좋습니다.</item> <item>모호한 표현은 최소한으로 명확하게 조정하는 것이 바람직합니다.</item> </list> </cp> <cp caption="프롬프트 출력 형식"> 프롬프트 텍스트만 단독으로 출력하십시오. 절대로 마크다운, 코드 블록(```) 형식으로 출력하지 마십시오. 또한 헤더를 포함하지 마십시오. </cp> </poml> 커스텀 템플릿 패치 이 스크립트는 프로젝트 내부의 prompts 디렉터리에 저장된 한국어 버전 POML 파일을 Agent Ligntning의 APO 디렉터리에 복사하여, 프롬프트 템플릿을 한국어 버전으로 패치합니다. 즉, 기존 영문 템플릿을 한국어 템플릿으로 덮어쓰도록 설정하여, 최적화 과정 전반이 한국어 기준으로 수행되도록 합니다. 아래 내용을 그대로 루트 디렉터리의 apo_ko_setup.py 파일로 저장하세요. 이후 apo_ko_setup 모듈을 import하는 것만으로, 앞서 정의한 한국어 템플릿이 APO 내부에 자동으로 적용됩니다. # apo_ko_setup.py import shutil from pathlib import Path import agentlightning.algorithm.apo as apo_mod def patch_apo_for_korean(): """APO 라이브러리의 영어 프롬프트를 한국어 프롬프트로 교체""" prompts_dir = Path(__file__).parent / "prompts" apo_base_dir = Path(apo_mod.__file__).parent apo_prompts_dir = apo_base_dir / "prompts" files = { "text_gradient_ko.poml": "text_gradient_variant01.poml", "apply_edit_ko.poml": "apply_edit_variant01.poml", } if not apo_prompts_dir.exists(): print(f"APO 프롬프트 디렉터리를 찾을 수 없습니다: {apo_prompts_dir}") return for ko_file, apo_file in files.items(): ko_path = prompts_dir / ko_file apo_path = apo_prompts_dir / apo_file if ko_path.exists(): shutil.copy(ko_path, apo_path) else: print(f"{ko_file} 없음") try: patch_apo_for_korean() except Exception as e: print(f"APO 패치 실패: {e}") 4-2. 데이터셋 준비 분류 태스크의 학습 및 평가에 사용할 데이터를 준비합니다. 4-3. 실행 및 결과 아래 코드는 APO 트레이너를 구성하고, 한국어 POML 템플릿을 사용해 프롬프트 최적화 루프를 실행하는 예제입니다. CLOVA Studio의 HCX-005 모델을 기반으로 APO 알고리즘을 초기화하고, 분류 작업에 맞는 초기 프롬프트 템플릿을 initial_resources에 직접 지정하여 학습을 시작합니다. @agl.rollout 데코레이터로 정의된 에이전트는 각 태스크를 실행하면서 LLM 응답을 생성하고, run_rollout에서 계산된 보상 값을 반환합니다. 이 보상 값은 APO가 다음 프롬프트를 수정하고 개선하는 데 핵심적인 학습 신호로 활용됩니다. 트레이너는 초기 프롬프트 템플릿을 기준으로 학습·검증 데이터셋을 반복 실행하며, 보상을 최대화하는 방향으로 프롬프트를 자동으로 수정하고 버전(v0, v1, v2…) 단위로 관리합니다. 학습이 완료되면, trainer.store에 저장된 프롬프트 버전들을 모두 불러와 테스트셋으로 다시 평가합니다. 이 중 가장 높은 성능을 기록한 프롬프트가 최종 버전으로 선택되며, 모든 버전의 프롬프트 내용과 테스트셋 점수는 prompt_history.txt 파일에 저장됩니다. # train_apo.py import os import random import asyncio import logging from copy import deepcopy from dotenv import load_dotenv import agentlightning as agl from openai import AsyncOpenAI from rollout import Task, run_rollout, ClovaClient from dataset import create_classification_dataset import apo_ko_setup # 한국어 POML 패치용 logging.getLogger("agentlightning").setLevel(logging.CRITICAL) load_dotenv() # --- 설정 --- BASE_URL = "https://clovastudio.stream.ntruss.com/v1/openai" API_KEY = os.getenv("CLOVA_STUDIO_API_KEY") MODEL_NAME = "HCX-005" RANDOM_SEED = 42 BEAM_ROUNDS = 1 BEAM_WIDTH = 1 # --- 전역 변수 --- task_counter = 0 @agl.rollout async def classification_agent(task: dict, prompt_template: agl.PromptTemplate) -> float: """ APO에서 호출되는 분류 에이전트. - task: 데이터셋에서 전달된 태스크(dict) - prompt_template: APO가 현재 시점에 사용 중인 시스템 프롬프트 템플릿 """ global task_counter try: task_obj = Task(**task) # APO가 최적화한 프롬프트를 system_prompt로 주입 if hasattr(prompt_template, "template"): prompt_str = prompt_template.template else: prompt_str = str(prompt_template) task_obj.system_prompt = prompt_str # ClovaClient는 컨텍스트 매니저로 사용하여 연결 정리 async with ClovaClient(model=MODEL_NAME) as client: _, reward = await run_rollout(task_obj, client) task_counter += 1 print(f"\r학습 중... (진행: {task_counter})", end="", flush=True) return reward except Exception as e: print(f"\nTask 오류: {e}") return 0.0 async def evaluate_prompt_on_dataset(prompt_template, dataset_tasks): """ 주어진 프롬프트 템플릿으로 데이터셋 평가. APO가 만든 프롬프트(각 버전)에 대해 test셋에서 평균 reward 계산. """ if hasattr(prompt_template, "template"): prompt_str = prompt_template.template else: prompt_str = str(prompt_template) rewards = [] async with ClovaClient(model=MODEL_NAME) as client: for task_item in dataset_tasks: try: task_obj = deepcopy(task_item) if isinstance(task_item, Task) else Task(**task_item) task_obj.system_prompt = prompt_str _, reward = await run_rollout(task_obj, client) rewards.append(reward) except Exception: rewards.append(0.0) return sum(rewards) / len(rewards) if rewards else 0.0 def extract_version_info(trainer_store): """ Trainer.store 내부에서 버전별 프롬프트를 추출. InMemoryLightningStore의 _resources를 직접 읽어서 v0, v1, v2 ... 버전별 prompt_template를 모은다. """ resources_dict = trainer_store._resources if hasattr(trainer_store, "_resources") else {} initial_prompt = None resources_prompts = {} for version in sorted(resources_dict.keys(), key=lambda v: int(v[1:])): resources_update = resources_dict[version] if not (hasattr(resources_update, "resources") and "prompt_template" in resources_update.resources): continue prompt = resources_update.resources["prompt_template"] resources_prompts[version] = prompt if version == "v0": initial_prompt = prompt return { "resources_dict": resources_dict, "resources_prompts": resources_prompts, "initial_prompt": initial_prompt, } def main(): # --- 데이터셋 분할 --- all_tasks = create_classification_dataset() random.seed(RANDOM_SEED) random.shuffle(all_tasks) total = len(all_tasks) train_tasks = all_tasks[: int(total * 0.6)] val_tasks = all_tasks[int(total * 0.6) : int(total * 0.8)] test_tasks = all_tasks[int(total * 0.8) :] # --- APO 설정 --- try: client = AsyncOpenAI(base_url=BASE_URL, api_key=API_KEY) algorithm = agl.APO( client, gradient_model=MODEL_NAME, apply_edit_model=MODEL_NAME, beam_rounds=BEAM_ROUNDS, beam_width=BEAM_WIDTH, ) except Exception as e: print(f"오류: {e}") return trainer = agl.Trainer( algorithm=algorithm, strategy=agl.SharedMemoryExecutionStrategy(main_thread="algorithm"), tracer=agl.OtelTracer(), initial_resources={ "prompt_template": agl.PromptTemplate( template=""" 당신은 분류기입니다. 입력 문장을 아래 5개 카테고리 중 해당되는 항목으로 분류하세요. 카테고리 정의: - 주행: 주행 및 내비게이션 관련 요청 - 차량 상태: 차량 진단/상태 확인 - 차량 제어: 차량 기능 조작 요청 - 미디어: 음악/라디오, 엔터테인먼트 요청 - 개인 비서: 전화, 메시지, 일정 등 개인 비서 기능 요청 출력 포맷: list 형태로 응답합니다. 해당되는 카테고리가 없다면 빈 배열로 응답하세요. 배열 내 문자열은 작은 따옴표로 감싸세요. """, engine="f-string", ) }, adapter=agl.TraceToMessages(), ) # --- 학습 실행 --- trainer.fit(agent=classification_agent, train_dataset=train_tasks, val_dataset=val_tasks) # --- 버전별 프롬프트 추출 --- if not (hasattr(trainer.store, "_resources") and trainer.store._resources): print("리소스 없음") return info = extract_version_info(trainer.store) if not info["initial_prompt"] or not info["resources_prompts"]: print("프롬프트 추출 실패") return # --- 테스트셋 평가 --- async def run_evaluation(): version_test_results = {} for version in sorted(info["resources_prompts"].keys(), key=lambda v: int(v[1:])): prompt = info["resources_prompts"][version] score = await evaluate_prompt_on_dataset(prompt, test_tasks) version_test_results[version] = score return version_test_results try: version_test_results = asyncio.run(run_evaluation()) # 최적 버전 선택 best_version = max(version_test_results.keys(), key=lambda v: version_test_results[v]) best_score = version_test_results[best_version] initial_test_score = version_test_results.get("v0", 0.0) print("\n" + "=" * 60) print("최종 평가 결과") print("=" * 60) print(f" 초기 프롬프트(v0): {initial_test_score:.3f}") print(f" 수정된 프롬프트({best_version}): {best_score:.3f}\n") # --- 프롬프트 히스토리 저장 --- with open("prompt_history.txt", "w", encoding="utf-8") as f: f.write("=" * 80 + "\n프롬프트 최적화 이력\n" + "=" * 80 + "\n\n") for version in sorted(info["resources_prompts"].keys(), key=lambda v: int(v[1:])): prompt = info["resources_prompts"][version] prompt_str = prompt.template if hasattr(prompt, "template") else str(prompt) score = version_test_results[version] f.write(f"[{version}] 테스트셋 점수: {score:.3f}\n") f.write("-" * 80 + "\n") f.write(f"{prompt_str}\n") f.write("=" * 80 + "\n\n") print("✓ prompt_history.txt 저장\n") except Exception as e: print(f"평가 중 오류: {e}") import traceback traceback.print_exc() if __name__ == "__main__": main() 다음은 위 코드를 실행했을 때 출력된 결과입니다. 동일한 테스트셋을 기반으로 비교한 결과, 수정된 프롬프트(v4)가 기존 프롬프트 대비 더 높은 분류 성능을 보여줌을 확인할 수 있습니다. ============================================================ 최종 평가 결과 ============================================================ 초기 프롬프트(v0): 0.835 수정된 프롬프트(v4): 0.945 ✓ prompt_history.txt 저장 다음은 APO 알고리즘을 통해 자동으로 개선된 프롬프트 v4의 원본입니다. 이후 필요에 따라 학습 파라미터를 조정해 추가적인 최적화 실험을 진행할 수도 있습니다. 문장 분류기를 위한 분류 작업을 수행해주세요. 분류할 카테고리는 다음과 같습니다: - 주행: 주행 및 내비게이션과 관련된 내용 - 차량 상태: 차량 진단이나 상태에 대한 정보 요청 - 차량 제어: 차랑 기능 조작 요청 - 미디어: 음악 또는 라디오 등의 엔터테인먼트 요청 - 개인 비서: 전화걸기, 메시지 보내기, 일정 등록 등과 같은 개인 비서 업무 요청 제공된 문장을 위의 5가지 카테고리 중 가장 적합하다고 생각하는 곳으로 분류하고 그 결과를 list 형태로 제시해 주세요. 예를 들어 아래와 같이 나타낼 수 있습니다: ```plaintext ['주행', '차량 제어'] ``` 위 예시처럼 해당하는 카테고리를 작은따옴표 안에 넣어서 list로 구성해주시면 됩니다. 만약 문장이 어떠한 카테고리에도 속하지 않는다면 빈 배열 `[]`을 반환하셔도 됩니다. 5. 맺음말 이번 쿡북에서는 CLOVA Studio 모델을 기반으로 롤아웃을 구성하고, APO를 통해 프롬프트를 자동으로 개선하는 전체 흐름을 살펴보았습니다. 단순한 분류 태스크도 보상 구조만 잘 설계하면 원하는 방향으로 모델을 안정적으로 유도할 수 있고, APO는 이 보상 신호를 활용해 프롬프트를 점진적으로 더 좋은 형태로 다듬어 줍니다. 이 구조는 다른 도메인의 에이전트에도 그대로 확장할 수 있는데요. 서비스 요구사항에 맞게 태스크, 보상 체계 등을 커스터마이즈하면, 보다 복잡한 워크플로우나 실제 서비스 환경에서도 안정적인 프롬프트를 자동으로 구축할 수 있습니다. 특히 프롬프트 성능이 곧 모델 품질로 이어지는 LLM 기반 시스템에서는, 이러한 자동 최적화가 품질을 빠르게 끌어올리는 데 효과적입니다. 이제 여러분의 서비스 맥락에 맞는 태스크를 넣어 보며 프롬프트가 어떻게 진화하는지 확인해 보세요. 🧐
  21. 안녕하세요. 같은 문제를 겪고 있는데 혹시 해결 하셨을까요?
  22. Authorization failed: [401] Unauthorized client가 납니다. Maps -> Application -> 등록 -> Dynamic Map으로 하고 local.properties에도 넣어놨고 혹시 몰라 <meta-data 에도 넣어놨는데 계속 401이 뜹니다.
  23. Authorization failed: [401] Unauthorized client가 납니다. Maps -> Application -> 등록 -> Dynamic Map으로 하고 local.properties에도 넣어놨고 혹시 몰라 <meta-data 에도 넣어놨는데 계속 401이 뜹니다.
  24. 조금 더 해봤는데 1. geoserver 어플리케이션에서 자체적으로 래스터 혹은 gpkg와 좌표계 조정해주는 기능이 있어서 초정밀 수준이 아니라면 이 기능으로 해결이 됩니다. 2. 다만 지오서버 벡터/레스터 운영 자체가 난이도가 있었습니다. 3. 위에 반투명 png 레스터 레이어를 올린 상태에서 마커 등의 조작은 원할하게 작동합니다. 저 주제가 아니라 다른 주제로 한건데 이쪽 링크를 참고하시면 좋을 것 같습니다 😄 URL 삼시세끼 모범밥상
  25. 안녕하세요. NAVER Maps JavaScript API v3를 사용 중인데 특정 환경에서만 발생하는 오류가 있어 문의드립니다. Issue와 실제 코드를 기반으로 AI가 작성 후 내용검수를 하였습니다. 어색한 부분이 있으면 양해 부탁드립니다. [증상] 네이버 지도 초기화 시 TypeError: Cannot read properties of undefined (reading 'projection') 오류가 환경에 따라 간헐적으로 발생합니다. - macOS Chrome: 처음 발생 후 현재는 정상 동작 - iOS WebView (iOS 26.1): 간헐적 오류 발생 - Staging 환경: 지속적으로 에러 발생 - Production 환경: 동일 기기, 동일 iOS 버전에서 정상 작동 - 재현성: 동일 코드에서도 발생할 때가 있고 안 할 때도 있음 (예측 불가) [에러 로그] 네이버 지도 초기화 중 오류: TypeError: Cannot read properties of undefined (reading 'projection') at new x.MapOptions (maps.js?ncpKeyId=xxxxx&submodules=panorama:11:13158) at x.Map._initMapOptions (maps.js?ncpKeyId=xxxxx&submodules=panorama:11:22770) at new x.Map (maps.js?ncpKeyId=xxxxx&submodules=panorama:11:15892) at initializeMap (naver-map.vue:1856:11) [환경 정보] - SDK 버전: NAVER Maps JavaScript API v3 - 스크립트 URL: https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=xxxxx&submodules=panorama - 프레임워크: Vue 3 + Quasar (TypeScript) - 문제 발생 환경: iOS 26.1 WebView, macOS Chrome (간헐적) [관련 코드] (스크립트 로드) const createScript = (resolve, reject) => { const script = document.createElement('script'); script.type = 'text/javascript'; script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${NAVER_MAP_CLIENT_ID}&submodules=panorama`; script.async = true; script.onload = () => { resolve(); }; document.head.appendChild(script); }; (지도 초기화) const initializeMap = () => { try { if (!naver || !naver.maps) { throw new Error('네이버 지도 API가 로드되지 않았습니다.'); } if (!naverMapRef.value) { throw new Error('지도 컨테이너 DOM 요소를 찾을 수 없습니다.'); } const mapOptions = { center: base, draggable: draggable, zoom: zoom, minZoom: minZoom, maxZoom: maxZoom, padding: padding, zoomControl: zoomControl, zoomControlOptions: { style: naver.maps.ZoomControlStyle.LARGE, position: naver.maps.Position.RIGHT_CENTER, }, mapTypeId: naver.maps.MapTypeId.NORMAL, mapTypeControl: mapTypeControl, mapTypeControlOptions: { mapTypeIds: ['normal', 'terrain'], style: naver.maps.MapTypeControlStyle.DROPDOWN, position: naver.maps.Position.TOP_RIGHT, }, mapDataControl: false, }; map = new naver.maps.Map(naverMapRef.value, mapOptions); } catch (error) { logger.error('네이버 지도 초기화 중 오류:', error); } }; [재현 단계] 1. 지도 컴포넌트 마운트 2. loadNaverMapScript() 호출 → 스크립트 로드 완료 3. initializeMap() 호출 → new naver.maps.Map() 실행 4. SDK 내부 MapOptions 생성 과정에서 projection 속성 접근 시 undefined 오류 [참고: 유사 사례] [topic/412] map panorama 생성시 오류가 발생합니다 - https://www.ncloud-forums.com/topic/412/ - 동일한 패턴: 간헐적 오류 발생 - "같은 위치라도 에러가 발생할 때도 있고 발생하지 않을 때도 있었습니다" - 우회 해결: "파노라마 맵을 초기에 로드하여 보이지 않는 상태로 놔둔 후..." 방식 [질문] 1. SDK 내부에서 projection이 undefined가 되는 조건이 있을까요? (예: WebGL 미지원, 특정 iOS 버전 등) 2. iOS WebView 환경에서 권장되는 초기화 방식이 별도로 있을까요? 감사합니다.
  26. 안녕하세요, @태훈2님, 현재 이미지를 포함한 학습은 지원하지 않고 있습니다. 감사합니다.
  27. HCX-005 튜닝 시, 이미지를 포함한 학습은 불가한가요? 오로지 텍스트만 가능한지요? api가이드 문서에 따로 방법이 나와있지 않아서, 여쭙습니다.
  1. Load more activity
×
×
  • Create New...