장르: 개념 심화. 앞선 Hermes의 실체 — 모델이 아니라 하네스, 그리고 스스로 도는 /goal 루프가 "Hermes가 결국 뭐냐"를 다뤘다면, 이번에는 그 아래층입니다. 진입점부터 저장소까지 내부가 어떻게 조립되어 있는지, 그리고 운영하면서 경험으로만 알던 규칙들이 설계상 어디서 나오는지를 원문에서 확인했습니다. 재현 절차가 아니라 이해의 기록입니다.

몇 주간 Hermes를 컨테이너로 띄우고 채널마다 봇을 붙여 운영해왔습니다. 그러면서 지키는 규칙이 몇 개 생겼습니다. "게이트웨이를 두 개 띄우지 마라", "채널을 늘릴 땐 컨테이너가 아니라 프로필을 늘려라" 같은 것들입니다. 대부분 한 번씩 데어보고 만든 규칙이라 왜 그런지는 설명하지 못하고 그냥 지키는 상태였습니다.

이번에 공식 개발자 문서를 아키텍처 개요부터 하위 8편까지 읽었습니다. 목표는 하나였습니다. 경험으로 만든 규칙에 설계상의 근거를 붙이는 것. 결과적으로 근거를 찾았고, 덤으로 놓치고 있던 함정 두 개도 발견했습니다.

1. 전체 지도 — 진입점 다섯, 코어 하나

껍데기를 걷으면 구조는 단순합니다.

[진입점]  CLI · Gateway · ACP(에디터) · Batch · API Server
                        ↓ 전부 수렴
[코어]    AIAgent
            ├─ Prompt Builder   + 압축/캐싱      "무엇을 보낼까"
            ├─ Provider Resolve + 3가지 API 모드  "어디로 보낼까"
            └─ Tool Dispatch    + Tool Registry   "무엇을 할 수 있나"
                        ↓
[저장]    Session Storage (SQLite+FTS5)  |  Tool Backends (Terminal 6종 / Browser / Web / MCP)

설계 원칙 목록에 "Platform-agnostic core" 항목이 있습니다. 하나의 AIAgent 클래스가 CLI, 게이트웨이, 에디터 연동, 배치, API 서버를 전부 담당하고, 플랫폼 차이는 진입점에만 존재합니다.

우리가 붙인 Mattermost 봇도 20여 개 어댑터 중 하나를 통해 들어갑니다. 어댑터가 책임지는 건 on_message()로 들어온 원시 이벤트를 표준 형식으로 바꾸는 것까지입니다. 그 다음 인가, 세션 키 결정, 에이전트 생성은 전부 공용 경로입니다.

여기서 예전에 헷갈렸던 게 풀렸습니다. require_mention이 Mattermost 어댑터의 설정이 아니라 게이트웨이 러너 단계의 필터인 이유입니다. 멘션이 아닌 메시지는 에이전트에 도달하기 전에 걸러집니다. 그래서 이 옵션의 동작이 플랫폼과 무관하게 똑같습니다.

2. 게이트웨이를 하나만 띄워야 하는 이유 세 가지

우리가 가장 강하게 지키는 규칙입니다. compose 파일에 docker run 방식과 compose 방식을 둘 다 적어두되 동시에 쓰지 않는다는 것. 이유를 이제 정확히 알게 됐습니다.

~/.hermes가 프로필 하나의 모든 상태를 담습니다.

~/.hermes/
├── state.db          # SQLite (WAL) — 세션 + 메시지 전체
├── gateway.pid       # 프로필 스코프 프로세스 추적
├── config.yaml       # 모델, 압축 설정, 명령 허용 목록
├── .env              # 봇 토큰, API 키
└── SOUL.md / MEMORY.md / USER.md

게이트웨이 두 개가 같은 디렉터리를 잡으면 세 군데가 동시에 깨집니다.

