├── .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 |
--------------------------------------------------------------------------------