본문으로 건너뛰기
블로그로 돌아가기
Hermes Fleet 만들기: mem0 + Qdrant + Ollama + Claude Code Stop hook으로 셀프호스팅 에이전트 스택 복제하기

Hermes Fleet 만들기: mem0 + Qdrant + Ollama + Claude Code Stop hook으로 셀프호스팅 에이전트 스택 복제하기

10분 읽기0

Hermes Fleet 만들기: mem0 + Qdrant + Ollama + Claude Code Stop hook으로 셀프호스팅 에이전트 스택 복제하기

왜 이걸 만들었나

@Mosescreates 라는 분이 올린 Hermes 플릿 구성 스레드를 보고 주말을 통째로 썼습니다. 원본은 6개 에이전트 프로파일, 맥 스튜디오와 맥북을 Tailscale로 묶은 2-머신 구성, 프로덕션급 런타임. 저는 그 중 "에이전트 여러 개가 하나의 메모리 계층을 공유한다" 는 부분이 궁금했습니다. 나머지는 스케일 문제일 뿐이라고 판단했고, 실제로 그렇더군요.

축소판을 만들었습니다.

  • 2개 프로파일 (CLI 코더 + Telegram 에이전트)
  • 1대 머신 (맥북 하나)
  • 오프라인 폴백은 1일차부터 포함

남은 질문은 하나였습니다. "공유 메모리 아키텍처가 진짜로 의미 있는가, 아니면 단일 세션 여러 개와 별 차이 없는가?" 48시간 돌려본 결과, 차이는 있었고 그 차이가 작지 않았습니다.

TL;DR: mem0 · Qdrant · Ollama 세 개를 하나의 벡터 컬렉션으로 묶고, Claude Code Stop hook이 매 턴 그 컬렉션에 쓰도록 배선하는 것이 이 스택의 전부입니다. 이 6가지 — 공유 Qdrant, 로컬 Ollama 임베딩, mem0 추상화, Stop hook, 네이티브 전용 provider 핀, 로컬 폴백 — 만 지키면 나머지는 취향입니다.

아키텍처 한눈에 보기

┌────────────────┐        ┌──────────────────┐
│ Claude Code    │        │ Telegram Agent   │
│ (CLI coder)    │        │ (개인 어시스턴트) │
└────────┬───────┘        └────────┬─────────┘
         │ Stop hook               │ 대화 종료 시
         │ mem0.add()              │ mem0.add()
         ▼                         ▼
┌────────────────────────────────────────────┐
│             mem0 (Python SDK)              │
│ LLM: OpenRouter (primary) / Ollama (fallback)
│ Embedder: Ollama nomic-embed-text (768d)   │
└────────────────────┬───────────────────────┘
                     │
                     ▼
         ┌────────────────────────┐
         │ Qdrant (Docker)        │
         │ collection: fleet_memory│
         └────────────────────────┘

비유하자면 여러 부서(에이전트)가 같은 서류함(Qdrant)을 쓰는 작은 회사입니다. 복도(mem0)가 하나라서 모두 같은 캐비닛에 넣고 꺼냅니다. 부서가 늘어도 서류함은 그대로예요.

전체 기동 순서

# 1) Qdrant (벡터 저장소)
docker run -d --name qdrant -p 6333:6333 \
  -v "$HOME/qdrant_data:/qdrant/storage" qdrant/qdrant

# 2) Ollama + 임베딩 모델
ollama pull nomic-embed-text
ollama serve &

# 3) mem0 (Python 3.11 권장)
python3.11 -m venv ~/.venv/fleet
source ~/.venv/fleet/bin/activate
pip install mem0ai

그리고 ~/.mem0/config.yaml:

vector_store:
  provider: qdrant
  config:
    host: localhost
    port: 6333
    collection_name: fleet_memory
    embedding_model_dims: 768  # nomic-embed-text 차원수 명시 (중요)

llm:
  provider: ollama
  config:
    model: llama3.1:8b        # 또는 원하는 로컬 LLM
    ollama_base_url: http://localhost:11434

embedder:
  provider: ollama
  config:
    model: nomic-embed-text
    ollama_base_url: http://localhost:11434

