'이전 대화가 쌓였습니다'의 오지목: 토큰 예산 계층을 고쳤더니 공유 GPU가 느려진 이유와 원복
'이전 대화가 쌓였다'는 안내만 반복하던 에이전트를 토큰 버킷 로그로 추적해 문구의 오지목, 줄일 수 없던 시스템 프롬프트·도구 스키마, 오히려 텍스트를 늘리던 truncate를 고쳤습니다. 그 수정이 잠들어 있던 압축 경로를 깨워 공유 GPU 지연을 만든 메커니즘을 확인해 원복했고, 재현으로 근본 압력이 검색 결과 크기에 있음을 확인했습니다.
Key Results
- 2,701 → 184,825 토큰
- 사전 예산 검사 추정 대비 실제 입력원복 후 같은 구성을 128K 윈도우 모델로 재현, 검색 도구 4회 병렬 결과 포함, 제공자 400 응답에 찍힌 실측치
- 100,000자 → 119,706자
- 자르라는 호출이 반환한 길이truncate 결함 재현, 상한 119,695토큰 지정
- 32,911 토큰
- 질문 없이 도구 스키마만의 입력로컬 하네스 스택 토큰 예산 로그
은행 폐쇄망에서 운영 중인 XGEN 에이전트 하나가 답변 대신 "입력이 모델이 한 번에 처리할 수 있는 양을 넘어 응답하지 못했습니다. 이전 대화 맥락이 많이 쌓였습니다. 새 대화로 시작해 주세요."라는 안내만 반복한다는 제보가 들어왔습니다. 제보자는 멀티턴 DB 메모리 노드를 의심해 지워 봤지만 증상은 그대로였고, 모델 문제도 아닌 것 같은데 어딘가에서 대화가 계속 쌓이는 느낌이 든다고 했습니다.
이 글은 그 안내 문구가 범인을 잘못 지목하고 있었다는 것을 토큰 버킷 로그로 확인하고, 에이전트 런타임의 토큰 예산 계층에서 줄일 수 없던 축과 오히려 텍스트를 늘리던 truncate 함수를 고친 과정을 다룹니다. 이어서 그 수정이 배포 후 공유 GPU의 지연을 만들어 전부 원복하게 된 경위와, 원복 뒤 같은 구성을 재현해 초과의 근본 압력이 어디에 있는지 확인한 과정까지 씁니다. 결함을 고친 것이 잠들어 있던 경로를 깨운 사례라, 수정의 정확성과 운영 영향이 어떻게 갈릴 수 있는지를 함께 정리합니다.
토큰 예산 계층의 구조
에이전트 노드는 LLM 호출 전에 입력을 버킷별로 추정합니다. 시스템 프롬프트(sys), 도구 스키마(tools), 사용자 텍스트(text), RAG·파일(rag), 대화 메시지(hist)입니다. 추정치에는 provider별 보정계수가 실리고(이 건의 경로에서는 1.26), 모델 윈도우는 vLLM의 /v1/models가 알려 주는 max_model_len이나 모델 카탈로그 값으로 정합니다. 예산을 넘으면 예산 집행 함수가 대화 메시지, RAG·파일, 사용자 텍스트 순서로 줄이고, 6월 30일부터는 대화 기록을 요약 LLM으로 압축하는 경로도 있었습니다.
LLM 호출 전
버킷 추정: sys · tools · text · rag · hist ([TOKEN_BUDGET] 로그)
예산 초과 시 축소: 대화 메시지 → RAG·파일 → 사용자 텍스트
(+ 요약 LLM으로 대화 기록 압축)
그래도 초과 → 안내 문구 (가장 큰 버킷을 지목)운영 조건도 중요했습니다. 폐쇄망은 vLLM 한 대를 여러 사용자가 공유합니다. 한 실행의 대형 프리필이 다른 사용자의 응답 시간에도 영향을 주는 구조입니다.
증상: 첫 질문에서도, 됐다 안 됐다
조사하면서 두 가지 사실이 더 확인됐습니다. 증상은 첫 질문에서도 났고, 같은 에이전트에서 됐다 안 됐다 했습니다. 대화가 누적되는 문제라면 상태는 단조롭게 나빠져야 하고 저절로 좋아지지 않습니다. 그래서 이 증상을 "입력이 한도 경계선에 걸려 있다"는 신호로 읽었고, 질문마다 크기가 달라지는 RAG 검색 결과와 도구 응답을 첫 번째 용의자로 놓았습니다.
제보자의 "쌓이는 느낌"도 그냥 넘기지 않고 확인했습니다. 실행 입출력 기록에서 한 interaction의 최근 15행 합계는 314,146자였고 단일 행 최대는 113,840자였는데, 1행짜리 interaction도 153,473자였습니다. 턴 수가 아니라 행 하나의 크기가 문제였습니다. interaction id가 어디서 고정되는지도 진입점별로 확인했습니다.
| 진입점 | id 생성 | 새 대화 시 |
|---|---|---|
| 채팅하기 / 새 대화 | 시각 + 랜덤 | 새로 생성 |
| 배포 챗봇 | 브라우저 저장소에 영구화 | 유지 |
| 배포 설정 봇 테스터 | 워크플로우 + 사용자로 고정 | 영구 고정 |
| 스케줄 실행 | 스케줄 고유 id | 유지 (74턴 / 72일 누적 확인) |
DB에서도 같은 워크플로우가 interaction id 87개에 87턴, 160개에 179턴으로 나타나 일반 채팅은 매번 새 id를 쓴다는 것이 확인됐습니다. 3일 이상 한 id에 쌓인 경우는 전체 6건이었고 그중 3건이 스케줄러였습니다. 저장된 워크플로우 286개 중 메모리 노드가 있는 54개도 확인했는데, interaction id가 저장값에 박제된 경우는 0건이었습니다. 누적 경로는 실재했지만 이 건의 직접 원인은 아니었습니다. 제보자의 가설은 절반만 맞았고, "모델 문제가 아니다"라는 판단은 맞았습니다.
원인 다섯 가지
문구가 범인을 잘못 지목했습니다. 안내 문구는 버킷 중 최댓값 하나로 정해졌습니다. 시스템 프롬프트 버킷은 시각 정보와 지시문 때문에 항상 0이 아니므로, 도구나 RAG가 진짜 원인이어도 다른 버킷이 작으면 sys가 1등이 되어 "이전 대화 맥락"으로 안내했습니다. 정상 실행 로그조차 total=285 (sys=283 tools=0 text=2 rag=0 hist=0)였습니다. 이 상태에서 초과가 나면 무조건 그 문장이 나옵니다. 이 문구 때문에 며칠 동안 대화 누적과 멀티턴 메모리를 쫓았습니다.
sys는 서로 다른 것을 뭉뚱그리고 있었습니다. sys 버킷에는 지시문, 가드·가이드, 과거 대화 컨텍스트가 함께 들어 있었습니다. 같은 sys 초과라도 지시문이 큰 것인지 과거 대화가 큰 것인지 구분할 수 없었습니다.
시스템 프롬프트는 어느 단계에서도 줄지 않았습니다. 예산 집행은 대화 메시지, RAG·파일, 사용자 텍스트만 줄였습니다. 과거 대화 컨텍스트는 시스템 프롬프트를 만들 때 뒤에 추가된 뒤로는 축소 대상이 아니었고, 오히려 대화 요약본이 거기에 더해졌습니다. sys 단독으로 예산을 넘기면 질문을 1토큰으로 잘라도 실패하므로 그 대화는 매 턴 영구 실패합니다. 코드는 이 상태를 알고 "overhead+hist+rag 만으로 예산 초과, 사용자 입력 공간 없음"이라는 로그까지 남기면서 그대로 호출하고 있었습니다.
도구 스키마도 축소 경로가 없었습니다. 로컬 하네스 스택의 토큰 예산 로그에서 도구 스키마만 32,911토큰인 에이전트가 있었습니다. 질문 없이 3만 토큰에서 시작하는 셈입니다.
truncate 함수가 텍스트를 늘렸습니다. 가장 의외였던 원인입니다. 토큰 상한은 보정계수가 실린 추정치 단위인데, tiktoken 분기는 그 값을 raw 토큰 인덱스로 그대로 써서 앞뒤를 잘랐습니다. 앞쪽과 뒤쪽 구간의 합이 실제 토큰 수보다 크면 두 구간이 겹쳐 가운데가 중복됩니다. 100,000자를 상한 119,695토큰으로 자르라고 호출하자 119,706자가 반환됐습니다. 기존 RAG·파일 truncate와 사용자 텍스트 truncate에도 같은 결함이 있었고, 줄이려고 부른 함수가 입력을 키웠으므로 원 증상의 기여 요인이었을 가능성이 있습니다.
무엇을 어떤 순서로 줄일 것인가
수정은 원인마다 하나씩, 서로 독립적으로 되돌릴 수 있는 변경 4건으로 나눴습니다.
문구는 전체 추정의 40% 이상을 차지하는 버킷만 지목하고, 미달이면 일반 안내를 내게 했습니다. sys 안의 과거 대화 몫을 따로 세어 로그에도 출력하고, sys 중 절반 이상이 과거 대화면 "대화 누적", 아니면 "지시문·가이드"로 안내합니다.
과거 대화 컨텍스트는 시스템 프롬프트에 합치기 전에 자르는 단계를 새로 두었습니다. 여기서 상한을 어떻게 정할지가 핵심 판단이었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 고정 비율 (예산의 25%) | 단순하고 예측 가능 | 예산에 여유가 있는 정상 대화까지 매 턴 잘림. 경고가 답변 앞에 노출되는 회귀 | 1차 적용 후 반입 전 교정 |
| 예산에서 나머지 입력을 뺀 공간 | 남는 공간만큼만 허용, 여유가 있으면 손대지 않음 | 나머지 입력의 추정에 의존 | 채택 |
고정 비율 25%는 1차 변경에서 실제로 넣었다가 회귀를 확인하고 반입 전에 교정했습니다. 경고는 워크플로우 실행 경로에서 답변 전에 그대로 출력되고, 오류 마커를 지우는 정제기는 채팅 에이전트 경로 한 곳에만 있어 사용자에게 그대로 노출됐습니다. 과거 대화를 가장 낮은 우선순위에 두되, 잘라야 할 때만 자르는 것이 맞았습니다.
도구는 예산 - sys - 도구 - 대화 - RAG ≤ 0일 때만 개입합니다. 연결 순서대로 담다가 남은 공간의 절반을 넘는 도구부터 빼고, 최소 1개는 남깁니다. 경고 코드는 새로 만들지 않고 기존 코드를 재사용했습니다(다음 번호는 이미 다른 의미로 쓰이고 있었습니다).
truncate는 같은 텍스트에서 이미 구한 실측 비율로 환산하고 원래 길이로 상한을 걸었습니다.
raw_keep = min(len(ids), max(1, keep * len(ids) // current))처음에는 보정계수를 다시 곱하는 방식도 썼는데, 그러면 토큰 계산 공식을 복제하게 되어 두 곳이 어긋날 위험이 있어 후속 변경에서 제거했습니다. 이 밖에 provider를 "openai"로 하드코딩하던 부분을 노드 설정 값으로 바꿨고(미상이면 보정계수 1.20으로 과대추정하는 안전한 방향), 멀티턴 DB 메모리의 max_chars 기본값을 100,000에서 30,000으로 낮췄습니다. 이 값은 문자 절대값이라 모델 윈도우와 무관하고, 기존 워크플로우 53개는 모두 100,000이 저장돼 있어 기본값 변경의 영향을 받지 않습니다.
단위 테스트는 71개에서 87개로 늘었고 에러 12건은 변경 전과 같았습니다(11건은 pytest 미설치, 1건은 기존 가짜 커넥션의 rollback 부재). 배포 후 관찰 지침도 남겼습니다. 과거 대화 truncate 로그가 "N자 → M자"에서 M이 N보다 크면 즉시 롤백하고, window=32768이 보이면 vLLM 윈도우 조회가 실패한 것이므로 노드에 컨텍스트 윈도우를 명시하라는 내용입니다.
배포 후: 빨리 실패하던 실행이 느린 성공으로
8월 12일 빌드가 배포된 뒤 폐쇄망에서 지연 증가, 컨텍스트 안내 빈발, 도구 미호출, 실행 중단이 겹쳐 보고됐습니다. 4건을 전부 원복하기로 했습니다. 원복은 단일 커밋으로 하고, 대상 파일 6개가 시리즈 이전과 diff 0이며 테스트도 71개·에러 12로 베이스라인과 같음을 확인했습니다. 같은 시기의 별건 수정(서로게이트 문자 처리 등)은 diff에 섞이지 않게 범위를 좁혔고, 제품 기본 라인에는 이 계열이 반입된 적이 없어 원복 대상이 없었습니다.
그다음 해야 할 일은 "맞는 수정이 왜 지연을 만들었는가"를 설명하는 것이었습니다. 요약 LLM 호출 경로는 6월 30일부터 있었습니다. 그런데 truncate 증식 결함 때문에 압축 파이프라인이 사실상 작동하지 않았고, 초과 실행은 서버가 토큰만 세고 400을 반환하며 수 초 만에 끝났습니다. 수정으로 압축이 실제로 작동하기 시작하자 초과 실행이 "수만 토큰을 프리필하는 요약 LLM 호출"과 "예산을 꽉 채운 약 12만 토큰의 본 호출"로 이어졌습니다. 빨리 실패하던 실행이 몇 분짜리 느린 성공으로 바뀐 것이고, 그것이 지연으로 체감됐습니다. vLLM 한 대를 공유하는 환경이라 이런 대형 프리필은 다른 사용자의 응답까지 늦췄습니다. 요약이 이전 도구 호출 맥락을 지우면서 "도구를 부르지 않고 헤매다 멈춘다"는 체감에도 기여했습니다.
수정 전 초과 실행 → (압축 경로 무력) → 토큰 계산 → 400, 수 초 만에 실패
수정 후 초과 실행 → 요약 LLM 호출 (수만 토큰 프리필)
→ 본 호출 (약 12만 토큰 프리필) → 몇 분 뒤 성공
→ 공유 vLLM 점유, 다른 사용자 응답 지연원복에는 대가가 있었습니다. 문구 오지목과 truncate 증식 결함이 함께 되돌아왔고, 초과는 다시 "즉시 실패와 옛 문구"로 돌아갑니다. 지연을 없애기 위해 이 두 결함을 감수한 것입니다.
원복 후 재현: 사전 검사는 통과하고 루프 안에서 윈도우를 넘는다
원복이 배포된 외부망 데모 환경에서 먼저 원복본이 실제로 실행 중인지 확인했습니다. 파드 안에서 새로 추가했던 함수 이름이 0건이고 메모리 기본값이 100,000으로 복귀했으며, 런타임 로그에서 과거 대화 몫 출력이 빠진 옛 포맷이 찍혔습니다.
그다음 제보된 워크플로우 구성을 그대로 재현했습니다. 질문, 멀티턴 DB 메모리(max_chars 100,000), 정보검색(light 모드, top_k 50, threshold 0.1, 재정렬 없음), 에이전트(컨텍스트 윈도우 설정 0, 최대 출력 8,000, 탐색 10회), 답변입니다. 컬렉션은 실데이터 27,688청크(평균 1,098자)였습니다. 파드에서 폐쇄망 모델에 도달할 수 없어 실한도 128K인 gpt-4o로 같은 시나리오를 실행했습니다. 이 과정에서 외부망 환경에서도 vLLM 윈도우 조회가 실패해 32,768로 폴백하는 일이 실제로 일어나고 있음을 발견했습니다.
[2.1s] tool_call ×4 (질문의 네 항목을 각각 검색, 병렬)
[2.8~3.7s] tool_result ×4
[4s] 안내: 입력이 ... 넘어 응답하지 못했습니다. 연결된 도구가 많습니다.| 항목 | 값 | 측정 조건 |
|---|---|---|
| 사전 예산 검사 | 2,701 / 117,440 토큰, 통과 | 파드 로그 |
| provider 실측 입력 | 184,825 토큰 (messages 182,585 + functions 2,240) | 128,000 한도 모델의 400 응답 |
| 검색 1회당 결과 | 약 45.6k 토큰 | 4회 합 약 182.5k, 사전 시뮬레이션 회당 52.7k와 부합 |
| 초과 시 종료까지 | 4초 | 원복본, 압축 경로 미동작 |
구조가 그대로 드러났습니다. 사전 검사는 루프 시작 전의 입력만 보므로 통과하고, 루프 안에서 검색 도구 결과가 쌓이며 윈도우를 넘습니다. 스트리밍 UI의 도구 결과 표시는 회당 약 17k자로 잘려 보였지만 실제로는 회당 45k 토큰이었고, 기준은 provider의 실측치였습니다. 옛 문구는 이번에도 오지목했습니다. 사전 버킷 중 도구 스키마(2,328)가 가장 커서 "연결된 도구가 많습니다"라고 안내했지만, 진짜 원인인 도구 결과에는 버킷이 없습니다. 원복한 수정이 살아 있었다면 도구 응답 크기를 지목하는 문구가 나왔을 자리입니다.
같은 실행에서 top_k가 10이었다면 4회 병렬 검색이어도 약 42k로 통과했을 규모였습니다. 초과를 만드는 근본 압력은 예산 계층이 아니라 검색 top_k, DB 최대 행 수, 폴더 필터 상한, 메모리 크기 같은 상위 설정에 있었습니다.
회고와 남은 과제
배운 것은 세 가지입니다. 첫째, 사용자에게 보이는 진단 문구는 디버깅의 출발점을 정합니다. 문구가 틀리면 조사가 며칠씩 엉뚱한 곳으로 갑니다. 증상이 재발하면 문구가 아니라 토큰 예산 로그의 버킷 수치를 먼저 봐야 합니다. 둘째, 결함을 고치면 그 결함 때문에 잠들어 있던 경로가 깨어납니다. truncate 수정은 정확했지만, 그로 인해 처음으로 실제 동작한 요약 압축이 공유 GPU의 비용 구조를 바꿨습니다. 배포 전 검증에 "이 수정으로 새로 실행되기 시작하는 경로는 무엇인가"라는 질문이 필요했습니다. 셋째, 사전 검사는 루프 안에서 생기는 입력을 보지 못합니다. 도구 결과의 크기를 상위 설정에서 제한하지 않으면 예산 계층만으로는 막을 수 없습니다. 재현 중 배선을 잘못 연결하면서 확인한 규칙도 있습니다. 정보검색 노드는 light 모드여도 에이전트의 도구 입력이 아니라 RAG 컨텍스트 입력에 연결해야 내장 검색 도구가 실리고, 도구 입력에 연결하면 도구가 0개가 됩니다.
같은 원칙은 이후 작업에도 이어졌습니다. 9월에는 노드의 최대 답변 길이 안내가 어느 배포에서나 정적 65536이던 것을, 런타임 예산이 쓰는 두 근거(vLLM 실측 max_model_len, 모델 카탈로그 값)로 배포 모델 기준으로 좁혔습니다. 폐쇄망은 모델을 26,000토큰으로 서빙하고 있어 기존 안내가 실제의 2.5배였습니다. 이때 provider 기본값 같은 추정치는 쓰지 않고, 근거가 없으면 좁히지 않게 했습니다. 런타임 예산에는 추정이라도 필요하지만, UI 상한을 추정으로 좁히면 근거 없이 사용자 입력을 제한하게 되기 때문입니다.
남은 과제는 근본 압력 쪽입니다. 검색 노드의 top_k·threshold 조정과 함께 재정렬 미구현, top_k 미준수, 폴더 필터 버그가 별건으로 남아 있고, 고정 interaction id 경로 세 곳도 정리 대상입니다. 원복으로 되돌아간 문구 오지목과 truncate 증식 결함 역시 기록상 그대로 남아 있습니다.