왜 툴 결과가 컨텍스트를 잡아먹는가

에이전트는 파일 읽기, 쉘 명령, HTTP 호출, DB 조회 같은 툴을 반복 호출한다. 문제는 이 결과가 원문 그대로 대화 히스토리에 누적된다는 점이다. npm test 출력 8천 토큰, 패키지 락 파일 4만 토큰, API 응답 JSON 2만 토큰이 그대로 쌓이면 몇 턴 만에 컨텍스트 윈도우가 포화된다.

포화의 비용은 세 가지다. 첫째, 매 턴마다 전체 히스토리를 다시 전송하므로 입력 토큰 과금이 선형으로 증가한다. 둘째, 핵심 지시가 노이즈에 묻혀 모델의 지시 준수율이 떨어진다. 셋째, 윈도우 한계에 닿으면 앞부분이 잘려 나가 작업 맥락을 잃는다.

핵심 아이디어: 원문은 버리고 요지만 남긴다

대부분의 툴 결과에서 모델이 실제로 다음 판단에 쓰는 정보는 전체의 5% 미만이다. 테스트 결과는 "통과/실패 개수와 실패한 케이스명"만 있으면 되고, 파일 검색은 "매칭된 경로와 라인 번호"면 충분하다. 원문 전체를 요지로 압축해 히스토리에 넣고, 원문이 다시 필요하면 재조회하는 전략이 기본이다.

요약 파이프라인 구성

툴 실행과 모델 사이에 요약 레이어를 끼운다. 임계값을 넘는 결과만 요약하고, 짧은 결과는 그대로 통과시킨다. 요약 방식은 두 가지를 섞는다. 규칙 기반(정규식·파싱)으로 처리 가능한 건 LLM을 쓰지 않고, 자유형 텍스트만 소형 모델로 요약한다.

import re, subprocess, tiktoken

enc = tiktoken.get_encoding("cl100k_base")
TOKEN_LIMIT = 1500

def count(text: str) -> int:
    return len(enc.encode(text))

def summarize_pytest(raw: str) -> str:
    # 규칙 기반: 요약에 LLM을 쓰지 않는다
    m = re.search(r"(\d+) passed(?:, (\d+) failed)?", raw)
    fails = re.findall(r"FAILED (\S+)", raw)
    head = m.group(0) if m else "결과 파싱 실패"
    if fails:
        return f"{head}\n실패 케이스:\n" + "\n".join(fails[:20])
    return head

def run_tool(cmd: list[str]) -> str:
    raw = subprocess.run(cmd, capture_output=True, text=True).stdout
    if count(raw) <= TOKEN_LIMIT:
        return raw                      # 짧으면 그대로
    if cmd[0] == "pytest":
        return summarize_pytest(raw)    # 규칙 기반 우선
    return llm_summarize(raw)           # 자유형만 LLM

LLM 요약은 원본 참조를 함께 남긴다

자유형 결과를 소형 모델로 요약할 때는 반드시 원문을 어딘가에 저장하고, 요약본에 재조회 키(파일 경로, 캐시 ID)를 남긴다. 그래야 모델이 세부가 필요할 때 "그 로그의 300~350라인을 다시 보여줘"처럼 부분 재조회를 할 수 있다.

import hashlib, json, pathlib

CACHE = pathlib.Path("/tmp/tool_cache")

def llm_summarize(raw: str) -> str:
    key = hashlib.sha1(raw.encode()).hexdigest()[:12]
    CACHE.mkdir(exist_ok=True)
    (CACHE / f"{key}.txt").write_text(raw)   # 원문 보존

    prompt = (
        "다음 툴 출력을 300토큰 이내로 요약하라. "
        "에러·경고·핵심 수치는 반드시 보존하고, 반복 로그는 생략하라.\n\n"
        f"{raw[:12000]}"
    )
    summary = call_small_model(prompt)       # 예: haiku급 소형 모델
    return json.dumps({
        "summary": summary,
        "cache_id": key,
        "hint": f"원문 재조회: read_cache('{key}')",
    }, ensure_ascii=False)

요약 방식 선택 기준

결과 유형권장 방식이유
테스트/린트 출력규칙 기반 파싱형식이 고정적, LLM 불필요
대용량 JSON/API 응답필드 화이트리스트필요한 키만 추출이 정확·저렴
긴 로그·문서 본문소형 LLM 요약비정형이라 규칙화 어려움
코드 파일 전체요약 대신 부분 재조회요약 시 세부 손실 위험

주의점: 무엇을 절대 요약하면 안 되는가

요약은 정보 손실을 전제로 한다. 정확한 값이 후속 판단을 좌우하는 대상은 요약 금지 목록으로 관리해야 한다. 마이그레이션 SQL, 설정 diff, 스택 트레이스, 정확한 에러 코드가 대표적이다. 특히 코드 편집 대상 파일을 요약해 넘기면 모델이 존재하지 않는 라인을 수정하려는 환각이 발생한다.

또한 소형 모델 요약도 토큰과 지연을 쓴다. 매 툴 호출마다 요약하면 오히려 느려지므로 임계값(예: 1500토큰) 이상에만 적용하고, 규칙 기반으로 끝낼 수 있는 건 LLM을 거치지 않는다. 요약률과 재조회 발생 빈도를 로깅해 임계값을 주기적으로 조정한다.

정리

핵심은 "원문 누적 대신 요지 누적 + 온디맨드 재조회"다. 규칙 기반을 우선하고 비정형만 소형 LLM으로 처리하며, 원문은 캐시에 보존해 손실을 되돌릴 수 있게 한다. 이 구조만으로도 장기 실행 에이전트의 입력 토큰과 지시 이탈을 함께 줄일 수 있다.