에이전트 운영 회고 (2/7) — AI Cron에서 하루 1000만 토큰이 낭비되고 있었다

bash만 실행하면 되는 작업에 LLM 컨텍스트를 전부 로드하던 구조적 결함


핵심 요약

  • AI 스케줄러에서 [SILENT] 태그가 "출력 억제"로만 해석되고 "LLM 스킵"으로는 작동하지 않는 구조적 결함 패턴
  • bash-only 작업 6개, 196 틱/일, ~10.1M 토큰/일이 불필요하게 소비되던 케이스의 측정·진단
  • macOS launchd로 이관 후 196→24 틱/일, ~10.5M→0.4M 토큰/일, 약 96% 감소
  • LLM 스케줄러 설계 시 "작업별 LLM 필요성 판정 → 실행 경로 분기"가 선행돼야 한다는 원리

이 글이 전달하는 것

LLM 에이전트에 cron 스타일 스케줄러를 붙여 운영할 때 자주 발생하는 토큰 누수 패턴을 정리한다. 태그 기반 필터가 의도대로 분기되지 않을 때 어떤 측정으로 문제를 드러낼 수 있는지, bash-only 작업을 OS 스케줄러로 이관할 때 어떤 모듈 구조와 plist 템플릿을 쓰는지, 그리고 업스트림 API에 어떤 파라미터가 빠져 있을 때 이런 누수가 구조화되는지를 다룬다.

전체 톤은 "경험담"이 아니라 "측정 가능한 결함과 대응 기법" 쪽이다.


문제 패턴: "태그로 의도를 표시했지만 경로가 무시한다"

AI 스케줄러에서 memory-session-scan 이라는 작업이 10분마다 돌아가는데, 순수 bash 작업임에도 매 실행마다 토큰 비용이 기록되고 있었다.

이 작업은 세션 파일을 스캔해서 sqlite에 기록하는 I/O 작업으로, LLM 판단이 끼어들 여지가 없다. 그런데 실행 경로상 LLM 호출이 발생하고 있다는 것은 태그 해석 계층이 실제 분기로 연결되지 않았다는 신호다.


작동 원리: 컨텍스트 로딩이 실행 경로에 하드코딩된 구조

scheduler.py:742에 결함이 응축돼 있다.

def _execute_job(self, job: CronJob) -> None:
    ...
    result = self.agent.run_conversation(
        message=job.command,
        context_files=["SOUL.md", "USER.md", "CLAUDE.md"]
    )

run_conversation()은 호출될 때마다 SOUL.md, USER.md, CLAUDE.md를 컨텍스트로 로드한다. 합산하면 4K+ 토큰. 결정적 지점은 이 경로가 모든 작업에 무조건 적용된다는 점이다. [SILENT] 태그가 붙은 bash-only 작업도 동일 경로를 탄다.

[SILENT] 태그는 "LLM 없이 실행"을 의도한 마커였지만, 실제 구현에서는 실행 후 로그 출력만 억제하는 데 쓰였다. 태그 해석 로직이 _execute_job() 내부에 존재하지 않았기 때문이다. 즉, 태그는 존재하되 실행 경로에서 분기점을 만들지 않는 전형적 결함이다.


측정: 낭비 규모 정량화

6개 작업을 하나씩 집어서 일일 토큰 낭비를 계산한다.

작업 빈도 틱/일 추정 토큰/틱 일일 낭비
memory-session-scan 10분마다 144 ~38,000 ~5.5M
memory-micro-cycle 30분마다 48 ~94,000 ~4.5M
기타 4개 다양 4 다양 ~0.1M
합계 196 ~10.1M

하루 10.1M 토큰. 그중 LLM 판단이 실제로 요구되는 비율은 0이다. 파일 스캔, 로그 정리, 체크섬 비교 같은 결정론적 작업만으로 구성돼 있다.

비용으로 환산하지 않더라도 이 토큰들은 컨텍스트 윈도우를 점유하고 API 응답 레이턴시를 추가한다. 로컬 모델이라도 낭비 구조는 동일하다.


해결 기법: bash-only 작업을 OS 스케줄러로 이관

LLM 판단이 필요 없는 작업은 OS 스케줄러 계층으로 내려야 한다. macOS에서는 launchd가 담당한다.

modules/cron-launchd/ 모듈을 신설한다. 구조는 단순하다.

modules/cron-launchd/
├── __init__.py
├── manager.py          # plist 생성/설치/제거
└── plists/             # 생성된 plist 파일 저장

각 bash-only 작업마다 .plist를 생성하고 ~/Library/LaunchAgents/에 설치한다. CLI는 memcore 서브커맨드로 통합한다.

