대화가 길어져도 채팅 앱의 토큰 비용을 일정하게 유지하는 방법
요약
채팅 앱에서 대화가 길어짐에 따라 발생하는 토큰 비용과 지연 시간 문제를 해결하기 위한 전략을 소개합니다. 롤링 요약(Rolling Summary)과 축자적 윈도우(Verbatim Window)를 결합하여 문맥 유지와 비용 절감을 동시에 달성하는 방법을 다룹니다.
핵심 포인트
- 롤링 요약과 축자적 윈도우 조합으로 문맥 유지 및 비용 최적화
- 메시지 개수가 아닌 토큰 예산 기반의 동적 윈도우 크기 조절
- 프롬프트 오버헤드를 고려한 토큰 마진 확보 전략
모든 채팅 기능에는 동일한 조용한 문제가 있습니다. 첫 번째 메시지는 비용이 거의 들지 않습니다. 하지만 백 번째 메시지는 엄청난 비용이 발생하는데, 그 시점에는 매 턴마다 전체 백로그 (backlog)를 다시 전송하고 있기 때문입니다.
우리는 어시스턴트가 밈 (meme)으로 대답하는 채팅 앱인 Meme Chat AI를 구축했습니다. 대화가 충분히 길어지면 매 답변마다 5천, 1만, 2만 개의 토큰에 달하는 히스토리를 보내기 시작하며, 그중 대부분은 오래되었거나 사용자가 방금 입력한 내용과 무관한 것들입니다. 모델은 여전히 이 모든 것을 읽어야 하고, 당신은 여전히 그 모든 것에 대해 비용을 지불해야 하며, 그동안 지연 시간 (latency)은 계속해서 늘어납니다. 우리가 이에 대해 무엇을 했는지, 그리고 단일 클라이언트가 혼자서 비용을 치솟게 만들지 못하도록 그 앞에 배치한 속도 제한기 (rate limiter)에 대해 설명하겠습니다.
해결책의 형태
단순한 선택지들은 둘 다 좋지 않습니다. 전체 대화 기록을 보내거나 (비용이 무한히 증가함), 마지막 몇 개의 메시지만 보내는 것 (모델이 채팅 초반에 일어난 일을 잊어버림)입니다. 우리는 둘 다 원하지 않았습니다.
우리가 정착한 패턴은 롤링 요약 (rolling summary)과 축자적 윈도우 (verbatim window)의 조합입니다. 모든 프롬프트 (prompt)는 다음과 같은 형태를 띱니다:
[ 안정적인 시스템 / 페르소나 프롬프트 (persona prompt) ]
[ 이전 턴들의 요약 ]
[ 마지막 N개의 턴, 토씨 하나 틀리지 않고 그대로 ]
...
이전 턴들은 버려지지 않습니다. 대신 계속 업데이트되는 요약본 안으로 접혀 들어갑니다. 최근의 턴들은 작성된 그대로 유지되는데, 이는 모델이 다음 메시지에 답변하기 위해 높은 충실도 (fidelity)로 실제로 필요로 하는 부분이기 때문입니다. 그 어떤 것도 소리 없이 사라지지 않습니다. 메시지는 축자적 윈도우 안에 있거나, 요약본 안에 있습니다.
메시지 수가 아닌 토큰 단위로 윈도우 크기 조절하기
우리의 첫 번째 버전은 윈도우를 고정된 메시지 수로 제한했습니다. 그것은 잘못된 조절 방식임이 드러났습니다.
고정된 개수는 모두에게 동일하게 불이익을 주며, 이는 잘못된 사람들에게 불이익을 준다는 것을 의미합니다. 더 높은 티어 (tier)의 사용자는 사용할 수 있는 훨씬 더 큰 입력 예산 (input budget)을 가지고 있으므로, 무료 사용자의 대화처럼 공격적으로 요약을 시작할 이유가 없습니다. 하지만 "마지막 12개의 메시지만 유지"라는 고정된 규칙은 정확히 그렇게 작동했습니다.
따라서 우리는 토큰 예산 (token budget)을 기준으로 윈도우 (window) 크기를 결정합니다. 계획된 입력 허용량에서 모든 프롬프트에 함께 따라붙는 고정 오버헤드 (fixed overhead, 즉 페르소나 프롬프트, 요약 슬롯, 현재 턴)를 뺀 나머지 공간을 원문 메시지 (verbatim tail)가 대부분 채우도록 합니다.
function verbatimBudgetTokens(maxInputTokens: number): number {
const headroom = maxInputTokens - PROMPT_OVERHEAD_TOKENS;
if (headroom <= 0) return 0;
...
여기서 0.85라는 수치는 의도적인 것입니다. 우리의 토큰 계산은 추정치이며, 실제로 비용을 청구하는 것은 제공업체 (provider)의 계산이기 때문입니다. 마진 (margin)을 남겨둠으로써 두 추정치 사이의 미세한 차이가 발생하더라도, 조립된 프롬프트가 모델의 실제 입력 제한을 초과하지 않도록 합니다. 또한 토큰 예산 위에 메시지 개수에 대한 하드 실링 (hard ceiling)을 설정해 두었는데, 이는 순수하게 안전 장치로서 아주 짧은 한 단어짜리 턴 (turn)이 쏟아져 들어와 프롬프트나 데이터베이스 읽기 (database reads)를 팽창시키는 것을 방지하기 위함입니다. 일반적인 사용 환경에서는 토큰 예산이 제한 요소로 작용하며, 메시지 개수 제한은 거의 작동할 일이 없습니다.
절단 (Truncation)은 주 메커니즘이 아닌 보조 수단입니다
요약 (summary)이 장기적인 성장을 관리합니다. 하지만 조립 (assembly) 과정에서 모델로 전달하기 전 마지막 확인을 거칩니다. 프롬프트를 구성하고, 토큰을 계산한 뒤, 만약 어떤 이유로든 예산을 초과한다면 가장 오래된 원문 메시지를 삭제하고 다시 계산합니다. 적합할 때까지 이 과정을 반복합니다.
let current = recent.slice();
let messages = build(current);
let inputTokens = countMessagesTokens(messages);
...
시스템 프롬프트 (system prompt), 요약, 그리고 현재 턴은 삭제 대상이 아닙니다. 이들은 하중을 지탱하는 핵심 요소입니다. 오직 최근 기록의 뒷부분 (recent-history tail)만이 가장 오래된 것부터 차례대로 잘려 나갑니다. 실제로 이 루프는 거의 실행되지 않는데, 이미 윈도우 크기가 그 안에 맞도록 조정되었기 때문입니다. 이 루프는 단일 텍스트 덩어리가 붙여넣어져 추정치를 초과하는 예외적인 상황을 위해 존재하며, API가 거부할 프롬프트를 절대 전달하지 않도록 보장합니다.
가장 저렴한 토큰은 다시 보내지 않는 토큰입니다
미묘하게 비용을 부풀리는 원인 중 하나는 첨부 파일(attachments)이었습니다. 사용자가 이미지나 GIF를 보낼 때, 해당 턴(turn)은 비용이 많이 듭니다. 정지 영상 하나만으로도 이미지 부분에 수백 개의 토큰이 소모될 수 있으며, 프레임으로 샘플링되는 GIF의 경우 그 몇 배에 달할 수 있습니다. 모델은 이미지가 도착하는 해당 턴에는 이 모든 정보가 필요하지만, 5턴 뒤에는 그것이 필요하지 않습니다.
따라서 첨부 파일이 포함된 턴이 히스토리(history)로 넘어가면, 픽셀 데이터를 다시 보내는 대신 짧은 텍스트 플레이스홀더(placeholder)로 압축합니다:
// 한때 이미지를 포함했던 과거의 턴
"[User sent an image]"
이렇게 하면 모델은 매번 시각적 토큰 비용을 지불하지 않고도 "사용자가 여기서 무언가를 보여주었다"는 맥락을 유지할 수 있습니다. 실제 이미지 데이터는 오직 현재 턴에서만 허용됩니다.
캐싱(caching)에 대해 알아두어야 할 두 가지
두 가지 설계 선택은 프롬프트 캐시(prompt cache)에 관한 것입니다. 현재 대부분의 제공업체는 이전에 확인한 토큰에 대해 대폭 할인된 가격을 책정하고 있습니다.
첫째, 크고 정적인 페르소나 프롬프트(persona prompt)를 가장 앞에 배치하여 모든 턴과 모든 사용자에게 바이트 단위로 동일하게(byte-identical) 유지합니다. 사용자별 정보(이름, 언어, 사용자별 메모리 등)는 그 뒤의 두 번째 블록에 위치하므로, 비용이 많이 드는 캐싱 가능한 접두사(prefix)는 사용자마다 형태가 변하지 않습니다.
둘째, 요약(summary)은 실제로 다시 요약할 때만 변경됩니다. 요약이 안정적인 동안에는 [persona][summary] 접두사가 턴 사이에서도 캐싱 가능한 상태로 유지됩니다. 이것이 우리가 매 메시지마다 다시 요약하지 않는 이유이기도 합니다. 우리는 이를 배치(batch) 처리합니다. 백그라운드 요약기(background summarizer)는 턴의 개수나 토큰 양이 충분히 쌓였을 때만 오래된 턴들을 요약에 통합합니다. 끊임없이 다시 요약하는 것은 요약 길이를 아주 조금 줄이기 위해 접두사를 계속 변화시켜 캐시 히트(cache hits)를 버리는 행위이며, 이는 좋지 않은 거래입니다.
요약기 자체는 요청 경로(request path)와 분리되어, 더 저렴한 유틸리티 모델(utility model)을 통해 백그라운드 작업(background job)으로 실행됩니다. 사용자의 답변은 요약 작업이 완료될 때까지 기다리지 않습니다.
Rate limiting, 지루함을 유지하기
토큰 규율 (Token discipline)은 대화당 비용을 제어합니다. 이는 클라이언트가 엔드포인트(endpoint)를 무차별적으로 공격하는 문제에 대해서는 아무런 조치를 취하지 않습니다. 이를 위해 우리는 스트리밍 함수 (streaming function) 앞에 IP당 작은 제한기 (limiter)를 배치했으며, 새로운 인프라를 구축하는 대신 이미 보유하고 있던 데이터베이스를 활용했습니다.
이는 고정 윈도우 (fixed window) 방식입니다. IP당 시간당 문서 1개, 원자적 증가 (atomic increment), 카운트가 임계값을 넘으면 거부하는 방식입니다.
const hourBucket = Math.floor(Date.now() / WINDOW_MS);
const docId = `${ipKey(ip)}_${hourBucket}`;
...
알고리즘보다 더 중요한 몇 가지 세부 사항은 다음과 같습니다:
IP는 저장소에 닿기 전에 해싱 (hashing)되므로, 클라이언트의 원본 주소 로그를 보관하지 않습니다. 버킷 (bucket)에는 expireAt이 포함되어 있어, TTL (Time To Live) 정책이 오래된 문서를 정리하므로 컬렉션 (collection)이 무한히 커지지 않습니다. 또한, 키로 사용할 IP가 없거나 로컬에서 실행 중일 때는 제한기가 'fail open'(제한을 적용하지 않고 통과)되도록 설계되어, 단일 localhost 주소로 개발할 때 몇 분마다 제한에 걸리는 일이 발생하지 않습니다. 비용은 요청당 한 번의 읽기(read)와 한 번의 쓰기(write)이며, 이는 LLM 호출에 비하면 매우 저렴합니다.
고정 윈도우에는 알려진 약점이 있습니다. 클라이언트가 1:59에 한 윈도우 분량의 요청을 모두 보내고, 2:00에 또 다른 한 윈도우 분량의 요청을 보낼 수 있다는 점입니다. 슬라이딩 윈도우 (sliding window)나 토큰 버킷 (token bucket) 방식은 이를 완만하게 만들어 줍니다. 우리의 트래픽 규모에서는 단순한 버전이 적절한 엔지니어링 수준이었으며, 상위 단계의 아무것도 건드리지 않고도 나중에 언제든지 제한을 강화할 수 있습니다.
이를 통해 얻은 것
긴 대화가 선형적으로 더 비싸지는 현상이 멈췄습니다. 턴 (turn)당 비용은 메시지 수에 따라 상승하는 대신, 요금제 예산에 의해 설정된 범위 내에서 평탄화되었습니다. 오래된 컨텍스트 (context)는 사라지는 대신 요약본으로 생존하고, 최근의 컨텍스트는 정확하게 유지되며, 페르소나 프롬프트 (persona prompt)는 여러 턴에 걸쳐 캐시 (cached)된 상태로 유지됩니다. 속도 제한기 (rate limiter)는 단 한 번의 추가 읽기와 쓰기 비용으로 개별 클라이언트가 미칠 수 있는 피해 범위 (blast radius)를 제한합니다.
이 중 어느 것도 생소한 것은 아닙니다. 요약 버퍼 (summary buffer), 토큰 예산 (token budget), 오래된 첨부 파일에 대한 플레이스홀더 (placeholder), 그리고 데이터베이스의 카운터 (counter)일 뿐입니다. 유용했던 부분은 확장 (scale)의 기준으로 토큰 예산을 선택한 것과, 캐시 접두사 (cache prefix)를 사후 고려 사항이 아닌 보호해야 할 대상으로 취급한 것이었습니다.
이 모든 기능은 실제 서비스 중인 Meme Chat AI에서 작동하고 있으니, 결과물이 어떻게 구현되었는지 확인하고 싶다면 방문해 보시기 바랍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기