장르: 개념 심화. 같은 날 쓴 Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유가 내부의 가로 지도를 그렸다면, 이 글은 그중 에이전트 루프 한 덩어리를 세로로 관통한다.
공식 문서의 에이전트 루프 편을 한 줄씩 따라가며 질문 32개를 던졌고, 그 답을 순서대로 정리했다. 중간에 읽고 만든 모델 세 개가 산수와 코드 구조 앞에서 깨졌다. 그 정정 과정도 같이 적는다.
이 글에서 다루는 것
프로바이더의 정의와 종류, 같은 모델이 프로바이더에 따라 달라지는 이유, 컨텍스트 윈도우를 정하는 세 개의 층, 턴 라이프사이클 아홉 단계와 되돌아가는 지점, 메시지 역할 규칙, 툴 결정의 3단계, 병렬 툴 호출, 레지스트리를 우회하는 특수 툴, 콜백 여덟 개, 폴백 메커니즘, 게이트웨이의 정의, 과금 단위, 압축 두 층의 상호작용, 에이전트 객체의 수명.
저장 계층(SQLite 스키마), 에디터 연동 프로토콜, 프롬프트 3계층의 내부 구성, 터미널 백엔드 여섯 종류는 앞선 글에서 다뤘으므로 여기서는 반복하지 않는다.
1. 에이전트 코어는 전체 코드의 1%다
앞선 글에서 "진입점 다섯 개가 코어 하나로 수렴한다"를 확인했다. 그 코어가 얼마나 큰지 실측해봤다.
공개 저장소의 최신 기준으로, 테스트를 제외한 파이썬 997개 파일 총 31.25 MB 중에서:
| 크기 | 비중 | |
|---|---|---|
에이전트 코어 (run_agent.py) |
318,029 B | 1.02% (크기 순위 15위/997) |
| CLI 디렉터리 | 7.45 MB | 23.83% |
| 에이전트 보조 모듈 | 5.02 MB | 16.05% |
| 플러그인 | 4.82 MB | 15.41% |
| 툴 | 4.21 MB | 13.47% |
| 게이트웨이 | 4.04 MB | 12.94% |
게이트웨이 러너 파일 하나(1.19 MB)가 에이전트 코어의 3.7배다. 문서가 코어를 "large file"이라 부르지만 이 저장소 기준으로는 최대가 아니다.
작은 이유는 위임 때문이다. 코어는 직접 일하지 않고 프롬프트 빌더, 프로바이더 해석기, 툴 디스패처, 압축기에 넘긴다. 설계 원칙에 이렇게 쓰여 있다.
플랫폼 중립 코어 — 하나의 에이전트 클래스가 CLI, 게이트웨이, 에디터 연동, 배치, API 서버를 모두 담당한다. 플랫폼 차이는 진입점에 있지 에이전트에 있지 않다.
실무적으로는 이게 가장 쓸모 있는 성질이다. 채널 봇이든 CLI든 예약 작업이든 대화 루프 동작이 완전히 같으므로, CLI에서 재현되는 문제는 채널에서도 재현되고 그 반대도 성립한다. 디버깅할 때 가장 먼저 써먹을 성질이다.
2. 프로바이더란 무엇인가
"어디서 받아오느냐"만으로는 부족하다
프로바이더 해석기의 출력이 곧 정의다.
(프로바이더, 모델)
→ { 프로바이더, api_mode, base_url, api_key, 출처, 만료·갱신 메타데이터 }
주소만이면 api_mode가 필요 없다. 그런데 프로바이더를 바꾸면 프로토콜 자체가 바뀐다.
| 애그리게이터 경유 | 벤더 네이티브 | |
|---|---|---|
api_mode |
chat_completions |
anthropic_messages |
| 직렬화 경로 | OpenAI 포맷 그대로 | 전용 어댑터 경유 |
| 프롬프트 캐싱 | 조건부 | 지원 |
| 키 형식 | 다름 | 다름 |
즉 프로바이더는 주소와 말투와 신분증의 묶음이다.
왜 하필 "provider"인가
제공되는 것이 모델 가중치가 아니라 모델을 대신 돌려주는 서비스이기 때문이다. 애그리게이터는 모델을 만들지도 호스팅하지도 않지만 제공한다. "vendor"(파는 사람)나 "host"(호스팅하는 사람)로는 이 관계가 안 맞는다.
그런데 이 단어가 세 군데서 겹친다
코드에서는 실제로 model-providers라고 부르는데 문서 산문에서만 앞을 뗀다. 문제는 같은 시스템에
provider가 세 종류 있다는 점이다.
| 종류 | 성격 |
|---|---|
| 모델 프로바이더 | 여러 개 나열 가능(폴백 체인) |
| 메모리 프로바이더 | 프로필당 하나만 활성 |
| 컨텍스트 엔진 | 하나만 활성 |
메모리 상태를 보는 명령이 말하는 "provider"와 설정 점검 명령이 검사하는 "provider"는 다른 것이다. 운영하면서 "provider가 하나만 된다더니 왜 폴백은 여러 개지?" 하는 혼동이 여기서 온다. 하나만 되는 건 메모리 쪽이다.
번들 프로바이더는 18종이 넘는다. 애그리게이터, 벤더 직접, 중국계, 클라우드(Bedrock/Foundry/NIM), 로컬(Ollama, LM Studio), 그리고 OpenAI 호환이면 뭐든 붙일 수 있는 범용 슬롯까지. 추가는 플러그인 디렉터리에 등록 함수를 호출하는 파일 하나를 넣으면 끝이고, 해석기 코드에 분기를 추가할 필요가 없다.
키가 새지 않는 구조
프로바이더 개념이 존재하는 실용적 이유 하나가 이것이다. 각 키가 자기 base_url에 스코프된다. 애그리게이터 키는 애그리게이터 엔드포인트로만 나가고, 커스텀 엔드포인트에는 다른 키가 쓰인다. 그래서 로컬 모델 서버를 붙였다가 상용 키가 거기로 새는 사고가 구조적으로 안 난다.
설정 파일이 환경변수를 이긴다
해석 우선순위는 명시적 런타임 요청, 설정 파일, 환경변수, 기본값 순이다. 문서가 이유를 밝힌다.
셸에 남아 있던 낡은 export가, 사용자가 마지막으로 고른 엔드포인트를 조용히 덮어쓰는 것을 막는다.
환경변수를 고쳤는데 반영이 안 되는 것처럼 보이면 대개 설정 파일이 이기고 있는 것이다.
3. 같은 모델도 프로바이더가 다르면 다르게 동작한다
문서가 직접 증명하는 사례가 네 가지다.
컨텍스트 윈도우가 최대 3.9배 차이
같은 모델 슬러그인데:
| 경유 경로 | 컨텍스트 |
|---|---|
| OAuth 코딩 백엔드 | 272K (하드캡) |
| 벤더 직접 / 애그리게이터 | 1.05M |
| 또 다른 개발도구 | 400K |
272K짜리를 기본 50%로 압축하면 모델이 쓸 수 있는 창의 절반을 버리는 셈이다. 그래서 이 조합만 특별 처리해서 트리거를 85%로 올리는 로직이 따로 들어 있다.
프롬프트 캐싱 가능 여부가 갈린다
네이티브 경로일 때만 캐시 브레이크포인트를 넣는다(요청당 최대 4개). 멀티턴 입력 비용을 약 75% 줄인다. 20턴 대화에서 시스템 프롬프트 8K가 매 턴 재전송되면 160K를 정가로 내는데, 캐싱되면 대부분이 캐시 히트 가격이다.
압축 시점이 달라진다
임계값은 비율이므로 컨텍스트 차이가 그대로 압축 시점 차이가 된다. 압축은 정보 손실이니, 일찍 압축되는 경로에서 봇이 "아까 말한 그거"를 더 자주 잊는다.
폴백 시 클라이언트가 재생성된다
폴백이 네이티브 경로로 넘어가면 호환 클라이언트가 아니라 전용 클라이언트를 새로 빌드하고 캐싱 설정을 재평가한다.
양자화 수준이나 레이트 리밋 같은 것도 실무에서는 차이를 만들지만, 이건 문서 근거가 없는 일반 지식이므로 구분해둔다.
진단할 때 모델명만 보면 안 된다는 게 결론이다. "모델을 바꿨는데 왜 다르게 동작하지?"의 답이 모델이 아니라 프로바이더인 경우가 많다.
4. 컨텍스트 윈도우를 정하는 세 개의 층
이 세션에서 가장 중요한 발견이다. "모델이 정한다"는 절반만 맞다.
① 모델 아키텍처 — 학습 시점에 정해진 최대치 (모델 제작자)
② 서빙 설정 — 실제로 열어주는 창 (프로바이더) ← 실사용값 결정
③ 클라이언트의 믿음 — 그렇다고 알고 있는 값 (메타데이터 레지스트리)
②의 증거는 모델 ID 접미사에 남아 있다. -fast, -200k, -1024k, [1m] 같은 꼬리표는
모델 이름이 아니라 배포·라우팅 식별자다. 모델이 컨텍스트를 정한다면 이런 게 존재할 이유가 없다.
①의 사례도 있다. 같은 벤더의 상위 모델은 1M, 경량 모델은 200K다. 출력 상한은 또 별개로
128K와 64K다. 그래서 조회 API의 필드 이름이 context_window가 아니라 max_input_tokens다.
모델의 속성이 아니라 이 배포가 받아주는 입력 상한이라는 뜻이다.
③이 우리가 실제로 보는 값이다
여기가 반전이었다. 에이전트는 프로바이더에게 컨텍스트를 물어보지 않는다. 자기 테이블에서 조회한다. 모델별 컨텍스트 길이를 담은 모듈과, 커뮤니티 모델 레지스트리를 연동하는 모듈이 따로 있다.
즉 우리가 보는 컨텍스트 수치는 모델이 응답으로 알려준 값도 아니고 프로바이더가 헤더로 내려준 값도 아니다. 외부 레지스트리 등록값을 읽어온 것이다.
이게 왜 위험한가 하면 틀려도 에러가 안 나기 때문이다.
| 등록값이 실제보다 | 결과 |
|---|---|
| 크면 | 압축을 늦게 걸어서 API가 컨텍스트 초과로 거부 |
| 작으면 | 창의 일부만 쓰고 불필요하게 자주 압축 |
둘 다 "봇이 자꾸 맥락을 잃는다" 또는 "가끔 응답이 안 온다"로만 나타난다. 원인이 메타데이터 한 줄에 있다는 걸 알아채기 어렵다.
5. 그 값은 어디에 저장되는가 — 아무 데도 안 된다
설정 파일에 컨텍스트 길이 항목은 없다. 저장되는 건 사용자가 고른 것(모델 이름, 프로바이더, 압축 비율)뿐이고, 거기서 파생되는 토큰 수는 매번 다시 계산한다.
재계산 시점은 셋이다. 에이전트가 생성될 때, 모델을 전환할 때, 폴백이 발동할 때. 문서도 "모델별 임계값 오버라이드는 모델 전환마다 재해석된다"고 명시하고, 컨텍스트 엔진 플러그인이 재정의할 훅 이름까지 밝힌다.
설계로는 옳다. 저장해뒀다면 모델을 바꿨을 때 낡은 값이 남아 압축 시점이 어긋났을 것이다.
대신 관측성이 나빠진다. "지금 이 모델의 컨텍스트를 몇으로 알고 있나"를 물어보는 명령이 없다. 로그에서 압축이 실제로 발동한 지점을 역산하거나, 세션 DB에 남은 압축 이벤트를 보는 게 사실상 유일한 확인 경로다.
6. 턴 라이프사이클 아홉 단계
원문의 아홉 단계를 그대로 옮기면 이렇다.
① task_id 생성 (미제공 시)
② 사용자 메시지를 대화 히스토리에 추가
③ 시스템 프롬프트 빌드 또는 캐시된 것 재사용
④ preflight 압축 필요 여부 검사
⑤ 대화 히스토리 → API 메시지 빌드 (api_mode 별 분기)
⑥ 임시 프롬프트 레이어 주입 (예산 경고, 컨텍스트 압박)
⑦ 프롬프트 캐싱 마커 적용
⑧ 중단 가능한 API 호출
⑨ 응답 파싱
- 툴 호출이 있으면: 실행 → 결과 추가 → ⑤로 복귀
- 텍스트면: 세션 저장 → 메모리 flush → 반환
눈여겨볼 단계가 셋이다.
③의 "재사용"이 핵심이다. 설계 원칙 중에 "프롬프트 안정성"이 있다. 대화 도중에는 시스템 프롬프트가 바뀌지 않는다. 사용자가 명시적으로 모델을 바꾸는 것만 예외다.
⑥의 "임시"는 히스토리에 저장되지 않는다는 뜻이다. "예산 90 중 80 사용" 같은 경고가 히스토리에 눌러앉으면 다음 턴 캐시가 깨지고 문맥도 오염된다. 그래서 이번 요청에만 붙였다가 버린다.
⑧은 스레드 두 개로 돌아간다.
메인 스레드 API 스레드
대기: HTTP POST
- 응답 준비 완료 ───▶ 프로바이더로
- 중단 이벤트
- 타임아웃
중단되면(사용자가 새 메시지를 보내거나, 중지 명령을 치거나, 시그널이 오면) API 스레드는 버려지고 응답은 폐기된다. 부분 응답이 히스토리에 주입되지 않는다는 게 중요하다. 중단해도 대화가 오염되지 않는다.
7. 툴 루프는 왜 하필 5번으로 되돌아가는가
⑨에서 툴 호출이 있으면 ⑤로 돌아간다. 왜 ⑧이 아니고, 왜 ③이 아닐까. 이유가 셋이다.
히스토리가 변했으니 재직렬화가 필요하다. 툴 결과 메시지가 붙었는데, 이걸 프로바이더가 이해하는 형태로 다시 바꿔야 한다. api_mode마다 표현이 다르다. ⑧로 바로 갈 수 없는 이유다.
임시 레이어를 다시 계산해야 한다. 반복 카운터가 올라갔고 토큰 사용량도 늘었다. "예산 80% 소진" 경고는 이 시점에 재평가돼야 의미가 있다.
캐시 마커를 재배치해야 한다. 프리픽스가 길어졌으니 브레이크포인트를 다시 놓아야 적중이 유지된다.
그런데 더 중요한 건 ③과 ④로는 안 돌아간다는 점이다.
③으로 안 가는 건 프롬프트 안정성의 구현이다. 턴 중간에 시스템 프롬프트를 재조립하면 캐시 프리픽스가 통째로 깨져서 비용이 폭증한다.
④로 안 가는 건 부작용이 있다. 압축 검사가 턴당 한 번뿐이라는 뜻이기 때문이다. 툴을 여러 번 호출하는 긴 작업이면 한 턴 안에서 컨텍스트가 계속 부풀어도 다시 안 잡힌다. 16절의 결론이 여기서 나온다.
8. 메시지 역할과 교대 규칙
내부적으로는 역할이 넷뿐이다. system(지시문), user(모델 입장의 상대방), assistant(모델
자신의 출력), tool(툴 실행 결과).
교대 규칙은 이렇다.
시스템 이후: User → Assistant → User → Assistant → ...
툴 호출 중: Assistant(툴 호출) → Tool → Tool → ... → Assistant
assistant 연속 금지 / user 연속 금지 / tool 만 연속 허용
지켜야 하는 이유가 셋이다. 프로바이더가 거부한다(스타일 권고가 아니라 400 에러가 나는 제약이다). 모델이 교대 구조로 학습됐다(연속 발화는 학습 분포 밖이라 품질이 떨어진다). 대화 경계가 모호해진다.
tool만 연속이 허용되는 이유는 단순하다. 하나의 assistant 메시지가 툴 호출 셋을 담을 수 있고
각각이 하나의 결과 메시지로 응답돼야 한다. 1 대 N 구조가 병렬 툴 호출의 필연적 결과다.
채널에 여러 사람이 있으면 규칙이 깨진다
#sales 채널:
A: @bot 이번 분기 목표 알려줘
B: 아 그거 내가 어제 올린 문서에 있어
C: 봇아 그 문서 요약해줘
모델이 보는 역할 라벨:
{"role": "user", ...} ← A
{"role": "user", ...} ← B 연속 user, 규칙 위반
{"role": "user", ...} ← C
어댑터가 병합하거나 이름 접두사를 붙여서 해결해야 한다. 우리가 쓰는 어댑터가 어느 쪽인지는 아직 확인하지 못했다.
멘션 필수 설정이 이 문제를 상당 부분 우회한다. 멘션 없는 메시지가 걸러지니 연속 발화 확률이 낮아진다. 대신 부작용이 있다. 위 예시에서 B의 발언이 걸러지면 모델은 "어제 올린 문서"의 존재를 모른 채 C의 요청을 받는다. 채널 봇이 맥락을 놓치는 전형적 패턴이고, 설정 문제가 아니라 구조다.
압축도 이 규칙에 묶여 있다
압축 요약 메시지를 넣을 때 역할이 동적으로 정해진다. 앞이 user면 요약을 assistant로, 앞이 assistant면 user로 넣는다. 내용이 아니라 앞뒤 역할이 결정한다.
같은 이유로 정리 함수가 하나 더 있다. 압축으로 툴 호출은 살아남았는데 결과가 잘려나가면 고아 호출이 생겨 프로바이더가 반려하므로, 잘린 쪽에 스텁 결과를 주입한다. 반대로 결과만 남고 호출이 사라졌으면 결과를 제거한다.
9. 툴은 누가 정하는가 — 3단계
"디스패치가 툴을 결정하는 단계인가"라는 질문에서 시작했는데, 답은 아니었다. 결정은 3단계로 나뉘고 주체가 셋이다.
| 단계 | 주체 | 결정하는 것 |
|---|---|---|
| ① 메뉴판 | 시스템 | 어떤 툴이 존재하는 것으로 보일지 |
| ② 주문 | 모델 | 그중 무엇을 어떤 인자로 부를지 |
| ③ 배선 | 레지스트리 | 그 이름에 어떤 함수를 연결할지 |
메뉴판 단계의 두 겹 필터
첫째는 툴셋 필터다. 플랫폼별로 켜고 끄고, 설정 파일에 저장된다.
둘째가 흥미롭다. 각 툴이 가용성 판정 함수를 가질 수 있다.
if entry.check_fn:
try:
available = bool(entry.check_fn())
except Exception:
available = False # 예외가 나면 사용 불가로 간주 (fail-safe)
if not available:
continue # 스키마에서 통째로 제외
API 키 존재 여부, 서비스 구동 여부, 바이너리 설치 여부 같은 걸 본다. 결과는 호출당 캐시되므로 같은 판정 함수를 공유하는 툴들은 한 번만 검사한다.
이게 운영에서 실제로 나타나는 방식이 재밌다. 문서 검색 MCP 서버가 죽어 있으면 판정 함수가 False를 반환하고 모델은 그 툴의 존재조차 모른다. 그래서 봇이 "검색이 실패했습니다"가 아니라 "제가 사규를 조회할 방법이 없네요"라고 답한다.
이 두 문장을 구분하는 게 진단의 출발점이다. 전자면 툴이 살아 있고 실행이 실패한 것, 후자면 툴이 메뉴에서 빠진 것이다.
메뉴판 단계에는 하나가 더 있다. 필터링이 끝난 뒤 코드 실행 툴과 브라우저 툴의 설명문에서 사라진 툴 이름을 제거한다. 안 그러면 모델이 없는 툴을 호출한다.
레지스트리란
싱글턴 툴 등록소다. 모든 툴 파일이 import 시점에 자기를 등록한다. 이름, 툴셋, 스키마, 핸들러, 가용성 함수, 비동기 여부, 이모지까지 한 번에 넘긴다. 현재 70개가 넘는 툴과 28개쯤의 툴셋이 있다.
발견 방식이 특이하다. 파일을 AST로 파싱해서 최상위에 등록 호출이 있는 것만 골라 import한다. 함수 안에 있는 호출은 매칭되지 않으므로 헬퍼 모듈은 로드되지 않는다. 유지할 import 목록이 없다.
선택적 툴의 import 에러(예: 이미지 생성 라이브러리 부재)는 잡아서 로그만 남기고 다른 툴 로딩을 막지 않는다.
디스패치가 하는 일
조회 한 번과 부가 작업 넷이다.
dispatch("terminal", {"command": "rm -rf /opt/data"})
↓ ① 조회 이름 → 등록 항목 (dict 조회, 판단 없음)
↓ ② 위험 검사 패턴 매칭 → 승인 콜백
↓ ③ 비동기 판정 브리지 필요 여부
↓ ④ 에러 봉인 예외 → JSON 에러 문자열
"고른다"기보다 "이름으로 찾아 실행한다"가 정확하다. 선택지가 여럿 있어 판단하는 게 아니라 키로 조회한다. 이름이 없으면 비슷한 걸 찾아주는 로직 없이 그냥 실패한다.
에러 봉인은 2중이다. 디스패치가 핸들러 예외를 잡아 JSON으로 만들고, 상위 함수가 그 전체를 다시 감싼다. 모델은 절대 처리되지 않은 예외를 보지 않는다.
위험 명령 승인은 정규식 목록으로 돌아간다. 재귀 삭제, 파일시스템 포맷, WHERE 없는 DELETE,
시스템 설정 덮어쓰기, 파이프로 받은 스크립트 실행, 포크밤 등. CLI에서는 대화형 프롬프트,
채널에서는 비동기 승인 메시지로 나간다. 재밌는 건 보조 LLM이 저위험 명령을 자동 승인하는
옵션이 있다는 점이다. rm -rf node_modules/는 패턴에 걸리지만 안전하니까.
10. 툴 여러 개를 동시에, 그런데 확인 질문이 끼면
모델이 한 번에 툴 셋을 부르는 경우:
read_file(README.md) + terminal(pytest) + web_search("...")
단일 호출은 메인 스레드에서 직접, 복수는 스레드풀로 동시 실행한다. 각각 20ms, 40초, 2초라면 순차는 42초, 동시는 40초다.
완료 순서와 무관하게 결과는 원래 호출 순서대로 히스토리에 들어간다.
반례가 있다. 확인 질문 툴이 섞이면 순차가 강제된다.
clarify("prod DB를 지울까요, staging을 지울까요?")
terminal("psql -c 'DROP TABLE users'")
확인 질문은 사람의 답을 기다려야 하는데, 그 사이 삭제 명령이 병렬로 돌면 물어본 의미가 없다. 그래서 대화형 툴은 동시 실행에서 제외된다. 삭제 명령은 여기에 더해 패턴 매칭으로 승인 프롬프트도 걸린다.
11. 레지스트리를 우회하는 네 개의 툴
할 일 관리, 메모리 쓰기, 세션 검색, 하위 에이전트 위임. 이 넷은 레지스트리 디스패치 이전에 가로채인다.
이유는 순환 의존 회피다. 의존성 체인이 이렇게 생겼다.
레지스트리 (의존성 없음, 모든 툴 파일이 import)
↑
툴 파일들 (import 시점에 등록)
↑
툴 디스패처
↑
에이전트 코어, CLI, 배치 러너
레지스트리는 체인 맨 아래이고 에이전트 코어는 맨 위다. 디스패치가 에이전트를 알아야 한다면 순환이 생긴다. 그런데 이 네 툴은 에이전트 인스턴스 상태가 반드시 필요하다. 특히 위임 툴은 에이전트를 새로 인스턴스화해야 한다. 무상태 레지스트리에서 할 수 있는 일이 아니다.
여기서 설계가 영리하다. 스키마는 등록하되 핸들러는 스텁 에러를 반환한다.
- 스키마를 등록 안 하면 모델이 메모리 툴의 존재조차 모른다
- 핸들러를 정상 구현하면 가로채기 버그를 아무도 눈치 못 챈 채 상태 없는 반쪽 동작이 나간다
두 요구를 동시에 만족시키는 방법이다. 안티패턴이 아니라 방어다.
이게 우리 운영 이슈와 직결된다. 메모리 툴이 에이전트 레벨에서 가로채인다는 건 메모리 쓰기가 에이전트가 실제로 돌 때만 일어난다는 뜻이다. 멘션 없는 메시지가 에이전트 실행 전에 걸러지면 메모리 툴은 애초에 호출될 기회가 없다. "메모리 프로바이더가 채널 전체를 안 담는다"는 현상이 프로바이더 설정 문제가 아니라 실행 경로 문제인 이유다.
12. 콜백 여덟 개 — 보이는 것과 안 보이는 것
대화 실행 함수는 동기 블로킹이다. 툴을 90번 호출하면 5분간 아무것도 반환하지 않는다. 콜백이 없으면 CLI는 얼어붙고 채널 사용자는 봇이 죽었다고 생각한다.
| 콜백 | 발화 시점 |
|---|---|
| thinking | 모델이 사고 시작/종료 |
| stream_delta | 스트리밍 토큰마다 |
| tool_gen | 스트림에서 툴 호출이 파싱될 때 (실행 전) |
| reasoning | 모델이 추론 내용을 반환할 때 |
| tool_progress | 각 툴 실행 전/후 |
| clarify | 확인 질문 툴 호출 시 (블로킹) |
| step | 에이전트 턴 하나 완료 후 |
| status | 상태 전이 |
"콜백은 사용자에게 보이는 메시지인가" — 절반만 맞다
콜백은 알림 훅이고, 화면에 띄울지는 플랫폼이 정한다. 같은 진행 콜백이:
| 진입점 | 결과 |
|---|---|
| CLI | 스피너 옆 표시, 끝나면 사라짐 |
| 채널 게이트웨이 | 채널에 진행 메시지 포스팅 (보임) |
| 에디터 연동 | 상태 표시줄 갱신, 채팅에는 안 뜸 |
| 배치 러너 | 아무것도 안 함 (콜백 미등록) |
주목할 게 둘 있다.
툴 생성 콜백은 실행 전에 발화한다. 스트리밍 중 툴 호출 JSON이 완성되는 순간 알려주므로, "곧 이걸 실행합니다"를 응답이 다 오기도 전에 보여줄 수 있다. 체감 지연을 줄이는 장치다.
확인 질문 콜백만 블로킹이다. 나머지는 던지고 잊는 알림인데 이건 사용자 입력을 기다린다. 10절의 순차 강제가 이 콜백 때문이다.
13. 폴백은 왜 필요하고 언제 막히는가
외부 추론 API는 신뢰할 수 없고, 실패는 대화 도중에 일어난다.
트리거 세 지점
- 유효하지 않은 응답에 대한 재시도 소진 후 (선택지 없음, 내용 누락)
- 재시도가 무의미한 클라이언트 에러 — 401, 403, 404 → 즉시 폴백
- 일시적 에러에 대한 재시도 소진 후 — 429, 5xx
2번과 3번의 구분이 핵심이다. 429나 5xx는 기다리면 나아질 수 있으니 먼저 재시도하지만, 인증 실패나 모델 자체가 사라진 경우는 재시도가 무의미하므로 바로 넘어간다.
활성화 과정
1. 이미 활성화됐거나 미설정이면 즉시 중단
2. 새 클라이언트 빌드 (인증을 다시 해석)
3. api_mode 재판정
4. 모델·프로바이더·주소·클라이언트를 통째로 교체
5. 네이티브 경로면 전용 클라이언트를 별도 빌드
6. 프롬프트 캐싱 재평가
7. 재발동 방지 플래그
8. 재시도 카운트 리셋 후 루프 계속
대화 히스토리는 그대로 유지된다. 폴백이 없으면 30턴짜리 작업이 31턴에서 끝나고 처음부터 다시 해야 한다.
폴백이 안 되는 것
| 지원 | |
|---|---|
| 하위 에이전트 위임 | ❌ 프로바이더는 물려받지만 폴백 설정은 안 물려받음 |
| 보조 작업(요약·비전 등) | ❌ 독립적인 자동 감지 체인 |
| 예약 작업 | ✅ 설정에서 읽어 전달 |
그리고 이게 우리가 겪은 장애의 메커니즘이었다
예전 장애에서 무료 모델이 사라져 404가 났고, 동시에 자격증명 파일 소유권이 어긋나 401이 났다. 둘 다 트리거 2번에 해당하니 폴백이 발동해야 했다.
그런데 폴백은 2단계에서 새 클라이언트를 만들며 인증을 다시 해석한다. 자격증명 파일을 읽을 수 없으면 그 해석 자체가 실패한다. 폴백 경로가 통째로 봉쇄되는 것이다.
문서에 "401/403이면 폴백 전에 자격증명 갱신을 시도한다"고 쓰여 있는데, 갱신은 쓰기를 수반한다. 소유권이 잘못돼 있으면 갱신도 폴백도 못 한다.
교훈: 폴백 체인을 설정해두는 것만으로 부족하다. 폴백 대상의 자격증명을 컨테이너 사용자로 읽고 쓸 수 있는지까지 확인해야 한다.
14. 게이트웨이란 무엇인가
메시징 플랫폼과 에이전트 사이를 잇는, 프로필당 하나씩 도는 장수 프로세스다.
채널 웹소켓 이벤트
↓ 어댑터가 표준 이벤트로 정규화
게이트웨이 러너
├─ 1. 인가 검사 (allowlist / DM 페어링)
├─ 2. 멘션 필터 ← 걸리면 에이전트가 안 만들어짐
├─ 3. 세션 키 해석
├─ 4. 세션 위생 검사
├─ 5. 에이전트 인스턴스 생성 ← 여기서 처음 만들어짐
├─ 6. 대화 실행
└─ 7. 어댑터를 통해 응답 배달
역할이 일곱이다. 플랫폼 어댑터 20종, 세션 라우팅, 사용자 인가, 슬래시 커맨드 디스패치, 훅 시스템, 예약 작업 틱, 백그라운드 유지보수.
같은 프로필에 두 개를 붙일 일이 있나
일부러 하는 사람은 없다. 사고로 일어난다. 근거는 업스트림이 이 충돌을 예상하고 플래그를 만들어 뒀다는 점이다. 게이트웨이 실행 명령에 "이미 돌고 있으면 갈아치워라"는 옵션이 있다. 절대 안 일어나는 일이면 이 플래그가 존재할 이유가 없다.
사고 경로가 셋이다.
- compose 파일에 두 가지 실행 경로가 공존한다. 하나로 올려둔 상태에서 문서의 다른 명령을 복붙하면 컨테이너 둘이 같은 데이터 디렉터리를 마운트한다
- 프로세스 감시자가 자동 재기동한다. kill하면 즉시 새로 뜨는데, 죽은 줄 알고 수동으로 하나 더 띄우면 둘이 된다
- 디버깅하려고 포그라운드로 실행했는데 감시자가 관리하는 것이 이미 돌고 있다
깨지는 방식은 앞선 글에 정리했다. 체감 증상은 봇이 같은 메시지에 두 번 답장하거나 대화 맥락이 랜덤하게 사라지는 것이다.
15. 과금 단위는 턴이 아니라 API 호출이다
내부적으로 "턴"은 사용자 메시지 하나에서 최종 응답 하나까지이고, 그 안의 모든 툴 반복을 포함한다. 작업 ID 하나가 이 범위를 식별한다.
이 ID가 필요한 이유가 있다. 툴이 인자와 함께 작업 ID를 받고, 터미널 시스템이 작업별 작업 디렉터리 오버라이드를 지원한다.
[작업 A] "프로젝트 디렉터리로 가서 빌드해줘"
→ cd /srv/app 오버라이드가 작업 A에 기록
→ make build 같은 작업이라 그 디렉터리에서 실행
[작업 B] "로그 좀 보여줘" ← 새 작업, 오버라이드 없음
로그를 뒤질 때 작업 ID로 grep하면 그 턴의 툴 실행이 전부 묶여 나온다. 컨테이너 시계가 UTC라 현지 시각으로 범위를 자르는 것보다 정확하다.
그런데 과금은 다른 단위다
턴 하나
├─ 반복 1 → API 호출 #1 ← 과금
├─ 반복 2 → API 호출 #2 ← 과금
└─ 반복 3 → API 호출 #3 ← 과금
API는 stateless라 매 호출마다 전체 히스토리를 다시 보낸다. 실제로 계산해보면:
기준 컨텍스트 (시스템 프롬프트 + 스킬 + 메모리 + 히스토리) = 40,000 토큰
반복 1 ─ 입력 40,000 → 문서 검색, 결과 +5,000
반복 2 ─ 입력 45,000 → 파일 읽기, 결과 +8,000
반복 3 ─ 입력 53,000 → 최종 답변, 출력 2,000
────────────────────────────────────────────
입력 합계 138,000 / 출력 합계 2,000
프리미엄 모델 단가(입력 100만 토큰당 $5, 출력 $25)를 적용하면 질문 하나에 $0.74다. 기준 컨텍스트의 3.45배이고, 배수는 대략 반복 수와 같다.
기본 반복 상한은 90회다. 여기에 하위 에이전트는 독립 예산(기본 50)을 받으므로, 부모와 하위의 총합은 부모 상한을 넘을 수 있다.
완화 장치는 셋이다. 프롬프트 캐싱(약 75% 절감), 반복 상한 하향, 압축 임계값 하향.
여기서 컨텍스트가 큰 모델일수록 유리하다는 직관이 깨진다. 임계값은 비율이라 창이 클수록 압축 전 절대 토큰 수가 커지고 곧 호출당 비용이 커진다. 1M 모델을 기본 50%로 두면 500,000 토큰이 쌓일 때까지 압축을 안 한다. 모델별로 비율을 따로 낮추는 설정이 존재하는 이유다.
16. 압축 안전망이 한 번도 안 걸리는 이유
압축기가 두 층 있다.
| 에이전트 층 | 게이트웨이 층 | |
|---|---|---|
| 값 | 0.50 (기본) | 0.85 |
| 설정 가능? | 가능 | 하드코딩, 불가 |
| 실행 시점 | 턴 진입 시 | 에이전트 생성 전 |
| 발동 조건 | 없음 | 히스토리 4개 이상 + 압축 활성화 |
| 토큰 근거 | API 실측 | 실측 우선, 없으면 문자 추정 |
비대칭이 의도로 보인다. 에이전트 층은 워크로드마다 튜닝해야 하니 열어두고, 게이트웨이 층은 최후 방어선이라 못 만지게 잠가뒀다. 열어두면 누군가 95%로 올려놓고 방어선을 무력화한다.
먼저: 50%가 아니라 75%였다
숨은 규칙이 있다.
컨텍스트 윈도우가 512K 미만인 모델은 임계값 하한이 0.75로 상향된다 (상향 전용)
작은 모델에서 50%는 대화 시작하자마자 걸리는 지점이라 그걸 방지하는 장치다. 컨텍스트 60K 환경의 실제 수치는 이렇게 나온다.
컨텍스트 = 60,000
임계값 = 0.50 설정 → 512K 미만이라 0.75로 상향
압축 발동 = 60,000 × 0.75 = 45,000
꼬리 보호 예산 = 45,000 × 0.20 = 9,000
요약 최대 = min(60,000 × 0.05, 12,000) = 3,000
게이트웨이 안전망 = 60,000 × 0.85 = 51,000
그리고: 51,000에는 도달하지 않는다
45,000에서 압축이 걸리면 크기가 이만큼 줄어든다.
머리 (앞부분 3개 메시지) ≈ 1,000
요약 (최대 3,000) ≈ 3,000
꼬리 (예산 9,000) ≈ 9,000
─────────
~13,000
13,000에서 다시 자라기 시작하고 45,000에서 또 걸린다. 45,000은 51,000보다 작다.
단조 증가하는 한 51,000에는 영원히 도달하지 않는다.
앞선 글에서 나는 이 둘을 "50% 그물을 통과하면 85% 그물에 걸린다"는 2단 구조로 설명했는데 틀린 그림이었다. 정확히는 이렇다.
45,000 = 상시 작동하는 유일한 실질 문턱
51,000 = 45,000이 고장났을 때에만 의미가 생기는 후방 방어선
문서에 남아 있던 흔적도 이 결론과 맞는다. 게이트웨이 임계값을 에이전트와 같은 50%로 맞춰봤더니 매 턴 압축이 터져서 되돌렸다는 기록이 있다. 원래 잉여여야 정상인 층이었다.
그럼 언제 걸리나 — 넷뿐이고 전부 고장이다
(가) 압축 검사가 안 돌았을 때. 7절에서 본 대로 턴 진입 시 한 번만 검사한다. 툴을 여러 번 호출하면 한 턴 안에서 계속 부풀 수 있다. 다만 게이트웨이도 턴 중간엔 못 막고 다음 턴에야 잡는데, 그때는 45,000도 같이 걸린다.
(나) 압축했는데 충분히 안 줄었을 때. 진짜 케이스다. 아래 상세.
(다) 압축이 꺼졌거나 조용히 실패 중일 때. 요약 모델의 컨텍스트가 메인보다 작으면 요약 생성이 실패하는데, 경고 로그만 남기고 중간 대화를 요약 없이 버린다. 화면에는 아무 에러도 안 뜬다.
(라) 두 층이 다른 자로 재고 있을 때. 게이트웨이는 직전 턴 토큰 기록이 없으면 문자 추정으로 떨어진다. 그러면 45,000(실측)과 51,000(추정)은 같은 자로 잰 값이 아니다.
추정이지만 짚어둘 만한 것: 문자 기반 추정은 보통 영어 기준으로 보정된다. 한국어는 글자 수 대비 토큰이 더 많이 나오는 경향이 있어서 추정치가 실제보다 작게 나올 가능성이 있다. 그러면 안전망이 필요한 순간에 안 걸린다.
(나) 상세: 개수 하한이 예산을 이긴다
꼬리 보호는 예산 기준이지만 개수 하한이 따로 있다. 예산으로 지킬 수 있는 메시지가 하한보다 적으면 개수 쪽이 이긴다. 기본 하한은 20개다.
9,000 토큰 예산으로는 3개밖에 못 지킴
→ 20개 하한 발동
→ 실제 꼬리 = 20 × 평균 2,500 = 50,000 토큰
→ 압축 후 크기 = 1,000 + 3,000 + 50,000 = 54,000 ← 51,000 초과
문서도 예산 초과 가능성을 인정한다. 마지막 사용자 발화 보장 옵션 설명에 "이 보장이 예산을 이긴다. 꼬리가 예산을 초과할 수 있다"고 쓰여 있다.
검색 결과가 오가는 프로필이 정확히 이 조건이다. 사규 조문과 소스 코드는 툴 출력이 크다.
운영 규칙으로 삼기로 했다: 85%가 발동한 로그는 그 자체가 버그 리포트다.
17. 에이전트는 메시지마다 새로 태어난다
컨텍스트 길이를 매번 재계산한다면 "매번"이 언제인가. 메시지 하나마다다. 채널에서 10번 주고받으면 에이전트 객체가 10번 만들어진다.
| 수명 | |
|---|---|
| 게이트웨이 프로세스 | 장수 (프로필당 1개) |
| 툴 레지스트리 (70개+) | 프로세스 수명 |
| 세션 DB | 영구 |
| 에이전트 인스턴스 | 메시지 1개 |
| 압축기 인스턴스 | 메시지 1개 |
| 컨텍스트 길이 / 임계값 | 메시지 1개 |
툴 레지스트리가 재사용된다는 게 중요하다. 문서에 "툴 등록은 import 시점에, 어떤 에이전트 인스턴스가 만들어지기도 전에 일어난다"고 명시돼 있다. 70개 파일을 파싱해서 로드하는 비싼 작업이 메시지마다 반복되지는 않는다.
재계산 자체도 싸다. 테이블 조회, 문자열 매칭 몇 번, 곱셈 하나. 메시지당 실제로 비싼 건 세션 히스토리 로드와 API 호출이다(전체 지연의 99%).
매번 재조립해도 캐시가 안 깨지는 이유
객체를 새로 만들면 시스템 프롬프트도 다시 조립하는데 캐싱이 유지된다. 결과 바이트가 같기 때문이다. 앞선 글에서 정리한 3계층 순서가 여기서 값을 한다.
[안정] 정체성 · 툴 가이드 · 스킬 ← 안 변함
[컨텍스트] 컨텍스트 파일 ← 거의 안 변함
[휘발] 메모리 · 프로필 · 타임스탬프 ← 매번 변함 ← 맨 뒤!
타임스탬프가 맨 뒤라 앞쪽 프리픽스는 매번 동일하다. 캐싱은 프리픽스 매칭이라 그대로 히트한다. 객체를 재사용하느냐보다 조립 결과가 결정적이냐가 중요하다.
다만 설정은 시작할 때 한 번만 읽는다
에이전트는 메시지마다 새로 만들어지지만 설정 파일과 환경변수는 게이트웨이 프로세스가 시작할 때 한 번만 읽는다. 설정을 바꾸고 "왜 반영이 안 되지?" 싶으면 거의 항상 재시작을 안 한 것이다.
18. 틀렸던 것 세 가지
| 처음 잡았던 모델 | 정정 | 깬 근거 |
|---|---|---|
| 채널 입력이 얇은 래퍼 함수를 거쳐, 에이전트가 판단해 본 함수를 호출한다 | 래퍼는 이 경로에 없다. 게이트웨이가 본 함수를 직접, 하드코딩으로 호출한다 | 래퍼가 본 함수를 감싸는 구조다. 게이트웨이는 메시지 배열과 토큰 사용량이 필요해 dict 반환이 필수다 |
| 50% 그물을 통과하면 85% 그물에 걸리는 2단 구조 | 45,000이 상시 유일 문턱, 51,000은 고장 시에만 의미가 있다 | 45,000 < 51,000. 압축 후 약 13,000으로 떨어지므로 도달 경로가 없다 |
| 컨텍스트 길이가 어딘가 설정되어 있다 | 어디에도 영구 저장되지 않는다. 매번 재계산한다 | 설정 파일에 항목이 없다. 모델 전환마다 재해석하는 훅이 따로 있다 |
두 번째는 대화 상대가 산수로 직접 잡아냈다. "45,000에서 압축했으면 51,000 갈 일이 없는 것 아니냐"는 한 문장이었다. 문서를 읽고 만든 모델이라도 숫자를 넣어 검산하면 깨지는 경우가 있다.
19. 우리가 할 일
요약 모델의 컨텍스트부터 확인한다. 문서가 "가장 흔한 압축 품질 저하 원인"으로 지목한 함정이다(16절 다). 요약용이라고 작고 싼 모델을 붙이는 게 정확히 이것이고, 화면에는 아무 표시가 없다. 경고는 에러 로그가 아니라 에이전트 로그에 남는다.
검색 프로필의 꼬리 보호 개수를 낮춘다. 기본값 20은 툴 출력이 작은 대화를 전제한 값이다. 채널마다 다르게 잡을 수 있으니, 문서를 많이 읽는 프로필은 절반 이하로 내리고 짧은 문답 위주 프로필은 기본값을 유지한다.
큰 컨텍스트 모델로 올릴 때는 임계값을 같이 내린다. 체크리스트는 세 줄이다. 메타데이터 레지스트리가 그 모델을 아는가, 인증 경로가 바뀌지 않는가(네이티브 경로는 자격증명 갱신에 파일 쓰기가 필요하다), 모델별 임계값을 낮췄는가.
폴백 대상의 자격증명 접근권을 확인한다. 13절의 교훈이다. 체인을 설정해두는 것만으로 부족하다.
85% 발동 로그를 알람으로 취급한다. 정상 동작에서 도달 불가능하므로 발동했다면 고장이다.
진단할 때 모델명만 보지 않는다. 3절. 모델 슬러그는 프로바이더와 짝을 이룰 때에만 의미가 있다.
20. 남은 질문
- 메타데이터 레지스트리의 디스크 캐시가 어디에 있고 얼마나 오래됐는지 확인하지 못했다. 캐시가 오래되면 신규 모델이 등록되지 않아 기본값으로 떨어지는데, 그 기본값이 무엇인지도 모른다.
- 이전 요약을 기억하는 필드가 압축기 인스턴스 필드라고 문서에 쓰여 있다. 그런데 17절대로 인스턴스가 메시지마다 죽는다면 두 번째 압축은 "이전 요약을 갱신"하지 못하고 매번 처음부터 요약하게 된다. 요약이 히스토리에 메시지로 남으니 거기서 복원할 수도 있지만 확인이 필요하다.
- 채널에 여러 사람이 있을 때 어댑터가 역할 충돌을 어떻게 처리하는지(8절).
- 한국어 문자 기반 토큰 추정의 오차 방향(16절 라).
- 검색 MCP 툴의 가용성 판정 함수가 서버 헬스체크인지 설정 존재 확인인지(9절). 어느 쪽이냐에 따라 진단 방법이 달라진다.
관련 글
- Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유 — 같은 날 쓴 가로 지도. 이 글의 6·7·16·17절은 그 글이 정리한 프롬프트 3계층과 이중 압축 위에서 숫자를 넣고 검산한 것이다. 저장 계층과 에디터 연동은 그쪽에만 있다.
- Hermes의 실체 — 모델이 아니라 하네스, 그리고 스스로 도는 /goal 루프 — 최상위 개념. 하네스가 무엇인지부터 잡고 싶다면 여기부터.
- 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401) — 13절이 이 장애의 메커니즘을 설명한다. 폴백이 왜 발동하지 못했는지가 여기서 풀린다.
- 사내 규정 문서 RAG — MCP 서버부터 크로스인코더 리랭커까지 — 9절의 가용성 판정과 19절의 꼬리 보호 조정이 직접 적용되는 대상.