별친구 대화에 멀티턴 맥락과 기억 검색 붙이기

2026. 6. 4. 22:48·IL/TIL

오늘은 Polaris의 별친구 대화 기능을 고도화했다.

기존에는 사용자가 별친구에게 말을 걸면 그 순간의 캐릭터 상태와 일부 context만 보고 답변하는 구조였다. 즉, "나 오늘 회사 다녀와서 너무 힘들었어"라고 말한 뒤에 다시 "아까 내가 뭐라고 했는지 기억해?"라고 물어도, 진짜로 기억하는 대화처럼 이어지기 어려웠다.

처음에는 그냥 최근 메시지를 prompt에 몇 개 붙이면 되지 않을까 싶었는데, 생각보다 고민할 게 많았다. 이건 단순 과제용 기능이 아니라 실제 운영 중인 서비스에 들어가는 기능이라서, 비용, 개인정보, DB 보관 정책, UX, 문서 정합성까지 같이 생각해야 했다.


오늘 구현한 것

이번 작업의 핵심은 별친구 대화를 "단발성 AI 응답"이 아니라 "세션 기반 멀티턴 대화"로 바꾸는 것이었다.

구현한 큰 범위는 다음과 같다.

  • 별친구 대화 세션 저장
  • 최근 대화 맥락을 prompt에 포함
  • 오래된 대화는 요약 기억으로 변환
  • pgvector 기반 기억 검색
  • SSE 응답에 세션/기억/토큰 메타데이터 추가
  • AI provider가 내려주는 실제 token usage 저장
  • PRD, API 명세, ERD, 정책 문서 v1.0 기준으로 정리

왜 멀티턴이 필요했나

별친구 대화는 그냥 챗봇이 아니라, 사용자가 키우는 캐릭터와 대화하는 기능이다.

그래서 사용자가 이런 식으로 말할 수 있다.

나 오늘 회사 다녀와서 너무 힘들었어

그 다음에 다시 이렇게 물어볼 수도 있다.

아까 나 회사 다녀와서 힘들었다고 한 거 기억해?

이때 별친구가 그냥 일반적인 위로만 하면 "AI 답변"처럼 느껴진다. 반대로 이렇게 답하면 훨씬 관계감이 살아난다.

무... 무! (해석: 무무가 기억하고 있다고 하네요. 아까 회사 다녀와서 많이 힘들었다고 했잖아요. 지금은 조금 괜찮나요?)

이 차이가 꽤 크다.

루틴 서비스에서 사용자가 다시 돌아오게 만드는 건 기능의 개수보다 "얘가 나를 조금은 기억하고 있네"라는 느낌일 수도 있다고 생각했다.


제일 많이 고민한 부분

1. 대화 원문을 저장해도 되는가

처음에는 대화 원문을 저장하지 않는 방향으로 생각했었다. 그게 개인정보 측면에서 가장 안전해 보였기 때문이다.

그런데 멀티턴 대화를 하려면 최소한 최근 대화는 저장해야 한다. 그렇지 않으면 사용자가 방금 한 말을 기억할 수 없다.

그래서 정책을 이렇게 잡았다.

  • 원문 메시지는 단기 멀티턴 맥락 용도로만 저장
  • prompt에는 최근 6턴만 포함
  • 원문 메시지는 24시간 보관
  • 장기 기억은 원문 전체가 아니라 요약본만 저장
  • 만료된 세션은 요약 후 memory로 변환

즉, "모든 대화를 영구 저장"하는 게 아니라 대화 경험에 필요한 만큼만 짧게 저장하고, 장기적으로는 요약 기억만 남기는 방식이다.


2. 최근 대화를 몇 턴까지 넣을 것인가

무작정 대화 전체를 prompt에 넣으면 비용이 계속 늘어난다. 대화가 10턴, 50턴, 100턴 쌓일수록 token 사용량이 계속 커진다.

그래서 최근 6턴만 prompt window로 사용하기로 했다.

최근 6턴만 prompt에 포함
오래된 대화는 요약 memory 검색으로 보완