# 깨지는 곳 근거
SQLite 라이터 경합 WAL 모드를 쓰는 이유가 문서에 그대로 적혀 있습니다. "concurrent readers + one writer". 리더는 여럿 괜찮지만 라이터는 하나를 전제합니다. 게이트웨이 둘은 둘 다 라이터입니다
PID 덮어쓰기 gateway.pid는 프로필 단위입니다. 나중에 뜬 프로세스가 앞의 PID를 덮어씁니다
토큰 락의 범위 한계 어댑터가 connect()에서 acquire_scoped_lock()으로 봇 토큰 중복 사용을 막지만, 같은 HERMES_HOME 안에서만 유효합니다. 컨테이너나 마운트가 갈리면 무력합니다

②가 특히 고약합니다. stop 명령이 한쪽만 죽이고 남은 프로세스가 계속 DB를 씁니다. 로그상으로는 "껐는데 봇이 계속 응답하는" 유령 현상으로 나타나서, 원인을 찾기 전까지 한참 헤매게 되는 종류입니다.

반대로 프로필이 다르면 완전히 안전합니다. default/opt/data/ 아래, 명명 프로필은 /opt/data/profiles/<name>/ 아래에 각자의 state.db, gateway.pid, config.yaml을 갖습니다. 설계 원칙 목록의 "Profile isolation" 항목이 이걸 보장합니다.

채널을 늘릴 때 컨테이너가 아니라 프로필을 늘리는 게 맞았습니다. 우리가 채널별 다중 에이전트 구축에서 택한 방식이 상류 설계가 의도한 격리 단위와 정확히 일치합니다. 운영 편의로 고른 줄 알았는데 사실은 유일하게 안전한 선택이었습니다.

프로세스 관리 관련해서 하나 더 알게 된 것: hermes gateway stop은 현재 프로필만 죽이고, --all을 붙이면 전역 프로세스 스캔으로 전부 죽입니다. 업데이트할 때 쓰라고 만든 옵션입니다.

3. 시스템 프롬프트를 3계층으로 쌓는 이유

Hermes는 시스템 프롬프트를 세 덩어리로 나눠 조립합니다.

계층 내용 변경 빈도
stable 에이전트 정체성(SOUL.md), 도구 사용 가이드, 스킬 목록, 환경 힌트 거의 안 바뀜
context 호출자가 넘긴 시스템 메시지, 프로젝트 컨텍스트 파일 프로젝트별
volatile 메모리 스냅샷, 사용자 프로필, 외부 provider 블록, 타임스탬프 매번

순서는 stable → context → volatile입니다. 이유가 세 가지인데 첫 번째가 압도적으로 큽니다.

캐시는 앞에서부터 맞춰본다

프롬프트 캐싱은 prefix 매칭입니다. 앞에서부터 몇 바이트까지 같은지로 캐시 적중을 판정합니다. 중간이 바뀌면 그 뒤는 전부 미스입니다.

그러니 변경 빈도 순으로 정렬하는 게 유일한 정답입니다. 순서가 반대라면 어떻게 되는지 생각해보면 명확합니다. 타임스탬프 한 줄 때문에 시스템 프롬프트 전체가 매번 캐시 미스가 되고, 뒤에 붙은 수천 토큰짜리 스킬 목록과 정체성 문서까지 전부 새로 읽힙니다.

나머지 두 이유

두 번째는 우선순위입니다. 문서에 "This ordering matters for precedence discussions"라고 직접 적혀 있습니다. LLM은 뒤에 오는 지시를 더 구체적인 것으로 취급하는 경향이 있습니다. "이 사용자는 X를 쓴다"가 "너는 엔지니어다"보다 뒤에 와야 충돌 시 구체적인 쪽이 이깁니다.

세 번째는 잘라내기 쉬움입니다. 서브에이전트에 작업을 위임할 때는 context 계층을 통째로 빼고 정체성도 하드코딩된 기본값으로 바꿉니다. 계층 경계가 있으니 이 조작이 한 줄입니다.

