|
| 1 | +--- |
| 2 | +title: 'Langchain 기반 챗봇 만들기 #3 - ConversationBufferMemory로 대화 문맥 유지하기' |
| 3 | +description: ConversationBufferMemory를 이용해 챗봇이 이전 대화를 기억하도록 만드는 과정을 Passthrough·Lambda |
| 4 | + Runnable 체인과 함께 단계별로 해설합니다. |
| 5 | +categories: Programming |
| 6 | +mermaid: true |
| 7 | +date: 2025-06-08 20:56 +0900 |
| 8 | +--- |
| 9 | +[Langchain 기반 챗봇 만들기 #2](/posts/chatbot_2/) 에서는 기본 Langchain 개념과 사용법을 살펴보았다. 이제는 memory 요소를 통해 챗봇이 이전 대화 문맥을 유지하도록 해보자. |
| 10 | + |
| 11 | +## 대화 문맥 |
| 12 | +기본적으로 OpenAI에서 제공하는 API는 **비상태(stateless)** 방식이다. 이전 대화 문맥을 기억하지 않고, 이번 API 호출에 포함된 입력만을 기준으로 응답을 생성한다. |
| 13 | + |
| 14 | +그렇다면 챗봇들은 어떻게 대화 문맥을 유지할까? |
| 15 | + |
| 16 | +답은 아주 단순하다. |
| 17 | + |
| 18 | +**API 요청 시, 이전 대화 내용을 함께 포함하여 보내면 된다.** |
| 19 | + |
| 20 | +```mermaid |
| 21 | +graph LR |
| 22 | + %% ────────── 1단계 ────────── |
| 23 | + subgraph 단계1["요청 #1 ↔ 응답 #1"] |
| 24 | + direction TB |
| 25 | + R1["📤 요청 #1<br/>User: 안녕?"] --> A1["📥 응답 #1<br/>AI: 안녕하세요!"] |
| 26 | + end |
| 27 | +
|
| 28 | + %% ────────── 2단계 ────────── |
| 29 | + subgraph 단계2["요청 #2 ↔ 응답 #2"] |
| 30 | + direction TB |
| 31 | + R2["📤 요청 #2<br/>(이전) User: 안녕?<br/>(이전) AI: 안녕하세요!<br/>User: 오늘 날씨 어때?"] --> A2["📥 응답 #2<br/>AI: 오늘은 맑아요."] |
| 32 | + end |
| 33 | +
|
| 34 | + %% ────────── 3단계 ────────── |
| 35 | + subgraph 단계3["요청 #3 ↔ 응답 #3"] |
| 36 | + direction TB |
| 37 | + R3["📤 요청 #3<br/>(이전) User: 안녕?<br/>(이전) AI: 안녕하세요!<br/>(이전) User: 오늘 날씨 어때?<br/>(이전) AI: 오늘은 맑아요.<br/>User: 내일도 맑을까?"] --> A3["📥 응답 #3<br/>AI: 내일도 맑을 가능성이 높아요."] |
| 38 | + end |
| 39 | +
|
| 40 | + %% ────────── 누적 화살표 ────────── |
| 41 | + A1 -. "이전 대화 포함" .-> R2 |
| 42 | + A2 -. "이전 대화 포함" .-> R3 |
| 43 | +``` |
| 44 | + |
| 45 | +위처럼 매 요청마다 이전 대화 내역을 누적해서 보내는 방식이다. |
| 46 | + |
| 47 | +Langchain에서는 이 번거로운 과정을 **`Memory` 모듈**로 추상화해 둔다. 그중에서도 가장 단순한 구현이 **`ConversationBufferMemory`** 다. |
| 48 | + |
| 49 | +## `ConversationBufferMemory`로 파이프라인 만들기 |
| 50 | +ConversationBufferMemory는 단순히 모든 이전 대화 기록을 순서대로 저장하고, 다음 프롬프트 호출 시 이 기록을 자동으로 전달해준다. |
| 51 | + |
| 52 | + |
| 53 | +### 전체 코드 |
| 54 | +예를 들어 다음과 같이 사용할 수 있다: |
| 55 | +```python |
| 56 | +from operator import itemgetter |
| 57 | +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder |
| 58 | +from langchain_openai import ChatOpenAI |
| 59 | +from langchain.memory import ConversationBufferMemory |
| 60 | +from langchain_core.runnables import RunnableLambda, RunnablePassthrough |
| 61 | + |
| 62 | +# 1. LLM 모델 정의 |
| 63 | +llm = ChatOpenAI(model="gpt-4.1-nano") |
| 64 | + |
| 65 | +# 2. Memory 정의 |
| 66 | +memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True) |
| 67 | + |
| 68 | +# 3. Prompt 정의 (이전 대화 내역 포함) |
| 69 | +prompt = ChatPromptTemplate.from_messages([ |
| 70 | + ("system", "당신은 친절한 AI 비서입니다."), |
| 71 | + MessagesPlaceholder(variable_name="chat_history"), |
| 72 | + ("human", "{input}") |
| 73 | +]) |
| 74 | + |
| 75 | +# 4. 체이닝 구성 |
| 76 | +chain = ( |
| 77 | + # ① 입력 dict에 과거 대화 기록(chat_history) 붙이기 |
| 78 | + RunnablePassthrough.assign( |
| 79 | + chat_history = ( |
| 80 | + RunnableLambda(lambda _: memory.load_memory_variables({})) |
| 81 | + | itemgetter("chat_history") |
| 82 | + ) |
| 83 | + ) |
| 84 | + # ② 프롬프트 → LLM → response 필드 추가 |
| 85 | + | RunnablePassthrough.assign( |
| 86 | + response = prompt | llm |
| 87 | + ) |
| 88 | + # ③ 메모리에 저장하고 response만 내보내기 |
| 89 | + | RunnableLambda(lambda d: ( |
| 90 | + memory.save_context( |
| 91 | + {"input": d["input"]}, |
| 92 | + {"output": d["response"].content} |
| 93 | + ) or d["response"] |
| 94 | + )) |
| 95 | +) |
| 96 | + |
| 97 | +# 5. 체인 실행 |
| 98 | +print(chain.invoke({"input": "안녕? 나는 홍길동이야."}).content) |
| 99 | +print(chain.invoke({"input": "내 이름이 뭐라고?"}).content) |
| 100 | + |
| 101 | +``` |
| 102 | + |
| 103 | +### 실행 결과 (예시) |
| 104 | +``` |
| 105 | +안녕하세요, 홍길동님. 무엇을 도와드릴까요? |
| 106 | +당신의 이름은 홍길동입니다. |
| 107 | +``` |
| 108 | + |
| 109 | +### 데이터가 흐르는 순서 |
| 110 | + |
| 111 | +| 단계 | Runnable 단계 | 입력 딕셔너리 → 출력 딕셔너리 | 역할 | |
| 112 | +| -- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | |
| 113 | +| ① | `RunnablePassthrough.assign(chat_history=…)` | `{"input": "..."}` → `{"input": "...", "chat_history": [...]}` | 현재 입력 dict에 **`chat_history`** 키를 추가한다. 값은 `ConversationBufferMemory.load_memory_variables()`가 돌려준 과거 메시지 리스트다. | |
| 114 | +| ② | `RunnablePassthrough.assign(response = prompt \| llm)` | `{"input": "...", "chat_history": [...]}` → `{"input": "...", "chat_history": [...], "response": AIMessage}` | 프롬프트를 완성해 LLM을 호출하고, 얻은 `AIMessage`를 `response` 키에 저장한다. | |
| 115 | +| ③ | `RunnableLambda(save_context…)` | 위 딕셔너리 → `AIMessage` | `memory.save_context()`로 **사용자‑AI 메시지 쌍을 버퍼에 push**하고, 마지막에는 `AIMessage`만 반환한다. | |
| 116 | + |
| 117 | +> **왜 Passthrough를 쓰나?** |
| 118 | +> `RunnablePassthrough.assign`은 "딕셔너리를 그대로 전달하면서 새 키를 덧붙이는" 데 최적화된 유틸이다. 이렇게 하면 중간 단계에서 원본 데이터를 잃지 않고 필요한 필드를 점진적으로 누적할 수 있다. |
| 119 | +
|
| 120 | +#### `memory.load_memory_variables({})`이 반환하는 구조 |
| 121 | + |
| 122 | +```python |
| 123 | +{ |
| 124 | + "chat_history": [ |
| 125 | + HumanMessage(content="안녕? 나는 홍길동이야."), |
| 126 | + AIMessage(content="안녕하세요, 홍길동님. 무엇을 도와드릴까요?") |
| 127 | + ] |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +* **키 이름**은 `memory_key="chat_history"`와 1:1로 대응해야 한다. |
| 132 | +* `return_messages=True` 옵션 덕분에 순수 문자열이 아니라 **Message 객체** 목록으로 돌려준다. 따라서 프롬프트에 그대로 삽입해도 형식 오류가 나지 않는다. |
| 133 | + |
| 134 | + |
| 135 | +### 이렇게 쓰면 좋은 경우 vs. 주의할 점 |
| 136 | + |
| 137 | +**장점** |
| 138 | + |
| 139 | +* 구현 난이도가 매우 낮다 — 몇 줄 만에 "기억하는 챗봇" 완성. |
| 140 | +* LLM이 모든 과거 대화를 보므로 맥락 일관성이 뛰어나다. |
| 141 | + |
| 142 | +**단점** |
| 143 | + |
| 144 | +* 대화 기록이 길어질수록 토큰 비용이 선형으로 증가한다. |
| 145 | +* 길이 제한(예: 128k)을 넘기면 오류가 발생하거나 맨 앞 메시지가 잘릴 수 있다. |
| 146 | + |
| 147 | +### 대안 |
| 148 | + |
| 149 | +* `ConversationTokenBufferMemory` : 토큰 수 기준으로 슬라이딩 윈도우 유지. |
| 150 | +* `SummaryMemory` : 오래된 대화를 LLM이 요약하여 짧은 한 문장으로 대체. |
| 151 | + |
0 commit comments