├── .env_sample ├── .gitignore ├── .python-version ├── 00-langgraph-(VoiceTutorial).ipynb ├── 00-langgraph.ipynb ├── README.md ├── data └── SPRI_AI_Brief_2023년12월호_F.pdf ├── pyproject.toml ├── rag ├── base.py ├── pdf.py └── utils.py ├── requirements.txt └── uv.lock /.env_sample: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=sk-proj-o0gulL2... 2 | TAVILY_API_KEY=tvly-mGxBGvzz... 3 | LANGSMITH_TRACING=true 4 | LANGSMITH_ENDPOINT=https://api.smith.langchain.com 5 | LANGSMITH_API_KEY=lsv2_sk_ed2278... 6 | LANGSMITH_PROJECT=LangGraph-Hands-On 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | .DS_Store 9 | 10 | # Virtual environments 11 | .venv 12 | .env 13 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /00-langgraph.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# LangGraph 핸즈온\n", 8 | "\n", 9 | "**참고하면 좋은 자료**\n", 10 | "\n", 11 | "- [LangChain 한국어 튜토리얼🇰🇷](https://wikidocs.net/book/14314)\n", 12 | "- [LangChain 한국어 튜토리얼 Github 소스코드](https://github.com/teddylee777/langchain-kr)\n", 13 | "- [테디노트 YouTube](https://www.youtube.com/c/@teddynote)\n", 14 | "- [테디노트 블로그](https://teddylee777.github.io/)\n", 15 | "- [테디노트 YouTube 로 RAG 배우기!](https://teddylee777.notion.site/YouTube-RAG-10a24f35d12980dc8478c750faa752a2?pvs=74)\n", 16 | "- [RAG 비법노트](https://fastcampus.co.kr/data_online_teddy)" 17 | ] 18 | }, 19 | { 20 | "cell_type": "markdown", 21 | "metadata": {}, 22 | "source": [ 23 | "## Part 0. 환경 설정\n", 24 | "\n", 25 | "**OpenAI API Key 설정**\n", 26 | "- https://wikidocs.net/233342\n", 27 | "\n", 28 | "**웹 검색을 위한 API 키 발급 주소**\n", 29 | "- https://app.tavily.com/\n", 30 | "\n", 31 | "회원 가입 후 API Key를 발급합니다." 32 | ] 33 | }, 34 | { 35 | "cell_type": "markdown", 36 | "metadata": {}, 37 | "source": [ 38 | "**설치를 진행합니다**" 39 | ] 40 | }, 41 | { 42 | "cell_type": "code", 43 | "execution_count": null, 44 | "metadata": {}, 45 | "outputs": [], 46 | "source": [ 47 | "%pip install langgraph langchain_openai langchain_teddynote faiss-cpu pdfplumber langchain_community" 48 | ] 49 | }, 50 | { 51 | "cell_type": "markdown", 52 | "metadata": {}, 53 | "source": [ 54 | "**실습자료를 다운로드 받습니다**" 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": 1, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "import os\n", 64 | "\n", 65 | "os.environ[\"OPENAI_API_KEY\"] = \"\" # 발급 받은 OpenAI API Key 입력\n", 66 | "os.environ[\"TAVILY_API_KEY\"] = \"\" # 발급 받은 Tavily API Key 입력" 67 | ] 68 | }, 69 | { 70 | "cell_type": "markdown", 71 | "metadata": {}, 72 | "source": [ 73 | "(선택 사항)\n", 74 | "\n", 75 | "LangSmith 추적을 원하는 경우 아래 LangSmith API Key 를 발급 받아 입력해 주세요.\n", 76 | "\n", 77 | "- 링크: https://smith.langchain.com\n", 78 | "- 회원 가입 후 - 설정 - 상단 API Keys 에서 발급" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "metadata": {}, 85 | "outputs": [], 86 | "source": [ 87 | "os.environ[\"LANGSMITH_API_KEY\"] = \"\" # 발급 받은 LangSmith API Key 입력\n", 88 | "os.environ[\"LANGSMITH_TRACING\"] = \"true\" # 추적 설정\n", 89 | "os.environ[\"LANGSMITH_ENDPOINT\"] = \"https://api.smith.langchain.com\" # 추적 엔드포인트\n", 90 | "os.environ[\"LANGSMITH_PROJECT\"] = \"LangGraph-Hands-On\" # 프로젝트 이름" 91 | ] 92 | }, 93 | { 94 | "cell_type": "markdown", 95 | "metadata": {}, 96 | "source": [ 97 | "## Part 1. 기본 ReAct Agent 구현" 98 | ] 99 | }, 100 | { 101 | "cell_type": "code", 102 | "execution_count": 3, 103 | "metadata": {}, 104 | "outputs": [], 105 | "source": [ 106 | "from langchain_openai import ChatOpenAI\n", 107 | "\n", 108 | "# 모델 설정\n", 109 | "model = ChatOpenAI(model_name=\"gpt-4o\")" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "metadata": {}, 115 | "source": [ 116 | "### 도구 (Tools) 설정\n", 117 | "\n", 118 | "도구(Tool)는 에이전트, 체인 또는 LLM이 외부 세계와 상호작용하기 위한 인터페이스입니다.\n", 119 | "\n", 120 | "LangChain 에서 기본 제공하는 도구를 사용하여 쉽게 도구를 활용할 수 있으며, 사용자 정의 도구(Custom Tool) 를 쉽게 구축하는 것도 가능합니다.\n", 121 | "\n", 122 | "**LangChain 에 통합된 도구 리스트는 아래 링크에서 확인할 수 있습니다.**\n", 123 | "\n", 124 | "랭체인에서 제공하는 사전에 정의된 도구(tool) 와 툴킷(toolkit) 을 사용할 수 있습니다.\n", 125 | "\n", 126 | "tool 은 단일 도구를 의미하며, toolkit 은 여러 도구를 묶어서 하나의 도구로 사용할 수 있습니다.\n", 127 | "\n", 128 | "관련 도구는 아래의 링크에서 참고하실 수 있습니다.\n", 129 | "\n", 130 | "**참고**\n", 131 | "- [LangChain Tools/Toolkits](https://python.langchain.com/docs/integrations/tools/)" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "metadata": {}, 137 | "source": [ 138 | "**검색 API 도구**\n", 139 | "\n", 140 | "Tavily 검색 API를 활용하여 검색 기능을 구현하는 도구입니다. \n", 141 | "\n", 142 | "**API 키 발급 주소**\n", 143 | "- https://app.tavily.com/\n", 144 | "\n", 145 | "발급한 API 키를 환경변수에 설정합니다.\n", 146 | "\n", 147 | "`.env` 파일에 아래와 같이 설정합니다.\n", 148 | "\n", 149 | "```\n", 150 | "TAVILY_API_KEY=tvly-abcdefghijklmnopqrstuvwxyz\n", 151 | "```\n", 152 | "\n", 153 | "**TavilySearch**\n", 154 | "\n", 155 | "**설명**\n", 156 | "- Tavily 검색 API를 쿼리하고 JSON 형식의 결과를 반환합니다.\n", 157 | "- 포괄적이고 정확하며 신뢰할 수 있는 결과에 최적화된 검색 엔진입니다.\n", 158 | "- 현재 이벤트에 대한 질문에 답변할 때 유용합니다.\n", 159 | "\n", 160 | "**주요 매개변수**\n", 161 | "- `max_results` (int): 반환할 최대 검색 결과 수 (기본값: 5)\n", 162 | "- `search_depth` (str): 검색 깊이 (\"basic\" 또는 \"advanced\")\n", 163 | "- `include_domains` (List[str]): 검색 결과에 포함할 도메인 목록\n", 164 | "- `exclude_domains` (List[str]): 검색 결과에서 제외할 도메인 목록\n", 165 | "- `include_answer` (bool): 원본 쿼리에 대한 짧은 답변 포함 여부\n", 166 | "- `include_raw_content` (bool): 각 사이트의 정제된 HTML 콘텐츠 포함 여부\n", 167 | "- `include_images` (bool): 쿼리 관련 이미지 목록 포함 여부\n", 168 | "\n", 169 | "**반환 값**\n", 170 | "- 검색 결과를 포함하는 JSON 형식의 문자열(url, content)" 171 | ] 172 | }, 173 | { 174 | "cell_type": "code", 175 | "execution_count": 4, 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "from langchain_teddynote.tools.tavily import TavilySearch\n", 180 | "\n", 181 | "# 웹 검색 도구를 설정합니다.\n", 182 | "web_search_tool = TavilySearch(\n", 183 | " max_results=6, # 최대 검색 결과\n", 184 | ")\n", 185 | "\n", 186 | "# 웹 검색 도구의 이름과 설명을 설정합니다.\n", 187 | "web_search_tool.name = \"web_search\"\n", 188 | "web_search_tool.description = \"Use this tool to search on the web\"" 189 | ] 190 | }, 191 | { 192 | "cell_type": "markdown", 193 | "metadata": {}, 194 | "source": [ 195 | "`PDFRetrievalChain`: PDF 문서 기반 Naive RAG 체인\n", 196 | "\n", 197 | "문서 기반 RAG 체인을 생성합니다. 이 체인은 주어진 PDF 문서를 기반으로 검색 기능을 제공합니다.\n", 198 | "\n", 199 | "**주요 매개변수**\n", 200 | "- `source_uri` (List[str]): PDF 문서의 경로\n", 201 | "- `model_name` (str): 사용할 모델의 이름\n", 202 | "- `k` (int): 반환할 최대 검색 결과 수 (기본값: 6)\n" 203 | ] 204 | }, 205 | { 206 | "cell_type": "code", 207 | "execution_count": 5, 208 | "metadata": {}, 209 | "outputs": [], 210 | "source": [ 211 | "from rag.pdf import PDFRetrievalChain\n", 212 | "\n", 213 | "# PDF 문서를 로드합니다.\n", 214 | "pdf = PDFRetrievalChain(\n", 215 | " [\"data/SPRI_AI_Brief_2023년12월호_F.pdf\"], model_name=\"gpt-4o-mini\", k=6\n", 216 | ").create_chain()\n", 217 | "\n", 218 | "# retriever와 chain을 생성합니다.\n", 219 | "pdf_retriever = pdf.retriever\n", 220 | "pdf_chain = pdf.chain" 221 | ] 222 | }, 223 | { 224 | "cell_type": "code", 225 | "execution_count": null, 226 | "metadata": {}, 227 | "outputs": [], 228 | "source": [ 229 | "# 검색 쿼리를 입력하여 검색 결과를 반환합니다.\n", 230 | "searched_docs = pdf_retriever.invoke(\"삼성전자가 만든 생성형 AI 이름을 찾아줘\")\n", 231 | "searched_docs" 232 | ] 233 | }, 234 | { 235 | "cell_type": "markdown", 236 | "metadata": {}, 237 | "source": [ 238 | "**추적**: https://smith.langchain.com/public/bdaa2410-0a6a-44c9-8e2b-c5d8628bf84e/r" 239 | ] 240 | }, 241 | { 242 | "cell_type": "code", 243 | "execution_count": null, 244 | "metadata": {}, 245 | "outputs": [], 246 | "source": [ 247 | "answer = pdf_chain.invoke(\n", 248 | " {\"question\": \"삼성전자가 만든 생성형 AI 이름을 찾아줘\", \"context\": searched_docs}\n", 249 | ")\n", 250 | "print(answer)" 251 | ] 252 | }, 253 | { 254 | "cell_type": "code", 255 | "execution_count": 8, 256 | "metadata": {}, 257 | "outputs": [], 258 | "source": [ 259 | "from langchain_core.tools.retriever import create_retriever_tool\n", 260 | "\n", 261 | "# PDF 문서를 기반으로 검색 도구 생성\n", 262 | "retriever_tool = create_retriever_tool(\n", 263 | " pdf_retriever,\n", 264 | " \"pdf_retriever\",\n", 265 | " \"Search and return information about SPRI AI Brief PDF file. It contains useful information on recent AI trends. The document is published on Dec 2023.\",\n", 266 | ")" 267 | ] 268 | }, 269 | { 270 | "cell_type": "code", 271 | "execution_count": null, 272 | "metadata": {}, 273 | "outputs": [], 274 | "source": [ 275 | "result = retriever_tool.invoke(\"삼성전자가 만든 생성형 AI 이름을 찾아줘\")\n", 276 | "print(result)" 277 | ] 278 | }, 279 | { 280 | "cell_type": "markdown", 281 | "metadata": {}, 282 | "source": [ 283 | "도구 목록을 정의 합니다. 이는 Agent 에게 제공될 도구 목록입니다. " 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": null, 289 | "metadata": {}, 290 | "outputs": [], 291 | "source": [ 292 | "# 도구 목록 정의\n", 293 | "tools = [web_search_tool, retriever_tool]\n", 294 | "tools" 295 | ] 296 | }, 297 | { 298 | "cell_type": "markdown", 299 | "metadata": {}, 300 | "source": [ 301 | "`create_react_agent`\n", 302 | "\n", 303 | "ReAct Agent 를 생성합니다. 이는 도구 목록을 제공하고, 사용자의 질문에 대한 답변을 생성합니다.\n", 304 | "\n", 305 | "- `model`: 사용할 모델\n", 306 | "- `tools`: 도구 목록\n", 307 | "- `prompt`: 시스템 프롬프트\n" 308 | ] 309 | }, 310 | { 311 | "cell_type": "code", 312 | "execution_count": 11, 313 | "metadata": {}, 314 | "outputs": [], 315 | "source": [ 316 | "from langgraph.prebuilt import create_react_agent\n", 317 | "\n", 318 | "simple_react_agent = create_react_agent(\n", 319 | " model, tools, prompt=\"You are a helpful assistant. Answer in Korean.\"\n", 320 | ")" 321 | ] 322 | }, 323 | { 324 | "cell_type": "markdown", 325 | "metadata": {}, 326 | "source": [ 327 | "**그래프 시각화**\n", 328 | "\n", 329 | "`visualize_graph` 함수는 그래프를 시각화합니다." 330 | ] 331 | }, 332 | { 333 | "cell_type": "code", 334 | "execution_count": null, 335 | "metadata": {}, 336 | "outputs": [], 337 | "source": [ 338 | "from langchain_teddynote.graphs import visualize_graph\n", 339 | "\n", 340 | "visualize_graph(simple_react_agent)" 341 | ] 342 | }, 343 | { 344 | "cell_type": "markdown", 345 | "metadata": {}, 346 | "source": [ 347 | "### 그래프 실행\n", 348 | "\n", 349 | "- `config` 파라미터는 그래프 실행 시 필요한 설정 정보를 전달합니다.\n", 350 | " - `resursion_limit`: 그래프 실행 시 재귀 최대 횟수를 설정합니다.\n", 351 | " - `thread_id`: 그래프 실행 시 스레드 아이디를 설정합니다.\n", 352 | "- `inputs`: 그래프 실행 시 필요한 입력 정보를 전달합니다.\n", 353 | "\n", 354 | "**참고**\n", 355 | "\n", 356 | "- 메시지 출력 스트리밍은 [LangGraph 스트리밍 모드의 모든 것](https://wikidocs.net/265770) 을 참고해주세요." 357 | ] 358 | }, 359 | { 360 | "cell_type": "code", 361 | "execution_count": null, 362 | "metadata": {}, 363 | "outputs": [], 364 | "source": [ 365 | "from langchain_teddynote.messages import stream_graph\n", 366 | "\n", 367 | "# Config 설정\n", 368 | "config = {\"configurable\": {\"resursion_limit\": 10, \"thread_id\": \"abc123\"}}\n", 369 | "\n", 370 | "# 입력 설정\n", 371 | "inputs = {\n", 372 | " \"messages\": [(\"human\", \"AI Brief 문서에서 삼성전자가 만든 생성형 AI 이름을 찾아줘\")]\n", 373 | "}\n", 374 | "\n", 375 | "# 그래프 스트림\n", 376 | "stream_graph(simple_react_agent, inputs, config)" 377 | ] 378 | }, 379 | { 380 | "cell_type": "markdown", 381 | "metadata": {}, 382 | "source": [ 383 | "추적: https://smith.langchain.com/public/145d8012-9791-4320-a17d-b2ec048d0110/r" 384 | ] 385 | }, 386 | { 387 | "cell_type": "markdown", 388 | "metadata": {}, 389 | "source": [ 390 | "**참고**: `config` 는 이전의 값을 재활용 합니다." 391 | ] 392 | }, 393 | { 394 | "cell_type": "code", 395 | "execution_count": null, 396 | "metadata": {}, 397 | "outputs": [], 398 | "source": [ 399 | "# 그래프 스트림\n", 400 | "stream_graph(\n", 401 | " simple_react_agent,\n", 402 | " {\"messages\": [(\"human\", \"claude 3.7 sonnet 관련 정보를 검색해줘\")]},\n", 403 | " config,\n", 404 | ")" 405 | ] 406 | }, 407 | { 408 | "cell_type": "markdown", 409 | "metadata": {}, 410 | "source": [ 411 | "**추적**: https://smith.langchain.com/public/e28f6b6c-463c-4211-8a75-3c88dfdcc41c/r" 412 | ] 413 | }, 414 | { 415 | "cell_type": "markdown", 416 | "metadata": {}, 417 | "source": [ 418 | "## Part 2. 멀티턴 대화를 위한 단기 메모리: `checkpointer`\n", 419 | "\n", 420 | "단기 메모리 기능이 없는 그래프는 이전 대화를 기억하지 못합니다.\n", 421 | "\n", 422 | "즉, 멀티턴 대화를 지원하지 않는다는 말이기도 합니다. 따라서, 다음과 같이 이전 대화를 기억하지 못합니다." 423 | ] 424 | }, 425 | { 426 | "cell_type": "code", 427 | "execution_count": null, 428 | "metadata": {}, 429 | "outputs": [], 430 | "source": [ 431 | "# 그래프 스트림\n", 432 | "stream_graph(\n", 433 | " simple_react_agent,\n", 434 | " {\"messages\": [(\"human\", \"안녕, 반가워! 내 이름은 테디야!\")]},\n", 435 | " config,\n", 436 | ")" 437 | ] 438 | }, 439 | { 440 | "cell_type": "code", 441 | "execution_count": null, 442 | "metadata": {}, 443 | "outputs": [], 444 | "source": [ 445 | "# 그래프 스트림\n", 446 | "stream_graph(simple_react_agent, {\"messages\": [(\"human\", \"내 이름이 뭐라고?\")]}, config)" 447 | ] 448 | }, 449 | { 450 | "cell_type": "markdown", 451 | "metadata": {}, 452 | "source": [ 453 | "### `MemorySaver`\n", 454 | "\n", 455 | "LangGraph 는 `Checkpointer` 를 사용해 각 단계가 끝난 후 그래프 상태를 자동으로 저장할 수 있습니다.\n", 456 | "\n", 457 | "이 내장된 지속성 계층은 메모리를 제공하여 LangGraph가 마지막 상태 업데이트에서 선택할 수 있도록 합니다. \n", 458 | "\n", 459 | "가장 사용하기 쉬운 체크포인터 중 하나는 그래프 상태를 위한 인메모리 키-값 저장소인 `MemorySaver`입니다." 460 | ] 461 | }, 462 | { 463 | "cell_type": "code", 464 | "execution_count": 17, 465 | "metadata": {}, 466 | "outputs": [], 467 | "source": [ 468 | "from langgraph.checkpoint.memory import MemorySaver\n", 469 | "\n", 470 | "# 메모리 설정\n", 471 | "memory = MemorySaver()" 472 | ] 473 | }, 474 | { 475 | "cell_type": "code", 476 | "execution_count": 18, 477 | "metadata": {}, 478 | "outputs": [], 479 | "source": [ 480 | "from langgraph.prebuilt import create_react_agent\n", 481 | "\n", 482 | "# ReAct Agent 생성(checkpointer 설정)\n", 483 | "simple_react_agent = create_react_agent(\n", 484 | " model,\n", 485 | " tools,\n", 486 | " checkpointer=memory,\n", 487 | " prompt=\"You are a helpful assistant. Answer in Korean.\",\n", 488 | ")" 489 | ] 490 | }, 491 | { 492 | "cell_type": "code", 493 | "execution_count": null, 494 | "metadata": {}, 495 | "outputs": [], 496 | "source": [ 497 | "# Config 설정\n", 498 | "config = {\"configurable\": {\"thread_id\": \"abc123\"}}\n", 499 | "\n", 500 | "# 그래프 스트림\n", 501 | "stream_graph(\n", 502 | " simple_react_agent,\n", 503 | " {\"messages\": [(\"human\", \"안녕, 반가워! 내 이름은 테디야!\")]},\n", 504 | " config,\n", 505 | ")" 506 | ] 507 | }, 508 | { 509 | "cell_type": "markdown", 510 | "metadata": {}, 511 | "source": [ 512 | "이번에는 이전 대화 내용을 잘 기억하는 것을 확인할 수 있습니다." 513 | ] 514 | }, 515 | { 516 | "cell_type": "code", 517 | "execution_count": null, 518 | "metadata": {}, 519 | "outputs": [], 520 | "source": [ 521 | "# 그래프 스트림\n", 522 | "stream_graph(simple_react_agent, {\"messages\": [(\"human\", \"내 이름이 뭐라고?\")]}, config)" 523 | ] 524 | }, 525 | { 526 | "cell_type": "markdown", 527 | "metadata": {}, 528 | "source": [ 529 | "**추적**: https://smith.langchain.com/public/16105167-e6db-4e26-add8-96cbf53191f7/r" 530 | ] 531 | }, 532 | { 533 | "cell_type": "markdown", 534 | "metadata": {}, 535 | "source": [ 536 | "## Part 3. LangGraph 워크플로우 구현\n", 537 | "\n", 538 | "이전의 `create_react_agent` 를 사용하여 에이전트를 구현해 보았습니다.\n", 539 | "\n", 540 | "하지만, 이전의 에이전트는 단일 에이전트 형태이기 때문에 복잡한 워크플로우를 구현하기 어렵습니다.\n", 541 | "\n", 542 | "이를 해결하기 위해서는 워크플로우를 구현해야 합니다." 543 | ] 544 | }, 545 | { 546 | "cell_type": "markdown", 547 | "metadata": {}, 548 | "source": [ 549 | "**Steps**\n", 550 | "1. State 정의(TypedDict 형식으로 정의)\n", 551 | "2. 노드 정의(함수로 구현)\n", 552 | "3. 그래프 생성(StateGraph 클래스 사용)\n", 553 | "4. 컴파일(checkpointer 설정)\n", 554 | "5. 실행\n", 555 | "\n", 556 | "### State 정의\n", 557 | "\n", 558 | "`State`: Graph 의 노드와 노드 간 공유하는 상태를 정의합니다.\n", 559 | "\n", 560 | "일반적으로 `TypedDict` 형식을 사용합니다." 561 | ] 562 | }, 563 | { 564 | "cell_type": "code", 565 | "execution_count": 21, 566 | "metadata": {}, 567 | "outputs": [], 568 | "source": [ 569 | "from typing import Annotated, TypedDict\n", 570 | "from langgraph.graph.message import add_messages\n", 571 | "\n", 572 | "\n", 573 | "# GraphState 상태 정의\n", 574 | "class GraphState(TypedDict):\n", 575 | " question: Annotated[str, \"User's Question\"] # 질문\n", 576 | " documents: Annotated[str, \"Retrieved Documents\"] # 문서의 검색 결과\n", 577 | " answer: Annotated[str, \"LLM generated answer\"] # 답변\n", 578 | " messages: Annotated[list, add_messages] # 메시지(누적되는 list)" 579 | ] 580 | }, 581 | { 582 | "cell_type": "markdown", 583 | "metadata": {}, 584 | "source": [ 585 | "### 노드(Node) 정의\n", 586 | "\n", 587 | "- `Nodes`: 각 단계를 처리하는 노드입니다. 보통은 Python 함수로 구현합니다. 입력과 출력이 상태(State) 값입니다.\n", 588 | " \n", 589 | "**참고** \n", 590 | "\n", 591 | "- `State`를 입력으로 받아 정의된 로직을 수행한 후 업데이트된 `State`를 반환합니다." 592 | ] 593 | }, 594 | { 595 | "cell_type": "code", 596 | "execution_count": 22, 597 | "metadata": {}, 598 | "outputs": [], 599 | "source": [ 600 | "from rag.utils import format_docs\n", 601 | "\n", 602 | "\n", 603 | "# 문서 검색 노드\n", 604 | "def retrieve_document(state: GraphState) -> GraphState:\n", 605 | " # 질문을 상태에서 가져옵니다.\n", 606 | " latest_question = state[\"question\"]\n", 607 | "\n", 608 | " # 문서에서 검색하여 관련성 있는 문서를 찾습니다.\n", 609 | " retrieved_docs = pdf_retriever.invoke(latest_question)\n", 610 | "\n", 611 | " # 검색된 문서를 형식화합니다.(프롬프트 입력으로 넣어주기 위함)\n", 612 | " retrieved_docs = format_docs(retrieved_docs)\n", 613 | "\n", 614 | " # 검색된 문서를 context 키에 저장합니다.\n", 615 | " return {\"documents\": retrieved_docs}\n", 616 | "\n", 617 | "\n", 618 | "# 답변 생성 노드\n", 619 | "def llm_answer(state: GraphState) -> GraphState:\n", 620 | " # 질문을 상태에서 가져옵니다.\n", 621 | " latest_question = state[\"question\"]\n", 622 | "\n", 623 | " # 검색된 문서를 상태에서 가져옵니다.\n", 624 | " documents = state[\"documents\"]\n", 625 | "\n", 626 | " # 체인을 호출하여 답변을 생성합니다.\n", 627 | " response = pdf_chain.invoke({\"question\": latest_question, \"context\": documents})\n", 628 | " # 생성된 답변, (유저의 질문, 답변) 메시지를 상태에 저장합니다.\n", 629 | " return {\n", 630 | " \"answer\": response,\n", 631 | " \"messages\": [(\"user\", latest_question), (\"assistant\", response)],\n", 632 | " }" 633 | ] 634 | }, 635 | { 636 | "cell_type": "markdown", 637 | "metadata": {}, 638 | "source": [ 639 | "### 그래프 생성\n", 640 | "\n", 641 | "- `StateGraph`: `State` 를 입력으로 받아 `Node` 를 실행하고 `State` 를 업데이트하는 그래프 생성 클래스.\n", 642 | "- `Edges`: 현재 `State`를 기반으로 다음에 실행할 `Node`를 결정.\n", 643 | "- `set_entry_point`: 그래프 진입점 설정.\n", 644 | "- `compile`: 그래프 컴파일." 645 | ] 646 | }, 647 | { 648 | "cell_type": "code", 649 | "execution_count": 23, 650 | "metadata": {}, 651 | "outputs": [], 652 | "source": [ 653 | "from langgraph.graph import END, StateGraph\n", 654 | "from langgraph.checkpoint.memory import MemorySaver\n", 655 | "\n", 656 | "# 그래프 생성\n", 657 | "workflow = StateGraph(GraphState)\n", 658 | "\n", 659 | "# 노드 정의\n", 660 | "workflow.add_node(\"retrieve\", retrieve_document)\n", 661 | "workflow.add_node(\"llm_answer\", llm_answer)\n", 662 | "\n", 663 | "# 엣지 정의\n", 664 | "workflow.add_edge(\"retrieve\", \"llm_answer\") # 검색 -> 답변\n", 665 | "workflow.add_edge(\"llm_answer\", END) # 답변 -> 종료\n", 666 | "\n", 667 | "# 그래프 진입점(entry_point) 설정\n", 668 | "workflow.set_entry_point(\"retrieve\")\n", 669 | "\n", 670 | "# 체크포인터 설정\n", 671 | "memory = MemorySaver()\n", 672 | "\n", 673 | "# 컴파일\n", 674 | "app = workflow.compile(checkpointer=memory)" 675 | ] 676 | }, 677 | { 678 | "cell_type": "markdown", 679 | "metadata": {}, 680 | "source": [ 681 | "컴파일이 완료된 그래프를 실행하고 시각화 합니다." 682 | ] 683 | }, 684 | { 685 | "cell_type": "code", 686 | "execution_count": null, 687 | "metadata": {}, 688 | "outputs": [], 689 | "source": [ 690 | "from langchain_teddynote.graphs import visualize_graph\n", 691 | "\n", 692 | "visualize_graph(app)" 693 | ] 694 | }, 695 | { 696 | "cell_type": "markdown", 697 | "metadata": {}, 698 | "source": [ 699 | "그래프 실행\n", 700 | "\n", 701 | "- `config` 파라미터는 그래프 실행 시 필요한 설정 정보를 전달합니다.\n", 702 | "- `recursion_limit`: 그래프 실행 시 재귀 최대 횟수를 설정합니다.\n", 703 | "- `inputs`: 그래프 실행 시 필요한 입력 정보를 전달합니다.\n", 704 | "\n", 705 | "**참고**\n", 706 | "\n", 707 | "- 메시지 출력 스트리밍은 [LangGraph 스트리밍 모드의 모든 것](https://wikidocs.net/265770) 을 참고해주세요.\n", 708 | "\n", 709 | "아래의 `stream_graph` 함수는 특정 노드만 스트리밍 출력하는 함수입니다.\n", 710 | "\n", 711 | "손쉽게 특정 노드의 스트리밍 출력을 확인할 수 있습니다." 712 | ] 713 | }, 714 | { 715 | "cell_type": "code", 716 | "execution_count": null, 717 | "metadata": {}, 718 | "outputs": [], 719 | "source": [ 720 | "from langchain_teddynote.messages import stream_graph, random_uuid\n", 721 | "\n", 722 | "# config 설정(재귀 최대 횟수, thread_id)\n", 723 | "config = {\"configurable\": {\"resursion_limit\": 10, \"thread_id\": random_uuid()}}\n", 724 | "\n", 725 | "# 질문 입력\n", 726 | "inputs = {\"question\": \"앤스로픽에 투자한 기업과 투자금액을 알려주세요.\"}\n", 727 | "\n", 728 | "# 그래프 실행\n", 729 | "stream_graph(app, inputs, config)" 730 | ] 731 | }, 732 | { 733 | "cell_type": "markdown", 734 | "metadata": {}, 735 | "source": [ 736 | "추적: https://smith.langchain.com/public/1aa445e0-672e-4f15-9253-1c8efcdb1355/r" 737 | ] 738 | }, 739 | { 740 | "cell_type": "markdown", 741 | "metadata": {}, 742 | "source": [ 743 | "## Part 4.Routing\n", 744 | "\n", 745 | "LLM 애플리케이션에서 라우팅은 입력 쿼리나 상태에 따라 적절한 처리 경로나 구성 요소로 요청을 전달하는 메커니즘입니다. \n", 746 | "\n", 747 | "LangChain/LangGraph에서 라우팅은 특정 작업에 가장 적합한 모델이나 도구를 선택하고, 복잡한 워크플로우를 관리하며, 비용과 성능 균형을 최적화하는 데 필수적입니다. \n", 748 | "\n", 749 | "**Agent**\n", 750 | "- 도구 선택을 하는 방식으로 라우팅\n", 751 | "- 따라서, 도우에 대한 description 이 상세하게 작성되어야 합니다.\n", 752 | "\n", 753 | "**LLM.with_structured_output**\n", 754 | "- Function Calling 을 사용하는 방식으로 라우팅" 755 | ] 756 | }, 757 | { 758 | "cell_type": "code", 759 | "execution_count": 26, 760 | "metadata": {}, 761 | "outputs": [], 762 | "source": [ 763 | "from typing import Literal\n", 764 | "from pydantic import BaseModel, Field\n", 765 | "from langchain_core.prompts import ChatPromptTemplate\n", 766 | "from langchain_openai import ChatOpenAI\n", 767 | "\n", 768 | "\n", 769 | "# 사용자 쿼리를 가장 관련성 높은 데이터 소스로 라우팅하는 데이터 모델\n", 770 | "class RouteQuery(BaseModel):\n", 771 | " \"\"\"Route a user query to the most relevant datasource.\"\"\"\n", 772 | "\n", 773 | " # 데이터 소스 선택을 위한 리터럴 타입 필드\n", 774 | " datasource: Literal[\"vectorstore\", \"web_search\"] = Field(\n", 775 | " ...,\n", 776 | " description=\"Given a user question choose to route it to `web_search` or a `vectorstore`.\",\n", 777 | " )\n", 778 | "\n", 779 | "\n", 780 | "# LLM 설정\n", 781 | "llm = ChatOpenAI(model=\"gpt-4o\", temperature=0)\n", 782 | "\n", 783 | "# llm 구조화된 출력 설정\n", 784 | "structured_llm_router = llm.with_structured_output(RouteQuery)\n", 785 | "\n", 786 | "# 프롬프트 설정\n", 787 | "system = \"\"\"You are an expert at routing a user question to a vectorstore or web search.\n", 788 | "The vectorstore contains documents related to AI Brief Report(SPRI) including Samsung Gause, Anthropic, etc.\n", 789 | "Use the vectorstore for questions on AI related topics. Otherwise, use `web_search`.\"\"\"\n", 790 | "\n", 791 | "# Routing 을 위한 프롬프트 템플릿 생성\n", 792 | "route_prompt = ChatPromptTemplate.from_messages(\n", 793 | " [\n", 794 | " (\"system\", system),\n", 795 | " (\"human\", \"{question}\"),\n", 796 | " ]\n", 797 | ")\n", 798 | "\n", 799 | "# 프롬프트 템플릿과 구조화된 LLM 라우터를 결합하여 질문 라우터 생성\n", 800 | "question_router = route_prompt | structured_llm_router" 801 | ] 802 | }, 803 | { 804 | "cell_type": "markdown", 805 | "metadata": {}, 806 | "source": [ 807 | "쿼리를 실행한 뒤 호출 결과의 차이를 비교합니다." 808 | ] 809 | }, 810 | { 811 | "cell_type": "code", 812 | "execution_count": null, 813 | "metadata": {}, 814 | "outputs": [], 815 | "source": [ 816 | "# 쿼리 실행\n", 817 | "question_router.invoke(\"삼성전자가 만든 생성형 AI 이름을 찾아줘\")" 818 | ] 819 | }, 820 | { 821 | "cell_type": "code", 822 | "execution_count": null, 823 | "metadata": {}, 824 | "outputs": [], 825 | "source": [ 826 | "# 쿼리 실행\n", 827 | "question_router.invoke(\"LangCon2025 이벤트의 날짜와 장소는?\")" 828 | ] 829 | }, 830 | { 831 | "cell_type": "markdown", 832 | "metadata": {}, 833 | "source": [ 834 | "다음은 `retrieve`, `generate`, `web_search` 노드를 구현한 코드입니다.\n", 835 | "\n", 836 | "- `retrieve`: 문서 검색 노드\n", 837 | "- `generate`: 답변 생성 노드\n", 838 | "- `web_search`: 웹 검색 노드\n", 839 | "\n", 840 | "이 노드들을 사용하여 워크플로우를 구현합니다.\n" 841 | ] 842 | }, 843 | { 844 | "cell_type": "code", 845 | "execution_count": 29, 846 | "metadata": {}, 847 | "outputs": [], 848 | "source": [ 849 | "from langchain_core.documents import Document\n", 850 | "\n", 851 | "\n", 852 | "# 문서 검색 노드\n", 853 | "def retrieve(state):\n", 854 | " question = state[\"question\"]\n", 855 | "\n", 856 | " # 문서 검색 수행\n", 857 | " documents = pdf_retriever.invoke(question)\n", 858 | "\n", 859 | " # 검색된 문서 반환\n", 860 | " return {\"documents\": documents}\n", 861 | "\n", 862 | "\n", 863 | "# 답변 생성 노드\n", 864 | "def generate(state):\n", 865 | " # 질문과 문서 검색 결과 가져오기\n", 866 | " question = state[\"question\"]\n", 867 | " documents = state[\"documents\"]\n", 868 | "\n", 869 | " # RAG 답변 생성\n", 870 | " generation = pdf_chain.invoke({\"context\": documents, \"question\": question})\n", 871 | "\n", 872 | " # 생성된 답변 반환\n", 873 | " return {\"generation\": generation}\n", 874 | "\n", 875 | "\n", 876 | "# 웹 검색 노드\n", 877 | "def web_search(state):\n", 878 | " # print(\"==== [WEB SEARCH] ====\")\n", 879 | " # 질문과 문서 검색 결과 가져오기\n", 880 | " question = state[\"question\"]\n", 881 | "\n", 882 | " # 웹 검색 수행\n", 883 | " web_results = web_search_tool.invoke({\"query\": question})\n", 884 | "\n", 885 | " # 검색된 문서 반환\n", 886 | " web_results_docs = [\n", 887 | " Document(\n", 888 | " page_content=web_result[\"content\"],\n", 889 | " metadata={\"source\": web_result[\"url\"]},\n", 890 | " )\n", 891 | " for web_result in web_results\n", 892 | " ]\n", 893 | " return {\"documents\": web_results_docs}" 894 | ] 895 | }, 896 | { 897 | "cell_type": "markdown", 898 | "metadata": {}, 899 | "source": [ 900 | "질문 라우팅 노드의 구현입니다.\n", 901 | "\n", 902 | "사용자의 질문에 대해 `question_router` 를 호출하여 적절한 데이터 소스로 라우팅합니다.\n", 903 | "\n", 904 | "- `web_search`: 웹 검색\n", 905 | "- `vectorstore`: 벡터 스토어\n", 906 | "\n", 907 | "라우팅 결과에 따라 적절한 노드로 라우팅합니다.\n" 908 | ] 909 | }, 910 | { 911 | "cell_type": "code", 912 | "execution_count": 30, 913 | "metadata": {}, 914 | "outputs": [], 915 | "source": [ 916 | "# 질문 라우팅 노드\n", 917 | "def route_question(state):\n", 918 | " print(\"==== [ROUTE QUESTION] ====\")\n", 919 | " # 질문 가져오기\n", 920 | " question = state[\"question\"]\n", 921 | " # 질문 라우팅\n", 922 | " source = question_router.invoke({\"question\": question})\n", 923 | " # 질문 라우팅 결과에 따른 노드 라우팅\n", 924 | " if source.datasource == \"web_search\":\n", 925 | " print(\"\\n==== [GO TO WEB SEARCH] ====\")\n", 926 | " return \"need to search web\"\n", 927 | " elif source.datasource == \"vectorstore\":\n", 928 | " print(\"\\n==== [GO TO VECTORSTORE] ====\")\n", 929 | " return \"search on DB\"" 930 | ] 931 | }, 932 | { 933 | "cell_type": "markdown", 934 | "metadata": {}, 935 | "source": [ 936 | "### 그래프 생성\n", 937 | "\n", 938 | "이 단계에서는 `web_search`, `retrieve`, `generate` 노드를 생성하고, 이들을 연결하는 조건부 엣지를 추가합니다.\n", 939 | "\n", 940 | "- `web_search`: 웹 검색 노드\n", 941 | "- `retrieve`: 문서 검색 노드\n", 942 | "- `generate`: 답변 생성 노드\n", 943 | "\n", 944 | "조건부 엣지: 질문 라우팅 노드에서 반환된 결과에 따라 적절한 노드로 라우팅합니다.\n", 945 | "\n", 946 | "- `need to search web`: 웹 검색 노드로 라우팅\n", 947 | "- `search on DB`: 벡터 스토어 노드로 라우팅\n", 948 | "\n", 949 | "\n" 950 | ] 951 | }, 952 | { 953 | "cell_type": "code", 954 | "execution_count": 31, 955 | "metadata": {}, 956 | "outputs": [], 957 | "source": [ 958 | "from langgraph.graph import END, StateGraph, START\n", 959 | "from langgraph.checkpoint.memory import MemorySaver\n", 960 | "\n", 961 | "# 그래프 상태 초기화\n", 962 | "workflow = StateGraph(GraphState)\n", 963 | "\n", 964 | "# 노드 정의\n", 965 | "workflow.add_node(\"web_search\", web_search) # 웹 검색\n", 966 | "workflow.add_node(\"retrieve\", retrieve) # 문서 검색\n", 967 | "workflow.add_node(\"generate\", generate) # 답변 생성\n", 968 | "\n", 969 | "# 그래프 빌드\n", 970 | "workflow.add_conditional_edges(\n", 971 | " START,\n", 972 | " route_question,\n", 973 | " {\n", 974 | " \"need to search web\": \"web_search\", # 웹 검색으로 라우팅\n", 975 | " \"search on DB\": \"retrieve\", # 벡터스토어로 라우팅\n", 976 | " },\n", 977 | ")\n", 978 | "workflow.add_edge(\"retrieve\", \"generate\") # 문서 검색 후 답변 생성\n", 979 | "workflow.add_edge(\"web_search\", \"generate\") # 웹 검색 후 답변 생성\n", 980 | "workflow.add_edge(\"generate\", END) # 답변 생성 후 종료\n", 981 | "\n", 982 | "\n", 983 | "# 그래프 컴파일\n", 984 | "app = workflow.compile(checkpointer=MemorySaver())" 985 | ] 986 | }, 987 | { 988 | "cell_type": "markdown", 989 | "metadata": {}, 990 | "source": [ 991 | "그래프를 시각화 합니다." 992 | ] 993 | }, 994 | { 995 | "cell_type": "code", 996 | "execution_count": null, 997 | "metadata": {}, 998 | "outputs": [], 999 | "source": [ 1000 | "from langchain_teddynote.graphs import visualize_graph\n", 1001 | "\n", 1002 | "visualize_graph(app)" 1003 | ] 1004 | }, 1005 | { 1006 | "cell_type": "markdown", 1007 | "metadata": {}, 1008 | "source": [ 1009 | "실행하고 결과를 확인합니다." 1010 | ] 1011 | }, 1012 | { 1013 | "cell_type": "code", 1014 | "execution_count": null, 1015 | "metadata": {}, 1016 | "outputs": [], 1017 | "source": [ 1018 | "config = {\"configurable\": {\"resursion_limit\": 10, \"thread_id\": \"123\"}}\n", 1019 | "\n", 1020 | "stream_graph(\n", 1021 | " app, {\"question\": \"앤스로픽에 투자한 기업과 투자금액을 알려주세요.\"}, config\n", 1022 | ")" 1023 | ] 1024 | }, 1025 | { 1026 | "cell_type": "code", 1027 | "execution_count": null, 1028 | "metadata": {}, 1029 | "outputs": [], 1030 | "source": [ 1031 | "config = {\"configurable\": {\"resursion_limit\": 10, \"thread_id\": \"123\"}}\n", 1032 | "\n", 1033 | "stream_graph(\n", 1034 | " app,\n", 1035 | " {\"question\": \"Claude 3.7 sonnet 관련 최신 뉴스를 검색해줘. 한글로 답변해줘\"},\n", 1036 | " config,\n", 1037 | ")" 1038 | ] 1039 | }, 1040 | { 1041 | "cell_type": "markdown", 1042 | "metadata": {}, 1043 | "source": [ 1044 | "## Part 5.Fan-out / Fan-in\n", 1045 | "\n", 1046 | "LangGraph에서 Fan-out/Fan-in은 복잡한 LLM 워크플로우 관리를 위한 중요한 패턴입니다.\n", 1047 | "\n", 1048 | "Fan-out은 단일 입력을 여러 병렬 작업으로 분배하는 패턴으로, 하나의 프롬프트나 쿼리를 여러 LLM, 도구, 또는 처리 단계로 동시에 전송하여 다양한 관점이나 접근 방식을 얻을 수 있게 합니다. 이는 복잡한 문제를 더 작고 전문화된 하위 작업으로 분할하거나 동일한 작업에 대해 여러 모델의 결과를 비교할 때 유용합니다.\n", 1049 | "\n", 1050 | "Fan-in은 Fan-out의 역과정으로, 여러 병렬 작업의 결과를 단일 출력이나 다음 단계로 통합합니다. 이는 다양한 모델이나 도구에서 생성된 결과를 종합하여 더 완전하고 정확한 최종 응답을 만들거나 여러 에이전트의 작업을 조정할 때 사용됩니다." 1051 | ] 1052 | }, 1053 | { 1054 | "cell_type": "code", 1055 | "execution_count": 35, 1056 | "metadata": {}, 1057 | "outputs": [], 1058 | "source": [ 1059 | "from typing import Annotated, Any\n", 1060 | "from typing_extensions import TypedDict\n", 1061 | "from langgraph.graph import StateGraph, START, END\n", 1062 | "from langgraph.graph.message import add_messages\n", 1063 | "\n", 1064 | "\n", 1065 | "# 상태 정의(add_messages 리듀서 사용)\n", 1066 | "class State(TypedDict):\n", 1067 | " aggregate: Annotated[list, add_messages]\n", 1068 | "\n", 1069 | "\n", 1070 | "# 노드 값 반환 클래스\n", 1071 | "class ReturnNodeValue:\n", 1072 | " # 초기화\n", 1073 | " def __init__(self, node_secret: str):\n", 1074 | " self._value = node_secret\n", 1075 | "\n", 1076 | " # 호출시 상태 업데이트\n", 1077 | " def __call__(self, state: State) -> Any:\n", 1078 | " print(f\"Adding {self._value} to {state['aggregate']}\")\n", 1079 | " return {\"aggregate\": [self._value]}\n", 1080 | "\n", 1081 | "\n", 1082 | "# 상태 그래프 초기화\n", 1083 | "builder = StateGraph(State)\n", 1084 | "\n", 1085 | "# 노드 A부터 D까지 생성 및 값 할당\n", 1086 | "builder.add_node(\"a\", ReturnNodeValue(\"I'm A\"))\n", 1087 | "builder.add_edge(START, \"a\")\n", 1088 | "builder.add_node(\"b\", ReturnNodeValue(\"I'm B\"))\n", 1089 | "builder.add_node(\"c\", ReturnNodeValue(\"I'm C\"))\n", 1090 | "builder.add_node(\"d\", ReturnNodeValue(\"I'm D\"))\n", 1091 | "\n", 1092 | "# 노드 연결\n", 1093 | "builder.add_edge(\"a\", \"b\")\n", 1094 | "builder.add_edge(\"a\", \"c\")\n", 1095 | "builder.add_edge(\"b\", \"d\")\n", 1096 | "builder.add_edge(\"c\", \"d\")\n", 1097 | "builder.add_edge(\"d\", END)\n", 1098 | "\n", 1099 | "# 그래프 컴파일\n", 1100 | "graph = builder.compile()" 1101 | ] 1102 | }, 1103 | { 1104 | "cell_type": "markdown", 1105 | "metadata": {}, 1106 | "source": [ 1107 | "그래프를 시각화 합니다." 1108 | ] 1109 | }, 1110 | { 1111 | "cell_type": "code", 1112 | "execution_count": null, 1113 | "metadata": {}, 1114 | "outputs": [], 1115 | "source": [ 1116 | "from langchain_teddynote.graphs import visualize_graph\n", 1117 | "\n", 1118 | "visualize_graph(graph)" 1119 | ] 1120 | }, 1121 | { 1122 | "cell_type": "markdown", 1123 | "metadata": {}, 1124 | "source": [ 1125 | "그래프를 시각화 합니다." 1126 | ] 1127 | }, 1128 | { 1129 | "cell_type": "code", 1130 | "execution_count": null, 1131 | "metadata": {}, 1132 | "outputs": [], 1133 | "source": [ 1134 | "# 그래프 실행\n", 1135 | "result = graph.invoke({\"aggregate\": []}, {\"configurable\": {\"thread_id\": \"foo\"}})\n", 1136 | "print(\"===\" * 30)\n", 1137 | "print(result[\"aggregate\"])" 1138 | ] 1139 | }, 1140 | { 1141 | "cell_type": "markdown", 1142 | "metadata": {}, 1143 | "source": [ 1144 | "### 일부만 Fan-out 하는 방법\n", 1145 | "\n", 1146 | "(Fan-out 의 순서 조정)\n", 1147 | "\n", 1148 | "조건부 엣지를 두어 일부만 Fan-out 할 수 있습니다." 1149 | ] 1150 | }, 1151 | { 1152 | "cell_type": "code", 1153 | "execution_count": 38, 1154 | "metadata": {}, 1155 | "outputs": [], 1156 | "source": [ 1157 | "from typing import Annotated, Sequence\n", 1158 | "from typing_extensions import TypedDict\n", 1159 | "from langgraph.graph import END, START, StateGraph\n", 1160 | "\n", 1161 | "\n", 1162 | "# 상태 정의(add_messages 리듀서 사용)\n", 1163 | "class State(TypedDict):\n", 1164 | " aggregate: Annotated[list, add_messages]\n", 1165 | " which: str\n", 1166 | "\n", 1167 | "\n", 1168 | "# 노드별 고유 값을 반환하는 클래스\n", 1169 | "class ReturnNodeValue:\n", 1170 | " def __init__(self, node_secret: str):\n", 1171 | " self._value = node_secret\n", 1172 | "\n", 1173 | " def __call__(self, state: State) -> Any:\n", 1174 | " print(f\"Adding {self._value} to {state['aggregate']}\")\n", 1175 | " return {\"aggregate\": [self._value]}\n", 1176 | "\n", 1177 | "\n", 1178 | "# 상태 그래프 초기화\n", 1179 | "builder = StateGraph(State)\n", 1180 | "builder.add_node(\"a\", ReturnNodeValue(\"I'm A\"))\n", 1181 | "builder.add_edge(START, \"a\")\n", 1182 | "builder.add_node(\"b\", ReturnNodeValue(\"I'm B\"))\n", 1183 | "builder.add_node(\"c\", ReturnNodeValue(\"I'm C\"))\n", 1184 | "builder.add_node(\"d\", ReturnNodeValue(\"I'm D\"))\n", 1185 | "builder.add_node(\"e\", ReturnNodeValue(\"I'm E\"))\n", 1186 | "\n", 1187 | "\n", 1188 | "# 상태의 'which' 값에 따른 조건부 라우팅 경로 결정 함수\n", 1189 | "def route_bc_or_cd(state: State) -> Sequence[str]:\n", 1190 | " if state[\"which\"] == \"cd\":\n", 1191 | " return [\"c\", \"d\"]\n", 1192 | " elif state[\"which\"] == \"bc\":\n", 1193 | " return [\"b\", \"c\"]\n", 1194 | " else:\n", 1195 | " return [\"b\", \"c\", \"d\"]\n", 1196 | "\n", 1197 | "\n", 1198 | "# 전체 병렬 처리할 노드 목록\n", 1199 | "intermediates = [\"b\", \"c\", \"d\"]\n", 1200 | "\n", 1201 | "builder.add_conditional_edges(\n", 1202 | " \"a\",\n", 1203 | " route_bc_or_cd,\n", 1204 | " intermediates,\n", 1205 | ")\n", 1206 | "for node in intermediates:\n", 1207 | " builder.add_edge(node, \"e\")\n", 1208 | "\n", 1209 | "\n", 1210 | "# 최종 노드 연결 및 그래프 컴파일\n", 1211 | "builder.add_edge(\"e\", END)\n", 1212 | "graph = builder.compile()" 1213 | ] 1214 | }, 1215 | { 1216 | "cell_type": "markdown", 1217 | "metadata": {}, 1218 | "source": [ 1219 | "그래프를 시각화 합니다." 1220 | ] 1221 | }, 1222 | { 1223 | "cell_type": "code", 1224 | "execution_count": null, 1225 | "metadata": {}, 1226 | "outputs": [], 1227 | "source": [ 1228 | "from langchain_teddynote.graphs import visualize_graph\n", 1229 | "\n", 1230 | "visualize_graph(graph)" 1231 | ] 1232 | }, 1233 | { 1234 | "cell_type": "code", 1235 | "execution_count": null, 1236 | "metadata": {}, 1237 | "outputs": [], 1238 | "source": [ 1239 | "# 그래프 실행(which: bc 로 지정)\n", 1240 | "result = graph.invoke({\"aggregate\": [], \"which\": \"bc\"})\n", 1241 | "print(\"===\" * 30)\n", 1242 | "print(result[\"aggregate\"])" 1243 | ] 1244 | }, 1245 | { 1246 | "cell_type": "code", 1247 | "execution_count": null, 1248 | "metadata": {}, 1249 | "outputs": [], 1250 | "source": [ 1251 | "# 그래프 실행(which: cd 로 지정)\n", 1252 | "result = graph.invoke({\"aggregate\": [], \"which\": \"cd\"})\n", 1253 | "print(\"===\" * 30)\n", 1254 | "print(result[\"aggregate\"])" 1255 | ] 1256 | }, 1257 | { 1258 | "cell_type": "markdown", 1259 | "metadata": {}, 1260 | "source": [ 1261 | "## Part 6.대화 기록 요약을 추가하는 방법\n", 1262 | "\n", 1263 | "대화 기록을 유지하는 것은 **지속성**의 가장 일반적인 사용 사례 중 하나입니다. 이는 대화를 지속하기 쉽게 만들어주는 장점이 있습니다. \n", 1264 | "\n", 1265 | "하지만 대화가 길어질수록 대화 기록이 누적되어 `context window`를 더 많이 차지하게 됩니다. 이는 `LLM` 호출이 더 비싸고 길어지며, 잠재적으로 오류가 발생할 수 있어 바람직하지 않을 수 있습니다. 이를 해결하기 위한 한 가지 방법은 현재까지의 대화 요약본을 생성하고, 이를 최근 `N` 개의 메시지와 함께 사용하는 것입니다. \n", 1266 | "\n", 1267 | "이 가이드에서는 이를 구현하는 방법의 예시를 살펴보겠습니다.\n", 1268 | "\n", 1269 | "다음과 같은 단계가 필요합니다.\n", 1270 | "\n", 1271 | "- 대화가 너무 긴지 확인 (메시지 수나 메시지 길이로 확인 가능)\n", 1272 | "- 너무 길다면 요약본 생성 (이를 위한 프롬프트 필요)\n", 1273 | "- 마지막 `N` 개의 메시지를 제외한 나머지 삭제\n", 1274 | "\n", 1275 | "이 과정에서 중요한 부분은 오래된 메시지를 삭제(`DeleteMessage`) 하는 것입니다. \n" 1276 | ] 1277 | }, 1278 | { 1279 | "cell_type": "code", 1280 | "execution_count": 42, 1281 | "metadata": {}, 1282 | "outputs": [], 1283 | "source": [ 1284 | "from typing import Literal, Annotated\n", 1285 | "from langchain_openai import ChatOpenAI\n", 1286 | "from langchain_core.messages import SystemMessage, RemoveMessage, HumanMessage\n", 1287 | "from langgraph.checkpoint.memory import MemorySaver\n", 1288 | "from langgraph.graph import MessagesState, StateGraph, START\n", 1289 | "from langgraph.graph.message import add_messages\n", 1290 | "\n", 1291 | "\n", 1292 | "# 대화 및 요약을 위한 모델 초기화\n", 1293 | "model = ChatOpenAI(model_name=\"gpt-4o\", temperature=0)\n", 1294 | "\n", 1295 | "\n", 1296 | "# 메시지 상태와 요약 정보를 포함하는 상태 클래스\n", 1297 | "class State(MessagesState):\n", 1298 | " messages: Annotated[list, add_messages]\n", 1299 | " summary: str" 1300 | ] 1301 | }, 1302 | { 1303 | "cell_type": "markdown", 1304 | "metadata": {}, 1305 | "source": [ 1306 | "LLM 답변 생성 노드를 구현합니다. 여기서 이전의 대화요약 내용이 있다면 이를 입력에 포함합니다.\n" 1307 | ] 1308 | }, 1309 | { 1310 | "cell_type": "code", 1311 | "execution_count": 43, 1312 | "metadata": {}, 1313 | "outputs": [], 1314 | "source": [ 1315 | "def generate(state: State):\n", 1316 | " # 이전 요약 정보 확인\n", 1317 | " summary = state.get(\"summary\", \"\")\n", 1318 | "\n", 1319 | " # 이전 요약 정보가 있다면 시스템 메시지로 추가\n", 1320 | " if summary:\n", 1321 | " # 시스템 메시지와 이전 메시지 결합\n", 1322 | " messages = [\n", 1323 | " SystemMessage(content=f\"Summary of conversation earlier: {summary}\")\n", 1324 | " ] + state[\"messages\"]\n", 1325 | " else:\n", 1326 | " # 이전 메시지만 사용\n", 1327 | " messages = state[\"messages\"]\n", 1328 | "\n", 1329 | " # 모델 호출\n", 1330 | " response = model.invoke(messages)\n", 1331 | "\n", 1332 | " # 응답 반환\n", 1333 | " return {\"messages\": [response]}" 1334 | ] 1335 | }, 1336 | { 1337 | "cell_type": "markdown", 1338 | "metadata": {}, 1339 | "source": [ 1340 | "요약이 필요한 상황인지를 판단합니다.\n", 1341 | "\n", 1342 | "여기서는 메시지 수가 6개 초과라면 요약 노드로 이동합니다." 1343 | ] 1344 | }, 1345 | { 1346 | "cell_type": "code", 1347 | "execution_count": 44, 1348 | "metadata": {}, 1349 | "outputs": [], 1350 | "source": [ 1351 | "from langgraph.graph import END\n", 1352 | "\n", 1353 | "\n", 1354 | "# 대화 종료 또는 요약 결정 로직\n", 1355 | "def should_continue(state: State) -> Literal[\"summarize_conversation\", END]:\n", 1356 | " # 메시지 목록 확인\n", 1357 | " messages = state[\"messages\"]\n", 1358 | "\n", 1359 | " # 메시지 수가 6개 초과라면 요약 노드로 이동\n", 1360 | " if len(messages) > 6:\n", 1361 | " return \"summarize_conversation\"\n", 1362 | " return END" 1363 | ] 1364 | }, 1365 | { 1366 | "cell_type": "markdown", 1367 | "metadata": {}, 1368 | "source": [ 1369 | "요약 노드를 구현합니다. 이전 요약 정보가 있다면 이를 입력에 포함하고, 없다면 새로운 요약 메시지를 생성합니다." 1370 | ] 1371 | }, 1372 | { 1373 | "cell_type": "code", 1374 | "execution_count": 45, 1375 | "metadata": {}, 1376 | "outputs": [], 1377 | "source": [ 1378 | "# 대화 내용 요약 및 메시지 정리 로직\n", 1379 | "def summarize_conversation(state: State):\n", 1380 | " # 이전 요약 정보 확인\n", 1381 | " summary = state.get(\"summary\", \"\")\n", 1382 | "\n", 1383 | " # 이전 요약 정보가 있다면 요약 메시지 생성\n", 1384 | " if summary:\n", 1385 | " summary_message = (\n", 1386 | " f\"This is summary of the conversation to date: {summary}\\n\\n\"\n", 1387 | " \"Extend the summary by taking into account the new messages above in Korean.\"\n", 1388 | " )\n", 1389 | " else:\n", 1390 | " # 요약 메시지 생성\n", 1391 | " summary_message = \"Create a summary of the conversation above in Korean:\"\n", 1392 | "\n", 1393 | " # 요약 메시지와 이전 메시지 결합\n", 1394 | " messages = state[\"messages\"] + [HumanMessage(content=summary_message)]\n", 1395 | " # 모델 호출\n", 1396 | " response = model.invoke(messages)\n", 1397 | " # 오래된 메시지 삭제\n", 1398 | " delete_messages = [RemoveMessage(id=m.id) for m in state[\"messages\"][:-2]]\n", 1399 | " # 요약 정보 반환\n", 1400 | " return {\"summary\": response.content, \"messages\": delete_messages}" 1401 | ] 1402 | }, 1403 | { 1404 | "cell_type": "markdown", 1405 | "metadata": {}, 1406 | "source": [ 1407 | "그래프 생성 및 컴파일\n" 1408 | ] 1409 | }, 1410 | { 1411 | "cell_type": "code", 1412 | "execution_count": 46, 1413 | "metadata": {}, 1414 | "outputs": [], 1415 | "source": [ 1416 | "# 워크플로우 그래프 초기화\n", 1417 | "workflow = StateGraph(State)\n", 1418 | "\n", 1419 | "# 대화 및 요약 노드 추가\n", 1420 | "workflow.add_node(\"conversation\", generate)\n", 1421 | "workflow.add_node(summarize_conversation)\n", 1422 | "\n", 1423 | "# 시작점을 대화 노드로 설정\n", 1424 | "workflow.add_edge(START, \"conversation\")\n", 1425 | "\n", 1426 | "# 조건부 엣지 추가\n", 1427 | "workflow.add_conditional_edges(\n", 1428 | " \"conversation\",\n", 1429 | " should_continue,\n", 1430 | ")\n", 1431 | "\n", 1432 | "# 요약 노드에서 종료 노드로의 엣지 추가\n", 1433 | "workflow.add_edge(\"summarize_conversation\", END)\n", 1434 | "\n", 1435 | "# 워크플로우 컴파일 및 메모리 체크포인터 설정\n", 1436 | "app = workflow.compile(checkpointer=MemorySaver())" 1437 | ] 1438 | }, 1439 | { 1440 | "cell_type": "markdown", 1441 | "metadata": {}, 1442 | "source": [ 1443 | "그래프를 시각화 합니다." 1444 | ] 1445 | }, 1446 | { 1447 | "cell_type": "code", 1448 | "execution_count": null, 1449 | "metadata": {}, 1450 | "outputs": [], 1451 | "source": [ 1452 | "from langchain_teddynote.graphs import visualize_graph\n", 1453 | "\n", 1454 | "visualize_graph(app)" 1455 | ] 1456 | }, 1457 | { 1458 | "cell_type": "markdown", 1459 | "metadata": {}, 1460 | "source": [ 1461 | "사용자 메시지 출력을 위한 함수를 구현(헬퍼 함수)" 1462 | ] 1463 | }, 1464 | { 1465 | "cell_type": "code", 1466 | "execution_count": 48, 1467 | "metadata": {}, 1468 | "outputs": [], 1469 | "source": [ 1470 | "def print_user_message(message):\n", 1471 | " print(\"\\n==================================================\\n\\n😎\", message)" 1472 | ] 1473 | }, 1474 | { 1475 | "cell_type": "markdown", 1476 | "metadata": {}, 1477 | "source": [ 1478 | "대화를 시작합니다. 우선 6개의 대화를 채워보도록 하겠습니다." 1479 | ] 1480 | }, 1481 | { 1482 | "cell_type": "code", 1483 | "execution_count": null, 1484 | "metadata": {}, 1485 | "outputs": [], 1486 | "source": [ 1487 | "# 메시지 핸들링을 위한 HumanMessage 클래스 임포트\n", 1488 | "from langchain_core.messages import HumanMessage\n", 1489 | "\n", 1490 | "# 스레드 ID가 포함된 설정 객체 초기화\n", 1491 | "config = {\"configurable\": {\"thread_id\": \"1\", \"resursion_limit\": 10}}\n", 1492 | "\n", 1493 | "# 첫 번째 메시지\n", 1494 | "print_user_message(\"안녕하세요? 반갑습니다. 제 이름은 테디입니다.\")\n", 1495 | "stream_graph(\n", 1496 | " app,\n", 1497 | " {\"messages\": [(\"human\", \"안녕하세요? 반갑습니다. 제 이름은 테디입니다.\")]},\n", 1498 | " config,\n", 1499 | ")\n", 1500 | "\n", 1501 | "# 두 번째 메시지\n", 1502 | "print_user_message(\"제 이름이 뭔지 기억하세요?\")\n", 1503 | "stream_graph(app, {\"messages\": [(\"human\", \"제 이름이 뭔지 기억하세요?\")]}, config)\n", 1504 | "\n", 1505 | "# 세 번째 메시지\n", 1506 | "print_user_message(\"제 취미는 Netflix 시리즈를 보는 것입니다.\")\n", 1507 | "stream_graph(\n", 1508 | " app, {\"messages\": [(\"human\", \"제 취미는 Netflix 시리즈를 보는 것입니다.\")]}, config\n", 1509 | ")" 1510 | ] 1511 | }, 1512 | { 1513 | "cell_type": "markdown", 1514 | "metadata": {}, 1515 | "source": [ 1516 | "결과를 확인합니다. 6개 대화를 했으므로, 요약본이 만들어 져야 합니다." 1517 | ] 1518 | }, 1519 | { 1520 | "cell_type": "code", 1521 | "execution_count": null, 1522 | "metadata": {}, 1523 | "outputs": [], 1524 | "source": [ 1525 | "# 상태 구성 값 검색\n", 1526 | "values = app.get_state(config).values\n", 1527 | "values" 1528 | ] 1529 | }, 1530 | { 1531 | "cell_type": "markdown", 1532 | "metadata": {}, 1533 | "source": [ 1534 | "이제 추가로 대화를 입력하여 요약본을 기반으로 잘 답변하는지 확인합니다." 1535 | ] 1536 | }, 1537 | { 1538 | "cell_type": "code", 1539 | "execution_count": null, 1540 | "metadata": {}, 1541 | "outputs": [], 1542 | "source": [ 1543 | "# 네 번째 메시지\n", 1544 | "print_user_message(\"제 취미가 뭐라고 했나요?\")\n", 1545 | "stream_graph(app, {\"messages\": [(\"human\", \"제 취미가 뭐라고 했나요?\")]}, config)" 1546 | ] 1547 | }, 1548 | { 1549 | "cell_type": "markdown", 1550 | "metadata": {}, 1551 | "source": [ 1552 | "추적: https://smith.langchain.com/public/c0f62f0b-74b5-4dc3-bd1c-3e474a0dbef9/r" 1553 | ] 1554 | }, 1555 | { 1556 | "cell_type": "markdown", 1557 | "metadata": {}, 1558 | "source": [ 1559 | "## Part 7. Human in the Loop\n", 1560 | "\n", 1561 | "LLM 애플리케이션에서 Human-in-the-loop 은 자동화된 AI 시스템과 인간의 개입 및 판단을 결합하는 접근 방식입니다. \n", 1562 | "\n", 1563 | "이 방식에서는 AI 시스템이 초기 처리와 분석을 수행하지만, 불확실하거나 중요한 결정이 필요한 시점에서 인간 전문가의 개입을 요청합니다. \n", 1564 | "\n", 1565 | "Human-in-the-loop 은 높은 정확도가 필요한 복잡한 상황, 윤리적 판단이 필요한 경우, 또는 AI의 신뢰도가 낮은 결과에 대해 검증이 필요할 때 특히 중요합니다.\n", 1566 | "\n", 1567 | "`from langgraph.types import interrupt`\n", 1568 | "\n", 1569 | "`interrupt` 함수는 인간의 개입을 요청하는 데 사용됩니다. 이 함수는 인간의 입력을 받아 처리하고, 결과를 반환합니다." 1570 | ] 1571 | }, 1572 | { 1573 | "cell_type": "code", 1574 | "execution_count": null, 1575 | "metadata": {}, 1576 | "outputs": [], 1577 | "source": [ 1578 | "from typing import TypedDict\n", 1579 | "import uuid\n", 1580 | "\n", 1581 | "from langgraph.checkpoint.memory import MemorySaver\n", 1582 | "from langgraph.constants import START\n", 1583 | "from langgraph.graph import StateGraph\n", 1584 | "from langgraph.types import interrupt, Command\n", 1585 | "\n", 1586 | "\n", 1587 | "class State(TypedDict):\n", 1588 | " messages: Annotated[list, add_messages]\n", 1589 | " evaluation: Annotated[str, \"Evaluation\"]\n", 1590 | "\n", 1591 | "\n", 1592 | "def llm_node(state: State):\n", 1593 | " llm = ChatOpenAI(model_name=\"gpt-4o-mini\", temperature=0)\n", 1594 | " response = llm.invoke(state[\"messages\"])\n", 1595 | " return {\"messages\": [response]}\n", 1596 | "\n", 1597 | "\n", 1598 | "def human_node(state: State):\n", 1599 | " value = interrupt(\n", 1600 | " # 사람의 피드백 요청\n", 1601 | " {\"text_to_revise\": state[\"messages\"][-1]}\n", 1602 | " )\n", 1603 | " return {\n", 1604 | " # 사람의 피드백으로 업데이트\n", 1605 | " \"messages\": [HumanMessage(content=value)]\n", 1606 | " }\n", 1607 | "\n", 1608 | "\n", 1609 | "def evaluation_node(state: State):\n", 1610 | " llm = ChatOpenAI(model_name=\"gpt-4o-mini\", temperature=0)\n", 1611 | " prompt = f\"\"\"\n", 1612 | "Here is the user's question and the model's response\n", 1613 | "The user's question: {state[\"messages\"][0]}\n", 1614 | "Model's response: {state[\"messages\"][-1]}\n", 1615 | "\n", 1616 | "Please rate whether the model's response accurately answered your question.\"\"\"\n", 1617 | " response = llm.invoke(prompt)\n", 1618 | " return {\"evaluation\": response}\n", 1619 | "\n", 1620 | "\n", 1621 | "# 그래프 생성\n", 1622 | "workflow = StateGraph(State)\n", 1623 | "\n", 1624 | "# 노드 추가\n", 1625 | "workflow.add_node(\"llm\", llm_node)\n", 1626 | "workflow.add_node(\"human\", human_node)\n", 1627 | "workflow.add_node(\"eval\", evaluation_node)\n", 1628 | "\n", 1629 | "# 엣지 추가\n", 1630 | "workflow.add_edge(START, \"llm\")\n", 1631 | "workflow.add_edge(\"llm\", \"human\")\n", 1632 | "workflow.add_edge(\"human\", \"eval\")\n", 1633 | "workflow.add_edge(\"eval\", END)\n", 1634 | "\n", 1635 | "# 체크포인터 설정\n", 1636 | "checkpointer = MemorySaver()\n", 1637 | "app = workflow.compile(checkpointer=checkpointer)\n", 1638 | "\n", 1639 | "visualize_graph(app)" 1640 | ] 1641 | }, 1642 | { 1643 | "cell_type": "markdown", 1644 | "metadata": {}, 1645 | "source": [ 1646 | "초기 질문을 수행합니다." 1647 | ] 1648 | }, 1649 | { 1650 | "cell_type": "code", 1651 | "execution_count": null, 1652 | "metadata": {}, 1653 | "outputs": [], 1654 | "source": [ 1655 | "# config 설정\n", 1656 | "config = {\"configurable\": {\"thread_id\": uuid.uuid4()}}\n", 1657 | "\n", 1658 | "stream_graph(app, {\"messages\": [(\"human\", \"2+6=?\")]}, config)" 1659 | ] 1660 | }, 1661 | { 1662 | "cell_type": "code", 1663 | "execution_count": null, 1664 | "metadata": {}, 1665 | "outputs": [], 1666 | "source": [ 1667 | "app.get_state(config).values" 1668 | ] 1669 | }, 1670 | { 1671 | "cell_type": "markdown", 1672 | "metadata": {}, 1673 | "source": [ 1674 | "`Command` 객체는 인간의 개입을 요청하는 데 사용됩니다. 이 객체는 인간의 입력을 받아 처리하고, 결과를 반환합니다.\n", 1675 | "\n", 1676 | "- `resume`: 피드백을 입력하고 남은 단계를 재게 합니다." 1677 | ] 1678 | }, 1679 | { 1680 | "cell_type": "code", 1681 | "execution_count": null, 1682 | "metadata": {}, 1683 | "outputs": [], 1684 | "source": [ 1685 | "stream_graph(app, Command(resume=\"2 + 6 = 9\"), config)" 1686 | ] 1687 | }, 1688 | { 1689 | "cell_type": "markdown", 1690 | "metadata": {}, 1691 | "source": [ 1692 | "- (인터럽트) 추적: https://smith.langchain.com/public/c251955b-6999-4983-9a92-6905700333d3/r\n", 1693 | "- (이후) 추적: https://smith.langchain.com/public/c251955b-6999-4983-9a92-6905700333d3/r " 1694 | ] 1695 | }, 1696 | { 1697 | "cell_type": "code", 1698 | "execution_count": null, 1699 | "metadata": {}, 1700 | "outputs": [], 1701 | "source": [ 1702 | "from typing import TypedDict\n", 1703 | "import uuid\n", 1704 | "\n", 1705 | "from langgraph.checkpoint.memory import MemorySaver\n", 1706 | "from langgraph.constants import START\n", 1707 | "from langgraph.graph import StateGraph\n", 1708 | "from langgraph.types import interrupt, Command\n", 1709 | "from langgraph.graph import END\n", 1710 | "\n", 1711 | "\n", 1712 | "class State(TypedDict):\n", 1713 | " messages: Annotated[list, add_messages]\n", 1714 | " evaluation: Annotated[str, \"Evaluation\"]\n", 1715 | "\n", 1716 | "\n", 1717 | "def llm_node(state: State):\n", 1718 | " llm = ChatOpenAI(model_name=\"gpt-4o-mini\", temperature=0)\n", 1719 | " response = llm.invoke(state[\"messages\"])\n", 1720 | " return {\"messages\": [response]}\n", 1721 | "\n", 1722 | "\n", 1723 | "def human_approval(state: State) -> Command[Literal[\"llm\", END]]:\n", 1724 | " is_approved = interrupt(\n", 1725 | " {\n", 1726 | " \"question\": \"Is this correct?\",\n", 1727 | " # 답변을 사람에게 보여주고 수정 요청\n", 1728 | " \"need_to_revise\": state[\"messages\"][-1],\n", 1729 | " }\n", 1730 | " )\n", 1731 | "\n", 1732 | " if is_approved:\n", 1733 | " return Command(\n", 1734 | " goto=END, update={\"messages\": [HumanMessage(content=\"You are great!\")]}\n", 1735 | " )\n", 1736 | " else:\n", 1737 | " return Command(\n", 1738 | " goto=\"llm\",\n", 1739 | " update={\n", 1740 | " \"messages\": [HumanMessage(content=\"You are wrong.. Please try again\")]\n", 1741 | " },\n", 1742 | " )\n", 1743 | "\n", 1744 | "\n", 1745 | "# 그래프 생성\n", 1746 | "workflow = StateGraph(State)\n", 1747 | "\n", 1748 | "# 노드 추가\n", 1749 | "workflow.add_node(\"llm\", llm_node)\n", 1750 | "workflow.add_node(\"human\", human_approval)\n", 1751 | "\n", 1752 | "# 엣지 추가\n", 1753 | "workflow.add_edge(START, \"llm\")\n", 1754 | "workflow.add_edge(\"llm\", \"human\")\n", 1755 | "\n", 1756 | "# 체크포인터 설정\n", 1757 | "checkpointer = MemorySaver()\n", 1758 | "app = workflow.compile(checkpointer=checkpointer)\n", 1759 | "\n", 1760 | "visualize_graph(app)" 1761 | ] 1762 | }, 1763 | { 1764 | "cell_type": "code", 1765 | "execution_count": null, 1766 | "metadata": {}, 1767 | "outputs": [], 1768 | "source": [ 1769 | "# config 설정\n", 1770 | "config = {\"configurable\": {\"thread_id\": uuid.uuid4()}}\n", 1771 | "\n", 1772 | "stream_graph(app, {\"messages\": [(\"human\", \"1, 2, 4, 10, ?\")]}, config)" 1773 | ] 1774 | }, 1775 | { 1776 | "cell_type": "code", 1777 | "execution_count": null, 1778 | "metadata": {}, 1779 | "outputs": [], 1780 | "source": [ 1781 | "app.get_state(config).values" 1782 | ] 1783 | }, 1784 | { 1785 | "cell_type": "code", 1786 | "execution_count": null, 1787 | "metadata": {}, 1788 | "outputs": [], 1789 | "source": [ 1790 | "stream_graph(app, Command(resume=False), config)" 1791 | ] 1792 | }, 1793 | { 1794 | "cell_type": "code", 1795 | "execution_count": null, 1796 | "metadata": {}, 1797 | "outputs": [], 1798 | "source": [ 1799 | "app.get_state(config).values" 1800 | ] 1801 | }, 1802 | { 1803 | "cell_type": "markdown", 1804 | "metadata": {}, 1805 | "source": [ 1806 | "## Part 8. Long-Term Memory" 1807 | ] 1808 | }, 1809 | { 1810 | "cell_type": "markdown", 1811 | "metadata": {}, 1812 | "source": [ 1813 | "**메모리**는 사람들이 현재와 미래를 이해하기 위해 정보를 저장하고, 검색하며 사용하는 인지 기능입니다.\n", 1814 | "\n", 1815 | "AI 애플리케이션에서 사용할 수 있는 [다양한 **장기 메모리 유형**](https://langchain-ai.github.io/langgraph/concepts/memory/#memory)이 있습니다.\n", 1816 | "\n", 1817 | "여기에서는 **장기 기억**을 저장하고 검색하는 방법으로 [LangGraph Memory Store](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore)를 소개합니다.\n", 1818 | "\n", 1819 | "`short-term (within-thread)` 및 `long-term (across-thread)` 메모리를 모두 사용하는 챗봇을 구축할 것입니다.\n", 1820 | "\n", 1821 | "사용자에 대한 사실인 장기 [**semantic memory**](https://langchain-ai.github.io/langgraph/concepts/memory/#semantic-memory)에 중점을 둘 것입니다." 1822 | ] 1823 | }, 1824 | { 1825 | "cell_type": "markdown", 1826 | "metadata": {}, 1827 | "source": [ 1828 | "**LangGraph Store**\n", 1829 | "\n", 1830 | "[LangGraph Memory Store](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore) 는 LangGraph에서 *thread 간* 정보를 저장하고 검색할 수 있는 방법을 제공합니다. \n", 1831 | "\n", 1832 | "이 저장소는 지속적인 `key-value` 데이터를 관리하기 위한 [오픈 소스 기본 클래스](https://blog.langchain.dev/launching-long-term-memory-support-in-langgraph/)로, 개발자가 자신만의 저장소를 쉽게 구현할 수 있도록 설계되었습니다. " 1833 | ] 1834 | }, 1835 | { 1836 | "cell_type": "code", 1837 | "execution_count": 61, 1838 | "metadata": {}, 1839 | "outputs": [], 1840 | "source": [ 1841 | "from langgraph.store.memory import InMemoryStore\n", 1842 | "\n", 1843 | "# LangGraph의 InMemoryStore 인스턴스 생성\n", 1844 | "persistant_memory = InMemoryStore()" 1845 | ] 1846 | }, 1847 | { 1848 | "cell_type": "markdown", 1849 | "metadata": {}, 1850 | "source": [ 1851 | "스토어에 객체(예: 메모리)를 저장할 때는 [Store](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore)에 다음 정보를 제공합니다.\n", 1852 | "\n", 1853 | "- **namespace** (`namespace`): 객체를 구분하는 데 사용되는 튜플 형태의 식별자입니다 (디렉토리와 유사).\n", 1854 | "- **key** (`key`): 객체의 고유 식별자입니다 (파일 이름과 유사).\n", 1855 | "- **value** (`value`): 객체의 실제 내용입니다 (파일 내용과 유사).\n", 1856 | "\n", 1857 | "`namespace`와 `key`를 사용하여 객체를 스토어에 저장하기 위해 [put](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore.put) 메서드를 사용합니다." 1858 | ] 1859 | }, 1860 | { 1861 | "cell_type": "code", 1862 | "execution_count": 62, 1863 | "metadata": {}, 1864 | "outputs": [], 1865 | "source": [ 1866 | "# 저장할 메모리의 사용자 ID 설정\n", 1867 | "user_id = \"teddy\"\n", 1868 | "\n", 1869 | "# 사용자 ID와 메모리 구분을 위한 네임스페이스 정의\n", 1870 | "namespace_for_memory = (\"memories\", user_id)\n", 1871 | "\n", 1872 | "# 고유 키 생성을 위한 UUID 생성\n", 1873 | "key = \"user_memory\"\n", 1874 | "\n", 1875 | "# 저장할 메모리 값으로 딕셔너리 정의\n", 1876 | "value = {\n", 1877 | " \"job\": \"AI Engineer\",\n", 1878 | " \"location\": \"Seoul, Korea\",\n", 1879 | " \"hobbies\": [\"Watching Netflix\", \"Coding\"],\n", 1880 | "}\n", 1881 | "\n", 1882 | "# 지정된 네임스페이스에 메모리 저장\n", 1883 | "persistant_memory.put(namespace_for_memory, key, value)" 1884 | ] 1885 | }, 1886 | { 1887 | "cell_type": "markdown", 1888 | "metadata": {}, 1889 | "source": [ 1890 | "[`search`](https://langchain-ai.github.io/langgraph/reference/store/#langgraph.store.base.BaseStore.search)을 이용하여 `namespace` 기준으로 `store`에서 객체를 검색할 수 있습니다. 이 메서드는 목록을 반환합니다." 1891 | ] 1892 | }, 1893 | { 1894 | "cell_type": "code", 1895 | "execution_count": null, 1896 | "metadata": {}, 1897 | "outputs": [], 1898 | "source": [ 1899 | "# 지정된 네임스페이스로부터 메모리 객체 검색\n", 1900 | "memories = persistant_memory.search(namespace_for_memory)\n", 1901 | "\n", 1902 | "# 검색된 메모리 객체의 메타데이터를 딕셔너리 형식으로 변환\n", 1903 | "memories[0].dict()" 1904 | ] 1905 | }, 1906 | { 1907 | "cell_type": "markdown", 1908 | "metadata": {}, 1909 | "source": [ 1910 | "**장기 메모리를 갖춘 챗봇**\n", 1911 | "\n", 1912 | "챗봇은 [두 가지 유형의 메모리](https://docs.google.com/presentation/d/181mvjlgsnxudQI6S3ritg9sooNyu4AcLLFH1UK0kIuk/edit#slide=id.g30eb3c8cf10_0_156)를 갖추어야 합니다.\n", 1913 | "\n", 1914 | "1. `Short-term (within-thread) memory`: 챗봇이 대화 내역을 지속적으로 저장하거나, 대화 세션 중에 중단을 허용할 수 있습니다.\n", 1915 | "2. `Long-term (cross-thread) memory`: 챗봇이 특정 사용자에 대한 정보를 *모든 대화 세션에 걸쳐* 기억할 수 있습니다.\n", 1916 | "\n", 1917 | "이 두 가지 메모리 유형을 통해 챗봇은 사용자와의 대화를 더욱 원활하고 개인화된 방식으로 관리할 수 있습니다. `Short-term memory`는 현재 대화 세션 내에서의 맥락을 유지하는 데 사용되며, `Long-term memory`는 사용자에 대한 지속적인 정보를 저장하여 여러 세션에 걸쳐 일관된 상호작용을 가능하게 합니다." 1918 | ] 1919 | }, 1920 | { 1921 | "cell_type": "code", 1922 | "execution_count": null, 1923 | "metadata": {}, 1924 | "outputs": [], 1925 | "source": [ 1926 | "from langgraph.checkpoint.memory import MemorySaver\n", 1927 | "from langgraph.graph import StateGraph, MessagesState, START, END\n", 1928 | "from langgraph.store.base import BaseStore\n", 1929 | "\n", 1930 | "from langchain_core.messages import HumanMessage, SystemMessage\n", 1931 | "from langchain_core.runnables.config import RunnableConfig\n", 1932 | "\n", 1933 | "from langchain_teddynote.graphs import visualize_graph\n", 1934 | "from langchain_teddynote.messages import stream_graph\n", 1935 | "\n", 1936 | "from langchain_openai import ChatOpenAI\n", 1937 | "\n", 1938 | "# 모델 초기화\n", 1939 | "model = ChatOpenAI(model_name=\"gpt-4o-mini\", temperature=0)\n", 1940 | "\n", 1941 | "# 모델 시스템 메시지 정의\n", 1942 | "MODEL_SYSTEM_MESSAGE = \"\"\"You are a helpful assistant with memory that provides information about the user. \n", 1943 | "If you have memory for this user, use it to personalize your responses.\n", 1944 | "Here is the memory (it may be empty): {memory}\"\"\"\n", 1945 | "\n", 1946 | "# 새로운 메모리 생성 지침 정의\n", 1947 | "CREATE_MEMORY_INSTRUCTION = \"\"\"\"You are collecting information about the user to personalize your responses.\n", 1948 | "\n", 1949 | "CURRENT USER INFORMATION:\n", 1950 | "{memory}\n", 1951 | "\n", 1952 | "INSTRUCTIONS:\n", 1953 | "1. Review the chat history below carefully\n", 1954 | "2. Identify new information about the user, such as:\n", 1955 | " - Personal details (name, job, location)\n", 1956 | " - Preferences (likes, dislikes)\n", 1957 | " - Interests and hobbies\n", 1958 | " - Past experiences\n", 1959 | " - Goals or future plans\n", 1960 | "3. Merge any new information with existing memory\n", 1961 | "4. Format the memory as a clear, bulleted list\n", 1962 | "5. If new information conflicts with existing memory, keep the most recent version\n", 1963 | "\n", 1964 | "Remember: Only include factual information directly stated by the user. Do not make assumptions or inferences.\n", 1965 | "\n", 1966 | "Based on the chat history below, please update the user information:\"\"\"\n", 1967 | "\n", 1968 | "\n", 1969 | "# call_model 함수 정의\n", 1970 | "def call_model(state: MessagesState, config: RunnableConfig, store: BaseStore):\n", 1971 | " \"\"\"Load memory from the store and use it to personalize the chatbot's response.\"\"\"\n", 1972 | "\n", 1973 | " # 설정에서 사용자 ID 가져오기\n", 1974 | " user_id = config[\"configurable\"][\"user_id\"]\n", 1975 | "\n", 1976 | " # 스토어에서 메모리 검색\n", 1977 | " namespace = (\"memories\", user_id)\n", 1978 | " key = \"user_memory\"\n", 1979 | " existing_memory = store.get(namespace, key)\n", 1980 | "\n", 1981 | " # 기존 메모리 내용 추출 및 프리픽스 추가\n", 1982 | " if existing_memory:\n", 1983 | " # 값은 메모리 키를 포함하는 딕셔너리\n", 1984 | " existing_memory_content = existing_memory.value.get(\"memories\")\n", 1985 | " else:\n", 1986 | " existing_memory_content = \"No existing memory found.\"\n", 1987 | "\n", 1988 | " # 시스템 프롬프트에 메모리 포맷\n", 1989 | " system_msg = MODEL_SYSTEM_MESSAGE.format(memory=existing_memory_content)\n", 1990 | "\n", 1991 | " # 메모리와 대화 기록을 사용하여 응답 생성\n", 1992 | " response = model.invoke([SystemMessage(content=system_msg)] + state[\"messages\"])\n", 1993 | "\n", 1994 | " return {\"messages\": response}\n", 1995 | "\n", 1996 | "\n", 1997 | "# write_memory 함수 정의\n", 1998 | "def write_memory(state: MessagesState, config: RunnableConfig, store: BaseStore):\n", 1999 | " \"\"\"Reflect on the chat history and save a memory to the store.\"\"\"\n", 2000 | "\n", 2001 | " # 설정에서 사용자 ID 가져오기\n", 2002 | " user_id = config[\"configurable\"][\"user_id\"]\n", 2003 | "\n", 2004 | " # 스토어에서 기존 메모리 검색\n", 2005 | " namespace = (\"memories\", user_id)\n", 2006 | " existing_memory = store.get(namespace, \"user_memory\")\n", 2007 | "\n", 2008 | " # 메모리 추출\n", 2009 | " if existing_memory:\n", 2010 | " existing_memory_content = existing_memory.value.get(\"memories\")\n", 2011 | " else:\n", 2012 | " existing_memory_content = \"No existing memory found.\"\n", 2013 | "\n", 2014 | " # 시스템 프롬프트에 메모리 포맷\n", 2015 | " system_msg = CREATE_MEMORY_INSTRUCTION.format(memory=existing_memory_content)\n", 2016 | " new_memory = model.invoke([SystemMessage(content=system_msg)] + state[\"messages\"])\n", 2017 | "\n", 2018 | " # 스토어에 기존 메모리 덮어쓰기\n", 2019 | " key = \"user_memory\"\n", 2020 | "\n", 2021 | " # 메모리 키를 포함하는 딕셔너리로 값 작성\n", 2022 | " store.put(namespace, key, {\"memories\": new_memory.content})\n", 2023 | "\n", 2024 | "\n", 2025 | "# 그래프 정의\n", 2026 | "workflow = StateGraph(MessagesState)\n", 2027 | "workflow.add_node(\"call_model\", call_model)\n", 2028 | "workflow.add_node(\"write_memory\", write_memory)\n", 2029 | "workflow.add_edge(START, \"call_model\")\n", 2030 | "workflow.add_edge(\"call_model\", \"write_memory\")\n", 2031 | "workflow.add_edge(\"write_memory\", END)\n", 2032 | "\n", 2033 | "# 장기 메모리 저장소 설정\n", 2034 | "across_thread_memory = InMemoryStore()\n", 2035 | "\n", 2036 | "# 단기 메모리 체크포인터 설정\n", 2037 | "within_thread_memory = MemorySaver()\n", 2038 | "\n", 2039 | "# 체크포인터 및 스토어를 포함하여 그래프 컴파일\n", 2040 | "app = workflow.compile(checkpointer=within_thread_memory, store=across_thread_memory)\n", 2041 | "\n", 2042 | "# 그래프 시각화\n", 2043 | "visualize_graph(app)" 2044 | ] 2045 | }, 2046 | { 2047 | "cell_type": "code", 2048 | "execution_count": null, 2049 | "metadata": {}, 2050 | "outputs": [], 2051 | "source": [ 2052 | "# 단기 메모리를 위한 쓰레드 ID 제공(thread_id)\n", 2053 | "# 장기 메모리를 위한 사용자 ID 제공(user_id)\n", 2054 | "config = {\"configurable\": {\"thread_id\": \"1\", \"user_id\": \"teddy\"}}\n", 2055 | "\n", 2056 | "# 사용자 입력\n", 2057 | "input_messages = [\n", 2058 | " HumanMessage(\n", 2059 | " content=\"안녕 반가워! 내 이름은 테디 입니다. 저의 취미는 코딩 하고 영화 보는 것입니다.\"\n", 2060 | " )\n", 2061 | "]\n", 2062 | "\n", 2063 | "# 그래프 실행\n", 2064 | "stream_graph(app, {\"messages\": input_messages}, config)" 2065 | ] 2066 | }, 2067 | { 2068 | "cell_type": "code", 2069 | "execution_count": null, 2070 | "metadata": {}, 2071 | "outputs": [], 2072 | "source": [ 2073 | "# 단기 메모리를 위한 쓰레드 ID 제공(thread_id)\n", 2074 | "# 장기 메모리를 위한 사용자 ID 제공(user_id)\n", 2075 | "config = {\"configurable\": {\"thread_id\": \"2\", \"user_id\": \"teddy2\"}}\n", 2076 | "\n", 2077 | "# 사용자 입력\n", 2078 | "input_messages = [HumanMessage(content=\"내 취미가 뭐였더라...\")]\n", 2079 | "\n", 2080 | "# 그래프 실행\n", 2081 | "stream_graph(app, {\"messages\": input_messages}, config)" 2082 | ] 2083 | }, 2084 | { 2085 | "cell_type": "code", 2086 | "execution_count": null, 2087 | "metadata": {}, 2088 | "outputs": [], 2089 | "source": [ 2090 | "# 단기 메모리를 위한 쓰레드 ID 제공(thread_id)\n", 2091 | "# 장기 메모리를 위한 사용자 ID 제공(user_id)\n", 2092 | "config = {\"configurable\": {\"thread_id\": \"3\", \"user_id\": \"teddy\"}}\n", 2093 | "\n", 2094 | "# 사용자 입력\n", 2095 | "input_messages = [HumanMessage(content=\"내 취미가 뭐였더라...\")]\n", 2096 | "\n", 2097 | "# 그래프 실행\n", 2098 | "stream_graph(app, {\"messages\": input_messages}, config)" 2099 | ] 2100 | }, 2101 | { 2102 | "cell_type": "code", 2103 | "execution_count": null, 2104 | "metadata": {}, 2105 | "outputs": [], 2106 | "source": [ 2107 | "persistant_memory.get((\"memories\", \"teddy\"), \"user_memory\").value" 2108 | ] 2109 | }, 2110 | { 2111 | "cell_type": "code", 2112 | "execution_count": null, 2113 | "metadata": {}, 2114 | "outputs": [], 2115 | "source": [ 2116 | "# 단기 메모리를 위한 쓰레드 ID 제공(thread_id)\n", 2117 | "# 장기 메모리를 위한 사용자 ID 제공(user_id)\n", 2118 | "config = {\"configurable\": {\"thread_id\": \"2\", \"user_id\": \"john\"}}\n", 2119 | "\n", 2120 | "# 사용자 입력\n", 2121 | "input_messages = [HumanMessage(content=\"안녕 반가워! 혹시 내 취미 기억해?\")]\n", 2122 | "\n", 2123 | "# 그래프 실행\n", 2124 | "stream_graph(app, {\"messages\": input_messages}, config)" 2125 | ] 2126 | }, 2127 | { 2128 | "cell_type": "code", 2129 | "execution_count": null, 2130 | "metadata": {}, 2131 | "outputs": [], 2132 | "source": [ 2133 | "# 단기 메모리를 위한 쓰레드 ID 제공(thread_id)\n", 2134 | "# 장기 메모리를 위한 사용자 ID 제공(user_id)\n", 2135 | "config = {\"configurable\": {\"thread_id\": \"3\", \"user_id\": \"teddy\"}}\n", 2136 | "\n", 2137 | "# 사용자 입력\n", 2138 | "input_messages = [HumanMessage(content=\"나에 대해 아는 정보 모두 말해줘\")]\n", 2139 | "\n", 2140 | "# 그래프 실행\n", 2141 | "stream_graph(app, {\"messages\": input_messages}, config)" 2142 | ] 2143 | }, 2144 | { 2145 | "cell_type": "markdown", 2146 | "metadata": {}, 2147 | "source": [ 2148 | "추적: https://smith.langchain.com/public/618341ed-4bc2-443f-8ad0-f96fc464fac8/r" 2149 | ] 2150 | } 2151 | ], 2152 | "metadata": { 2153 | "kernelspec": { 2154 | "display_name": ".venv", 2155 | "language": "python", 2156 | "name": "python3" 2157 | }, 2158 | "language_info": { 2159 | "codemirror_mode": { 2160 | "name": "ipython", 2161 | "version": 3 2162 | }, 2163 | "file_extension": ".py", 2164 | "mimetype": "text/x-python", 2165 | "name": "python", 2166 | "nbconvert_exporter": "python", 2167 | "pygments_lexer": "ipython3", 2168 | "version": "3.11.11" 2169 | } 2170 | }, 2171 | "nbformat": 4, 2172 | "nbformat_minor": 2 2173 | } 2174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LangGraph 핸즈온 튜토리얼 2 | 3 | LangGraph를 활용한 에이전트 핸즈온 튜토리얼 입니다. 4 | 5 | **참고하면 좋은 자료** 6 | 7 | - [LangChain 한국어 튜토리얼🇰🇷](https://wikidocs.net/book/14314) 8 | - [LangChain 한국어 튜토리얼 Github 소스코드](https://github.com/teddylee777/langchain-kr) 9 | - [테디노트 YouTube](https://www.youtube.com/c/@teddynote) 10 | - [테디노트 블로그](https://teddylee777.github.io/) 11 | - [테디노트 YouTube 로 RAG 배우기!](https://teddylee777.notion.site/YouTube-RAG-10a24f35d12980dc8478c750faa752a2?pvs=74) 12 | - [RAG 비법노트](https://fastcampus.co.kr/data_online_teddy) 13 | 14 | 15 | ## 소개 16 | 이 프로젝트는 LangGraph를 사용하여 AI 에이전트를 구축하고 실행하는 방법을 보여주는 예제와 가이드를 제공합니다. 17 | 18 | ## 설치 방법(Local) 19 | 20 | ```bash 21 | git clone https://github.com/teddylee777/LangGraph-HandsOn.git 22 | cd LangGraph-HandsOn 23 | pip install -r requirements.txt 24 | ``` 25 | 26 | - [일반 튜토리얼 버전](00-langgraph.ipynb) 27 | - [음성 튜토리얼 버전](00-langgraph-(VoiceTutorial).ipynb) 28 | 29 | ## Google Colab 30 | 31 | - [일반 튜토리얼 버전](https://colab.research.google.com/drive/1ERAveGAEvs8tR2KykSpddslUtOUx4CnF?usp=sharing) 32 | 33 | - [음성 튜토리얼 버전](https://colab.research.google.com/drive/1U-cO5Swv2Ae0c10khpuul1J7ARqCJ7Ty?usp=sharing) 34 | 35 | ## 환경 설정 36 | 37 | **OpenAI API Key 설정** 38 | - https://wikidocs.net/233342 39 | 40 | **웹 검색을 위한 API 키 발급 주소** 41 | - https://app.tavily.com/ 42 | 43 | 회원 가입 후 API Key를 발급합니다. 44 | 45 | 프로젝트 루트에 .env_sample 파일을 복사하여 .env 파일을 생성하고 다음과 같이 필요한 환경 변수를 설정하세요. 46 | 47 | ```bash 48 | # 환경 변수 설정 49 | export OPENAI_API_KEY="your_openai_api_key_here" 50 | export TAVILY_API_KEY="your_tavily_api_key_here" 51 | ``` 52 | 53 | ## LangSmith 추적 설정 54 | 55 | LangSmith 추적을 활성화하려면 다음 환경 변수를 설정하세요: 56 | 57 | ```bash 58 | export LANGSMITH_API_KEY="your_langsmith_api_key_here" 59 | export LANGSMITH_TRACING=true 60 | export LANGSMITH_PROJECT_NAME="LangGraph Hands-On" 61 | export LANGSMITH_API_URL="https://api.smith.langchain.com" 62 | ``` 63 | 64 | ## 사용 방법 65 | 66 | `00-langgraph.ipynb` 노트북 파일을 열어 실습을 시작하세요. 67 | 68 | ## 목차 69 | 70 | 이 저장소에는 다음과 같은 예제가 포함되어 있습니다. 71 | 72 | - **Part 0. 환경 설정** 73 | - **Part 1. 기본 ReAct Agent 구현** 74 | - 도구 (Tools) 설정 75 | - 그래프 실행 76 | - **Part 2. 멀티턴 대화를 위한 단기 메모리: checkpointer** 77 | - MemorySaver 78 | - **Part 3. LangGraph 워크플로우 구현** 79 | - State 정의 80 | - 노드(Node) 정의 81 | - 그래프 생성 82 | - **Part 4. Routing** 83 | - 그래프 생성 84 | - **Part 5. Fan-out / Fan-in** 85 | - 일부만 Fan-out 하는 방법 86 | - **Part 6. 대화 기록 요약을 추가하는 방법** 87 | - **Part 7. Human in the Loop** 88 | - **Part 8. Long-Term Memory** 89 | 90 | ## 의존성 91 | 92 | - Python 3.11 93 | - langchain 94 | - langgraph 95 | - jupyter 96 | - openai 97 | - langchain-teddynote 98 | 99 | ## 라이선스 100 | 101 | ``` 102 | MIT License 103 | 104 | Copyright (c) 2025 테디노트 105 | 106 | Permission is hereby granted, free of charge, to any person obtaining a copy 107 | of this software and associated documentation files (the "Software"), to deal 108 | in the Software without restriction, including without limitation the rights 109 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 110 | copies of the Software, and to permit persons to whom the Software is 111 | furnished to do so, subject to the following conditions: 112 | 113 | The above copyright notice and this permission notice shall be included in all 114 | copies or substantial portions of the Software. 115 | 116 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 117 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 118 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 119 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 120 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 121 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 122 | SOFTWARE. 123 | ``` 124 | 125 | ---- 126 | 127 | Happy coding! 🚀 128 | 129 | -------------------------------------------------------------------------------- /data/SPRI_AI_Brief_2023년12월호_F.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teddynote-lab/LangGraph-HandsOn/51ac8ef46c740d7b641cc6d67c078b13b53b7e27/data/SPRI_AI_Brief_2023년12월호_F.pdf -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "langgraph-agent-handson" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | dependencies = [ 8 | "faiss-cpu>=1.10.0", 9 | "jupyter>=1.1.1", 10 | "langchain-experimental>=0.3.4", 11 | "langchain-openai>=0.3.7", 12 | "langchain-teddynote>=0.3.42", 13 | "langgraph-supervisor>=0.0.4", 14 | "langgraph-swarm>=0.0.3", 15 | "matplotlib>=3.10.1", 16 | "notebook>=7.3.2", 17 | "pdfplumber>=0.11.5", 18 | "python-dotenv>=1.0.1", 19 | "trustcall>=0.0.37", 20 | ] 21 | -------------------------------------------------------------------------------- /rag/base.py: -------------------------------------------------------------------------------- 1 | from langchain_core.output_parsers import StrOutputParser 2 | from langchain_community.vectorstores import FAISS 3 | from langchain_openai import OpenAIEmbeddings, ChatOpenAI 4 | 5 | from abc import ABC, abstractmethod 6 | from operator import itemgetter 7 | from langchain import hub 8 | 9 | 10 | class RetrievalChain(ABC): 11 | def __init__(self): 12 | self.source_uri = None 13 | self.k = 10 14 | self.model_name = "gpt-4o-mini" 15 | 16 | @abstractmethod 17 | def load_documents(self, source_uris): 18 | """loader를 사용하여 문서를 로드합니다.""" 19 | pass 20 | 21 | @abstractmethod 22 | def create_text_splitter(self): 23 | """text splitter를 생성합니다.""" 24 | pass 25 | 26 | def split_documents(self, docs, text_splitter): 27 | """text splitter를 사용하여 문서를 분할합니다.""" 28 | return text_splitter.split_documents(docs) 29 | 30 | def create_embedding(self): 31 | return OpenAIEmbeddings(model="text-embedding-3-small") 32 | 33 | def create_vectorstore(self, split_docs): 34 | return FAISS.from_documents( 35 | documents=split_docs, embedding=self.create_embedding() 36 | ) 37 | 38 | def create_retriever(self, vectorstore): 39 | # retriever 생성 40 | dense_retriever = vectorstore.as_retriever( 41 | search_type="similarity", search_kwargs={"k": self.k} 42 | ) 43 | return dense_retriever 44 | 45 | def create_model(self): 46 | return ChatOpenAI(model_name=self.model_name, temperature=0) 47 | 48 | def create_prompt(self): 49 | return hub.pull("teddynote/rag-prompt") 50 | 51 | @staticmethod 52 | def format_docs(docs): 53 | return "\n".join(docs) 54 | 55 | def create_chain(self): 56 | docs = self.load_documents(self.source_uri) 57 | text_splitter = self.create_text_splitter() 58 | split_docs = self.split_documents(docs, text_splitter) 59 | self.vectorstore = self.create_vectorstore(split_docs) 60 | self.retriever = self.create_retriever(self.vectorstore) 61 | model = self.create_model() 62 | prompt = self.create_prompt() 63 | self.chain = ( 64 | {"question": itemgetter("question"), "context": itemgetter("context")} 65 | | prompt 66 | | model 67 | | StrOutputParser() 68 | ) 69 | return self 70 | -------------------------------------------------------------------------------- /rag/pdf.py: -------------------------------------------------------------------------------- 1 | from rag.base import RetrievalChain 2 | from langchain_community.document_loaders import PDFPlumberLoader 3 | from langchain_text_splitters import RecursiveCharacterTextSplitter 4 | from typing import List, Annotated 5 | from rag.utils import format_docs 6 | 7 | 8 | class PDFRetrievalChain(RetrievalChain): 9 | def __init__( 10 | self, 11 | source_uri: Annotated[str, "Source URI"], 12 | model_name="gpt-4o-mini", 13 | k: int = 6, 14 | ): 15 | self.source_uri = source_uri 16 | self.k = k 17 | self.model_name = model_name 18 | 19 | def load_documents(self, source_uris: List[str]): 20 | docs = [] 21 | for source_uri in source_uris: 22 | loader = PDFPlumberLoader(source_uri) 23 | docs.extend(loader.load()) 24 | 25 | return docs 26 | 27 | def format_docs(self, docs): 28 | return format_docs(docs) 29 | 30 | def create_text_splitter(self): 31 | return RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=50) 32 | -------------------------------------------------------------------------------- /rag/utils.py: -------------------------------------------------------------------------------- 1 | def format_docs(docs): 2 | return "\n".join( 3 | [ 4 | f"{doc.page_content}{doc.metadata['source']}{int(doc.metadata['page'])+1}" 5 | for doc in docs 6 | ] 7 | ) 8 | 9 | 10 | def format_searched_docs(docs): 11 | return "\n".join( 12 | [ 13 | f"{doc['content']}{doc['url']}" 14 | for doc in docs 15 | ] 16 | ) 17 | 18 | 19 | def format_task(tasks): 20 | # 결과를 저장할 빈 리스트 생성 21 | task_time_pairs = [] 22 | 23 | # 리스트를 순회하면서 각 항목을 처리 24 | for item in tasks: 25 | # 콜론(:) 기준으로 문자열을 분리 26 | task, time_str = item.rsplit(":", 1) 27 | # '시간' 문자열을 제거하고 정수로 변환 28 | time = int(time_str.replace("시간", "").strip()) 29 | # 할 일과 시간을 튜플로 만들어 리스트에 추가 30 | task_time_pairs.append((task, time)) 31 | 32 | # 결과 출력 33 | return task_time_pairs 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.6 2 | aiohttp==3.11.13 3 | aiosignal==1.3.2 4 | annotated-types==0.7.0 5 | anthropic==0.47.2 6 | anyio==4.8.0 7 | appnope==0.1.4 8 | argon2-cffi==23.1.0 9 | argon2-cffi-bindings==21.2.0 10 | arrow==1.3.0 11 | asttokens==3.0.0 12 | async-lru==2.0.4 13 | attrs==25.1.0 14 | babel==2.17.0 15 | beautifulsoup4==4.13.3 16 | bleach==6.2.0 17 | certifi==2025.1.31 18 | cffi==1.17.1 19 | charset-normalizer==3.4.1 20 | click==8.1.8 21 | comm==0.2.2 22 | contourpy==1.3.1 23 | cryptography==44.0.1 24 | cycler==0.12.1 25 | dataclasses-json==0.6.7 26 | debugpy==1.8.12 27 | decorator==5.2.1 28 | deepl==1.21.0 29 | defusedxml==0.7.1 30 | distro==1.9.0 31 | dydantic==0.0.8 32 | executing==2.2.0 33 | faiss-cpu==1.10.0 34 | fastjsonschema==2.21.1 35 | feedparser==6.0.11 36 | fonttools==4.56.0 37 | fqdn==1.5.1 38 | frozenlist==1.5.0 39 | googleapis-common-protos==1.68.0 40 | grpcio==1.70.0 41 | h11==0.14.0 42 | httpcore==1.0.7 43 | httpx==0.28.1 44 | httpx-sse==0.4.0 45 | idna==3.10 46 | ipykernel==6.29.5 47 | ipython==8.32.0 48 | ipywidgets==8.1.5 49 | isoduration==20.11.0 50 | jedi==0.19.2 51 | jinja2==3.1.5 52 | jiter==0.8.2 53 | joblib==1.4.2 54 | json5==0.10.0 55 | jsonpatch==1.33 56 | jsonpointer==3.0.0 57 | jsonschema==4.23.0 58 | jsonschema-specifications==2024.10.1 59 | jupyter==1.1.1 60 | jupyter-client==8.6.3 61 | jupyter-console==6.6.3 62 | jupyter-core==5.7.2 63 | jupyter-events==0.12.0 64 | jupyter-lsp==2.2.5 65 | jupyter-server==2.15.0 66 | jupyter-server-terminals==0.5.3 67 | jupyterlab==4.3.5 68 | jupyterlab-pygments==0.3.0 69 | jupyterlab-server==2.27.3 70 | jupyterlab-widgets==3.0.13 71 | kiwipiepy==0.20.3 72 | kiwipiepy-model==0.20.0 73 | kiwisolver==1.4.8 74 | langchain==0.3.19 75 | langchain-community==0.3.18 76 | langchain-core==0.3.40 77 | langchain-experimental==0.3.4 78 | langchain-openai==0.3.7 79 | langchain-teddynote==0.3.42 80 | langchain-text-splitters==0.3.6 81 | langgraph==0.3.0 82 | langgraph-checkpoint==2.0.16 83 | langgraph-prebuilt==0.1.0 84 | langgraph-sdk==0.1.53 85 | langgraph-supervisor==0.0.4 86 | langgraph-swarm==0.0.3 87 | langsmith==0.3.11 88 | lz4==4.4.3 89 | markupsafe==3.0.2 90 | marshmallow==3.26.1 91 | matplotlib==3.10.1 92 | matplotlib-inline==0.1.7 93 | mistune==3.1.2 94 | mmh3==4.1.0 95 | msgpack==1.1.0 96 | multidict==6.1.0 97 | mypy-extensions==1.0.0 98 | nbclient==0.10.2 99 | nbconvert==7.16.6 100 | nbformat==5.10.4 101 | nest-asyncio==1.6.0 102 | nltk==3.9.1 103 | notebook==7.3.2 104 | notebook-shim==0.2.4 105 | numpy==1.26.4 106 | olefile==0.47 107 | openai==1.64.0 108 | orjson==3.10.15 109 | overrides==7.7.0 110 | packaging==24.2 111 | pandas==2.2.3 112 | pandocfilters==1.5.1 113 | parso==0.8.4 114 | pdf2image==1.17.0 115 | pdfminer-six==20231228 116 | pdfplumber==0.11.5 117 | pexpect==4.9.0 118 | pillow==11.1.0 119 | pinecone-client==6.0.0 120 | pinecone-plugin-interface==0.0.7 121 | pinecone-text==0.9.0 122 | platformdirs==4.3.6 123 | prometheus-client==0.21.1 124 | prompt-toolkit==3.0.50 125 | propcache==0.3.0 126 | protobuf==5.29.3 127 | protoc-gen-openapiv2==0.0.1 128 | psutil==7.0.0 129 | ptyprocess==0.7.0 130 | pure-eval==0.2.3 131 | pycparser==2.22 132 | pydantic==2.10.6 133 | pydantic-core==2.27.2 134 | pydantic-settings==2.8.1 135 | pygments==2.19.1 136 | pyparsing==3.2.1 137 | pypdfium2==4.30.1 138 | python-dateutil==2.9.0.post0 139 | python-dotenv==1.0.1 140 | python-json-logger==3.2.1 141 | pytz==2025.1 142 | pyyaml==6.0.2 143 | pyzmq==26.2.1 144 | rank-bm25==0.2.2 145 | referencing==0.36.2 146 | regex==2024.11.6 147 | requests==2.32.3 148 | requests-toolbelt==1.0.0 149 | rfc3339-validator==0.1.4 150 | rfc3986-validator==0.1.1 151 | rpds-py==0.23.1 152 | send2trash==1.8.3 153 | setuptools==75.8.2 154 | sgmllib3k==1.0.0 155 | six==1.17.0 156 | sniffio==1.3.1 157 | soupsieve==2.6 158 | sqlalchemy==2.0.38 159 | stack-data==0.6.3 160 | tavily-python==0.5.1 161 | tenacity==9.0.0 162 | terminado==0.18.1 163 | tiktoken==0.9.0 164 | tinycss2==1.4.0 165 | tornado==6.4.2 166 | tqdm==4.67.1 167 | traitlets==5.14.3 168 | trustcall==0.0.37 169 | types-python-dateutil==2.9.0.20241206 170 | types-requests==2.32.0.20241016 171 | typing-extensions==4.12.2 172 | typing-inspect==0.9.0 173 | tzdata==2025.1 174 | uri-template==1.3.0 175 | urllib3==2.3.0 176 | wcwidth==0.2.13 177 | webcolors==24.11.1 178 | webencodings==0.5.1 179 | websocket-client==1.8.0 180 | wget==3.2 181 | widgetsnbextension==4.0.13 182 | yarl==1.18.3 183 | zstandard==0.23.0 184 | --------------------------------------------------------------------------------