이렇게 하면 비용을 어느 정도 고정할 수 있고, 오래된 이야기는 필요할 때 검색해서 꺼내는 구조로 가져갈 수 있다.


3. token을 추정해서 저장할지, 실제값만 저장할지

처음에는 token 사용량을 추정해서 저장하는 것도 생각했다. 하지만 곰곰이 생각해 보니 운영 레포에서 근거 없는 추정값을 DB에 저장하는 건 위험했다.

추정값은 실제 billing 기준도 아니고, 모델이나 토크나이저가 바뀌면 의미가 쉽게 깨진다. 나중에 대시보드에서 그 값을 보면 진짜 비용처럼 착각할 수도 있다.

그래서 최종 정책은 이렇게 잡았다.

AI provider가 실제 usage metadata를 내려주면 저장한다.
실제값이 없으면 null로 둔다.
추정 token 값은 저장하지 않는다.

이번 구현에서는 Spring AI 응답에서 provider usage metadata를 꺼내서 actualPromptTokens, actualCompletionTokens, actualTotalTokens로 내려주도록 했다.

실제 smoke test에서는 이런 값이 내려왔다.

prompt=6544
completion=59
total=6603

이걸 보고 나서야 "아, 이건 실제로 추적할 수 있겠다"는 감이 왔다.


DB 설계

이번에 추가한 테이블은 크게 3개다.

character_talk_sessions

대화 세션 단위의 정보를 저장한다.

  • sessionId
  • userId
  • characterId
  • status
  • expiresAt
  • messageCount
  • actual token 누적값
  • summaryCreatedAt

세션은 사용자 + 캐릭터 단위로 격리한다. 같은 사용자가 무무와 대화한 내용이 노바 대화에 섞이면 안 되기 때문이다.

character_talk_messages

세션 안의 사용자/assistant 메시지를 저장한다.

이 테이블은 장기 보관용이 아니라 최근 대화 맥락을 만들기 위한 단기 저장소다.

정책은 다음과 같다.

최근 6턴만 prompt에 사용
원문 메시지는 24시간 보관
장기 기억은 summary로만 남김

character_talk_memories

만료된 대화 세션을 요약해서 저장한다.

여기에는 요약문과 embedding vector를 저장한다. 사용자가 "아까 말한 거 기억해?"처럼 물어보면, 현재 메시지를 embedding으로 바꾸고 과거 memory와 유사도를 비교해서 관련 기억을 찾는다.

이번에는 gemini-embedding-001 기준 768차원 vector를 사용했다.


SSE 응답 구조

프론트에서는 SSE로 대화를 받는다.

이벤트는 크게 이렇게 나뉜다.

meta
delta
done
error

예시는 이런 식이다.

event: meta
data: {
  "requestId": "...",
  "sessionId": "talk_...",
  "newSession": false,
  "historyWindowTurns": 6,
  "memorySearchTopK": 3,
  "memoryHitCount": 1
}

event: delta
data: {
  "text": "무... 무!"
}

event: delta
data: {
  "text": " (해석: 무무가 기억하고 있다고 하네요."
}

event: done
data: {
  "requestId": "...",
  "fallbackUsed": false,
  "actualPromptTokens": 6544,
  "actualCompletionTokens": 59,
  "actualTotalTokens": 6603,
  "memoryHitCount": 1
}

delta는 프론트에서 순서대로 이어 붙이면 된다. meta에는 세션 정보와 기억 검색 정보가 들어가고, done에는 fallback 여부와 실제 token usage가 들어간다.


smoke test

테스트는 실제 사용자 시나리오처럼 해봤다.

1차 대화

너 지금 뭐 해?

별친구가 일반적인 현재 상태 기반 답변을 했다.

2차 대화

나 오늘 회사 다녀와서 너무 힘들었어

무무가 힘들었겠다고 위로하는 답변을 했다.

3차 대화

아까 나 회사 다녀와서 힘들었다고 한 거 기억해?