원저자 스니펫과 다른 점이 있습니다. 원본은 embedder 블록만 포함했는데, mem0의 default는 OpenAI (gpt-4o) + text-embedding-3-small입니다. llm 블록을 생략하면 mem0가 OpenAI로 조용히 fallback하고, 키가 없으면 401이 뜹니다. embedder만 바꾸고 llm을 놔두면 혼란이 큽니다. 세 블록(vector_store / llm / embedder) 모두 명시하는 것을 강하게 권장합니다.

embedding_model_dims 왜 박아야 하나

nomic-embed-text는 768차원을 뱉습니다. mem0의 Qdrant provider 기본값은 1536차원(OpenAI text-embedding-3-small)에 맞춰져 있어서, 차원이 어긋나면 Qdrant 컬렉션 생성 단계에서 실패하거나 저장이 silently 누락됩니다. mem0 Issue #3441 에서 정확히 이 케이스를 다룹니다. 숫자를 박아두는 4초가 30분 디버깅을 아껴줍니다.

Claude Code Stop hook — 이 스택의 연결고리

Stop hook은 Claude Code가 한 턴 응답을 마칠 때마다 발사됩니다. .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ~/.claude/hooks/mem_broadcast.py"
          }
        ]
      }
    ]
  }
}

여기서 원문의 훅 스크립트를 그대로 복사하면 돌지 않습니다. 원문은 payload.get("transcript", []) 를 읽는데, 공식 문서 기준 Stop hook의 실제 payload는 이렇습니다:

{
  "session_id": "string",
  "transcript_path": "/path/to/session.jsonl",
  "cwd": "/project/path",
  "hook_event_name": "Stop",
  "stop_hook_active": false
}

즉 transcript 배열이 들어오는 게 아니라, JSONL 파일 경로가 들어옵니다. 파일을 열어서 파싱해야 합니다. 수정한 스크립트:

#!/usr/bin/env python3
"""Claude Code Stop hook → mem0 writer.

Reads session transcript from the JSONL path provided by Claude Code,
extracts the last user/assistant turn, writes a memory entry with an
idempotency key derived from session_id + turn_index.
"""
import json
import os
import re
import sys
from pathlib import Path

from mem0 import Memory

REDACT_PATTERNS = [
    re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
    re.compile(r"ghp_[A-Za-z0-9]{36,}"),
    re.compile(r"Bearer\s+[A-Za-z0-9._\-]+"),
]


def redact(text: str) -> str:
    for pat in REDACT_PATTERNS:
        text = pat.sub("[REDACTED]", text)
    return text


