애드블럭 종료 후 사이트를 이용해 주세요.

ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 토큰은 돈이다 시즌2 1편 - 압축 도구가 자기 벤치마크의 baseline까지 압축한 작업기
    AI 엔지니어링 2026. 10. 20. 07:27
    728x90
    반응형
    격리 코드는 처음부터 있었다. 남의 hook만 격리하고 있었을 뿐이다.

     

    출시하고 나서 새 모델로 벤치마크를 다시 돌렸다. 영어 코퍼스 실행 결과를 훑는데 답변 하나가 한국어로 적혀 있었다. 영어 프롬프트에, 영어 register를 시스템 프롬프트로 넣고, 영어 답을 기대한 자리에서.

    그 한 줄이 아니었으면 그 실행 결과를 그대로 README에 박았을 것이다. 절감률은 예쁘게 나와 있었으니까.

    여기서 register는 출력의 문체·형식 규약을 말한다. 존댓말을 쓸지, 문장을 완결할지 같은 것. 내가 만든 도구는 이 register를 바꿔 같은 답을 더 적은 토큰으로 내게 한다. hook이나 플러그인으로 모델 컨텍스트에 뭔가를 주입하는 도구를 만들면서 그 효과를 자기 개발 환경에서 잰다면 남 얘기가 아니다. (2026년 6월 구현 기준, 8월에 다시 확인)

    격리 코드는 처음부터 있었다

    벤치 하네스에는 첫 버전부터 host isolation이 있었다. 실행하는 동안 ~/.claude/settings.json과 비교 대상 도구(caveman)의 활성화 플래그를 /tmp로 옮겼다가 끝나면 되돌린다. 주석에 이유도 적혀 있었다. 전역 hook이 자식 프로세스에 규칙을 주입하면 normal arm의 baseline이 오염된다. 그러면 절감률은 잴 수 있는 값이 아니게 된다.

    옮기는 목록에 내 도구의 상태 파일은 없었다.

    남의 hook이 내 측정을 망친다는 프레임은 처음부터 보였다. 내 hook은 그 프레임에 안 들어온다. 내 도구는 측정의 대상이지 환경이 아니라고 무의식 중에 분류해 뒀다. 관측자 효과를 막는 코드를 직접 써 놓고 관측자가 자기 자신인 경우를 빼먹었다.

    --system-prompt는 hook을 막지 못한다

    각 arm은 claude --print --system-prompt <rule>로 돈다. 이 플래그는 기본 시스템 프롬프트를 통째로 교체한다. 그러니 자식에게 도달하는 register 지시는 내가 넘긴 그 문자열 하나뿐이라고 생각했다. 여기가 틀렸다.

    hook은 시스템 프롬프트를 거치지 않는다. UserPromptSubmit hook이 돌려준 additionalContext는 system reminder로 감싸여 제출된 프롬프트 옆에 붙는다. 시스템 프롬프트를 무엇으로 바꾸든 이 경로는 그대로 열려 있다.

    // hook은 stdout에 JSON 한 덩어리를 뱉는다. 이건 시스템 프롬프트가 아니라
    // "이번 턴의 사용자 메시지에 덧붙는 컨텍스트"로 들어간다.
    function emit(additionalContext) {
      process.stdout.write(
        JSON.stringify({
          hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext },
        })
      );
    }

    그리고 이 hook이 입을 여느냐 마느냐를 결정하는 건 파일 하나다.

    // 활성화 상태가 있으면 매 턴 reminder를 주입하고, 없으면 아무것도 하지 않는다.
    const state = resolveActiveState(statePath);
    if (state) emit(buildReminder(state.lang, state.dial, state.flags));

    그날 내 홈 디렉터리에는 ko/full 상태가 남아 있었다. 그래서 네 개 arm 전부가 매 턴 "SCROOGE 활성 (ko/full). 개조식·음슴체(

    함/

    됨), 의미 명확 시 조사 드롭…"을 받았다. 영어 실행에도 한국어로 된 그 문장이 그대로 들어갔다. 모델은 순순히 한국어로 답했다. 내가 처음 본 그 한 줄이 거기서 나왔다.

    수치가 어느 쪽으로 틀렸는지 계산할 수 없었다

    처음엔 "그럼 절감률이 부풀었겠네"라고 반사적으로 생각했다. 아니었다. baseline이 같이 압축됐으니 scrooge ÷ normal 비율은 오히려 1에 가까워진다. 내 도구가 손해 보는 방향이다.

    그런데 오염은 모든 arm에 똑같이 들어갔어도 효과는 arm마다 달랐다. normal은 중립 baseline인데 압축됐다. terse는 대조군 역할을 잃었다. scrooge arm은 자기 규칙을 시스템 프롬프트와 hook으로 두 번 받았고, caveman arm은 남의 규칙 위에 한국어 지시를 덧입었다. 어떤 arm은 아래로, 어떤 arm은 위로, 어떤 arm은 아예 다른 언어로 밀렸다.

    그래서 폐기했다. 부호가 하나였다면 보정을 시도해 볼 수도 있다. 방향이 arm마다 다르면 사후에 계산할 방법이 없다. 그 시점엔 재측정이 더 좋게 나올지 나쁘게 나올지도 몰랐다. 결과적으로 더 좋게 나왔고, 그래서 다행이었다 — 나쁘게 나왔다면 나는 폐기 결정을 스스로 의심했을 테니까.

    옮길 것은 settings.json이 아니라 상태 파일이었다

    처음엔 격리 목록에 내 상태 파일을 한 줄 더하면 끝이라고 봤다. 그러다 원래 옮기던 settings.json이 눈에 걸렸다. 그 파일을 통째로 옮기면 부모 세션의 무관한 hook까지 전부 죽는다. 더 나쁘게는 설정이 사라진 상태에서 CLI가 마켓플레이스 플러그인을 기본 활성으로 되살릴 여지가 생긴다. 오염을 막으려고 옮긴 파일이 오염을 새로 만든다.

    그래서 방향을 뒤집었다. 옮기는 건 register hook의 활성화 상태 파일뿐. 상태가 없으면 hook은 애초에 입을 열지 않으니 settings.json은 건드릴 필요가 없다.

    # 격리 대상 = register hook의 활성화 상태 파일. settings.json은 기본적으로 손대지 않는다.
    import os
    from pathlib import Path
    
    claude = Path.home() / ".claude"
    state_files = [claude / ".scrooge-active", claude / ".caveman-active"]
    state_files += sorted(claude.glob(".scrooge-active-*"))  # 세션 단위 상태까지
    targets = [(sf, Path(f"/tmp/bench-{sf.name}.{os.getpid()}.bak")) for sf in state_files]
    # edge case: 부모 세션이 실행 도중 파일을 다시 만들면, 되돌릴 때 백업을 덮어쓰지 않고 버린다.

    여기에 두 겹을 더 얹었다. 하나는 측정 직전 사전 점검이다. 격리 창 안에서 상태 파일이 실제로 사라졌는지 확인한다. 남아 있으면 실행을 중단한다. 심각도는 둘로 나눴다. 지금 당장 주입하는 채널(남은 상태 파일, 활성 플래그)은 blocking, 설치는 돼 있지만 켜지지 않은 것(마켓플레이스에 놓인 플러그인, 스킬 심링크)은 advisory로 출력만. 이 구분을 안 하면 "설치돼 있음"만으로 실행이 막혀서 결국 아무도 사전 점검을 켜지 않는다.

    다른 하나가 실제 backstop이다. 실행이 끝난 뒤 각 행의 세션 transcript를 열어 register 지문을 찾는다. 걸린 행은 점수에서 빼고 --resume으로 재시도한다.

    import json, re
    from pathlib import Path
    
    SCROOGE_HOOK = re.compile(r"SCROOGE\s+(활성|active|MODE ACTIVE|OFF)", re.IGNORECASE)
    CAVEMAN = re.compile(r"caveman", re.IGNORECASE)
    NOISE_KEYS = ("cwd", "gitBranch")
    
    def injection_surface(session_path: Path) -> str | None:
        """주입 표면(첨부·hook·메시지)만 이어 붙인다. 파싱 안 되는 줄은 건너뛴다 —
        raw 텍스트를 그냥 스캔하면 아래 함정에 걸린다."""
        try:
            raw = session_path.read_text(encoding="utf-8")
        except OSError:
            return None
        chunks = []
        for line in raw.split("\n"):
            if not line.strip():
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(obj, dict):
                for k in NOISE_KEYS:   # ← 이 두 줄이 함정을 막는다
                    obj.pop(k, None)
            chunks.append(json.dumps(obj, ensure_ascii=False))
        return "\n".join(chunks)
    
    def detect_contamination(session_path: Path, arm: str) -> str | None:
        text = injection_surface(session_path)
        if text is None:
            return None
        if SCROOGE_HOOK.search(text):
            # 격리 후에는 어느 arm에서도 나오면 안 된다. scrooge arm도 마찬가지 —
            # 그 arm의 register는 오직 --system-prompt로만 들어와야 하므로.
            return "register hook injection in transcript"
        if "caveman" not in arm.lower() and CAVEMAN.search(text):
            return "caveman fingerprint in transcript"   # caveman arm은 그 단어를 가질 자격이 있다
        return None

    첫 버전은 자기 자신을 오염으로 신고했다

    처음엔 injection_surface 없이 transcript 원문을 통째로 정규식에 넣었다. 전부 오염으로 잡혔다.

    CLI가 거의 모든 줄에 cwd와 gitBranch를 찍는데 벤치를 돌린 작업 경로에 도구 이름이 들어 있었기 때문이다. 검출기가 "이 세션은 scrooge라는 문자열을 포함한다"는 이유로 모든 행을 버렸다. 지문을 찾을 곳은 문서 전체가 아니라 주입이 실제로 들어오는 표면이다. 그래서 줄마다 JSON으로 파싱해 메타데이터 키를 떼고 다시 직렬화한다. 파싱이 안 되는 줄은 스캔하지 않고 버린다 — 원문으로 훑는 순간 같은 함정으로 되돌아간다.

    이 행 단위 검출은 만들 때만 해도 사전 점검이 있으니 형식적인 이중 안전장치라고 생각했다. 두 달 뒤 다른 실험을 돌리다 보니 실제로 몇 행씩 걸렸다. 어떤 실행에서는 102행 중 1행, 다른 실행에서는 48행 중 2행. 사전 점검은 시작 시점의 한 컷인데 벤치는 그 뒤로 몇십 분을 더 돈다. 그 사이에 상태는 되살아난다.

    재측정 결과, 그리고 이 표에 붙는 조건

    격리한 뒤 다시 쟀다. 모델을 명시적으로 핀으로 고정했고, 토큰은 응답 id로 dedup 한 산문 기준으로만 셌다.

    실행 normal terse scrooge full caveman:full 절감
    한국어 (N=20) 3186 2597 972 1203 ~70%
    영어 (N=21) 3578 2509 969 1264 ~73%

    붙여야 할 조건이 셋 있다. 단일 실행 paired median이라 분산 추정치가 없고, 다시 돌리면 각 셀은 몇 퍼센트포인트 움직인다. 절감률은 한 자리 유효숫자 추정치로 읽어야 한다. 그리고 이 청정 실행에서는 모델 핀도 함께 바뀌었으므로, 출시 당시 표의 normal 중앙값 2235와 여기 3578을 나란히 놓고 "차이는 전부 오염 탓"이라고 말할 수 없다. 방향은 분명하지만 크기는 분해되지 않는다.

    확인 하나는 따로 했다. 규칙을 튜닝한 코퍼스와 겹치지 않는 별도 프롬프트 세트로 다시 재서 한국어 ~71%, 영어 ~68%가 나왔다. 헤드라인이 튜닝 코퍼스에 과적합된 산물은 아니라는 뜻이다.


    측정 도구가 측정 환경 안에 살면 그 도구를 끄는 일도 측정 절차의 일부다. 격리는 "경쟁 도구를 치우는 일"이 아니라 "주입 채널을 전부 세는 일"이다 — 내 것을 포함해서. 그리고 시작 시점 사전 점검만으로는 부족하다. 실행이 길면 상태는 도중에 되살아난다. 그걸 잡는 건 행 단위 검출뿐이다.

    지금 자기 벤치마크의 baseline arm 출력 하나를 열어서 거기 있어서는 안 될 지시가 묻어 있지 않은지 눈으로 확인해 보라.

    참고

    • Claude Code hooks 레퍼런스 — UserPromptSubmit이 돌려준 additionalContext가 "제출된 프롬프트 옆에" 붙는다는 서술. 이 글에서 --system-prompt가 hook을 막지 못하는 이유의 근거다. (2026-08 확인)
    반응형

    댓글

Designed by Tistory.