"Frozen"이라는 단어

실제 프롬프트 구성을 보면 메모리와 사용자 프로필 부분에 Frozen snapshot이라고 표기되어 있습니다.

대화 도중에 에이전트가 메모리 도구로 뭔가를 저장해도 그 세션의 시스템 프롬프트는 바뀌지 않습니다. 다음 세션에서 반영됩니다. 프롬프트가 다시 조립되는 시점은 세션 시작과 압축 발생 두 번뿐입니다.

이게 설계 원칙의 "Prompt stability" 항목입니다. 대화 중간에 시스템 프롬프트를 바꾸면 캐시가 통째로 날아가기 때문입니다.

우리가 채널별 페르소나를 넣을 때 시사점이 있습니다. 페르소나는 프로필의 설정 파일에 넣어 stable 계층에 굳혀야 합니다. 런타임에 채널마다 프롬프트를 갈아끼우는 방식으로 구현하면 매 턴 캐시를 깨뜨립니다. 페르소나 문자열에 시각이나 채널 인원수 같은 가변 요소를 섞는 것도 같은 이유로 피해야 합니다.

4. 압축과 캐싱은 다른 문제를 푼다

둘을 같은 범주로 묶어 생각하고 있었는데, 목적이 완전히 다릅니다.

압축(Compression) 캐싱(Caching)
푸는 문제 컨텍스트 윈도우의 물리적 한계 비용과 지연
안 하면 API가 요청을 거부 그냥 비쌈
손실 있음(요약이므로) 없음(동일한 입력)

압축은 생존, 캐싱은 경제성입니다. 그리고 둘은 서로 적대적입니다. 압축이 일어나면 메시지가 바뀌므로 그 구간의 캐시가 깨집니다. 그래서 임계값 조정이 예민한 작업입니다.

임계값이 두 개인 이유

들어오는 메시지 → 게이트웨이 세션 위생 (85%, 러프 추정, 루프 밖)   ← 안전망
                → 에이전트 압축기      (50%, 실제 토큰, 루프 안)   ← 평상시

게이트웨이 쪽이 훨씬 높습니다. 문서에 이유가 적혀 있는데, 50%로 맞췄더니 긴 메신저 세션에서 매 턴 조기 압축이 터졌다고 합니다.

납득이 갑니다. CLI는 사람이 앞에 앉아 연속으로 대화하지만, 메신저 세션은 밤새 방치됐다가 아침에 이어집니다. 그 사이 다른 사람들이 쏟아부은 메시지로 히스토리가 이미 부풀어 있을 수 있습니다. 그래서 메시지가 도착하자마자 루프 밖에서 한 번 걸러주는 안전망이 따로 필요했던 겁니다.

압축 알고리즘에서 인상 깊었던 것

전체 뼈대는 다른 하네스와 비슷합니다. "오래된 걸 요약하고, 최근 N개는 보호한다." 다만 몇 가지가 눈에 띄었습니다.

1단계가 LLM을 안 씁니다. 요약 모델을 호출하기 전에 오래된 도구 실행 결과를 그냥 버립니다. 에이전트 워크로드에서는 파일 내용, 검색 결과, 터미널 출력 같은 도구 결과가 토큰의 대부분을 차지하고, 그 대부분은 두 턴만 지나면 쓸모가 없습니다. 요약할 가치가 없는 걸 요약하느라 돈을 쓰지 않는 설계입니다.

원본이 남습니다. 압축이 일어나면 세션이 쪼개지고 부모 세션 ID로 연결됩니다. 원본 메시지는 DB에 그대로 있습니다. 요약이 중요한 걸 날려도 세션 검색으로 되찾을 수 있다는 뜻입니다. 메모리에서만 압축하는 방식이었다면 불가능한 일입니다.

압축 전략 자체가 교체 가능합니다. 추상 클래스로 되어 있어서 무손실 방식 같은 대안 구현을 플러그인으로 끼울 수 있습니다. 압축 알고리즘을 확장점으로 열어둔 건 흔하지 않습니다.

