저번 글에선 API를 통해 Gemini에게 질문을 하고 답변을 받는 구조를 만들었다. 하지만 저번 글에서 언급했듯 GPT Assistant API 기능을 제외한다면 이전 기억 없이 해당하는 질문에 답변만 하는 깡통이다. 똑같은 질문을 수십번해도 얘 입장에선 늘 새롭고 짜릿한 질문이다.
그렇다면 어떤 방식으로 기억을 제공해줄 수 있을까? 당연하게도 API를 사용하는 입장에서 모델에 변화를 줄 순 없으니 RAG(Retrieval-Augmented Generation) 방식으로 데이터베이스를 따로 제공해주어야 한다. 기억의 복잡한 구조는 추후로 미뤄두고 일단 최근 N개의 기억만이라도 넘겨주는 방식을 만들어보자.
참고로 기본적인 파이썬 명령어는 안다는 가정하에 진행된다.(설명이 안되어있지만 모르는 내용이 있다면 따로 찾아보기를 추천한다. 참고로 필자는 파이썬을 못하는 편이기에 기초적인 것도 설명되어있을 가능성이 높긴하다.)
저장될 기억은 간단하다. index, input, output. 3가지만 저장할 것이다.
index를 저장하는 이유는 디버깅용이기도 하고 최근 N개를 뽑아내기 위함이기도 하고, 데이터가 너무 많아질 경우 적당히 잘라내기 위함이다.
input/output의 구조는 {"user"|"assistant": 대화(str)}로 저장될 것이다.
만약 정상적으로 대답이 온다는 가정 하에 전체적인 형태는
[
{
"id": 0,
"user": "질문",
"assistant": "정말 핵심을 찔렀어"
}
]
형태로 저장할 것이다.
이렇게 쌓인 기억들 중 최근 K개(K는 hyperparameter)를 질문과 함께 보내줄 생각이다.
import json
json에 저장 가능한 종류는
- object: {"key": "value"} == dictinary
- array: [] == list
- string, 숫자(int, float), boolen, null(==None) 이다.
with open(경로, "r", encoding = "utf-8") as fp:
data = json.load(fp)
json파일을 read 모드로 열고 파일 내의 데이터를 읽어낸다.
fp: 출력 대상 파일 객체
*with ... as ...: open후에 close를 자동으로 해준다.
with open(경로, "w", encoding = "utf-8") as fp:
json.dump(data, fp, ensure_ascii = False, indent = 2)
dump: data의 내용을 파일에 덮어씀
data: json에 적을 내용
fp: 출력 대상 파일 객체
ensure_ascii: JSON 출력값을 ASCII문자로 변환 -> 기본값(TRUE)시에 한글이 ASCII로 변환됨
indent: 기본값(None)의 경우 한 줄로 쭉 이어쓴다. [{"a":0, "b":1}]
- 2 or 4: key:value값 별로 들여쓰기가 된다
[
{
"a":0,
"b":1
}
]
단, w로 열어서 파일을 덮어쓰기에 data는 이전에 받아 온 데이터에 추가되는 내용을 계속해서 append해준 후 덮어쓰는방식으로 진행해야 한다.
(a나 r+로 열면 이어쓰기가 가능하지만, 파일이 깨질 수 있다.)
지난시간에 만든 부분을 활용한다. 참고로 코딩은 gpt가 하기에 굉장히 방어적이다.
import json
import os
from google import genai
from google.genai import types
os는 파일 오류를 위해서 import한다.
MEM_PATH = "파일명"
TOP_K = N
instruction = "성격 설정"setting_config = types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_budget=0), # Disables thinking
system_instruction=instruction,
temperature=0.9,
max_output_tokens=400,
stop_sequences=["User:", "사용자:", "\n\n"],
)
경로와 TOP_K를 정한다. TOP_K가 클수록 더 많은 기억을 가져오기에 맥락 이해가 더 잘이루어지지만, 토큰을 많이 먹고 현재의 문맥이 희석될 가능성이 커진다.
그 외의 옵션은 이전과 동일하다.
def load_memory(path):
if not os_path_exists(path):
return []try:
with open(path, "r", encoding = "utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
return []
turn = []
for t in data:
if not isinstance(t, dict):
continue
if "user" not in t or "assistant" not in t:
continue
turn.append({
"id": len(turn),
"user": str(t["user"]),
"assistant": str(t["assistant"]),
})
return turnexcept (json.JSONDecodeError, OSError):
return []
load를 통해 json 파일에서 data를 가져온다 -> 정해진 틀대로 turn에 저장한다.
중간중간의 if, try구문은 예외사항들(파일이 열리지 않음/파일이 list형태가 아님/dictionary형태가 아님/user, assistant가 없음)을 처리한다.
def save_memory(path, memory):
tmp_path = path + ".tmp"
with open(tmp_path, "w", encoding = "utf-8") as f:
json.dump(memory, f, ensure_ascii = False, indent = 2)
os.replace(tmp_path, path)
굳이 tmp를 만든 후 os.replace로 변환한 이유는 혹시나 dump과정 중 프로그램이 강제종료 될 경우 잘못된 형태의 값이 json에 저장되지 않도록 하기 위함이다. (이를 원자적(atomic)이라고 부른다)
*이후에 SQLite로 변경할 경우 이런 과정을 굳이 하지 않아도 알아서 원자적으로 처리된다.
def build_contents(turns, user_input):
contents = []for t in turns:
contents.append(f"User: {t['user']}")
contents.append(f"Assistant: {t['assistant']}")contents.append(f"User: {user_input}")
return "\n".join(contents)
JSON파일에서 데이터를 받아올 땐 Value값만 들고와서 LLM에게 제공하기에 LLM입장에선 해당 내용이 독백인지, 대화인지를 구분하기가 힘들다.
단, 해당 방식도 이후에 들고오는 기억이 많아질수록 역할이 제대로 해석되지 않을 가능성이 존재한다. 이후에 GPT로 갈아탈 경우에는 "role"를 활용해서 더 명확하게 구분해줘야 한다.
def call_gemini(user_input, memory):
recent_memory = memory[-TOP_K:]
user_content = build_contents(recent_memory, user_input)response = client.models.generate_content(
model="gemini-2.5-flash",
contents=user_content,
config=setting_config,
)return response.text.strip()
최종적으로 gemini api를 활용해 답변을 받아올 함수이다.
가장 첫 줄
recent_memory = memory[-TOP K:]
memory의 뒤에서부터 K개를 가져온다는 것만 제외하면 이전과 동일하다.
나머지 부분은 모르겠다면 이전 포스팅을 참고하자.
def main():
memory = load_memory(MEM_PATH)print("대화 시작 (종료는 Ctrl+C) \n")
try:
while True:
user_input = input("Gemini에게 물어보기: ").strip()
if not user_input:
continuereply = call_gemini(user_input, memory)
print(f"Gemini: {reply}\n")new_turn = {
"id": len(memory),
"user": user_input,
"assistant": reply,
}
memory.append(new_turn)
save_memory(MEM_PATH, memory)except KeyboardInterrupt:
print("\n종료")if __name__ == "__main__":
main()
작동을 책임질 메인루프이다. 이전과 가장 달라진 점으론 이전엔 질문 한번을 하면 프로그램이 즉시 꺼졌지만, 이번엔 컨트롤+C(KeyboardInterrupt)를 누르기 전까지는 반복해서 질문할 수 있다.
그 외 과정은 처음 기획한 방향과 동일하다.
메모리를 load하고 -> 유저의 질문을 받고 -> 제미나이에게 질문과 메모리를 함께 넘겨준 후 -> 이번턴의 질문과 답변을 메모리에 추가한다.
결과를 한 번 보자.

가장 처음 턴에 "선생"이라는 호칭을 정한 후, 이후 모든 답변에 선생이라는 호칭이 정상적으로 들어간 모습을 확인할 수 있다.
이유는 모르겠는데 절대로 점심메뉴 추천은 안해준다;;(답변 토큰수가 너무 작은가?)

Json에도 정상적으로 저장된 모습이다.
대조군을 한번 보자.
우선 코드는
import os
from google import genai
from google.genai import typesclient = genai.Client()
instruction = "한국어로 대답해줘. 신난듯한 말투로"setting_config = types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_budget=0),
system_instruction=instruction,
temperature=0.9,
max_output_tokens=400,
stop_sequences=["User:", "사용자:", "\n\n"],
)def call_gemini(user_input):
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=user_input,
config=setting_config,
)return response.text.strip()
def main():
print("대화 시작 (종료는 Ctrl+C) \n")try:
while True:
user_input = input("Gemini에게 물어보기: ").strip()
if not user_input:
continue
reply = call_gemini(user_input)
print(f"Gemini: {reply}\n")
except KeyboardInterrupt:
print("\n종료")if name == "main":
main()
기존 코드에서 기억과 관련된 부분만 제외한 코드이다.