이때 memory 검색이 걸렸고, memoryHitCount=1이 내려왔다.

응답도 의도한 방향으로 나왔다.

무... 무! (해석: 무무가 기억하고 있다고 하네요. 아까 회사 다녀와서 많이 힘들었다고 했잖아요. 지금은 조금 괜찮나요?)

이걸 보고 이번 기능의 방향이 맞다고 느꼈다. 단순히 "위로 문장 생성"이 아니라, 사용자의 이전 말을 이어받는 느낌이 생겼다.


문서도 같이 업데이트했다

이번에는 코드만 고치고 끝내지 않으려고 했다. 이전에도 문서와 코드가 어긋나서 헷갈린 적이 많았기 때문이다.

그래서 아래 문서도 같이 맞췄다.

  • PRD
  • API 명세
  • ERD
  • backend policy summary

특히 기존 문서에는 "대화 원문은 저장하지 않는다"라는 문구가 있었는데, 이제는 실제 정책과 맞지 않아서 제거했다.

대신 이렇게 정리했다.

원문 메시지는 단기 멀티턴 맥락 용도로 제한 저장한다.
장기 기억은 원문 전체가 아니라 요약 memory로 남긴다.
실제 token usage가 있을 때만 저장하고, 추정값은 저장하지 않는다.

오늘 느낀 점

멀티턴 대화는 그냥 "채팅 기록을 저장한다"가 아니었다.

생각해야 할 게 많았다.

  • 얼마나 저장할 것인가
  • 얼마나 prompt에 넣을 것인가
  • 오래된 대화는 버릴 것인가, 요약할 것인가, 검색할 것인가
  • token 비용은 어떻게 추적할 것인가
  • 사용자가 "기억해?"라고 물었을 때 진짜 기억하는 것처럼 느껴지게 하려면 어떻게 해야 하는가
  • 개인정보와 UX 사이에서 어디까지 저장해야 하는가

처음에는 너무 일이 커지는 것 같아서 머리가 아팠는데, 정책을 하나씩 닫으니까 구조가 보이기 시작했다.

이번 작업에서 가장 크게 배운 건 이거다.

운영 기능은 "동작한다"보다 "어디까지 저장하고, 언제 버리고, 실패하면 어떻게 보이고, 문서에는 어떻게 남길지"까지 정해야 닫힌다.

그리고 AI 기능은 특히 더 그렇다. AI가 답을 잘하는 것도 중요하지만, 비용과 실패, 개인정보, fallback, 관측 가능성까지 같이 설계해야 한다.

오늘은 진짜 빡셌지만, 별친구가 조금 더 "살아 있는 캐릭터"에 가까워진 것 같아서 뿌듯했다.

저작자표시 비영리 (새창열림)

'IL > TIL' 카테고리의 다른 글

프론트... 백엔드... 매핑이란 멀까  (0) 2026.06.05
알림 및 장애 대응 Runbook  (1) 2026.06.02
RAG 개인화 리뷰... 튜터님의...?  (0) 2026.06.01
AI 외부 Provider 호출 보호와 Fallback 설계  (0) 2026.05.29
최종프로젝트 인프라 모니터링 설계  (0) 2026.05.28
'IL/TIL' 카테고리의 다른 글
  • 프론트... 백엔드... 매핑이란 멀까
  • 알림 및 장애 대응 Runbook
  • RAG 개인화 리뷰... 튜터님의...?
  • AI 외부 Provider 호출 보호와 Fallback 설계
견지
견지
개발로 개발하는지 새발로 개발하는지 내가 개인 건지 새인 건지 사람인 건지
  • 견지
    개발새발
    견지
  • 전체
    오늘
    어제
    • 분류 전체보기 (33)
      • ... (0)
      • IL (33)
        • TIL (29)
        • WIL (4)
        • MIL (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    HTML
    DB
    oracle
    JSP
    JavaScript
    java
    CSS
    git
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
견지
별친구 대화에 멀티턴 맥락과 기억 검색 붙이기
상단으로

티스토리툴바