Skip to content

Commit e12f2ae

Browse files
committed
chatbot_3: post added
1 parent a02df03 commit e12f2ae

File tree

1 file changed

+151
-0
lines changed

1 file changed

+151
-0
lines changed

_posts/2025-06-08-chatbot_3.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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

Comments
 (0)