맛이갔다. 직전 대화 내용이나 규칙도 전혀 기억하지 못한다.
간단하게 대화 내역을 제공해서 Gemini API가 기억을 가진 것처럼 구현해보았다. 물론 해당 방식의 경우 처음 정한 TOP_K 턴이 지나간다면 그 이전의 기억은 다시 까먹는 금붕어다(사실 금붕어도 기억력이 좋다는건 이제 모두가 안다).
이를 해결하기 위해선 TOP_K를 압도적으로 늘려서 모든 기억을 가져가면 될 것 같지만, 앞서 언급했듯 TOP_K가 너무 커지면 다른 문제들이 발생한다.
즉 이전 대화를 다 기억하는 것이 아닌, 사람처럼 일부 필요한 기억만 떠올리는 과정이 필수적이다. 그러므로 장기기억을 구현해보려 한다.
단지 그 전에 크게 두가지를 먼저 할 것이다.
현재 만든 단기기억은 장기기억을 만든다고 버려지지 않는다. 애초에 구조만 조금 바꿔서 그대로 활용되어야 한다. 단지 지금처럼 한 파일에 전부 다 구현을 하면 나중에 코드 수정도 까다롭고 가독성도 떨어지기에 파일을 분리할 것이다.
또한 이론적인 부분들을 먼저 짚고 갈 것이다. 물론 뭐 선형대수나 LLM 기초부터 하는 것은 아니고, 필요한 기술만 알아보고 넘어갈 것이다.
퇴근!