지난 글까지는 한 번의 요청에 무엇을 넣을지를 다뤘다. 그런데 대부분의 서비스는 한 번으로 끝나지 않는다. 사용자가 계속 물어보고, 에이전트가 도구를 열 번 스무 번 호출한다. 그때부터는 "무엇을 넣을까"가 아니라 "무엇을 계속 들고 갈까"가 문제가 된다.
이 문제가 까다로운 이유는 증상이 늦게 나타나기 때문이다. 세 번째 턴까지는 아무 이상이 없다가, 스무 번째 턴에서 갑자기 답이 이상해지고 비용 그래프가 튄다.
비용은 턴 수에 비례하지 않는다
가장 먼저 어긋나는 감각이 여기다. 매 턴 대화 전체를 다시 보내므로, 턴이 하나 늘 때 늘어나는 비용은 그 턴의 크기가 아니라 그 시점까지의 누적 전체다. 턴 의 토큰 수를 라 하면 턴짜리 대화의 총 입력 토큰은 이렇게 된다.
턴 크기가 평균 로 고른 경우 이 값은 대략 , 즉 턴 수의 제곱에 비례한다. 턴이 두 배가 되면 비용은 네 배다. 열 턴짜리 대화가 감당할 만해 보였다고 마흔 턴을 방치하면 비용은 열여섯 배가 되어 있다.
프롬프트 캐시가 이 곡선을 상당히 눌러 주지만 그것도 조건부다. 대화 앞부분이 그대로 남아 있어야 적중하는데, 접기·재정렬을 하는 순간 그 아래가 전부 캐시 미스가 된다. 이 점이 뒤에서 다시 나온다.
실제로 쌓이는 것은 사용자 메시지가 아니다
대화를 줄이려 할 때 대부분 사용자와 모델의 말부터 본다. 그런데 토큰을 세어 보면 비중이 완전히 다르다.
| 구성 요소 | 턴당 크기 | 오래되면 |
|---|---|---|
| 사용자 메시지 | 작다 | 의도가 남아 있어 값지다 |
| 모델의 답 | 중간 | 결론만 남기면 된다 |
| 도구 호출 인자 | 작다 | 무엇을 했는지의 기록이다 |
| 도구 응답 원문 | 아주 크다 | 대부분 다시 안 쓰인다 |
에이전트 대화에서 토큰의 대부분은 마지막 줄이다. 검색 결과 열 건, API 응답 JSON, 파일 내용 전문이 그대로 이력에 남아 매 턴 다시 실려 간다. 그런데 그 원문이 다시 필요한 경우는 드물다 — 모델은 이미 그것을 읽고 요약해 답에 반영했다.
그래서 접기의 첫 대상은 대화가 아니라 도구 응답의 원문이다. 여기만 손봐도 대부분의 대화에서 절반 이상이 줄어들고, 사용자가 한 말은 하나도 잃지 않는다.
언제 접을 것인가
접는 시점을 정하는 방식은 셋인데, 실제로는 첫째를 기본으로 두고 셋째를 예외로 붙이는 조합을 많이 쓴다.
턴 수로 정하기. "열 턴마다 접는다"는 구현이 가장 쉽지만 턴 크기가 제각각이라 실제 압박과 어긋난다. 짧은 턴 열 개는 접을 이유가 없고, 큰 도구 응답 두 개는 이미 넘친다.
예산 비율로 정하기. 입력 예산의 70%를 넘으면 접는다. 실제 압박에 반응하므로 이쪽이 낫다. 임계값은 한 번 접었을 때 다시 임계에 닿기까지 여러 턴이 지나가도록 잡는다 — 매 턴 접히면 캐시가 매 턴 깨진다.
주제가 바뀔 때 접기. 사용자가 완전히 다른 주제로 넘어간 지점은 접기에 가장 안전한 자리다. 다만 판단이 필요해서 자동화가 어렵고, 잘못 판단하면 방금 하던 얘기를 잃는다.
임계값을 낮게 잡을수록 창은 여유롭지만 캐시 적중률이 떨어진다는 것이 이 결정의 핵심이다. 접기 자체도 모델 호출이라 공짜가 아니다.
상태는 대화 밖에 둔다
접기보다 근본적인 해법이 하나 있다. 대화가 길어지며 잃는 것은 대개 사실 몇 개다 — 사용자의 이름, 고른 상품, 지금까지 확정된 조건. 이것들을 대화 이력에 맡겨 두면 접을 때마다 잃을 위험이 생긴다.
그래서 이런 값은 메시지가 아니라 구조화된 상태 블록에 담아 매 턴 새로 렌더링해 넣는다.
def build_messages(session, user_input):
state = render_state(session.state) # 항상 최신값으로 다시 만든다
history = session.folded_summary + session.recent_turns
return [
{"role": "system", "content": SYSTEM_PROMPT}, # 고정 — 캐시된다
*history, # 접힌 요약 + 최근 턴
{"role": "user", "content": f"{state}\n\n{user_input}"},
]
상태 블록을 마지막 사용자 메시지에 붙인 것이 의도적이다. 이 값은 매 턴 바뀌므로 앞쪽에 두면 캐시가 매번 깨진다. 뒤쪽에 두면 앞의 고정 부분은 그대로 재사용되고, 모델 입장에서도 가장 가까운 곳에 있어 잘 지켜진다.
한 가지 더, 상태 블록에 담을 것은 확정된 사실만이다. 추측이나 중간 판단을 넣으면 그것이 계속 다시 실려 들어와 모델이 그 추측을 사실로 굳힌다.
접을 때 지키는 세 가지
접기 구현에서 사고가 나는 자리는 대체로 정해져 있다.
- 짝을 깨지 않는다. 도구 호출 메시지와 그 응답은 한 쌍이다. 토큰이 큰 순서로 지우면 응답만 사라지고 호출이 남아 대화 구조가 깨진다. 항상 쌍 단위로 다룬다.
- 접힌 것을 되찾을 길을 남긴다. 도구 응답 원문을 버릴 때 식별자만은 남긴다.
[검색 결과 12건 — 요약: …, id=srch_8821]처럼 적어 두면 모델이 필요할 때 다시 불러올 수 있다. - 경계를 위로 올리지 않는다. 접을 때마다 대화의 앞부분이 바뀌므로 그 아래가 전부 캐시 미스다. 그러니 접는 지점은 되도록 아래로만 움직인다. 한 번 접힌 요약을 다시 고쳐 쓰면 그때마다 전체가 무효화된다.
세 번째가 특히 놓치기 쉽다. "요약을 매 턴 갱신해서 최신으로 유지한다"는 그럴듯하게 들리지만, 그렇게 하면 캐시가 한 번도 적중하지 않는다. 요약은 접는 순간 한 번 만들고 그다음부터는 건드리지 않는다.
오래된 지시가 새 지시를 이기는 문제
긴 대화의 마지막 함정은 비용이 아니라 지시 충돌이다. 다섯 번째 턴에서 "간결하게 답해"라고 했고 스무 번째 턴에서 "자세히 설명해"라고 하면, 두 지시가 같은 창 안에 함께 있다. 모델은 대체로 뒤엣것을 따르지만 확실하지는 않다.
이 문제는 접기로 해결되지 않는다 — 오히려 요약이 옛 지시를 "사용자는 간결한 답을 선호함"처럼 고정된 사실로 승격시켜 더 오래 살아남게 만든다. 지시성 발화는 요약에 넣지 말고 상태 블록의 한 필드로 관리하는 편이 낫다. 그러면 새 지시가 왔을 때 값을 덮어쓰면 되고, 창 안에 옛 지시가 남지 않는다.
정리하면 긴 대화의 관리는 세 갈래다. 도구 응답 원문처럼 부피는 크고 재사용은 안 되는 것을 먼저 접는다. 잃으면 안 되는 사실은 대화가 아니라 상태 블록에 둔다. 그리고 접는 지점은 캐시 경계를 아래로만 밀도록 정한다. 이 셋이 지켜지면 대화가 마흔 턴을 넘겨도 비용 곡선과 답의 품질이 함께 무너지지 않는다.
읽어주셔서 감사합니다. 😊