def main() -> int:
    payload = json.load(sys.stdin)

    # 무한 루프 방지: Stop hook이 재호출되는 경우 skip
    if payload.get("stop_hook_active"):
        return 0

    transcript_path = payload.get("transcript_path")
    if not transcript_path or not Path(transcript_path).exists():
        return 0

    # JSONL 파싱 → 마지막 user/assistant 턴 추출
    turns = []
    with open(transcript_path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                turns.append(json.loads(line))
            except json.JSONDecodeError:
                continue

    user_turn = next(
        (t for t in reversed(turns) if t.get("role") == "user"), None
    )
    assistant_turn = next(
        (t for t in reversed(turns) if t.get("role") == "assistant"), None
    )
    if not user_turn or not assistant_turn:
        return 0

    session_id = payload.get("session_id", "unknown")
    turn_index = len(turns)

    user_text = redact(str(user_turn.get("content", "")))[:2000]
    assistant_text = redact(str(assistant_turn.get("content", "")))[:2000]

    m = Memory.from_config(os.path.expanduser("~/.mem0/config.yaml"))
    m.add(
        f"User asked: {user_text}\nAssistant answered: {assistant_text}",
        user_id="fleet",
        metadata={
            "source": "claude_code",
            "session_id": session_id,
            "turn_index": turn_index,
            "idempotency_key": f"{session_id}:{turn_index}",
        },
    )
    return 0


if __name__ == "__main__":
    sys.exit(main())

Stop hook 알려진 버그 (중요)

Claude Code Issue #11786 은 v2.0.42 이후 도입된 회귀입니다. prompt 기반 Stop hook이 transcript에 접근하지 못하는 상황이 보고되어 있습니다. 이 글의 훅은 payload의 transcript_path를 직접 읽기 때문에 영향을 덜 받지만, 그 경로의 JSONL이 완전히 flush되지 않은 시점에 훅이 도는 레이스 컨디션이 드물게 목격됩니다. 저는 훅 시작부에 time.sleep(0.2) 를 넣어 완화했지만, 근본 해결은 아닙니다. 이슈 상태를 정기 확인하세요.

프로바이더 네이티브 핀 — 가장 중요한 YAML 4줄

원문에서 가장 동의하는 부분입니다. OpenRouter는 기본적으로 더 싸거나 빠른 provider로 fallback합니다. 그래야 가격이 싸거든요. 일반 챗봇이라면 괜찮지만, 공유 메모리에 쓰는 에이전트에게는 치명적입니다.

비유: 같은 사람의 의견을 매일 다른 쌍둥이에게서 듣는 것. 어제 Alibaba 라우팅으로 저장된 Qwen 응답과 오늘 Together 라우팅의 Qwen 응답이 메모리에 뒤섞이면, 나중에 "왜 그때 이렇게 말했지?"를 복기할 수 없습니다.

# 에이전트 프로파일 하나
primary:
  provider: openrouter
  model: qwen/qwen-3-72b-instruct   # 공식 slug는 최신 모델 페이지에서 확인
  provider_config:
    only: ["alibaba"]
    allow_fallbacks: false

fallback:
  provider: ollama
  model: gemma2:9b-instruct-q4_0

OpenRouter Provider Routing 문서provider.onlyallow_fallbacks: false 를 공식 지원합니다. 요청 헤더에 그대로 실어 보내면 되고, 내부 핑 실패 시 다른 곳으로 돌리지 않습니다. 핀이 깨지면 호출이 실패합니다 — 그게 원하는 동작입니다.

로컬 폴백은 1일차부터

네트워크가 죽은 순간 메모리 쓰기까지 같이 죽으면, 이 아키텍처의 매력이 반감됩니다. 맥북에 Gemma 2 9B 4비트 양자화 모델 하나 올려뒀습니다. 맥북 16GB RAM으로도 편하게 돕니다.

테스트: 대화 중간에 Wi-Fi를 30초 껐습니다. 응답 속도가 잠깐 느려지는 것 외에는 사용자 경험에 변화가 없었습니다. 메모리 쓰기는 로컬 LLM을 거쳐 Qdrant에 계속 쌓였고요. 복구된 뒤에는 primary로 자연스럽게 돌아왔습니다.

Ollama 성능 튜닝 두 줄

임베딩 배치 요청이 타임아웃되는 현상을 겪었습니다. 채팅 LLM이 GPU를 점유하면 임베딩이 starve됩니다. 환경 변수 두 개로 해결:

export OLLAMA_NUM_PARALLEL=4        # 모델당 동시 요청 수
export OLLAMA_MAX_LOADED_MODELS=1   # 상주 모델 제한 (GPU 스위칭 줄임)

Ollama envconfig 문서 에서 전체 목록을 볼 수 있습니다. 주의할 점은 GitHub Issue #4855 에서 보고된 것처럼 실제 동작이 문서와 다를 수 있다는 것입니다. 모니터링하세요.

주말에 배운 세 가지 교훈

1. Happy Eyeballs는 실제 문제입니다

macOS가 IPv6/IPv4 경로 중 빠른 쪽을 택하는 과정에서 연결이 랜덤하게 30초씩 지연됩니다. 증상: "가끔 Qdrant에 연결 못 함, 재시도하면 됨." 해결:

  • curl -4 강제 IPv4
  • ~/.ssh/configAddressFamily inet
  • 서비스 URL을 가능하면 127.0.0.1 로 고정 (localhost 해석 자체 회피)

2. Idempotency 키는 절대 스킵하지 마세요

"2개 프로파일인데 재시도가 뭐 얼마나 일어나겠어?" 라고 1일차에 skip했습니다. 일요일에 점검하니 같은 대화 턴이 메모리에 두 번 들어있더군요. Qdrant가 잠깐 타임아웃났고, mem0가 재시도했고, 결과적으로 두 번 모두 성공한 케이스입니다.

모든 mem0.add 호출에 metadata.idempotency_key = f"{session_id}:{turn_index}" 를 넣고, 저장 전에 Qdrant에서 해당 키를 조회하여 있으면 skip하세요. Moshe가 @brian_cheong 덕분에 task_server v1.1.3에 같은 패턴을 박아둔 이유가 이겁니다.

3. 셀프호스팅 임베딩이 곧 셀프 소유입니다

OpenAI 임베딩을 썼다면 주말에 몇 센트 정도 나왔을 겁니다. 큰 돈이 아닙니다. 다만 48시간치 작업 메모리가 통째로 외부 업체 DB에 인덱싱되어 있다는 사실이 남습니다. Ollama로 임베딩하면 전기세만 듭니다. 제 MacBook 배터리 기준 주말 전체가 1회 완충 안짝이었습니다.

OpenRouter가 내일 사라져도 Ollama fallback과 로컬 Qdrant가 멀쩡합니다. "플랫폼이 아무것도 결정하지 않는다" 는 원문 표현이 딱 맞습니다.

뺀 것들 (선택 사항)

제외한 것대체 수단
두 번째 머신 (Tailscale 메시)단일 머신 로컬호스트
launchd 서비스 유닛brew services + docker ps
cron 백업 잡Time Machine
fleet status CLIbrew services list && docker ps

Moshe는 프로덕션이라 이 모두를 갖추어야 합니다. 주말 복제라면 선택입니다.

절대 빼면 안 되는 것 (load-bearing):

  1. 공유 Qdrant 컬렉션
  2. 로컬 Ollama 임베딩
  3. mem0 추상화
  4. Claude Code Stop hook
  5. 네이티브 전용 provider 핀
  6. 로컬 LLM 폴백

직접 체감한 차이

주말 전체를 돌려보고 가장 놀랐던 순간: Claude Code에서 Next.js 리팩토링을 한 시간쯤 한 뒤 Telegram 에이전트에게 "오늘 저녁에 뭐 먹을까" 같은 잡담을 던졌는데, "오늘 코드 많이 짰으니 부담 없는 국수 어때?" 라고 답하더군요. Claude Code 세션 내용을 mem0에서 읽어 참고한 거예요.

같은 맥락이 반대로도 작동합니다. Telegram에서 새벽에 잡담한 결정을 다음 날 오전 Claude Code가 기억하고 있습니다. 컨텍스트 전환 비용이 없는 작업 환경이라는 표현이 추상적으로 들릴 수 있지만, 직접 써보면 체감 차이가 꽤 큽니다.

확장 방향

  • 프로파일 추가: YAML 4-6줄 복사하고 model slug 바꾸기
  • 메모리 네임스페이스: user_idfleet:project-a 식으로 파티셔닝
  • 메모리 retention: mem0의 delete_all / history API로 오래된 엔트리 압축
  • 멀티 머신: Tailscale + Qdrant 단일 인스턴스 원격 연결

마무리

원저자 표현을 한 번 더 빌리면, "기술은 새로울 게 없고, 일은 배선에 있다" 입니다. mem0도 Qdrant도 Ollama도 Stop hook도 각자 존재합니다. 이들을 하나의 저장소에 묶고, idempotency와 redaction 규율을 지키면 저장소를 신뢰할 수 있게 됩니다. 저장소가 신뢰할 만해지면 에이전트들이 그 저장소 위에서 서로의 일을 공유하기 시작합니다.

스케일은 취향입니다. 6 프로파일 2 머신이든, 2 프로파일 1 머신이든, 아키텍처가 유지되는 한 차이는 없습니다. 다만 공유 메모리 계층만은 1일차부터 올바르게 깔아두세요. 나중에 바꾸기 가장 어려운 부분입니다.

참조 출처

  1. mem0 Qdrant 공식 문서
  2. Qdrant × mem0 통합 가이드
  3. Qdrant Docker 설치
  4. Ollama nomic-embed-text 모델 페이지
  5. Claude Code Hooks 공식 문서
  6. OpenRouter Provider Routing
  7. OpenRouter Model Fallbacks
  8. Claude Code Issue #11786 — Stop hook transcript 회귀 버그
  9. mem0 Issue #3441 — Ollama embedder 저장 버그
  10. Ollama envconfig 소스
  11. mem0 + Ollama + openclaw 공식 예제

크레딧: 원저자 Moshe (@Mosescreates), Hermes / mem0 / Qdrant / Ollama 메인테이너. 이 글의 모든 스크립트와 설정은 원저자 공개 스레드를 기반으로 저자가 주말에 직접 구동한 기록입니다.

© 퀀텀점프클럽 정상록