AI 기억 보강, 이력 몰아넣지 말고 세 가지 방법 선택
다중 대화에서 모델이 이전 발화를 기억하지 못하는 문제를 두고, 이력 전체를 프롬프트에 넣는 대신 잘라내기·요약·검색 세 방법의 선택 기준을 다룬다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
다중 턴 대화를 두 번째 턴까지 진행해 보면 바로 알게 된다: 모델이 직전 문장을 전혀 기억하지 못한다는 것을. 해결책도 아주 직관적이다——매번 히스토리 메시지 배열을 다시 컨텍스트에 집어넣는 것. 하지만 세 번째, 열 번째 턴 이후에는 히스토리가 점점 쌓이고, 컨텍스트 윈도우는 한정되어 있으며 token에는 비용이 든다. "무작정 전량 집어넣기"라는 방법은 오래 갈 수 없다. 실제로 경험을 좌우하는 것은 초과 후의 처리 전략이다: 절단(截断), 요약(总结), 검색(检索) 세 가지 길은 각기 다른 비용 모델을 가진다. 이 글은 하나의 LangChain Memory 학습 프로젝트(소스 파일 9개)를 바탕으로, 세 가지 길 각각의 최소 구현을 뜯어서 보여주고, 마지막에 Milvus 검색식 메모리의 완전한 읽기·쓰기 폐루프까지 함께 제시한다. 코드는 정적으로 정리한 것이며, 실행은 검증되지 않았다. Milvus는 로컬 localhost:19530이 필요하고, 모델은 .env의 API_KEY에 의존한다.
먼저 전제를 분명히 하자: 메모리 = 매번 히스토리를 다시 보내는 것
노트의 한 문장이 본질을 찔러 꿰뚫는다(readme.md L8): "대형 모델은 무상태(stateless)이므로, 지난번 문답을 기반으로 계속 물어보고, 답한다." 그러므로 "메모리"의 구현은 바로 메시지 배열의 유지 루프다(src/history.mjs):
const history = new InMemoryChatMessageHistory() await history.addMessage(new HumanMessage("너 오늘 뭐 먹어?")) const messages = [systemMessage, ...(await history.getMessages())] const response = await model.invoke(messages) await history.addMessage(response) // AI 응답도 다시 저장
이것은 임시 기억이다: 프로세스 메모리에 있으므로 재시작하면 사라진다. 파일로 바꾸면 장기 기억이 된다(src/history2.mjs):
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system' const history = new FileSystemChatMessageHistory({ filePath: path.join(process.cwd(), 'chat_history.json'), sessionId: "user_session_001", // 다중 사용자 구분 })
임포트 경로가 core가 아니라 @langchain/community라는 점에 주목하자——자료 작성자가 이 함정을 겪었다(core에서 임포트하면 not exported 오류 발생). history3.mjs는 영속화를 검증했다: 동일한 파라미터로 새 인스턴스를 만들고 getMessages()로 히스토리를 복원하면, 세 번째 턴 대화가 매끄럽게 이어진다.
노선 1: 절단——비용은 극히 낮지만 대화를 토막 낸다
두 가지 단위(입도)(src/memory/truncation-memory.mjs):
// 개수 기준: 한 줄 slice const trimmedMessages = allMessages.slice(-4) // token 기준: langchain의 trimMessages const enc = getEncoding('cl100k_base') const trimmedMessages = await trimMessages(allMessages, { maxTokens: 100, tokenCounter: async (messages) => countTokens(messages, enc), strategy: 'last', // 최근 것을 남김 })
소스 주석이 핵심을 짚는다: "모델마다 token 계산 방식이 다르다", 그래서 tokenCounter는 모델에 맞춰 커스터마이즈해야 한다(여기서는 js-tiktoken의 cl100k_base로 항목마다 encode하여 누적한다). 절단의 문제도 직관적이다: slice는 "내 이름은 이사야"와 "안녕 이사야"라는 문답 쌍을 갈라놓을 수 있어, 컨텍스트가 토막 난다.
노선 2: 요약——호출 한 번을 더 써서 정보 보존을 얻는다
잘려나간 옛 메시지를 그냥 버려서는 안 된다. 모델이 먼저 한 번 소화하게 할 수 있다(src/memory/summarization-memory.mjs, 6개 초과 시 발동, 최근 2개 유지):
// ① 옛 메시지 배열 → 대화 문자열 const conversationText = getBufferString(messagesToSummarize, '사용자', '어시스턴트') // ② 모델에게 요약시키기 const summary = `다음 대화의 핵심 내용을 요약하고, 중요한 메시지는 보존해 주세요: ${conversationText}\n요약:` const summaryResponse = await model.invoke([new SystemMessage(summary)]) // ③ 비우기 → ④ 최근 메시지 다시 저장 → ⑤ 요약 다시 저장 await history.clear() for (const message of recentMessages) await history.addMessage(message) await history.addMessage(new AIMessage(summaryResponse.content)) // 요약을 AIMessage로
소스 주석에서 외워둘 만한 디테일 두 가지:
- model.invoke()는 날 문자열을 받지 않는다 ——메시지 객체 배열만 받으며, 각 항목은 반드시 "누가 말했는지"를 표시해야 한다.
- token 버전(summarization-memory2.mjs)은 maxTokens=200으로 발동하며, 역순으로 누적하여 keepRecentTokens=80인 최근 메시지를 채운다. 주석은 clear / 요약 재저장을 /clear와 /compact에 대응시킨다.
세 가지 길의 비용 모델:
전략 | 발동 | 비용 | 정보 보존 | 구현 절단 | 개수/token 초과 | 극히 낮음 | 옛 메시지 폐기 | slice / trimMessages 요약 | 개수(6)/token(200) 초과 | 모델 호출 한 번 추가 | 요약이 요점 보존 | getBufferString→invoke→clear 검색 | 매 턴 문답 | embedding + 벡터 검색 | 관련도순으로 되가져옴 | Milvus search→prompt 주입
노선 3: 검색——세션을 넘나들며 필요할 때 되가져오기, Milvus 폐루프
절단과 요약은 모두 "최근 몇 개"에 갇혀 있다. 아주 오래전에 나눈 "제 직업은 소프트웨어 엔지니어입니다"를, 열 턴 뒤에 "제 직업이 뭐죠"라고 다시 물으면 되가져올 수 없다. 검색식 메모리의 해법: 히스토리를 벡터 DB에 저장하고, 답변 전에 관련도순으로 검색해 주입한다.
DB 구축(src/memory/insert-conversation.mjs): 컬렉션 conversation의 다섯 필드——id(VarChar 기본키), vector(FloatVector, dim=1024), content(VarChar 5000), round(Int64), timestamp(VarChar 100, Milvus에는 datetime 타입이 없으므로 소스 주석이 일부러 이 점을 표시했다). 벡터 필드에는 IVF_FLAT + COSINE 인덱스를 만든다. 삽입 전에 각 대화 텍스트를 먼저 embedQuery로 1024차원 벡터로 변환한다.
폐루프(src/memory/retrieval-memory.mjs 매 턴 반복):
// 읽기: 검색 주입 const queryVector = await getEmbedding(input) const searchResult = await client.search({ collection_name: 'conversation', vector: queryVector, metric_type: MetricType.COSINE, limit: 2, output_fields: ['id', 'content', 'round', 'timestamp'], }) // '관련 히스토리 세션 + 사용자 질문'으로 조립 const contextMessages = relevantHistory ? [new HumanMessage(`관련 히스토리 세션:\n${relevantHistory}\n\n사용자 질문: ${input}`)] : [userMessage] const response = await model.invoke(contextMessages) // 쓰기: 대화 되쓰기 const conversationText = `사용자: ${input}\n어시스턴트: ${response.content}` const convId = `conv_${Date.now()}_${i+1}` await client.insert({ collection_name: 'conversation', data: [{ id: convId, content: conversationText, round: i+1, timestamp: new Date().toISOString(), vector: await getEmbedding(conversationText) }], })
읽기와 쓰기 각각 한 단계: 답변 전에는 query를 벡터화하고, COSINE top-2를 뽑아 prompt를 조립한다. 답변 후에는 이번 턴 대화 텍스트를 벡터화하여 conv_타임스탬프_턴 을 id로 되쓴다. 이것이 바로 노트에서 계획한 방향이다(readme.md L50-51: "20개 대화할 때마다 요약을 한 번 발동하고, 요약을 생성하여 milvus 벡터 데이터베이스에 저장")——현재 구현은 매 턴마다 쓰는 것이며, 요약 발동 조건은 아직 추가되지 않았다.
정리: Milvus 폐루프 자체 점검 리스트
- Milvus 로컬 19530이 기동되어 있음(실행 미검증)
- 컬렉션의 다섯 필드가 완비되었고, vector 차원이 embedding 출력과 일치(1024)
- IVF_FLAT + COSINE 인덱스가 생성되었고 loadCollection됨
- search의 output_fields에 content/round/timestamp 포함
- 되쓰기 id가 유일(타임스탬프+턴)
- 검색 결과가 비었을 때 fallback은 원 질문만 전송
마무리: 즉시 실행할 수 있는 점검 하나
전이 가능한 판단: 세 가지 길은 상호 배타적 선택지가 아니라 계층적으로 조합하는 것이다——메모리는 이번 턴을 관리하고, 요약은 압축을 관리하며, 벡터 DB는 세션 간을 관리한다. 진짜 경험을 결정하는 것은 발동 조건과 비용이다(절단은 거의 무료, 요약은 호출 한 번 추가, 검색은 embedding 한 번 추가). 지금 당장 할 수 있는 일 하나: 당신의 채팅 앱을 열어 실제 대화 한 번의 token 총량을 세어 보고, 그것이 모델 상한에서 얼마나 멀리 떨어져 있는지 보라——이 숫자가 당신이 절단을 먼저 올릴지, 아니면 요약+검색을 바로 올릴지를 결정한다. 이 글의 코드는 정적 정리이며, 실행은 검증되지 않았다.
태그: LangChain, Memory, Milvus, 벡터 데이터베이스
BreezeJiang
83
글
5.3k
읽음
29
팔로워