에이전트에게 기억력 부여하기 — NVIDIA NIM을 활용한 멀티 턴 대화 (Multi-Turn Conversations)
요약
NVIDIA NIM을 활용하여 AI 에이전트에게 멀티 턴 대화 능력을 부여하는 방법을 설명합니다. 대화 기록을 세션 객체로 관리하여 지속성을 확보하되, 도구 호출(tool-call)의 무결성을 유지하기 위해 메시지가 아닌 '턴(turn)' 단위로 컨텍스트를 관리하는 기술적 해결책을 제시합니다.
핵심 포인트
- 에이전트의 대화 지속성을 위해 메시지 리스트를 세션 객체로 관리
- 컨텍스트 윈도우 및 토큰 비용 절감을 위한 대화 기록 관리 필요성
- 도구 호출(tool-call) 오류 방지를 위해 메시지가 아닌 '턴' 단위로 기록 삭제
- assistant 메시지와 tool 메시지 간의 ID 쌍(pairing) 규칙 준수 중요성
Part 6에서 우리가 구축한 에이전트는 매우 영리합니다. 계획을 세우고, 도구(tools)를 체이닝하며, 정말 어려운 질문에도 답변합니다. 하지만 금붕어 같은 기억력을 가지고 있기도 합니다. 에이전트에게 _"AI 클럽은 언제 모이나요?"_라고 물어보고 좋은 답변을 얻은 뒤, _"그날까지 며칠 남았나요?"_라고 물어보면, 에이전트는 _"그날"_이 무엇인지 전혀 알지 못합니다. 모든 질문이 백지 상태에서 시작되는 것입니다.
이것이 바로 _쿼리 도구 (query tool)_와 어시스턴트 (assistant) 사이의 차이점입니다. 진정한 어시스턴트는 대화를 유지합니다. 방금 무엇을 물었는지 기억하고, 이미 언급된 내용에 비추어 "그것", "그 두 가지", "두 번째 것" 등을 해석하며, 사용자가 같은 말을 반복하게 만들지 않습니다.
해결책은 생각보다 간단합니다. Part 6에서는 messages 리스트가 에이전트 함수 내부에 존재하여 각 질문이 끝날 때마다 버려졌습니다. 이번 포스트에서는 이 리스트를 함수 밖으로 꺼내 세션 객체 (session object)로 옮겨, 한 턴에서 다음 턴으로 이어지도록 만들 것입니다. 이것이 작업의 대부분입니다. 흥미로운 부분, 즉 사람들이 어려워하는 부분은 대화가 충분히 길어져서 도구 호출(tool-call) 기록을 깨뜨리지 않으면서 오래된 턴을 잊기 시작해야 할 때 어떤 일이 발생하는가 하는 점입니다.
저는 USC의 NVIDIA Developer Champion인 B Torkian입니다. 시리즈의 Part 7입니다.
추가되는 기능
Turn 1: 사용자 질문 → 에이전트 도구 루프 실행 → 답변 ┐
Turn 2: 사용자 질문 → 에이전트 도구 루프 실행 → 답변 │ 모두 하나의
Turn 3: ... ┘ messages 리스트 공유
...
Part 1의 채팅 호출, Part 2의 리트리버 (retriever), Part 3의 가드레일 (guardrail), 그리고 Part 6의 세 가지 도구는 모두 변경 없이 그대로 유지됩니다. 유일하게 새로운 아이디어는 **지속성 (persistence)**입니다. 즉, 호출 간에 메시지 기록을 유지하는 것입니다.
"그저 메시지 리스트를 유지하기"에 함정이 있는 이유
기록을 지속시키는 것은 의도상 한 줄이면 됩니다. 새로운 리스트를 시작하는 대신 동일한 리스트에 계속 추가하는 것이죠. 하지만 대화는 무한히 길어지며, 결국 오래된 턴을 다듬지 않으면 컨텍스트 윈도우 (context window)를 초과하게 되고 필요하지 않은 토큰 비용을 지불하게 됩니다.
여기에 함정이 있습니다. 도구 호출 (tool calling)을 사용할 때, API는 다음과 같은 쌍(pairing) 규칙을 강제합니다: 모든 role="tool" 메시지는 반드시 이전 assistant 메시지에 포함된 tool_calls 항목과 ID를 통해 일치해야 합니다. 따라서 만약 단순히 "가장 오래된 메시지 4개"를 잘라냈는데, 그중 하나가 도구를 요청한 assistant 메시지였고, 그 바로 뒤에 온 tool 결과값은 _유지_했다면, 당신은 고아(orphan) 메시지를 만든 것입니다. 이제 도구 결과는 히스토리에 더 이상 존재하지 않는 tool_call_id를 참조하게 되며, NVIDIA NIM (OpenAI 호환 엔드포인트와 마찬가지로)은 유효성 검사 오류 (validation error)와 함께 요청을 거부합니다.
해결책은 메시지가 아닌 턴 (turns) 단위로 생각하는 것입니다. 하나의 턴은 한 사용자의 메시지부터 다음 사용자 메시지 전까지의 모든 것을 포함합니다: 사용자의 질문, 그 사이의 모든 assistant/tool 교환, 그리고 최종 답변까지 말이죠. 즉, _전체 턴_을 추가하거나 삭제해야 합니다. 구체적으로 말하면, 사용자 메시지 경계에서만 잘라내야(trim) 하며, 그래야만 도구 호출과 그 결과를 분리하지 않을 수 있습니다.
1단계 — 설정 사항 가져오기
client, MODEL, 그리고 파트 2의 knowledge_base + retrieve_context, 그리고 파트 6의 세 가지 도구(search_campus_info, get_current_time, days_until_weekday)가 필요합니다. Colab 노트북에는 간결한 사전 요구 사항 셀이 있으며, 독립형 part7_memory_agent.py는 모든 것을 처음부터 정의합니다.
동일한 호스팅 엔드포인트의 동일한 meta/llama-3.3-70b-instruct를 사용합니다. 여기서는 낮은 온도 (low temperature) 설정이 파트 6보다 훨씬 더 중요합니다. 이에 대한 자세한 내용은 마지막에 다룹니다.
MODEL = "meta/llama-3.3-70b-instruct"
LOCAL_TZ = "America/Los_Angeles"
2단계 — 기억하는 세션
파트 6에서는 루프가 로컬 messages = [...]를 소유했습니다. 여기서는 해당 리스트를 객체 내부로 이동합니다. 이것이 개념적인 핵심 변화입니다: 함수가 반환될 때 사라졌던 상태 (state)가 이제 self에 존재하며 호출 간에도 지속됩니다.
class ChatSession:
def __init__(self, max_turns: int = 8, verbose: bool = True):
self.system = {"role": "system", "content": SYSTEM_PROMPT}
...
여기서 클로저 (closure) 대신 클래스 (class)를 사용하는 이유는 한 가지입니다. 바로 메모리가 _가시적 (visible)_이라는 점입니다. print(session.messages)를 통해 모델이 정확히 무엇을 기억하고 있는지 확인할 수 있으며, session.reset()은 이를 초기화하는 명확한 방법이 됩니다. 클로저에 숨겨진 상태 (hidden state)는 잘못된 멘탈 모델 (mental model)을 심어줄 수 있습니다.
3단계 — 전체 히스토리를 활용한 턴 루프 (turn loop)
chat()은 6부의 도구 루프 (tool loop)와 두 가지 차이점이 있습니다. 로컬 리스트 대신 self.messages (지속적인 리스트)에 메시지를 추가하며, 메모리가 제한된 범위 내에 유지되도록 반환하기 전에 _trim()을 호출한다는 점입니다.
def chat(self, user_message: str) -> str:
self.messages.append({"role": "user", "content": user_message})
...
멀티 턴 (multi-turn) 모드에서는 시스템 프롬프트 (system prompt)가 실제로 중요한 역할을 수행합니다. 시스템 프롬프트는 모델에게 대화 내의 역참조 (back-references)를 해결하도록 지시하며, 무엇보다 중요한 것은 다시 검색하는 대신 히스토리에 이미 있는 사실을 재사용하도록 지시한다는 점입니다. 이 구절이 없다면, 70B 모델은 두 턴 전에 이미 가져온 정보에 대해 search_campus_info를 다시 호출하는 경우가 발생할 것입니다.
가치를 증명하는 코드가 한 줄 더 있습니다. 이 코드는 두 날짜가 얼마나 가까운지 비교하기 위해, 모델이 각 날짜에 대해 days_until_weekday를 호출하고 반환된 숫자를 비교해야 하며, 날짜 수를 직접 추정해서는 안 된다고 지시합니다. 이 구절이 없다면, 모델은 "어느 쪽이 더 빠른가요?"라는 질문이 들어온 턴에서 머릿속으로 즐겁게 날짜 산술을 수행하다가 틀린 답을 내놓게 됩니다. 비교 작업을 도구 (tool)를 통해 수행하도록 유도하는 것은 6부에서 배운 것과 동일한 교훈을 줍니다. 즉, 함수가 정확하게 계산할 수 있는 상황에서 모델이 추측하게 만들지 마십시오.
4단계 — 대화 나누기
session = ChatSession(verbose=True)
for user_message in [
"When does the USC AI Club meet?", # search -> "Thursday"
...
단독으로는 성립할 수 없는 두 개의 턴을 살펴보겠습니다:
- "How many days until that?" — _that_이라는 단어는 문장 자체 내에 지칭 대상(referent)이 없습니다. 모델은 히스토리(history)에서 Turn 1을 읽어 이를 'Thursday'로 해석하고,
days_until_weekday("Thursday")를 호출합니다. 히스토리를 제거하면 이 질문은 의미가 없습니다. - "Which of those two is sooner?" — 모델은 서로 다른 턴(Turn)에서 검색한 두 가지 사실(AI Club = Thursday, office hours = Tuesday)을 유지하고 이를 비교해야 합니다. 이는 두 정보가 모두 메모리(memory)에 남아 있기 때문에 가능한 일입니다.
Step 5 — 메모리가 실제로 작동하고 있음을 증명하기
session.reset()
print("You: How many days until that?")
print(f"Assistant: {session.chat('How many days until that?')}")
질문은 동일하지만, 히스토리는 비어 있습니다. 이전에 아무것도 없다면, _"that"_은 지칭 대상이 없으므로 에이전트는 해석할 내용이 없어 실패(fall back)하게 됩니다. 유일하게 변한 변수는 대화 내용의 존재 여부였으며, 이것이 바로 핵심입니다.
Step 6 — 실제로 구축한 것과 여전히 부족한 것
이제 어시스턴트는 연속성(continuity)을 갖추었습니다:
- Workshop 1은 뇌(brain)를 부여했습니다.
- Workshop 2는 사실(facts)에 대한 기억(retrieval, 검색)을 부여했습니다.
- Workshop 3은 판단력(judgment)을 부여했습니다.
- Workshop 4는 이식성(portability)을 부여했습니다.
- Workshop 5는 손(hands, 하나의 도구)을 부여했습니다.
- Workshop 6은 계획(plan, 체이닝된 도구들)을 부여했습니다.
- Workshop 7은 대화(conversation)에 대한 기억을 부여했습니다.
더 나아가 진행할 때 명심해야 할 세 가지 사항은 다음과 같습니다:
- 히스토리 윈도우(history window)는 형식적인 것이 아니라 실제적인 제한 사항입니다. 특정 사실이 유지되는 턴(turn) 범위를 벗어나 밀려나면, 모델은 해당 내용을 참조할 수 없습니다. 이때 70B 모델은 잊었다고 인정하는 대신 가끔씩 이전에 말한 내용을 환각 (confabulate) 하기도 합니다.
max_turns=2로 설정한 뒤 1번째 턴에 대해 후속 질문을 던져보세요. 모델이 잊었다고 말하는 대신 답변을 지어내는 것을 볼 수 있을 것입니다. 이러한 실패 사례가 바로 프로덕션 시스템에서 오래된 턴을 요약하거나, 리스트 대신 데이터베이스에 메모리를 저장하는 정확한 이유입니다. - 메시지가 아닌 턴 단위로 자르세요. 고립된
tool_call_id에러는 초보자의 멀티 턴 에이전트가 망가지는 가장 흔한 방식입니다. 사용자 경계(user boundaries)에서 자르는 것이 가장 단순하고 안전한 규칙입니다. - 온도(temperature)를 낮게 유지하세요. 온도가 높으면 모델이 턴 사이에 도구 사용 경로(tool path)를 변경하므로, 후속 질문이 이전 질문과 다른 경로를 택할 수 있습니다.
temperature=0.2는 대화의 일관성을 유지해 줍니다.
이후의 모든 내용 — 요약(summarization), 장기 기억을 위한 벡터 스토어(vector store), 사용자별 세션(per-user sessions), 답변 스트리밍(streaming) — 은 동일한 루프를 감싸고 있는 일반적인 소프트웨어 기술입니다. 에이전트는 여전히 모델 호출을 반복하는 while 루프입니다. 이제 단지 기억할 수 있는 리스트를 가졌을 뿐입니다.
코드 가져오기
Repo: github.com/torkian/nvidia-nim-workshop
One-click Colab: part7_memory_agent.ipynb 열기
Local Python: 리포지토리 내의 part7_memory_agent.py (pip install -r requirements.txt 실행 후 python3 part7_memory_agent.py 실행).
MIT 라이선스입니다. 저는 USC에서 이 작업을 수행하고 있습니다. 이를 포크(fork)하여 지식 베이스(knowledge base)와 도구(tools)를 여러분의 학교, 동아리, 프로젝트에 맞게 교체해 보세요.
전체 시리즈
- Part 1: 30분 만에 NVIDIA NIM으로 첫 번째 AI 앱 구축하기
- Part 2: 수동 RAG에서 실제 검색으로 — NVIDIA NIM을 활용한 임베딩 기반 RAG (Embedding-Based RAG)
- Part 3: AI 앱이 거짓말을 하지 않도록 가드레일 (Guardrails) 추가하기
- Part 4: 자체 GPU에서 NVIDIA NIM 실행하기
- Part 5: 챗봇에서 에이전트로 — NVIDIA NIM을 활용한 도구 호출 (Tool Calling)
- Part 6: 하나의 도구에서 계획으로 — NVIDIA NIM을 활용한 다단계 에이전트 (Multi-Step Agents)
- Part 7 (본 포스트): 에이전트에게 기억력 부여하기 — NVIDIA NIM을 활용한 멀티 턴 대화 (Multi-Turn Conversations)
전체 시리즈를 한 번에 읽고 싶은 분들을 위해 Medium에 통합된 긴 형식의 버전이 게시되어 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기