python -m memcore launchd-install

python -m memcore launchd-status

python -m memcore launchd-uninstall

memory-session-scan 의 plist 템플릿.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.hermes.memory-session-scan</string>
    <key>ProgramArguments</key>
    <array>
        <string>/path/to/python</string>
        <string>-m</string>
        <string>memcore</string>
        <string>session-scan</string>
    </array>
    <key>StartInterval</key>
    <integer>600</integer>
    <key>RunAtLoad</key>
    <false/>
</dict>
</plist>

분류 기준: LLM cron에 남길 작업 vs OS 스케줄러로 내릴 작업

이관 후 AI 스케줄러에 남긴 작업 5개는 LLM 사용이 정당화되는 것들이다.

작업 이유
heartbeat-tick 에이전트 상태 판단 + 응답 생성
daily-brief 일일 요약 생성 (LLM 필수)
reflect 자기 반성 사이클 (LLM 필수)
research-scan 리서치 트리거 판단
monthly-memory 장기 기억 압축 (LLM 필수)

bash로 대체 불가하며, 콘텐츠 생성 또는 비결정론적 판단이 포함된다. 분기 기준은 단순하다. "이 작업의 출력이 결정론적 함수로 기술 가능한가?" 그렇다면 OS 스케줄러, 그렇지 않다면 LLM cron.


측정 결과

지표 이관 전 이관 후 변화
AI cron 작업 수 11 5 -55%
일일 틱 수 ~196 ~24 -88%
일일 토큰 소비 ~10.5M ~0.4M -96%

11개 작업 중 6개가 launchd로 이동했다. 토큰 소비는 10.5M에서 0.4M 수준으로 내려갔다.


한계와 업스트림 이슈

이 해법은 증상 처리에 가깝다. 근본 원인은 run_conversation()skip_context_files 파라미터가 없다는 설계 결함이다. 호출 측에서 "이 작업은 bash-only"라는 신호를 줘도, API 계층에서 컨텍스트 파일 로딩을 건너뛸 경로 자체가 없다.

이 누락은 GitHub #7876으로 트래킹 중이다. skip_context_files=True 파라미터가 추가되면, launchd 이관 없이도 AI cron 내부에서 처리 가능해진다. 단, 추가되더라도 결정론적 작업을 OS 스케줄러에 위임하는 분리 원칙은 유지할 가치가 있다. LLM 스케줄러는 LLM이 필요한 작업에만 책임을 지는 편이 실패 격리와 디버깅 면에서 유리하다.


적용 가능 범위

이 패턴은 Hermes 고유의 문제가 아니다. LLM 에이전트에 주기적 작업을 붙일 때 반복적으로 발생하는 구조적 결함에 가깝다. 다음 조건 중 하나라도 해당한다면 동일한 누수가 의심된다.

  • 스케줄러가 작업별로 컨텍스트 파일을 강제 로딩한다
  • "silent", "quiet", "no-llm" 같은 태그가 있지만 실행 경로에서 분기되지 않는다
  • 순수 I/O 작업이 주기적으로 실행되는데 토큰 비용이 기록된다

진단 절차는 세 단계로 요약된다. (1) 작업별 틱당 토큰 소비를 로깅한다. (2) 각 작업의 출력이 결정론적 함수로 기술 가능한지 분류한다. (3) 결정론적 작업은 OS 스케줄러로 이관하거나, 컨텍스트 로딩 스킵 경로를 만든다.


열린 질문

  • 태그 의미론을 실행 경로와 어떻게 강제로 연결할 것인가 (런타임 검증? 타입 시스템?)
  • Linux/Windows 환경에서 동일 분리를 유지하려면 systemd/Task Scheduler용 어댑터를 어디까지 추상화할 것인가
  • "LLM 판단이 필요한가"를 개발자가 아닌 스케줄러가 자동 판정할 수 있는가 (정적 분석 기반 휴리스틱)

태그로 의도를 표시하는 것만으로는 부족하다. 실행 경로에서 그 의도가 실제로 분기를 만드는지를 설계 단계에서 검증해야 한다는 것이 이 케이스의 원리적 교훈이다.

시리즈 전체 안내: 시리즈 목차

댓글

이 블로그의 인기 게시물

"LLM 핵심 학습 (1/6) — 기본: 토큰화·임베딩·어텐션·위치 인코딩"

"LLM 핵심 학습 (2/6) — 파인튜닝: LoRA·QLoRA·증류·Adapter"

"ML 기초 학습 (1/9) — 머신러닝과 sklearn: 학습의 좌표계"