1. 배경: 대화가 길어지면 한도를 넘는 입력
KT 유선가입 에이전트의 대화 흐름이 안정화되자 다른 문드러났습니다. 느리고 비쌌습니다.
대화 한 번에 들어가는 입력 토큰을 재봤더니 평균 76.59k 였습니다. 최대치는 92.83k까지 올라갔습니다. 컨텍스트 한도에 근접하는 수치라, 멀티턴이 길어지면 오버플로우 오류가 나기 시작했습니다.
토큰이 많다는 건 세 가지를 동시에 의미했습니다. 호출 비용이 비싸고, 네트워크로 오가는 데이터가 커서 느리고, 긴 컨텍스트 속에서 모델이 중요한 값을 놓칠 확률이 올라간다는 것입니다.
2. 문제 정의: 프롬프트 축소의 한계
처음에는 당연히 프롬프트를 의심했습니다. 지시문을 다듬고, 예시를 줄이고, 중복된 설명을 걷어냈습니다.
거의 줄지 않았습니다.
토큰 구성을 실제로 계측한 뒤에야 원인을 확인했습니다. 프롬프트는 전체의 일부일 뿐이었고, 대부분은 매 턴 컨텍스트에 주입되는 API 응답 JSON이었습니다.
구조가 이랬습니다. 매 턴마다 필요할지도 모르는 데이터를 전부 조회해서 컨텍스트에 넣고 있었습니다. 여러 계통의 API를 호출해 받은 JSON을 그대로 문자열로 만들어 붙이는 방식입니다.
문제는 그 JSON이 사람이 아니라 시스템 연동을 위해 만들어진 것이라는 점이었습니다. 화면에 쓰지도 않는 내부 코드, 관리용 플래그, null로 가득한 선택 필드, 중첩된 메타데이터가 잔뜩 들어 있었습니다. LLM이 답변을 만드는 데 실제로 필요한 건 그중 극히 일부였습니다.
3. 기술적 난점
두 가지가 겹쳤습니다.
첫째, 무엇이 필요한지 미리 알 수 없습니다. 사용자가 다음에 무슨 말을 할지 모르니 전부 넣어두는 방식이었습니다. 이는 방어적으로 옳은 선택입니다. 빠진 데이터 때문에 답하지 못하는 실패는 사용자에게 바로 보이지만, 토큰이 많아 생기는 비용은 청구서에만 보입니다. 드러나는 실패를 먼저 막는 것이 초기 판단이었고, 그 자체로는 타당합니다.
뒤집힌 것은 비용이 한도에 닿으면서였습니다. 오버플로우가 나기 시작하자 토큰 초과도 사용자에게 보이는 실패가 됐습니다. 두 실패가 같은 층위에 놓이자, 전부 넣는 방식은 더 이상 안전한 쪽이 아니었습니다.
둘째, JSON은 구조상 낭비가 심합니다. 같은 형태의 레코드가 100개 있으면 키 이름 100세트가 반복됩니다. 중괄호와 따옴표와 콜론도 전부 토큰입니다. 데이터가 아니라 형식이 토큰을 먹고 있었습니다.
4. 대안 검토
1안. 모델을 컨텍스트가 더 긴 것으로 바꾼다
- 장점: 코드를 안 고쳐도 오버플로우가 해결됩니다.
- 단점: 비용과 지연은 그대로거나 더 나빠집니다. 문제를 미루는 것이지 푸는 게 아닙니다. 그리고 컨텍스트가 길수록 모델이 중간 정보를 놓치는 현상은 여전합니다.
2안. 응답을 요약해서 넣는다
LLM으로 한 번 요약한 뒤 컨텍스트에 주입합니다.
- 장점: 토큰이 확실히 줄어듭니다.
- 단점: 요약에 또 LLM 호출이 듭니다. 비용을 줄이려다 호출을 늘리는 것입니다. 게다가 요약 과정에서 숫자가 왜곡될 수 있는데, 금액이나 날짜가 왜곡되면 상용 환경에서 허용할 수 없는 오류가 됩니다.
3안. 필요한 것만 호출하고, 포맷을 바꾼다
호출 자체를 선별하고, 남은 데이터도 경량 포맷으로 직렬화합니다.
- 장점: 근본적입니다. 비용과 지연이 함께 줄어듭니다.
- 단점: 어떤 단계에 무엇이 필요한지 판단하는 로직을 직접 만들어야 합니다.
5. 설계 결정 및 근거
3안을 골랐습니다. 그리고 두 단계로 나눠 적용했습니다.
5.1 호출 선별: 단계별 필수 API만 호출
앞서 상태 객체를 도입해둔 게 여기서 힘을 발휘했습니다. 현재 어떤 단계이고 어떤 슬롯이 비어 있는지를 시스템이 이미 알고 있었기 때문에, "지금 필요한 데이터"를 특정할 수 있었습니다.
이에 따라 매 턴 전부 호출하던 것을 그만두고, 현재 발화와 슬롯 상태를 평가해 필수적인 것만 골라 호출하는 라우팅 계층을 만들었습니다. 여러 호출이 필요하면 병합해서 한 번에 처리했습니다.
여기서 확인한 사항은 다음과 같습니다. "무엇이 필요한지 미리 알 수 없습니다"는 초기 전사실 틀렸다는 것입니다. 상태를 제대로 관리하고 있으면 대부분의 경우 알 수 있습니다. 다 넣어두는 건 상태를 모를 때 어쩔 수 없이 하는 방어였습니다.
5.2 직렬화 포맷 변경: JSON에서 표 형식으로
호출을 줄이고 남은 데이터도 여전히 무거웠습니다. 이에 따라 두 가지를 했습니다.
불필요한 필드를 제거했습니다. 내부 관리 코드, 화면에 안 쓰는 플래그, null 필드를 걷어냈습니다.
남은 데이터를 표 형태로 직렬화했습니다. 같은 구조의 레코드가 반복될 때 JSON은 매 레코드마다 키를 반복하지만, 표는 헤더를 한 번만 쓰면 됩니다.
[ JSON ] [ 표 형식 ]
{"name":"A","price":1000, name | price | type
"type":"X"}, A | 1000 | X
{"name":"B","price":2000, B | 2000 | Y
"type":"Y"}중괄호와 따옴표와 반복되는 키가 전부 사라지므로, 레코드가 많아질수록 차이가 커집니다.
응답 쪽에도 손을 댔습니다. 불필요한 안내 문구를 걷어내고 고정 템플릿 기반으로 바꿨습니다. 이는 토큰뿐 아니라 출력 형식이 일정해져서 화면 컴포넌트 파싱 실패가 줄어드는 효과도 있었습니다.
6. 실험 결과
| 측정 항목 | 개선 전 | 개선 후 | 감소율 |
|---|---|---|---|
| 평균 입력 토큰 | 76.59k | 27.97k | 63.4%↓ |
| 최대 입력 토큰 | 92.83k | 67.43k | 27.3%↓ |
| 평균 응답 토큰 | 기준 | - | 19.1%↓ |
부수 효과가 두 개 있었습니다.
오버플로우 오류가 사라졌습니다. 최대 입력이 27.3% 줄면서 긴 대화에서도 한도 안에 머물게 됐습니다. 평균보다 이 최대값 개선이 운영상 더 중요했습니다. 장애는 평균이 아니라 최악의 경우에 터지기 때문입니다.
지연이 줄었습니다. 오가는 데이터가 작아지니 네트워크 병목이 풀렸습니다. 파이프라인 단계를 늘리면서도 응답 시간이 나빠지지 않은 건 이 덕이 큽니다.
7. 결론 및 시사점
최적화하기 전에 측정해야 합니다. 프롬프트를 며칠 다듬었는데 거의 효과가 없었습니다. 토큰 구성을 실제로 찍어봤다면 첫날에 알았을 일입니다. 직관은 대체로 눈에 잘 보이는 것(프롬프트)을 범인으로 지목하는데, 실제 범인은 자동으로 주입되어 눈에 안 띄는 쪽이었습니다.
LLM에 넣는 데이터는 LLM을 위해 다시 만들어야 합니다. 시스템 간 연동용 JSON을 그대로 넣는 건 편하지만 낭비입니다. 사람에게 보고서를 줄 때 DB 덤프를 던지지 않는 것과 같습니다. 컨텍스트에 들어가는 데이터는 별도의 표현 계층을 거쳐야 한다고 생각하게 됐습니다.
적절한 구조 설계는 이후 단계에서 이득으로 회수됩니다. 이 최적화가 가능했던 건 앞서 상태 객체를 만들어뒀기 때문입니다. 그때는 대화 맥락을 지키려고 만든 것이지 토큰을 줄이려던 게 아니었습니다. 상태를 알고 있다는 사실이 나중에 "필요한 것만 고르기"를 가능하게 했습니다. 구조를 제대로 잡아두면 예상하지 못한 곳에서 이득이 돌아옵니다.