되돌린 조정 두 건

문서에 실전에서 시도했다가 되돌린 사례가 기록되어 있습니다. 이게 가장 유용했습니다.

중간 압박 경고를 제거했습니다. "컨텍스트가 차고 있다"고 모델에게 알렸더니 복잡한 작업을 조기에 포기하더랍니다. 지금은 경고 없이 임계값에서 조용히 압축만 합니다.

게이트웨이 위생 임계값을 50%에서 85%로 올렸습니다. 위에서 말한 매 턴 압축 문제입니다.

둘 다 "이론적으로 맞아 보이는 설계"가 실제로는 역효과를 낸 사례입니다. "더 자주 압축하면 안전하겠지"가 틀렸다는 걸 남의 실패로 배울 수 있어서 좋았습니다.

5. API 모드가 세 개인 이유

Hermes는 모델 API를 세 가지 모드로 호출합니다.

모드 대상
chat_completions OpenAI 호환 엔드포인트 대다수
codex_responses OpenAI Codex / Responses API
anthropic_messages Anthropic 네이티브 Messages API

왜 이 둘만 따로 뺐나가 궁금했는데, 답은 와이어 포맷이 근본적으로 다르기 때문입니다.

chat_completionsmessages 배열에 역할과 내용을 넣고 응답에서 도구 호출을 꺼내는 익숙한 형태입니다. 사실상 업계 표준이 됐습니다.

anthropic_messages는 구조가 다릅니다. 시스템 프롬프트가 messages 배열 밖의 별도 파라미터고, 내용이 블록 배열이며, 도구 실행 결과가 별도 역할이 아니라 user 메시지 안에 들어갑니다. 순서만 바꿔서 되는 게 아니라 구조 자체를 재조립해야 해서 전용 어댑터 파일이 따로 있습니다.

codex_responses추론 항목을 보존해서 다음 턴에 되돌려줘야 합니다. 세션 DB의 메시지 테이블에 추론 항목 전용 컬럼이 따로 있는 게 이 때문입니다.

결국 자체 포맷을 밀어붙일 만큼 힘이 있는 곳이 이 둘뿐이라는 이야기입니다. 나머지는 OpenAI 포맷을 따라가는 게 채택에 유리했습니다.

같은 모델도 경로에 따라 모드가 다르다

경로 모드 캐싱
네이티브 provider anthropic_messages 가능
중개 서비스 경유 chat_completions 가능 (중개 서비스가 캐시 제어를 통과시킴)

중개 서비스가 포맷을 번역해주기 때문입니다. 캐싱은 양쪽 다 됩니다.

키가 엔드포인트에 묶여 있다

이건 몰랐는데 중요합니다. API 키가 base URL 단위로 스코프되어 있습니다. 중개 서비스용 키는 그 서비스 도메인에만 전송되고, 커스텀 엔드포인트에는 범용 키가 폴백으로 갑니다.

로컬 모델 서버나 사내 프록시에 엉뚱한 키가 새어나가는 걸 막는 설계입니다. provider를 갈아탈 때 안심해도 되는 근거를 하나 확보했습니다.

해석 우선순위도 흥미롭습니다. 명시적 요청 → 설정 파일 → 환경변수 → 기본값 순인데, 환경변수가 설정 파일보다 아래인 이유가 명시되어 있습니다. 셸에 남아 있는 오래된 export 하나가 사용자가 방금 고른 엔드포인트를 조용히 덮어쓰는 걸 막기 위해서입니다.

모델을 바꾸면 캐시가 날아간다

이 대목이 우리에게 직접 걸립니다.

provider 측 캐시는 (모델 + 계정) 조합으로 스코프됩니다. 그래서 세션 중간에 모델을 전환하거나, 1차 모델이 실패해서 폴백이 돌거나, 크리덴셜이 로테이션되면 다음 요청은 캐시 적중이 0이고 전체 대화를 할인 없는 가격으로 다시 읽습니다.

