사내 메신저에 붙여둔 AI 에이전트가 며칠째 잘 돌고 있었다. 그런데 문득 궁금해졌다. 이 봇은 어제 나눈 대화를 기억하고 있나? 기억한다면 어디에? 공식 문서를 읽고 시작했는데, 두 군데가 실제 코드와 달랐다. 확인하러 컨테이너를 열었더니 더 큰 게 나왔다.
시스템 프롬프트는 세 층으로 쌓인다
에이전트가 매 요청마다 모델에 보내는 시스템 프롬프트는 한 덩어리가 아니라 세 층으로 조립된다.
| 층 | 내용 | 변경 빈도 |
|---|---|---|
| stable | 아이덴티티(페르소나 파일), 툴·모델 가이드, 스킬 인덱스, 환경·플랫폼 힌트 | 거의 안 바뀜 |
| context | 호출자가 준 시스템 메시지 + 프로젝트 컨텍스트 파일 | 프로젝트 단위 |
| volatile | 메모리 스냅샷, 사용자 프로필, 외부 메모리 블록, 타임스탬프·세션·모델 라인 | 매 세션 |
순서가 중요하다. stable → context → volatile 순으로 이어 붙인다.
이유는 프롬프트 캐싱이다. 모델 제공자 쪽 캐시는 prefix 매칭이다. 앞에서부터 몇 바이트까지 같은지로 히트를 판정한다. 자주 바뀌는 것을 앞에 두면 그 뒤의 수천 토큰짜리 스킬 인덱스와 페르소나까지 전부 캐시 미스가 된다. 그래서 안 바뀌는 것부터 쌓는다.
문서가 틀린 곳 1 — 메모리 파일은 그 경로에 없다
공식 문서는 메모리 파일이 ~/.hermes/MEMORY.md에 있다고 쓴다. 실제 코드는 다르다.
def get_memory_dir() -> Path:
"""Return the profile-scoped memories directory."""
return get_hermes_home() / "memories"
홈 루트가 아니라 그 아래 memories/ 서브디렉터리다. 컨테이너를 열어보니 실제로도
memories/MEMORY.md였다.
사소해 보이지만, 문서만 믿고 ~/.hermes/MEMORY.md를 편집하면 아무 일도 일어나지 않는다.
에러도 안 난다. 새 파일이 하나 생길 뿐이고 봇은 계속 원래 파일을 읽는다. 이런 종류의 실패가
제일 오래 걸린다.
문서가 틀린 곳 2 — 타임스탬프는 분이 아니라 날짜 단위다
문서 예시에는 프롬프트 안에 Current time: 2026-03-30T14:30:00-07:00처럼 분 단위 시각이
들어간다고 되어 있다. 실제 코드는:
# Date-only (not minute-precision) so the system prompt is byte-stable
# for the full day. Minute-precision changes invalidate prefix-cache KV
# on every rebuild path.
timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y')}"
날짜만 넣는다. 주석이 이유까지 적어놨다. 분 단위로 넣으면 프롬프트를 다시 조립할 때마다 캐시가 통째로 날아가기 때문이다.
저장된 실제 프롬프트에서도 Conversation started: Tuesday, July 14, 2026으로 확인됐다.
하루 동안은 이 줄이 바뀌지 않는다.
실무적 의미: 캐시가 안 먹는 원인을 찾을 때 타임스탬프를 먼저 의심할 필요가 없다. 같은 날 여러 세션을 시작해도 시스템 프롬프트는 바이트 단위로 동일하다.
메모리는 두 파일이고, 담는 기준이 다르다
에이전트의 지속 메모리는 파일 두 개다. 이름이 비슷해 헷갈리는데 기준이 명확히 갈린다.
MEMORY.md |
USER.md |
|
|---|---|---|
| 기준 | 에이전트 자신의 노트 — 환경 사실, 프로젝트 관례, 툴 특이점, 배운 것 | 사용자가 누구인가 — 이름, 역할, 선호, 커뮤니케이션 스타일 |
| 상한 | 2,200자 | 1,375자 |
엔트리는 §(section sign)로 구분하고 멀티라인이 가능하다. 상한이 토큰이 아니라 문자인 것은
모델에 독립적인 값을 쓰기 위해서다.
툴 스키마에 저장 규칙이 박혀 있다.
- 우선순위: 사용자 선호·교정 > 환경 사실 > 절차
- 저장 금지: 사소·자명한 정보, 다시 찾으면 되는 사실, 원시 데이터 덤프, 작업 진행 상황, 완료 로그, 임시 TODO
- 그리고 한 줄: "재사용 가능한 절차는 메모리가 아니라 스킬로."
마지막 줄이 나중에 중요해진다.
채널 봇에서 메모리는 사람마다가 아니라 프로필마다 하나다
여기서 구조적 문제가 하나 나온다.
메신저 채널에 붙은 봇은 채널 × 사용자 조합마다 세션이 따로 생긴다. 김대리와 박과장이 같은 채널에서 봇을 불러도 서로 다른 세션이다. 그런데 메모리 파일은 프로필 하나당 하나다.
프로필 (sales)
├── memories/MEMORY.md ←── 파일 1개
└── 세션들
├── 김대리 세션 ┐
├── 박과장 세션 ├── 셋 다 같은 파일을 읽고 같은 파일에 쓴다
└── 이차장 세션 ┘
무슨 일이 벌어지냐면:
월요일 김대리: "내 사번으로 견적서 올려줘"
봇이 사번을 메모리에 저장 ✅ 파일에 기록됨
화요일 박과장이 같은 채널에서 질문
→ 박과장의 새 세션 시작
→ 시스템 프롬프트에 그 메모리 스냅샷이 삽입됨
→ 박과장의 프롬프트 안에 김대리 사번이 들어간다 ⚠️
→ 모델은 이걸 "지금 대화 중인 사용자의 사번"으로 읽는다
가정이 아니었다. 우리 기본 프로필 메모리에 특정 개인의 사번이 실제로 들어 있었다.
메모리 툴이 정의한 user 타깃의 기준은 "the user = who the user is"다. 단수형이다.
1:1 CLI나 개인 메신저 DM을 상정한 설계지, 여러 사람이 쓰는 채널 모델이 아니다.
그래서 채널 봇에서는:
| 권장 | |
|---|---|
MEMORY.md |
조건부 사용 — 채널 전체에 참인 것만. 개인 정보 금지 |
USER.md |
끄기. 사용자가 N명인데 프로필 파일은 1개 |
| 개인별 사실 | 외부 메모리 저장소를 쓰거나, 아예 저장하지 않기 |
Frozen — 세션 시작 때 찍은 사진
메모리에서 가장 헷갈리는 동작이 이것이다.
세션이 시작될 때 메모리 파일을 읽어 프롬프트에 복사한다. 그 뒤로 메모리를 고쳐도 그 세션의 프롬프트는 바뀌지 않는다. 사진을 한 장 찍어두고 세션 내내 그 사진을 쓴다.
09:00 세션 시작 → 메모리 파일을 읽어 프롬프트에 복사(사진 촬영)
09:05 사용자: "우리 배포 서버는 A야"
봇: 메모리에 저장
→ 파일은 즉시 바뀜 ✅ 디스크 갱신
→ 프롬프트는 안 바뀜 ❌ 사진 그대로
09:06 "배포 서버 뭐라고 했지?"
→ 프롬프트에는 없지만 09:05 대화가 히스토리에 있어 답은 나옴
(다음 세션) 다시 촬영 → 그제야 프롬프트에 들어감
코드 주석이 이유를 그대로 적어놨다.
Both are injected into the system prompt as a frozen snapshot at session start. Mid-session writes update files on disk immediately (durable) but do NOT change the system prompt — this preserves the prefix cache for the entire session.
턴마다 메모리를 반영하면 매 턴 프롬프트 전체가 캐시 미스가 된다. 우리 봇의 프롬프트는 18,000자 규모다. 그걸 매 턴 새로 읽히는 대신, 사진을 얼려두는 쪽을 택한 것이다.
그 사진은 어디 저장되나 — SQLite 컬럼 하나
조립된 프롬프트는 파일이 아니라 SQLite 테이블의 컬럼에 문자열로 저장된다.
state.db → sessions 테이블 → system_prompt 컬럼
왜 이런 구조냐면, 게이트웨이는 턴마다 에이전트 객체를 새로 만들기 때문이다. 프로세스 메모리에 캐시할 수가 없다. 코드 주석이 직설적이다.
…the gateway path, which constructs a fresh agent per turn and depends on this DB roundtrip.
턴 1 → 캐시 없음 → 조립 → DB 컬럼에 저장
턴 2 → 새 에이전트 → DB에서 읽기 → 런타임 일치 검사 통과?
예 → 저장본을 한 글자도 안 바꾸고 재사용 ← 캐시 히트
아니오 → 재조립
그 "런타임 일치 검사"가 보는 것은 프롬프트 끝의 Model:과 Provider: 두 줄뿐이다.
저장된 값과 현재 값이 다르면 재조립한다. 모델을 바꾸거나 폴백이 돌면 캐시가 0부터 다시라는 뜻인데,
이전 장애 기록에서 폴백이 돌았던
상황의 숨은 비용이 여기 있다.
실측해보니 채널 세 개 모두 프롬프트가 18,000자대였고, 한 곳은 12일째 같은 세션을 쓰고 있었다. 7월 14일에 찍은 사진을 7월 26일에도 쓰고 있다는 뜻이다.
WAL — "게이트웨이를 하나만 띄워라"의 다른 얼굴
세션 DB는 SQLite의 WAL(Write-Ahead Log) 모드로 열려 있다. 쓰기를 본체 파일에 직접 하지 않고,
변경분을 별도 -wal 파일에 덧붙인다. 읽기는 본체와 -wal을 겹쳐 읽는다.
state.db 본체
state.db-wal 변경 로그
state.db-shm 공유 메모리 인덱스
합쳐지는(체크포인트) 시점은 세 가지다.
- 쓰기 50번마다 — 코드가 명시적으로
PRAGMA wal_checkpoint(TRUNCATE)를 부른다.TRUNCATE인 게 중요하다. 주석이 왜 바꿨는지 적어놨다. "PASSIVE checkpoint … never truncates the WAL file — the file stays at its high-water mark." 한 번 커진 로그가 계속 디스크를 차지하는 걸 막으려고 0바이트로 잘라낸다. - 로그가 1000페이지(약 4MB)를 넘으면 — SQLite가 알아서.
- 마지막 커넥션이 닫힐 때.
여기서 나오는 실무 규칙 셋:
- WAL은 "읽기 여럿 + 쓰기 하나"다. 같은 데이터 디렉터리에 게이트웨이를 두 개 띄우면 안 되는 이유 중 하나가 이거다. 두 번째 writer는 잠금 오류가 나거나, 더 나쁘게는 둘 다 성공한 것처럼 보이면서 데이터가 어긋난다.
- 조회할 때는 읽기 전용으로 연다. 실수로 두 번째 writer가 되는 사고가 원천 차단된다.
sqlite3.connect('file:/path/to/state.db?mode=ro', uri=True) - 백업할 때 본체만 복사하면 안 된다.
-wal에 아직 반영 안 된 최근 변경이 빠진다. 세 파일을 다 복사하거나 SQLite의 backup API로 일관 스냅샷을 뜬다.
그리고 하나 더 — 오래 열어둔 조회 세션이 있으면 체크포인트가 못 돈다. 그 세션이 보고 있는 옛 스냅샷 구간을 지울 수 없기 때문이다. 터미널에 SQLite를 띄워놓고 방치하지 말 것.
스킬 인덱스가 프롬프트의 70%를 먹고 있었다
프롬프트를 실제로 뜯어보니 구조가 이랬다.
라인 4 | 툴 행동 가이드
라인 40 | ## Skills (mandatory)
라인 46 | <available_skills> ← 인덱스 시작
라인 158 | </available_skills> ← 113줄
라인 159 | Conversation started: …
162줄 중 113줄, 약 70%가 스킬 인덱스였다.
들어가는 건 스킬 본문이 아니라 이름 + 한 줄 설명뿐이다. 본문은 모델이 필요할 때 따로 읽는다. 그런데 그 한 줄짜리들이 쌓여서 이만큼이 된다.
smart-home: Skills for controlling smart home devices…
- openhue: Control Philips Hue lights, scenes, rooms via OpenHue CLI.
영업 채널 봇이 필립스 휴 조명 제어 스킬 설명을 매 세션 이고 다니고 있었다. 캐시되니까 반복 비용은 아니지만, 첫 턴의 캐시 쓰기와 컨텍스트 예산은 실제로 나간다. 채널별로 안 쓰는 카테고리를 정리하면 눈에 띄게 준다.
자가개선은 네 개의 루프다
"에이전트가 스스로 나아진다"는 게 메모리 파일 두 개 얘기인 줄 알았는데, 아니었다. 네 개다.
| 루프 | 담는 것 | 트리거 |
|---|---|---|
| 메모리 | 사실 — 환경, 선호, 관례 | 10턴마다 넛지, 세션 경계 |
| 스킬 | 절차 — 워크플로, 함정, 명령어 | 세션 경계 회고, 즉시 패치 |
| 커레이터 | 스킬 라이브러리 정리 | 7일 주기 |
| 외부 메모리 저장소 | 대화 유래 사실 (용량 무제한) | 대화 중 저장 툴 호출 |
무게중심은 스킬 쪽이다. 근거가 두 개 있다.
첫째, 메모리 툴 스키마가 직접 선을 긋는다. "Reusable procedures belong in a skill, not memory."
둘째, 세션 경계 회고 프롬프트의 톤이 다르다.
- 메모리 회고: "…consider saving to memory if appropriate. If nothing is worth saving, just say 'Nothing to save.'" — 소극적
- 스킬 회고: "Be ACTIVE — most sessions produce at least one skill update, even if small. A pass that does nothing is a missed learning opportunity, not a neutral outcome." — 적극적
그리고 스킬 회고는 사용자의 스타일 교정을 메모리 신호가 아니라 스킬 신호로 우선 취급한다.
Frustration signals like 'stop doing X', 'this is too verbose', 'don't format like this' … are FIRST-CLASS skill signals, not just memory signals.
Memory captures 'who the user is'. Skills capture 'how to do this class of task for this user'.
스킬에 무엇을 넣고, 무엇을 넣지 말아야 하나
실제로 에이전트가 만든 스킬을 하나 열어봤다. 컨테이너 환경에서 에이전트 자신의 도구가 죽었을 때 어떻게 살리는지를 담은 8KB짜리 문서였다. 글의 결이 이랬다.
증상: 모든 실행 도구 호출이 실패한다
근본 원인: 샌드박스 백엔드가 중첩 컨테이너를 띄우려다 실패
안 통하는 것: 게이트웨이 재시작으로는 안 고쳐진다
통하는 것: <설정 명령> 후 컨테이너 재시작
증상 → 근본 원인 → 안 통하는 방법 → 통하는 명령어. 특히 세 번째, "이건 해봤자 소용없다"는 반례가 있는 게 좋았다.
쓸 자리를 고르는 우선순위도 정해져 있다. ① 이번에 로드했던 스킬을 패치 → ② 기존 상위 스킬에 하위 절 추가 → ③ 지원 파일 추가 → ④ 새 스킬 생성. 4번에는 작명 제약이 붙는다.
The name MUST NOT be a specific PR number, error string, feature codename, or 'fix-X / debug-Y / audit-Z-today' session artifact. If the proposed name only makes sense for today's task, it's wrong.
그런데 "넣지 마라" 목록이 더 중요하다
✗ 환경 의존 실패 — 바이너리 없음, 자격증명 미설정, 패키지 미설치
✗ 툴에 대한 부정 단언 — "브라우저 툴은 안 된다", "X는 고장났다"
✗ 세션 중 저절로 해결된 일시적 오류
✗ 일회성 작업 서사
이유가 코드 주석에 있다.
These become persistent self-imposed constraints that bite you later when the environment changes. … Negative claims harden into refusals the agent cites against itself for months after the actual problem was fixed.
이게 남 얘기가 아니다. 며칠 전 무료 모델이 없어져서 봇 전체가 죽은 적이 있다 (장애 기록). 만약 그때 회고가 돌아 "이 제공자는 안 된다"를 스킬에 박았다면, 모델이 복구된 뒤에도 봇이 몇 달간 스스로를 그 규칙으로 막았을 것이다.
올바른 형태는 프롬프트가 지시하는 대로 고치는 명령어다.
If a tool failed because of setup state, capture the FIX (install command, config step, env var to set) — never 'this tool does not work' as a standalone constraint.
외부 메모리 저장소와 RAG 서버는 다른 것이다
이 둘을 같은 것으로 착각하기 쉬운데, 완전히 다르다. 우리 개발 채널에는 둘 다 따로 붙어 있었다.
| 외부 메모리 저장소 | RAG MCP 서버 | |
|---|---|---|
| 뭘 담나 | 대화에서 나온 사실 | 미리 색인된 코퍼스 |
| 누가 넣나 | 에이전트가 대화 중 자동으로 | 오프라인 파이프라인(사람이) |
| 언제 읽히나 | 세션 시작 시 프롬프트에 주입 + 툴 | 모델이 툴을 부를 때만 |
한 줄로: 저장소는 "봇이 대화하며 배운 것", RAG는 "회사가 미리 넣어준 것".
메모리 저장소 플러그인은 8종이 딸려 온다. 지식 그래프형, 사용자 모델링형, 서버 측 LLM 추출형 등 성격이 제각각인데 로컬에서 API 키 없이 쓸 수 있는 건 하나뿐이다. 그리고 프로필당 하나만 활성화된다. 채널마다 다른 걸 쓸 수는 있지만, 한 채널 안에서 A를 B로 바꾸면 A에 쌓인 것은 넘어가지 않는다. 마이그레이션 경로가 없다.
떼어낼 때 조심할 게 하나 있다. 저장소가 제공하던 툴이 사라지는데, 페르소나 파일에 써둔 "그 툴로 저장하라"는 지침은 남는다. 존재하지 않는 도구를 부르라는 지시가 프롬프트에 계속 실린다. 우리 개발 채널 페르소나가 정확히 그 상태가 될 뻔했다.
세션은 언제 끝나는가 — 네 가지 모드
여기서부터가 이 세션의 본론이었다.
| 모드 | 동작 |
|---|---|
both |
유휴 또는 매일 정각, 먼저 오는 쪽 |
idle |
N분 무활동 시 |
daily |
매일 정해진 시각에 |
none |
자동 리셋 없음 (설치 마법사가 "recommended"로 표시) |
우리 설정은 none이었다. 기본값이고 권장값이니 그대로 뒀던 것이다.
idle은 세션 수명이 아니라 무활동 타이머다
처음에 idle 1440분(24시간)을 권할 뻔했는데, 코드를 보고 생각을 바꿨다.
idle_deadline = entry.updated_at + timedelta(minutes=policy.idle_minutes)
if now > idle_deadline: return True
updated_at은 메시지가 올 때마다 갱신된다. 즉 하루 한 번씩만 대화해도 타이머가 계속
리셋되어 세션이 영원히 안 끝난다. 업무 채널이 정확히 그 패턴이다.
매일 쓰는 채널에는 daily가 맞다. 다만 컨테이너 시계가 UTC라 현지 새벽 시각으로 맞추려면
시차를 계산해야 한다.
none의 도미노 — 루프 세 개가 멈춰 있었다
none이 그냥 "세션이 안 끊긴다"로 끝나는 게 아니었다. 코드 주석이 직접 말한다.
A
mode == "none"session never expires, so the watcher will never finalize it.
세션이 끝나지 않으면 세션 종료 훅이 영원히 안 뜬다. 그리고 자가개선 루프 네 개 중 세 개가 그 훅에 매달려 있다.
mode: none
→ 세션 만료 안 됨
→ 종료 훅 미발화
├─ 외부 메모리 저장소가 대화 추출을 못 받음
├─ 메모리 회고가 안 돎
├─ 스킬 회고가 안 돎
└─ Frozen 스냅샷이 안 녹음 → 메모리를 고쳐도 채널에 반영 안 됨
특히 마지막 줄에 주석이 명시적이다. 캐시 압박으로 에이전트가 미리 정리될 때를 대비한 보정
로직조차 none이면 건너뛴다.
Only fires for sessions the expiry watcher will eventually finalize. For
mode == "none"sessions the watcher never runs, so … we skip the commit.
그래서 실제로 어땠나
채널 봇 4대의 메모리 파일 — 전부 1,706바이트, 같은 타임스탬프
프로필을 만들 때 복사된 뒤 보름 동안 한 바이트도 자라지 않았다. 사용자 프로필 파일은 아예 하나도 없었다. 에이전트가 만든 스킬도 전체에서 하나뿐이었고, 그마저 채널이 아니라 터미널 세션에서 나온 것이었다.
자가개선이 켜져 있다고 믿고 있었는데, 설정 한 줄 때문에 처음부터 돌지 않고 있었다.
세션 종료가 실제로 하는 일
그럼 세션을 끊으면 대화가 날아가느냐. 확인해봤다. DB에서 일어나는 일은 이게 전부다.
UPDATE sessions SET ended_at = ?, end_reason = ? WHERE id = ? AND ended_at IS NULL
컬럼 두 개 UPDATE. DELETE가 없다. 메시지 테이블은 손도 안 댄다.
사라지는 것과 남는 것이 명확히 갈린다.
사라지는 것 — 봇의 작업 기억 하나뿐이다. 다음 메시지에 새 세션이 생기고 대화 히스토리가 빈 배열이 된다. 봇은 "아까 그 견적서"를 모른다.
남는 것 — 네 겹이다.
- 메신저 채널 스크롤백 (봇 세션과 무관하게 메신저 서버에 있다)
- 에이전트의 메시지 테이블 (삭제 경로가 아예 없다)
- 전문 검색으로 꺼낼 수 있다. 프롬프트가 이걸 쓰라고 지시까지 한다. "use session_search to recall it before asking them to repeat themselves."
- 회고가 추출한 사실 (다음 세션 프롬프트에 자동으로 실린다)
"기억을 지운다"가 아니라 "작업 기억을 비우고 장기 기억으로 옮긴다"이다. 사람이 자고 일어나는 것과 비슷하다. 어제 대화를 문장 단위로 재생하진 못하지만 결론은 기억하고, 필요하면 기록을 찾는다.
다만 우리 경우엔 세 경로 중 두 개가 죽어 있었다. 회고가 안 돌아 4번이 비어 있고, 채널 봇이 쓰는 소형 무료 모델은 툴 호출 규율이 약해 3번의 신뢰도가 낮다.
none이 사주는 것도 있다 — 내가 놓쳤던 것
여기까지 쓰고 나서 되짚어보니, none의 손실만 세고 이득을 안 세고 있었다.
none은 연속 맥락을 사준다. 설치 마법사가 "recommended"로 표시한 이유가 그거다.
다만 단서가 셋 붙는다.
- 오래되면 압축으로 뭉개진다. 컨텍스트가 절반쯤 차면 압축이 돌아 앞 3개와 뒤 20개만 원문으로 남고 가운데는 요약본이 된다. 연속이지만 손실이 있는 기억이다.
- 기억하는 건 "채널의 대화"가 아니라 "나와 봇의 멘션 스레드"다. 세션이 사람별로 갈리고, 멘션 안 된 메시지는 애초에 히스토리에 없다.
none은 "끝내지 마라"가 아니라 "사람이 끝내라"다. 마법사 문구가until /reset이라고 쓴다. 문제는none자체가 아니라 아무도 리셋 명령을 치지 않는 것이었다.
그래서 선택지는 셋이다.
| 기억 | 자가개선 | 비용 | |
|---|---|---|---|
none + 아무도 안 끊음 (우리 상태) |
연속, 오래되면 뭉개짐 | ❌ 정지 | 압축 반복 |
none + 주제 끝나면 리셋 |
주제 단위 연속 | ✅ 주제마다 | 리셋 시 재조립 |
daily |
하루 단위 끊김 | ✅ 매일 | 하루 1회 재조립 |
두 번째가 원래 의도된 형태다. 현재 트래픽(12일간 5턴)에서는 none의 이득이 이론적이고 손실은
실측되니 daily가 낫지만, 트래픽이 늘면 계산이 뒤집힌다.
채널에서 사람들끼리 나눈 대화는 왜 기억하지 못하나
"그럼 채널에서 사람들끼리 오간 대화를 봇이 기억하게 하려면?" 이건 설정으로 안 된다.
멘션 필터가 에이전트가 돌기 전 단계에 있다. 걸러진 메시지는 에이전트도, 메모리도, 외부 저장소도 구경조차 못 한다. 필터를 끄면 봇이 모든 메시지에 답한다. 채널이 못 쓰게 되고 메시지당 LLM 호출 하나라 비용이 트래픽에 비례한다.
답은 별도 수집 파이프라인이다. 이미 RAG 서버가 돌고 있으니 코퍼스를 하나 더 붙이는 일이다.
① 수집 — REST 폴링이 MVP에 적합하다. 읽기 전용 별도 봇 계정을 쓰는 게 깔끔하다.
② 세그먼트 분할 — 여기가 설계의 핵심이다. 메시지 하나를 청크로 쓰면 안 된다. "네 그렇게 하죠" 한 줄이 검색되어 나와봐야 쓸모가 없다. 스레드 단위나 무활동 30분 기준으로 묶고, 화자를 반드시 보존한다.
[채널] sales
[기간] 2026-07-20 14:03 ~ 14:41
[참여자] 김대리, 박과장
---
김대리: 견적 마감이 언제죠?
박과장: 25일. 근데 단가표가 아직 안 왔어요.
규정 문서 코퍼스와 청킹 전략이 완전히 다르다. 규정은 조문과 별표 단위였고, 대화는 시간·화자 단위다. 별도 코퍼스로 분리해야 한다.
③ 노출 — 검색 툴을 하나 추가하고 페르소나에 언제 부를지 지침을 넣는다. 도구가 없을 때 다른 수단으로 대체하지 말고 "조회할 수 없다"고 답하라는 가드를 반드시 포함한다.
다만 코드보다 정책이 먼저다
채널 전체 대화를 색인한다는 건 사내 대화를 검색 가능하게 만드는 일이다.
어느 채널을 색인할지 / DM은 예외 없이 제외 / 누가 검색할 수 있는지 / 구성원이 알고 있는지 / 보존 기간 / 삭제된 메시지 처리. 마지막 항목이 자주 누락된다. 폴링 방식은 삭제 이벤트를 못 보므로, 원본과 대조해 사라진 메시지를 색인에서도 빼는 정리 작업이 따로 필요하다.
그리고 이걸 만들면 앞의 세션 리셋 고민이 대부분 사라진다. 세션이 끊겨도 검색으로 복구되고,
멘션 스레드뿐 아니라 사람들끼리 나눈 대화까지 찾을 수 있어 none으로 지키던 것보다 넓은
기억을 얻는다.
진단 함정 — 컨테이너에 재귀 grep을 돌리지 말 것
작업 중 컨테이너 소스를 뒤지려고 최상위 경로에 재귀 grep을 돌렸다. 그 경로에는
node_modules와 가상환경이 들어 있었다.
Docker 데몬이 멈췄다. docker exec는 물론 docker ps까지 수 분간 응답하지 않았다.
확인하려던 항목 세 개를 결국 미확인으로 남겼다.
--include=*.py 같은 필터만으로는 부족하다. 탐색 자체가 전체 트리를 훑기 때문이다.
디렉터리를 좁혀야 한다.
# 이렇게 하지 말 것
docker exec c grep -rn "패턴" /opt/app/ --include=*.py
# 이렇게
docker exec c grep -rn "패턴" /opt/app/agent/ /opt/app/gateway/
메타 교훈 — 규칙에 검사 수단을 붙이지 않으면 규칙은 지켜지지 않는다
이 세션의 기록을 남기는 절차 자체를 재사용 가능한 형태로 정리했다. 그 과정에서 같은 실패를 두 번 겪었고, 두 번 다 같은 방식으로 고쳐졌다.
첫 번째. 한국어 마크다운에는 잘 걸리는 함정이 있다. 강조 기호 뒤에 조사를 붙이면
(**채널 ID(26자)**를) CommonMark 규칙상 닫는 기호로 인정되지 않아 별표가 화면에 그대로 노출된다.
닫는 ** 앞이 괄호나 따옴표이고 뒤가 조사일 때 발생하는데, 한국어는 괄호)+조사 조합이 흔해
특히 자주 걸린다.
이 규칙을 문서로 적어뒀는데도 위반이 났다. 그래서 검사 명령을 한 줄 붙였다.
grep -nP '\*\*[^*\n]*[)"'"'"'`\]][ ]*\*\*[가-힣]' <파일>
붙이고 처음 돌렸더니 내 위반 4건이 잡혔다. 규칙을 직접 쓴 사람이 그 규칙을 어긴 것이다.
두 번째. 긴 세션을 문서로 옮길 때 "다룬 소재를 전부 열거하고 다 넣어라"는 규칙을 뒀다. 열거했고, 다 넣었다고 스스로 판정했고, 커밋했다.
나중에 실제로 검사해보니 4건이 빠져 있었다. 그리고 빠진 4건이 전부 대화 앞부분 소재였다. 최근 문답은 또렷하고 앞부분은 흐릿해서, 기억에 의존해 열거하면 초반이 통째로 누락된다. 열거 단계가 막으려던 실패가 열거를 하고도 난 것이다. 열거했다는 것과 그 목록이 실제로 문서에 반영됐다는 것은 다른 문제이기 때문이다.
고친 방식은 첫 번째와 같다. 열거할 때 항목마다 앵커(문서에 반드시 나타날 고유 문자열 — 설정 키, 파일명, 수치, 코드 심볼)를 붙여두고, 다 쓴 뒤 그 앵커로 문서를 훑는다.
while IFS='|' read -r label anchor; do
grep -qF "$anchor" "$DOC" && echo "OK $label" || echo "❌ 누락: $label"
done < 앵커목록
"메모리 설명" 같은 추상 라벨은 앵커가 될 수 없다. 기계가 확인할 수 있어야 한다.
두 사례의 공통점이 이 글에서 제일 오래 남을 것 같다. 자연어로 적은 규칙은 지켜지지 않는다. 자기 신고도 믿을 수 없다. 검사할 수 있는 형태로 바꿔놓지 않으면, 규칙을 쓴 본인부터 어긴다.
정리 — 확인한 것과 못 한 것
바꿔야 할 것들이 나왔고, 순서에 의존성이 있었다.
- 채널 봇의 사용자 프로필 기능을 끈다
- 공용 메모리에서 개인 식별정보를 뺀다
- 세션 리셋을
daily로 바꾼다 - 채널 대화 수집 파이프라인 (정책 결정 먼저)
1번을 3번보다 먼저 해야 한다. 리셋을 켜는 순간 회고가 돌기 시작하면서 사용자 프로필 파일이 생기기 때문이다. 지금은 파일이 없어 실질 피해가 없다.
확인하지 못한 것도 셋 남겼다. 메모리 저장소 비활성화와 초기화의 차이, 관찰 전용 모드의 존재 여부, 저장소 데이터의 복구 가능성. 전부 앞서 말한 Docker 정지 때문에 막혔다.
추론과 실측을 섞지 않는 게 이런 기록의 최소 조건이라고 생각한다. 모르는 건 모른다고 적어두는 편이, 다음 사람이 추론을 사실로 믿고 그 위에 쌓는 것보다 낫다.
관련 글
- Hermes 내부를 열어보다 — 프롬프트 3계층, API 모드 분기, 게이트웨이를 하나만 띄워야 하는 이유 — 이 글이 실측으로 정정한 프롬프트 3계층 서술의 원본
- 에이전트 루프 완전 해부 — 질문 32개로 훑은 프로바이더·툴·콜백·폴백·컨텍스트·비용 — 압축과 캐싱의 상세. 이 글의 캐시 실측이 그쪽 논의에 붙는다
- Hermes의 실체 — 모델이 아니라 하네스, 그리고 스스로 도는 /goal 루프 — 자가개선을 처음 다룬 글. 이 글이 그것을 네 개 루프로 구체화했다
- 봇 전체 무응답 — 무료 모델 소멸(404) + auth.json 권한 사고(401) — 모델 전환이 캐시를 0으로 만드는 비용, 그리고 "부정 단언을 스킬에 넣지 마라"의 실제 사례
- 사내 규정 RAG, 한 줄씩 되묻다 — 하이브리드 검색과 리랭커의 내부 동작 — 채널 대화 수집이 참고할 청킹 전략의 대조군
- Hermes dev 채널 holographic provider 도입 — 외부 메모리 저장소를 붙인 절차