LLM 에이전트 기초 (4) - Context: 토큰 예산, 도구 결과 다이어트, 압축
루프가 길어질 때 컨텍스트가 부풀어 오르는 문제를 토큰 측정, 도구 결과 정리, 대화 압축, 프롬프트 캐싱으로 다룹니다.
LLM 에이전트 기초 시리즈의 4편입니다. 3편의 루프가 길어질 때 무엇이 터지는지 보고 막습니다.
루프는 대화를 지우지 않고 쌓습니다
3편의 run_agent를 다시 봅니다. 루프가 한 바퀴 돌 때마다 messages에 모델의 응답 하나와 도구 결과 하나가 추가되고, 지우는 코드는 어디에도 없습니다. 에이전트의 컨텍스트는 턴마다 단조 증가합니다.
여기에 두 번째 사실이 겹칩니다. Messages API는 상태가 없어서 매 요청이 시스템 프롬프트와 도구 정의와 지금까지의 히스토리를 전부 다시 보냅니다. 스텝을 N번 도는 루프는 히스토리를 N번 재전송합니다. 그래서 비용은 스텝 수에 선형이 아니라 제곱에 가깝게 늘어납니다.
숫자로 봅니다. 시스템 프롬프트와 도구 정의를 합쳐 800토큰, 사용자 요청이 40토큰이라 고정 앞부분이 840토큰입니다. 한 스텝마다 모델 응답 120토큰과 도구 결과 600토큰, 합쳐서 720토큰이 히스토리에 붙습니다.
| 스텝 수 N | N번째 요청의 입력 토큰 | 누적 입력 토큰 |
|---|---|---|
| 1 | 840 | 840 |
| 5 | 3,720 | 11,400 |
| 10 | 7,320 | 40,800 |
| 20 | 14,520 | 153,600 |
누적 입력 토큰은 840N + 720 x N(N-1)/2이고 뒤쪽 항이 N의 제곱입니다. 스텝을 10회에서 20회로 두 배 늘렸을 뿐인데 누적 입력은 3.8배가 됩니다. claude-opus-5의 입력 단가가 100만 토큰당 5달러이니 대화 한 건에 각각 약 0.20달러와 약 0.77달러, 문의 한 건 값입니다. 컨텍스트 한도에 닿는 것도 문제지만 한도에 닿기 한참 전부터 요금이 먼저 아픕니다.
이 편은 그 곡선을 눕힙니다. 도구 결과를 줄이고, 오래된 결과를 버리고, 히스토리를 압축하고, 앞부분을 캐시합니다.
먼저 재야 합니다
고치기 전에 재는 것이 먼저입니다. 감으로 판단하면 엉뚱한 곳을 줄이게 됩니다. Claude API는 실제 요청을 보내지 않고 토큰을 세는 엔드포인트를 제공합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# context.py
import json
from agent import client, MODEL, run_tool
from tools import TOOL_SPECS
def count_tokens(messages, system=None) -> int:
"""이 messages로 요청을 보내면 입력 토큰이 몇 개일지 셉니다."""
return client.messages.count_tokens(
model=MODEL, system=system or "",
tools=TOOL_SPECS, messages=messages,
).input_tokens
tools와 system을 반드시 같이 넘겨야 합니다. 도구 정의도 입력 토큰에 포함되는데, 인자를 빼면 도구 세 개 분량인 수백 토큰이 빠진 숫자가 나옵니다. tiktoken으로 대신 세면 안 됩니다. OpenAI 모델용 토크나이저라 Claude와 토큰 경계가 다르고, 도구 정의와 메시지 구조가 요청으로 직렬화되는 방식도 반영하지 못합니다.
루프에는 응답을 받은 직후 response.usage를 매 스텝 찍습니다. input이 계단처럼 올라가는 폭이 곧 한 스텝이 히스토리에 더한 양입니다. 이때 input_tokens는 캐시에 걸리지 않은 나머지만 셉니다. 실제 프롬프트 전체 크기는 input_tokens + cache_creation_input_tokens + cache_read_input_tokens이고, 캐싱을 붙인 뒤에 input_tokens만 보고 줄었다고 판단하면 안 되는 이유가 여기 있습니다.
가장 큰 범인은 도구가 돌려준 원본 JSON입니다
0편의 ORDERS는 필드가 일곱 개뿐이라 작아 보입니다. 하지만 실제 주문 시스템의 조회 API는 그렇지 않습니다.
1
2
3
4
5
6
7
8
9
{
"order_id": "20260801-0012",
"customer": {"id": "u_88213", "name": "이수진", "grade": "GOLD", "joined_at": "2024-03-11"},
"items": [{"sku": "EAR-9921", "name": "무선 이어폰", "option": "블랙 / 케이스 포함",
"price": 89000, "qty": 1, "category_path": ["가전", "음향", "이어폰"]}],
"payment": {"method": "CARD", "issuer": "신한", "installment": 0},
"shipping_history": [{"at": "2026-08-01T14:02:00", "code": "PICKED", "hub": "동탄1"}],
"status": "배송완료", "...": "이하 30여 개 필드"
}
모델이 환불 가능 여부를 판단하는 데 필요한 것은 주문번호, 상품명, 금액, 상태, 배송완료일 정도입니다. 나머지는 토큰을 먹고 판단을 흐릴 뿐입니다. 도구를 실행한 직후, 결과를 tool_result에 넣기 전에 잘라냅니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# context.py
import os
import uuid
ARTIFACT_DIR = "artifacts"
MAX_ITEMS = 5 # 목록은 상위 N건만
MAX_CHARS = 800 # 잘라낸 뒤에도 이보다 길면 파일로 밀어냄
KEEP_FIELDS = {
"search_orders": ["order_id", "item", "price", "status", "ordered_at"],
"get_order": ["order_id", "customer", "item", "price", "status", "delivered_at"],
"request_refund": ["order_id", "status", "reason"],
}
def dump(name, output) -> str:
"""전문을 파일에 남기고 경로만 돌려줍니다."""
os.makedirs(ARTIFACT_DIR, exist_ok=True)
path = os.path.join(ARTIFACT_DIR, f"{name}-{uuid.uuid4().hex[:8]}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
return path
def slim(name, output):
keys = KEEP_FIELDS.get(name)
if keys is None or (isinstance(output, dict) and "error" in output):
return output # 모르는 도구와 에러는 그대로 넘깁니다
if isinstance(output, list):
slimmed = {
"items": [{k: v for k, v in r.items() if k in keys} for r in output[:MAX_ITEMS]],
"total": len(output),
}
if len(output) > MAX_ITEMS:
slimmed["full_result"] = dump(name, output)
return slimmed
slimmed = {k: v for k, v in output.items() if k in keys}
text = json.dumps(slimmed, ensure_ascii=False)
if len(text) > MAX_CHARS:
slimmed = {"preview": text[:MAX_CHARS], "full_result": dump(name, output)}
return slimmed
원칙은 세 가지입니다. 필요한 필드만 남기고, 목록은 상위 N건만 보내고, 잘라낸 전문은 파일에 남깁니다. 세 번째가 중요합니다. 그냥 버리면 모델이 나중에 전부를 요구할 때 방법이 없지만, 파일로 남겨두면 파일을 읽는 도구를 하나 더 주는 것으로 해결됩니다.
주문 12건을 조회한 결과를 count_tokens로 재 봅니다.
1
2
원본 : 7412 토큰
정리 후: 214 토큰
한 스텝에서 7,198토큰이 사라집니다. 이 절감은 한 번만 이득이 아닙니다. 히스토리에 남아 남은 스텝마다 다시 전송될 뻔한 양이라, 열 스텝짜리 루프라면 절감이 그만큼 곱해집니다. 루프에서는 tool_result의 content를 만들 때 json.dumps(slim(block.name, output))으로 바꾸기만 하면 됩니다.
오래된 도구 결과 버리기
다이어트를 해도 스텝이 계속 늘면 히스토리는 자랍니다. 그런데 열 스텝째에 이르면 두 번째 스텝의 조회 결과 전문은 대개 쓸모가 없습니다. 모델은 이미 그걸 보고 다음 행동을 정했고 필요한 결론은 그 뒤 응답에 들어 있습니다. 최근 몇 턴의 결과만 원본으로 두고 앞쪽은 요약 한 줄로 바꿉니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# context.py
NEVER_PRUNE = {"request_refund"} # 되돌릴 수 없는 행동의 기록은 원본 유지
def _is_tool_turn(m) -> bool:
return m["role"] == "user" and isinstance(m["content"], list) \
and any(b.get("type") == "tool_result" for b in m["content"])
def prune(messages, keep_recent=3):
"""오래된 tool_result의 content만 한 줄 요약으로 교체합니다."""
turns = [i for i, m in enumerate(messages) if _is_tool_turn(m)]
old = set(turns[:-keep_recent]) if len(turns) > keep_recent else set()
pruned = []
for i, message in enumerate(messages):
if i not in old:
pruned.append(message)
continue
blocks = []
for b in message["content"]:
name = b.get("_tool_name", "")
keep = b.get("type") != "tool_result" or name in NEVER_PRUNE
blocks.append(b if keep else {**b, "content": f"[생략됨] {name} 호출 결과"})
pruned.append({**message, "content": blocks})
return pruned
tool_result 블록 자체를 지우면 안 됩니다. 대응하는 tool_use 블록이 앞의 assistant 메시지에 남아 있는데 짝이 사라지면 API가 400을 냅니다. 블록은 남기고 content 문자열만 갈아 끼웁니다. 어떤 도구의 결과였는지 한 줄로 쓰려면 이름을 기억해야 해서 tool_result를 만들 때 _tool_name 같은 자체 키를 심어두고, 요청을 보내기 직전에 밑줄로 시작하는 키를 떼어냅니다.
버리면 안 되는 것이 있습니다.
- 사용자의 원 요청.
messages[0]은 그대로 둡니다. 이게 흐려지면 에이전트는 자기가 왜 이 일을 하는지 모르는 채로 계속 돕니다. - 확정된 사실. 조회로 알아낸 주문번호, 금액, 날짜처럼 이후 판단의 근거가 되는 값입니다. 전문은 버려도 이 값들은 요약 한 줄에 남깁니다.
- 되돌릴 수 없는 행동의 기록.
request_refund를 이미 실행했다는 사실이 사라지면 모델은 아직 안 한 줄 알고 한 번 더 부릅니다. 환불이 두 번 접수됩니다.NEVER_PRUNE이 이걸 막습니다.
압축: 히스토리를 요약으로 교체하기
정리와 삭제로도 안 되는 지점이 옵니다. 스무 턴, 서른 턴을 넘어가면 남길 것만 남겨도 부담스럽습니다. 이때는 모델에게 진행 상황을 요약시키고 그 요약으로 히스토리 전체를 갈아 끼웁니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# context.py
COMPACT_THRESHOLD = 30_000
SUMMARY_PROMPT = """지금까지의 진행 상황을 요약하세요. 이 요약만 보고
다른 사람이 작업을 이어받을 수 있어야 합니다. 다음을 빠짐없이 포함하세요.
1. 사용자의 원래 요청 (표현을 바꾸지 말고 그대로)
2. 조회로 확정한 사실 (주문번호, 금액, 날짜, 상태를 값 그대로)
3. 이미 실행한 되돌릴 수 없는 행동과 그 결과
4. 실패한 시도와 실패한 이유
5. 아직 남은 할 일
관련 없던 조회 결과와 중간 추론 과정은 넣지 마세요.
추측은 넣지 말고, 확인한 것만 쓰세요."""
def compact(messages, system=None):
response = client.messages.create(
model=MODEL, max_tokens=2048, system=system or "",
messages=messages + [{"role": "user", "content": SUMMARY_PROMPT}],
)
summary = "".join(b.text for b in response.content if b.type == "text")
return [{
"role": "user",
"content": f"<progress_summary>\n{summary}\n</progress_summary>\n\n"
f"위 요약은 지금까지의 진행 상황입니다. 이어서 작업하세요.",
}]
다섯 항목 중 4번이 자주 빠집니다. 실패한 시도를 안 남기면 압축 직후의 에이전트는 방금 실패한 경로를 그대로 다시 밟습니다. 없는 주문번호로 조회했다가 에러를 받은 기록이 사라지면 같은 번호로 또 조회합니다.
압축을 거는 시점에도 규칙이 있습니다. tool_use와 tool_result가 짝을 이룬 상태, 즉 도구 결과를 전부 채워 넣은 뒤에만 압축합니다. 도구 호출이 미결인 채로 히스토리를 갈아 끼우면 짝이 깨지고 다음 요청이 400을 냅니다. 루프에서는 client.messages.create 호출 직전이 안전한 지점입니다.
Claude API에는 서버 쪽에서 같은 일을 해주는 compaction 베타 기능도 있습니다. 다만 직접 짜 보면 무엇을 남기고 무엇을 버릴지가 전부 우리 결정이라는 것이 분명해집니다.
프롬프트 캐싱과 effort
앞의 세 장치는 보내는 양을 줄입니다. 캐싱은 방향이 다릅니다. 보내는 양은 그대로 두고 같은 앞부분을 서버가 다시 계산하지 않게 합니다. 프롬프트 엔지니어링 7편에서 단발 호출 기준으로 다뤘는데, 에이전트는 대화 한 건 안에서도 앞부분을 스텝 수만큼 재사용하므로 이득이 훨씬 큽니다.
1
2
3
4
5
6
7
8
9
10
# context.py
SYSTEM_BLOCKS = [
{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}
]
response = client.messages.create(
model=MODEL, max_tokens=4096,
system=SYSTEM_BLOCKS, tools=TOOL_SPECS, messages=messages,
output_config={"effort": "high"}, # low | medium | high | xhigh | max
)
여기서 알아야 할 것이 렌더 순서입니다. 요청은 tools → system → messages 순으로 이어 붙여집니다. 그래서 시스템 프롬프트 마지막 블록에 breakpoint를 두면 그 앞의 도구 목록까지 함께 캐시됩니다. 도구 정의를 따로 캐시할 필요가 없습니다.
같은 순서 때문에 무효화 규칙도 정해집니다. 캐시는 앞부분 일치로 동작하므로 앞쪽에서 한 바이트만 달라져도 뒤가 전부 무효입니다. 도구 목록이 요청마다 달라지거나 순서가 바뀌면 tools가 맨 앞이라 시스템 프롬프트까지 통째로 날아가고, 루프 도중에 모델을 바꿔도 캐시는 모델별로 따로 있어 전부 다시 씁니다. 그리고 시스템 프롬프트에 현재 시각을 넣는 것이 대표적인 실수입니다. 오늘 날짜까지는 괜찮지만 datetime.now()를 초 단위로 찍어 넣으면 매 스텝이 다른 프롬프트가 됩니다. 적중률이 0으로 고정되고, 그 사실은 오류로 드러나지 않습니다. 바뀌는 값은 messages의 사용자 메시지에 넣습니다.
제약도 있습니다. breakpoint는 요청당 최대 4개이고 claude-opus-5의 최소 캐시 길이는 512토큰입니다. 이보다 짧은 앞부분은 cache_control을 붙여도 조용히 캐시되지 않고, 오류가 아니라 cache_creation_input_tokens: 0으로 나타납니다. 기본 유지 시간은 5분입니다. 4개까지 허용되니 시스템 프롬프트 뒤에 하나, 매 턴 마지막 메시지 블록에 하나를 더 두면 히스토리까지 누적으로 캐시됩니다.
붙였다고 되는 것이 아니라 usage.cache_read_input_tokens로 확인해야 합니다.
1
2
3
[step 0] cache_read=0 cache_write=871 uncached=42
[step 1] cache_read=871 cache_write=0 uncached=721
[step 2] cache_read=871 cache_write=0 uncached=1530
두 번째 스텝부터 0이 아니면 걸린 것입니다. 계속 0이라면 앞부분에 변하는 값이 섞여 있습니다.
토큰을 줄이는 마지막 손잡이가 위 코드의 output_config={"effort": ...}입니다. claude-opus-5는 thinking이 기본으로 켜져 있고 그 깊이를 이 값으로 조절합니다. 기본값은 high이며, 낮추면 사고 토큰과 전체 지출이 함께 줄고 도구 호출도 더 적게 뭉쳐 나갑니다. 도구를 하나 고르기만 하면 되는 단순한 라우팅은 low로 내려도 됩니다. 반대로 여러 주문 중 환불 대상을 고르는 판단, 도구가 에러를 냈을 때 다음 수를 정하는 복구 경로, 되돌릴 수 없는 행동을 실행할지 정하는 지점에서는 낮추지 마세요. 여기서 아끼는 몇백 토큰보다 잘못 실행된 환불 한 건이 훨씬 비쌉니다. 참고로 effort는 응답 길이를 줄이는 손잡이가 아닙니다. 응답이 장황하면 프롬프트로 지시해야 합니다.
다 붙인 루프, 그리고 남은 문제
지금까지의 장치를 3편의 run_agent에 전부 끼웁니다. 구조는 그대로이고 다섯 군데가 늘었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# context.py
MAX_STEPS = 20
def run_agent(user_message, max_steps=MAX_STEPS, effort="high") -> str:
messages = [{"role": "user", "content": user_message}]
for step in range(max_steps):
# (1) 임계치를 넘으면 압축. tool_result가 모두 채워진 지점이라 안전합니다
if count_tokens(messages, SYSTEM_BLOCKS) > COMPACT_THRESHOLD:
messages = compact(messages, SYSTEM_BLOCKS)
response = client.messages.create(
model=MODEL, max_tokens=4096,
system=SYSTEM_BLOCKS, # (2) 시스템 뒤 breakpoint로 도구까지 캐시
tools=TOOL_SPECS, messages=messages,
output_config={"effort": effort}, # (3) 사고 깊이
)
u = response.usage
print(f"[step {step}] uncached={u.input_tokens} "
f"cache_read={u.cache_read_input_tokens}")
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return "".join(b.text for b in response.content if b.type == "text")
results = []
for block in response.content: # 이 안은 3편과 같고, 두 곳만 다릅니다
if block.type != "tool_use":
continue
output, is_error = run_tool_safe(block) # 3편의 try/except를 함수로 뺀 것
results.append({
"type": "tool_result", "tool_use_id": block.id, "is_error": is_error,
"content": json.dumps( # (4) 도구 결과 다이어트
slim(block.name, output), ensure_ascii=False),
"_tool_name": block.name,
})
messages.append({"role": "user", "content": results})
messages = prune(messages, keep_recent=3) # (5) 오래된 결과 요약으로 교체
return "단계 한도에 도달했습니다."
같은 스무 스텝짜리 대화를 3편 코드와 이 코드로 각각 돌려 보면, 도구 결과 다이어트가 히스토리 증가폭을 줄이고 캐싱이 고정 앞부분의 재계산을 없애서 누적 비용이 자릿수 단위로 벌어집니다. 계측 코드가 이미 붙어 있으니 직접 재 보는 것이 가장 확실합니다.
이제 이 에이전트는 스무 턴을 넘겨도 컨텍스트가 터지지 않습니다. 그런데 오래 돌 수 있게 만든 결과 더 위험해진 것이 하나 있습니다. 이 루프는 여전히 모델이 원하면 request_refund를 그냥 실행합니다. 조회를 열 번 더 하는 것은 토큰만 쓰고 끝나지만 환불은 되돌릴 수 없습니다. NEVER_PRUNE에 request_refund를 넣은 것은 그 행동이 특별하다는 사실을 인정한 셈인데, 정작 실행할지 말지는 아무도 묻지 않고 있습니다.
5편에서 되돌릴 수 없는 행동 앞에 사람을 세웁니다. 어떤 도구에 그 게이트를 걸지 정하는 기준부터 만듭니다.