문서가 개발자에게 직접 경고합니다. "Don't add features that silently swap the model or credentials mid-session."

무료 모델 소멸과 권한 사고로 봇이 전부 멈췄던 장애에 숨은 후속 비용이 있었다는 뜻입니다. 폴백이 도는 동안 응답은 복구됐지만, 그 세션들의 캐시 비용은 조용히 튀었을 겁니다. 청구서에만 나타나는 종류의 대가라 그때는 알아채지 못했습니다.

6. 한 턴이 실제로 도는 순서

세 축이 독립적인 줄 알았는데 명확한 순서가 있었습니다.

 1. 작업 ID 생성
 2. 사용자 메시지를 히스토리에 추가
 3. 시스템 프롬프트 빌드 또는 캐시된 것 재사용      ← Prompt Builder
 4. 사전 압축이 필요한지 확인 (50% 초과 시)         ← Compression
 5. API 메시지 빌드 (모드별 포맷 변환)              ← Provider Resolution 결과에 의존
 6. 일회성 레이어 주입 (예산 경고 등)
 7. Anthropic이면 캐싱 마커 적용                    ← Caching
 8. 중단 가능한 API 호출
 9. 응답 파싱 — 도구 호출이 있으면 실행 후 ⑤로 루프  ← Tool Dispatch
                텍스트면 세션 저장, 메모리 flush, 반환

전체 그림으로 정리하면 이렇습니다.

[프로세스 시작 — 1회]  도구 레지스트리 로딩
[세션 생성   — 1회]  Provider Resolution → 모드/엔드포인트/키 확정
                     Prompt Builder      → 시스템 프롬프트 (frozen)
[매 턴      — 반복]  압축 확인 → 포맷 변환 → 캐싱 마커 → API 호출
                          ↑                                  ↓
                          └────── Tool Dispatch ←──── 도구 호출?

Provider Resolution이 루프보다 먼저 확정되는 게 핵심입니다. ⑤의 포맷 변환과 ⑦의 캐싱 여부가 모두 모드에 의존하기 때문입니다.

미묘하지만 중요한 지점 두 개를 발견했습니다.

⑨의 루프가 ③이 아니라 ⑤로 되돌아갑니다. 도구를 실행할 때마다 시스템 프롬프트를 다시 만들지 않습니다. 앞서 말한 Prompt stability 원칙이 코드 레벨에서 강제되는 지점입니다.

④의 압축 확인이 사용자 메시지를 추가한 뒤, API 호출 전에 있습니다. 터진 뒤에 에러를 받고 복구하는 게 아니라 보내기 전에 미리 줄이는 방식입니다.

중단이 가능한 이유

API 호출을 백그라운드 스레드에 두고 메인 스레드가 (응답 / 인터럽트 / 타임아웃) 셋 중 먼저 오는 걸 기다립니다. 인터럽트가 이기면 API 스레드를 그냥 버립니다. 부분 응답이 히스토리에 절대 들어가지 않습니다.

메신저에서 봇이 답하는 중에 새 메시지를 보내면 하던 걸 접고 새 걸 받는 동작의 정체가 이것이었습니다.

7. 도구 시스템 — 백엔드 분리와 단방향 의존

정의와 실행 장소를 분리한다

모델이 보는 건 언제나 terminal(command="...") 하나입니다. 그 명령이 어디서 실행되는지는 설정이 결정합니다.

도구 백엔드
Terminal local, docker, ssh, singularity, modal, daytona (6종)
Browser 5종
Web 4종
MCP 동적 — 외부 서버가 런타임에 도구를 실어옴

모델 프롬프트를 한 글자도 안 바꾸고 "이 에이전트는 샌드박스에서만 명령을 실행한다"로 전환할 수 있습니다.

