게이트웨이 경로를 파다가 "그래서 대화 기록은 어디 있나"로 넘어갔다. 답은 SQLite 파일 하나였다. 별도 DB 서버가 없다는 게 처음엔 단순해 보였는데, 열어보니 그 단순함을 유지하려고 들어간 장치들이 꽤 있었다. 그리고 그중 하나 때문에 우리 청소 명령이 아무것도 안 지우고 있었다는 걸 알게 됐다.
파일 하나가 DB다
$HERMES_HOME/state.db
$HERMES_HOME/state.db-wal
$HERMES_HOME/state.db-shm
Postgres도 Redis도 없다. 이전에는 세션마다 JSONL 파일을 쓰는 방식이었는데 지금은 SQLite로 통합됐다. CLI에서 한 대화와 메신저 봇이 한 대화가 같은 파일에 들어간다.
테이블 구성은 이렇다.
| 테이블 | 담는 것 |
|---|---|
sessions |
세션 메타, 토큰 수, 과금 |
messages |
세션별 전체 메시지 |
session_model_usage |
모델별·태스크별 사용량 귀속 |
messages_fts |
전문 검색 (content + tool_name + tool_calls) |
messages_fts_trigram |
trigram 토크나이저 |
messages_fts_cjk |
cjk_unicode61 토크나이저 |
state_meta |
키/값 메타 |
gateway_routing |
게이트웨이 라우팅 메타 |
compression_locks |
프로세스 간 압축 잠금 |
async_delegations |
비동기 위임 장부 |
schema_version |
단일 행. 마이그레이션 상태 |
현재 스키마 버전은 23이다. 단순 컬럼 추가는 선언적으로 처리하고(살아있는 컬럼과 정의를
비교해 없는 것만 ALTER TABLE ADD COLUMN), 버전 게이트는 데이터 마이그레이션과 인덱스 변경에만 쓴다.
동시 쓰기 — convoy effect를 피하려는 설계
여기가 흥미로웠다. 게이트웨이, CLI 세션, 워크트리 에이전트가 같은 state.db를 공유한다.
SQLite는 WAL 모드에서 동시 reader 여럿 + writer 하나를 허용한다. 그럼 writer가 겹치면?
_WRITE_MAX_RETRIES = 15
_WRITE_RETRY_MIN_S = 0.020 # 20ms
_WRITE_RETRY_MAX_S = 0.150 # 150ms
_CHECKPOINT_EVERY_N_WRITES = 50
네 가지를 조합한다.
- SQLite 타임아웃을 기본 30초 대신 1초로 줄인다
- 애플리케이션 레벨에서 랜덤 지터(20~150ms)를 섞어 최대 15회 재시도
BEGIN IMMEDIATE로 경합을 트랜잭션 시작 시점에 드러낸다- 쓰기 50회마다 WAL 체크포인트(PASSIVE)
주석에 이유가 적혀 있다.
This avoids the "convoy effect" where SQLite's deterministic internal backoff causes all competing writers to retry at the same intervals.
SQLite 내부 백오프는 결정적이다. 그래서 경쟁하는 writer들이 같은 간격으로 몰려 계속 부딪힌다. 랜덤 지터를 넣는 건 그 줄서기를 흩뜨리려는 것이다. 네트워크 프로토콜에서 익숙한 패턴인데 DB 접근 계층에 들어와 있는 게 재미있었다.
다만 이건 경합을 견디는 장치이지 동시 쓰기를 허용하는 장치가 아니다. 재시도 15회를 다 실패하면 그 메시지는 기록에서 사라진다. "게이트웨이를 하나만 띄워야 한다"는 규칙이 여전히 유효한 이유다.
전문 검색 인덱스가 세 개인 이유
messages_fts 하나면 될 것 같은데 셋이다.
기본 FTS5 토크나이저는 공백과 구두점으로 자른다. 영어에는 맞지만 한국어·중국어·일본어에는 안 맞는다. "게이트웨이내부"를 한 덩어리로 보기 때문에 "내부"로는 안 잡힌다.
그래서 마이그레이션 v10이 messages_fts_trigram을 추가하고 기존 행을 전부 백필했다.
trigram은 세 글자씩 겹쳐 자르므로 부분 문자열 검색이 된다. messages_fts_cjk는
cjk_unicode61 토크나이저로 한 겹 더 받친다.
검색 API는 이렇게 생겼다.
db.search_messages("docker deployment") # 암묵적 AND
db.search_messages("error", source_filter=["cli"]) # CLI 세션만
db.search_messages("bug", exclude_sources=["telegram"]) # 특정 플랫폼 제외
db.search_messages("help", role_filter=["user"]) # 사용자 발화만
FTS5 문법이 그대로 통한다. "exact phrase", a OR b, a NOT b, deploy*(접두 매칭).
사용자 입력을 그대로 넣으면 FTS5 문법이 깨지므로 정리하는 함수가 있는데, 거기 규칙 하나가
눈에 띄었다 — 하이픈 들어간 말을 따옴표로 감싼다(chat-send → "chat-send"). FTS5가
하이픈을 NOT 연산자로 읽기 때문이다. 짝 안 맞는 따옴표를 버리고 매달린 불리언 연산자
(hello AND)도 정리한다.
검색 결과에는 >>>매치<<< 표시가 붙은 snippet, 앞뒤 한 개씩의 문맥(200자로 잘림), 그리고
부모 세션의 소스·모델·시작시각이 함께 온다.
api_content — 화면용과 전송용을 따로 보관한다
messages 테이블에서 가장 눈여겨본 컬럼이다.
api_contentis a byte-fidelity sidecar: the exact content string sent to the API for this message when it differs fromcontent... It preserves the wire bytes for prompt-cache-stable replay. NULL means content was sent verbatim.
화면에 보여줄 내용과 API에 실제로 보낸 바이트를 따로 저장한다. 둘이 갈리는 경우는 임시
메모리 주입, 플러그인 주입, persist 오버라이드 같은 것들이다. 같으면 NULL이라 공간도 안 쓴다.
왜 이런 게 필요한지는 프롬프트 캐시를 생각하면 바로 나온다. 캐시는 바이트 단위로 걸린다. 대화를 재생할 때 한 글자라도 다르면 캐시가 깨지고, 그 턴은 전체 대화를 다시 읽는 값을 문다. "페르소나를 런타임에 주입하지 마라", "프롬프트에 타임스탬프를 넣지 마라" 같은 운영 규칙을 전에 정리했었는데, 그 규칙들의 물리적 근거가 이 컬럼 하나였던 셈이다.
같은 테이블에 reasoning, reasoning_details, codex_reasoning_items, codex_message_items도
있다. 추론 과정을 노출하는 provider의 원문을 JSON 문자열로 보관한다.
예외 하나가 명시돼 있는 것도 좋았다. 홀로 남은 서로게이트 문자는 sqlite3가 바인딩하지 못해 제거된다 — 어차피 대화 루프가 모든 송신 페이로드에서 걸러내므로 실질 차이가 없다는 설명까지 붙어 있다.
세션은 사슬이 될 수 있다
sessions.parent_session_id가 자기 테이블을 참조한다. 세션이 계보를 이룬다는 뜻인데,
발생 조건이 하나로 못 박혀 있다: 컨텍스트 압축이 세션 분할을 유발할 때.
WITH RECURSIVE lineage AS (
SELECT * FROM sessions WHERE id = ?
UNION ALL
SELECT s.* FROM sessions s JOIN lineage l ON s.id = l.parent_session_id
)
SELECT id, title, started_at, parent_session_id FROM lineage;
운영에서 의미가 있다. 긴 대화 하나가 DB에서는 여러 행으로 흩어져 있을 수 있다. 특정 채널의 대화를 전부 보려고 세션 키로만 조회하면 압축 이후 부분을 놓친다. 계보를 따라가야 한다.
제목에도 계보가 반영된다. 제목은 NULL이 아닌 것끼리 유일해야 해서(부분 유니크 인덱스), 같은 제목을 다시 쓰면 번호가 붙는다.
db.get_next_title_in_lineage("Fix Docker Build") # → "Fix Docker Build #2"
과금 원장이 곧 세션 테이블이다
sessions에 토큰·비용 컬럼이 줄지어 있다. input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, billing_provider,
billing_mode, estimated_cost_usd, actual_cost_usd, cost_status, pricing_version,
api_call_count.
마이그레이션 v20과 v22가 session_model_usage를 만들어 모델별·태스크별로 사용량을 쪼갰다.
즉 별도 계측 도구를 붙이지 않아도 SQL만으로 채널별 비용이 나온다.
SELECT model, COUNT(*) AS sessions,
SUM(input_tokens), SUM(output_tokens), SUM(estimated_cost_usd)
FROM sessions WHERE model IS NOT NULL
GROUP BY model ORDER BY 5 DESC;
cache_read_tokens가 별도 컬럼인 게 실무에서 유용하다. 캐시 히트율이 떨어지면 여기서 먼저 보인다.
청소 명령이 아무것도 안 지우고 있었다
정리 API는 넉넉하다.
db.export_session("sess_abc123")
db.export_all(source="cli")
db.prune_sessions(older_than_days=90)
db.clear_messages("sess_abc123") # 메시지만, 세션 레코드는 유지
db.delete_session("sess_abc123") # 둘 다
문제는 prune_sessions의 조건이다. 문서에 한 줄로 적혀 있다.
Delete old sessions (only ended sessions)
종료된 세션만 지운다. 그런데 우리 배포는 세션 리셋을 꺼둔 상태라 세션이 좀처럼 끝나지 않는다. 즉 이 명령이 구조적으로 거의 아무것도 지우지 않는다.
DB가 계속 자라는 걸 보고 "청소가 안 돌고 있나" 의심했다면 방향이 틀린 것이다. 청소는 돈다. 지울 대상이 없다. 세션 만료 정책을 먼저 손대지 않으면 어떤 청소 주기를 걸어도 소용없다.
이런 종류의 함정이 제일 고약하다. 명령이 실패하지 않고 조용히 0건을 반환하기 때문이다.
프로필마다 DB가 따로다
경로가 $HERMES_HOME/state.db이므로, 채널마다 프로필을 따로 두는 구성이라면 DB도 따로다.
<홈>/state.db ← 기본 프로필
<홈>/profiles/sales/state.db
<홈>/profiles/finance/state.db
프로필 간 세션 격리의 물리적 실체가 이 파일 분리다. "격리돼 있다"는 게 추상적 개념이 아니라 그냥 다른 파일이라는 뜻이다. 백업도 마이그레이션도 파일 단위로 하면 된다.
여기서 격리 축이 둘이라는 게 보인다.
| 축 | 가르는 것 | 실체 |
|---|---|---|
| 프로필 | 설정·메모리·자격증명·DB | 서로 다른 디렉터리 |
| 세션 키 | 그 안에서 채널·사용자·스레드 | 같은 DB의 다른 행 |
채널 하나에 프로필 하나를 붙이는 건 상류가 요구하는 게 아니라 우리가 고른 추가 격리다. 상류는 프로필 하나로 여러 채널을 처리하는 걸 전제한다.
이 DB 밖에 있는 것
헷갈리기 쉬워서 정리해둔다.
- 문서 RAG — 완전히 별개 저장소다. "봇이 대화하며 배운 것"과 "미리 넣어둔 지식"은 다른 물건이다
- 외부 memory provider — 로컬 provider는 자체 SQLite, 클라우드 provider는 남의 서버
- 배치 실행기와 RL 트래젝토리 — 문서가 "여기 저장 안 함"이라고 명시한다
배운 것
- 단순한 선택은 공짜가 아니라 대가를 다른 곳에서 치른다. DB 서버를 안 띄우는 대신 쓰기 경합 처리를 애플리케이션이 떠안았다. 랜덤 지터, 짧은 타임아웃, 주기적 체크포인트가 전부 그 값이다.
- 인덱스가 세 개인 건 낭비가 아니라 언어 문제였다. 한글로 검색해보기 전에는 하나로 충분해 보인다.
- 조건절 한 줄이 기능 전체를 무력화할 수 있다.
only ended sessions가 그랬다. API 문서의 괄호 안 조건을 흘려보내면 안 되는 이유다. - 캐시 최적화 규칙은 대개 저장 계층에 흔적이 있다.
api_content컬럼 하나가 프롬프트 캐시 관련 운영 규칙 여러 개를 한 번에 설명해줬다.
관련 글
- 문서가 코드와 어긋난 네 곳 — 게이트웨이 인가 경로를 실측으로 다시 그렸다 — 같은 세션의 앞부분. 이 글의
session_key가 거기서 어떻게 만들어지는지, 그리고 왜 사용자마다 갈리는지를 다룬다. - 에이전트는 무엇을 기억하고 언제 잊는가 — 메모리 스냅샷, 세션 수명, 그리고 멈춰 있던 자가개선 루프 — 이 글의 "세션이 끝나지 않는다"가 왜 그런지, 그리고 그것이 자가개선 루프까지 멈춰 세운 경위.
- 에이전트 루프 완전 해부 — 질문 32개로 훑은 프로바이더·툴·콜백·폴백·컨텍스트·비용 — 과금 컬럼에 쌓이는 값이 어디서 나오는지. 컨텍스트 압축이 세션을 쪼개는 그 압축이기도 하다.
- Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유 — 게이트웨이를 하나만 띄워야 하는 이유의 절반이 이 글의 WAL 단일 writer 제약이다.