MCP가 "동적"인 이유도 같은 맥락입니다. 사내 규정 문서 RAG를 MCP 서버로 붙인 것이 정확히 이 방식입니다. Hermes 코드에는 RAG 관련 코드가 한 줄도 없고, MCP 서버가 스스로 도구 스키마를 제공합니다.

도구 추가가 코어를 건드리지 않는 이유

의존 방향이 단방향입니다.

registry.py         (아무것도 import 안 함)
     ↑
tools/*.py          (각자 최상위에서 register() 호출)
     ↑
model_tools.py      (registry import + 탐색 트리거)
     ↑
run_agent.py, cli.py

탐색 함수가 도구 디렉터리를 훑되, AST 파싱으로 최상위 등록 호출이 있는 모듈만 import합니다. 그래서 새 도구는 파일 하나 추가가 전부입니다. import 목록 수정도, 코어 파일 수정도 필요 없습니다.

만약 순환 의존이 있었다면 새 도구를 추가할 때마다 "언제 import해야 순환이 안 터지지"를 풀어야 했을 겁니다. 지연 로딩으로 우회하면 등록 시점이 불확실해져서 "어떤 도구가 있는지"가 실행 순서에 따라 달라집니다.

등록이 import 시점이라는 것도 의미가 있습니다. 에이전트 인스턴스가 생기기 전에 전체 도구 목록이 확정되므로, "이 프로필이 쓸 수 있는 도구"를 에이전트 없이 조회할 수 있습니다.

안전장치도 있습니다. 선택적 도구의 import 실패는 잡아내서 다른 도구 로딩을 막지 않습니다. 다만 이름 충돌은 경고 후 나중 등록이 이깁니다. 플러그인이 내장 도구를 덮어쓸 수 있다는 뜻이라 커스텀 도구 이름은 신중하게 지어야 합니다.

없는 도구는 아예 안 보여준다

각 도구는 가용성 확인 함수를 가질 수 있습니다. API 키가 없으면 그 도구를 스키마에서 아예 뺍니다. 예외가 나면 "사용 불가"로 처리하는 안전 우선 방식입니다.

나아가 필터링 후에 코드 실행이나 브라우저 도구의 스키마를 동적으로 패치해서 실제로 통과한 도구만 참조하게 만듭니다. 모델이 없는 도구를 부르는 환각을 원천 차단하는 접근입니다.

도구 실행 결과는 2중으로 감싸집니다. 핸들러가 뭘 던지든 잡아서 에러 JSON으로 바꿉니다. 모델은 항상 잘 형성된 문자열을 받습니다.

8. 놓치고 있던 함정 둘

읽는 과정에서 우리 환경에 직접 걸리는 것 두 개를 발견했습니다.

stdio MCP 서버는 print() 한 줄이면 죽는다

에디터 연동 프로토콜 문서에 이런 문장이 있습니다. "Stdout is reserved for JSON-RPC transport. Human-readable logs go to stderr."

에디터가 에이전트를 자식 프로세스로 띄우고 stdin/stdout을 통신 채널로 씁니다. 즉 stdout이 네트워크 소켓 역할을 합니다.

에디터 ──write JSON──▶ [stdin]  에이전트 프로세스  [stdout] ──read JSON──▶ 에디터
                                                  [stderr] ──▶ 로그

여기에 print("loading...") 한 줄이 섞이면 상대편 JSON 파서가 그 줄에서 깨집니다.

그리고 이건 stdio 방식 MCP 서버에도 똑같이 적용됩니다. 우리 rag-mcp 서버에 print()가 하나라도 있으면 Hermes가 붙는 순간 조용히 죽거나 도구 목록이 비어 보입니다.

디버깅할 때 흔히 하는 "일단 print 찍어보자"가 여기서는 진단을 더 어렵게 만드는 행동입니다. 반드시 로깅으로 stderr에 쓰거나 파일에 남겨야 합니다. dev 채널에서 RAG 도구가 안 보이는 증상이 나오면 여기부터 확인할 생각입니다.

한국어 검색용 테이블이 따로 있다

세션 저장소 스키마를 보다가 발견했습니다.

├── messages_fts          — FTS5 (기본 토크나이저)
├── messages_fts_trigram  — FTS5 (trigram — CJK / 부분 문자열)

전문 검색 테이블이 두 개입니다. 기본 FTS5 토크나이저는 공백으로 단어를 자르기 때문에 한국어는 이걸로 사실상 검색이 안 됩니다. trigram 토크나이저 테이블이 그걸 커버합니다.

우리 dev 프로필에서 쓰는 메모리 provider가 numpy 부재로 의미 검색이 꺼지고 렉시컬 검색으로 폴백 중입니다. 그 함정을 다뤘던 기록에서 확인한 상태 그대로입니다.

그렇다면 한국어 검색 품질이 전적으로 어느 토크나이저를 타느냐에 달려 있습니다. 어느 쪽을 타는지 확인하고, 결과에 따라 numpy 설치의 우선순위를 다시 매길 생각입니다.

9. 저장소는 하나가 아니다

세션 저장소가 유일한 저장 방식인 줄 알았는데 아니었습니다. 문서가 직접 선을 긋습니다. "Batch runner and RL trajectories are NOT stored here (separate systems)."

저장소 형식 내용
state.db SQLite + FTS5 세션, 메시지, 토큰 사용량, 과금
config.yaml YAML 모델, 압축 임계값, 명령 허용 목록
.env dotenv 봇 토큰, API 키
gateway.pid 텍스트 프로세스 추적
SOUL.md / MEMORY.md / USER.md 마크다운 정체성, 메모리·프로필 스냅샷
cron 작업 JSON 스케줄 잡
외부 memory provider 제각각 자체 DB 또는 원격

세션과 메시지에 한해서는 state.db가 유일한 저장소가 맞습니다. 예전의 세션별 JSONL 파일 방식을 대체한 것입니다. 다만 시스템 전체로 보면 저장 위치가 일곱 군데로 흩어져 있습니다.

과금 관련 컬럼이 세션 테이블에 있다는 것도 처음 알았습니다. 입력/출력 토큰뿐 아니라 캐시 읽기 토큰과 캐시 쓰기 토큰이 따로 기록됩니다. 5절에서 말한 캐시 적중률을 실제 데이터로 검증할 수 있다는 뜻입니다.

10. 정리하며

경험으로 만든 규칙 두 개에 근거가 붙었습니다.

  • 게이트웨이 중복 금지 → WAL의 단일 라이터 전제, PID 덮어쓰기, 토큰 락의 범위 한계
  • 채널 추가는 프로필 추가 → 설계 원칙의 Profile isolation 항목

그리고 앞으로 할 일 다섯 개가 생겼습니다.

  • rag-mcp 서버 코드에서 stdout 출력 점검 (8절)
  • 한국어 검색이 어느 FTS5 테이블을 타는지 확인 (8절)
  • 채널별 페르소나를 stable 계층에 고정, 가변 요소 배제 (3절)
  • 모델 폴백 시 캐시 비용을 장애 기록에 반영 (5절)
  • 압축 설정을 함부로 조이지 않기 (4절)

마지막 항목이 의외로 중요합니다. 운영하다 보면 "컨텍스트가 자꾸 터지니 압축을 더 자주 돌리자"는 결론에 도달하기 쉬운데, 원 개발팀이 그걸 해봤다가 되돌렸다는 기록이 남아 있습니다. 같은 실수를 반복하지 않아도 되는 건 문서가 실패까지 적어둔 덕분입니다.

남은 의문도 몇 개 있습니다. trigram 테이블이 실제 검색 경로에서 언제 선택되는지는 문서에 없어서 소스를 봐야 합니다. 중개 서비스 경유 시 캐시 제어가 실제로 통과되는지는 세션 테이블의 캐시 토큰 컬럼으로 검증할 수 있을 것 같습니다.

관련 글