본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 28. 11:31

AI 에이전트를 위한 CRDT 복제 메모리 메시(Memory Mesh) 구축하기

요약

멀티 에이전트 환경에서 에이전트 간 공유 컨텍스트를 유지하기 위해 CRDT 기반의 복제 메모리 시스템인 'Memory Mesh'를 구축하는 방법을 다룹니다. 중앙 코디네이터 없이도 서로 다른 호스트와 프레임워크를 사용하는 에이전트들이 의미론적 메모리를 동기화할 수 있는 구조를 제안합니다.

핵심 포인트

  • 중앙 집중식 데이터베이스 없이 에이전트 간 메모리 공유 가능
  • CRDT를 활용하여 인스턴스 간 양방향 자동 복제 구현
  • Claude Code, AutoGen 등 서로 다른 프레임워크 간 호환성 확보
  • 오프라인 동작 및 피어 투 피어(P2P) 방식의 상태 동기화 지원

제가 시도했던 모든 멀티 에이전트(multi-agent) 설정은 동일한 벽에 부딪혔습니다. 에이전트들이 함께 무언가를 기억할 수 없다는 점이었습니다.

각 Claude Code 세션은 항상 처음부터 시작되었습니다. 동일한 리포지토리(repo)에서 작업하는 두 에이전트는 상대방이 무엇을 했는지 전혀 알지 못했습니다. 제가 계속해서 구축해 온 "공유 컨텍스트(shared context)"는 미완성된 오케스트레이션(orchestration) 스크립트들의 무덤으로 변했습니다. 결국 저는 오케스트레이터(orchestrator)를 완전히 포기하고, 누락된 조각을 직접 만들었습니다. 그것은 중앙 코디네이터(central coordinator)나 프레임워크 종속성(framework lock-in) 없이, 에이전트 군단에게 공유된 의미론적 메모리(semantic memory)와 조정 계층(coordination layer)을 제공하는 서버입니다.

이 포스트는 제가 가장 흥미롭다고 생각하는 부분, 즉 메모리가 CRDT로서 인스턴스 간에 어떻게 복제되는지에 대해 다룹니다.

문제의 형태

에이전트 군단(fleet of agents)은 하위 에이전트(sub-agents)를 가진 단일 에이전트와는 다릅니다.

  • 세션은 종료됩니다. 다음 세션은 이전 세션이 중단된 지점부터 다시 시작해야 하며, 때로는 다른 머신에서 시작해야 할 수도 있습니다.
  • 서로 다른 호스트(host)에 있는 여러 에이전트가 관련된 작업을 수행합니다. 이들은 작업을 중복하거나, 덮어쓰거나, 충돌해서는 안 됩니다.
  • 모든 상태(state)를 고정할 수 있는 단일 프로세스가 없습니다. 제 노트북의 봇, CI의 봇, 그리고 동료의 머신에 있는 봇은 모두 동일한 메모리를 볼 수 있어야 합니다.
  • 에이전트들이 모두 동일한 프레임워크를 사용하는 것도 아닙니다. 하나는 Claude Code이고, 하나는 AutoGen 스크립트이며, 하나는 API를 직접 호출하는 크론 잡(cron job)입니다. 이들은 HTTP 외에는 공통점이 없습니다.

명백한 해답("모든 것을 중앙 데이터베이스에 넣는다")은 오프라인에 가까운 동작이 필요하거나, 모든 팀의 데이터가 하나의 공유 서버를 통해 흐르는 것을 원치 않거나, 호스트 간의 피어 투 피어(peer-to-peer) 방식이 필요할 때까지는 작동합니다.

대신 제가 원했던 것은 다음과 같습니다: 각 호스트는 자체 SQLite 데이터베이스를 가진 자체 서버를 실행합니다. 서버 쌍은 서로 "연결(link)"될 수 있으며, 그 이후부터 메모리는 양방향으로 자동 복제됩니다. 쿼럼(quorum)도, 리더 선출(leader election)도, 중앙 집중식 요소도 필요 없습니다.

이것은 전형적인 CRDT 문제입니다.

Two Artel instances meshing: memory written on one shows up on the other and vice versa, no central coordinator

왜 CRDT인가, 그리고 어떤 것을 사용할 것인가

제약 조건은 다음과 같습니다:

  1. 중앙 조정자(central coordinator) 없음. 각 인스턴스는 로컬에 기록하고, 피어(peer)로부터 비동기적으로 읽어옵니다.
  2. 수렴(Convergence). 동일한 작업 세트를 확인한 두 인스턴스는 반드시 동일한 상태로 끝나야 합니다.
  3. 멱등성(Idempotency). 동일한 항목을 두 번 입력해도 결과는 동일해야 합니다.
  4. 무한 루프 없음. A → B → A → B와 같은 흐름은 종료되어야 하며, 영원히 메아리치듯 반복되어서는 안 됩니다.

데이터는 메모리 항목(내용, 태그, 신뢰도 및 몇 가지 메타데이터 필드가 포함된 노트라고 생각하세요)의 집합입니다. 일부는 업데이트되고, 일부는 툼스톤(tombstone, 삭제 표시) 처리됩니다. 필드별 마지막 쓰기 승리(last-write-wins) 외에 실제적인 "병합(merge)" 의미론은 없습니다.

저는 LWW-Element-Set CRDT를 선택했습니다. 이는 이러한 방식의 성장 및 툼스톤(grow-and-tombstone) 데이터에 대해 수렴성이 공식적으로 증명되었습니다. 구현에는 중요한 세 가지 요소가 있습니다:

1. 항목은 불변의 원본 UUID(immutable origin UUID)로 키가 지정됩니다. 모든 메모리 항목은 생성 시점에 원본 인스턴스에서 발행된 id를 가집니다. 해당 항목이 피어로 복제될 때, 피어는 동일한 ID로 이를 저장합니다. 입력 시 재발행하지 않습니다. 이것이 멱등성(idempotency)을 저렴하게 만드는 비결입니다. INSERT OR REPLACE만으로 충분하기 때문입니다.

2. 병합 규칙은 max(version)이며, updated_at을 타이브레이크(tiebreak, 동점 처리)로 사용합니다. 각 항목은 원본에서 업데이트할 때마다 증가하는 단조로운(monotonic) version 카운터를 가집니다. 피어들은 (version, updated_at) 튜플을 비교합니다. 마지막 쓰기 승리(last-write-wins) 방식이지만, 버전이 우선하므로 호스트 간의 벽시계 왜곡(wall-clock skew)은 실제 상황에서 거의 문제가 되지 않습니다. (버전이 같고 시계가 왜곡된 경우가 유일한 이론적 예외 케이스이지만, 여러 대의 머신에서 3개월 동안 실행하는 동안 이 상황을 겪지 않았습니다.)

3. Origin guard(기원 보호 장치)가 루프를 방지합니다. 모든 엔트리(entry)는 이를 생성한 인스턴스의 ID인 origin 필드를 포함합니다. 피어(peer)로부터 데이터를 수집(ingesting)할 때, origin이 자신의 것과 일치하는 엔트리는 건너뜁니다. 따라서 A가 B에게 엔트리를 푸시(push)하고, B의 피드(feed)가 이를 다시 A에게 에코(echo)하더라도, A는 이를 버립니다. A→B→A 구조는 단 한 번의 왕복(round-trip) 후에 종료됩니다.

전송 방식: 오직 피드(feeds)뿐

복제 프로토콜(replication protocol)은 커스텀 바이너리(custom binary) 방식이 아닙니다. Atom과 JSON Feed를 사용합니다.

각 인스턴스는 두 개의 URL에 자신의 메모리를 게시합니다:

GET /memory/feed.atom?mesh_token=...
GET /memory/feed.json?mesh_token=...

두 방식 모두 일반적인 RSS 스타일의 피드이지만, 한 가지 차이점이 있습니다. JSON Feed 형식은 각 아이템에 CRDT 메타데이터(origin, version, parents, scope, deleted_at 등)를 담은 커스텀 _artel 확장 필드를 포함합니다. 이것이 병합(merge)을 가능하게 하는 핵심입니다.

각 인스턴스의 메시 폴러(mesh poller)는 단순한 RSS 리더(RSS reader)입니다. N분마다 연결된 각 피어의 피드를 폴링(poll)하고, 새로운 엔트리를 순회하며, 병합 규칙을 적용한 뒤, 로컬 SQLite에 기록합니다. 이것이 전체 복제 루프(replication loop)의 전부입니다. Python 코드 약 150줄 정도의 분량입니다.

커스텀 프로토콜 대신 피드를 사용하기로 한 결정은 의도적이었습니다:

  • 어떤 HTTP 클라이언트든 피드를 폴링할 수 있습니다. 따라서 curl을 사용하여 메시를 디버깅할 수 있습니다.
  • 피드는 본질적으로 풀 기반(pull-based)이므로, 초기 연결을 제외하고는 어느 쪽도 상대방의 주소를 미리 알 필요가 없습니다.
  • LAN 피어들은 mDNS(_artel._tcp.local.)를 통해 서로를 발견합니다. UI에서 클릭 한 번이면 연결됩니다.

화려한 가십 프로토콜(gossip protocol), 머클 트리 동기화(Merkle tree sync), 또는 안티 엔트로피(anti-entropy) 메커니즘은 없습니다. 오직 피드 폴링뿐입니다. 결과적으로 이는 LLM 플릿(fleet)이 생성하는 처리량(throughput)을 감당하기에 충분했습니다. 아무리 열성적인 에이전트라 하더라도 분당 몇 개의 엔트리만 작성하며, 이는 피드 폴러가 처리할 수 있는 수준보다 훨씬 낮습니다.

임베딩(embeddings)을 위한 SQLite + sqlite-vec

백엔드 저장소(backing store)는 WAL 모드의 SQLite입니다. 의미론적 검색 (semantic search)을 위해, sqlite-vec은 벡터 유사도 (vector similarity)를 위한 가상 테이블을 제공합니다. 임베딩 (embeddings)은 작은 모델을 사용하여 로컬에서 계산되며, 메인 memory 테이블과 동일한 UUID를 키로 갖는 memory_vec 테이블에 삽입됩니다.

CREATE VIRTUAL TABLE memory_vec USING vec0(
    id TEXT PRIMARY KEY,
    embedding FLOAT[384]
...

검색은 조인 (join) 방식입니다:

SELECT m.*, mv.distance
FROM memory_vec mv
JOIN memory m ON m.id = mv.id
...

기존의 필터들 (태그, 프로젝트, 신뢰도 하한선, 유형)과 결합하면, 인덱싱 전략 (indexing strategy)에 대해 고민할 필요가 없을 정도로 충분히 빠릅니다. 전체 메모리 테이블은 WAL 모드 SQLite에 있으며, 읽기 작업이 쓰기 작업을 차단하지 않습니다.

CRDT 병합 (merge)이 발생하면 (예를 들어, 피어의 엔트리 버전이 로컬 버전을 덮어쓰는 경우), 임베딩이 다시 계산되고 memory_vec 행이 교체됩니다. 의미론적 검색 인덱스는 항상 병합된 콘텐츠와 동기화됩니다.

아카이비스트(The archivist): 메모리 상태를 건강하게 유지하는 백그라운드 에이전트

설계 과정에서 필요할 것이라고 예상하지 못했던 또 다른 요소는 결국 제가 _아카이비스트 (archivist)_라고 부르게 된 것입니다. 이는 모든 활동을 감시하고 유지보수를 수행하는 장기 실행 백그라운드 프로세스입니다.

이 프로세스는 루프를 돌며 다섯 가지 작업을 수행합니다:

  1. 합성 (Synthesis). 최근 메모리 항목들을 읽고, 서로 관련된 항목들을 찾아낸 뒤, LLM (Large Language Model)에게 해당 클러스터를 요약하는 연결 문서 (connecting doc)를 작성하도록 요청합니다. 이 연결 문서는 적절한 태그가 지정된 또 다른 메모리 항목일 뿐이므로, 향후 검색 시에도 나타납니다.
  2. 중복 제거 / 병합 (Dedup / merge). 기존 항목과 의미론적으로 매우 유사해 보이는 새로운 항목이 들어오면 (코사인 거리 (cosine distance)가 임계값 미만인 경우), 이를 병합하려고 시도합니다. 병합 작업 역시 두 콘텐츠와 "통합된 버전을 생성하라"는 프롬프트 (prompt)를 함께 LLM에게 전달하여 처리합니다.
  3. 감쇠 (Decay). 한동안 다시 작성되지 않은 항목들은 confidence (신뢰도) 필드 값이 낮아집니다. 자주 읽히는 항목은 이 대상에서 제외됩니다. 특정 하한선 아래로 떨어지면, 해당 항목들은 기본 검색 결과에서 사라집니다 (단, 신뢰도가 낮은 항목을 명시적으로 요청할 경우에는 여전히 존재합니다).
  4. 승격 (Promotion). 충분히 오랫동안 안정적이고, 자주 읽히며, 높은 신뢰도를 유지한 항목들은 type="memory"에서 type="doc"로 승격됩니다. 승격된 항목은 감쇠 대상에서 제외되며 정식 참조 자료 (canonical reference material)로 취급됩니다.
  5. 프로젝트 브리프 (Project briefs). 각 프로젝트에 대해, "여기서 무슨 일이 일어나고 있는지"를 짧은 산문 형태의 요약으로 유지하며, 이는 project-brief 태그가 붙은 doc 타입의 항목으로 관리됩니다. 이 브리프는 모든 에이전트 세션이 시작될 때 자동으로 노출됩니다.

아카이비스트 (archivist)는 그저 또 다른 에이전트일 뿐입니다. 자체적인 자격 증명 (credentials)을 가지고 있으며, 이벤트 스트림 (event stream)을 폴링 (poll)하고, API 호출을 수행합니다. 서버의 관점에서 볼 때, 역할 기반 액세스 제어 (RBAC, role-based access control) 외에는 아카이비스트에게 부여된 특별한 권한은 없습니다 (서버는 아카이비스트가 아무런 근거 없이 새로운 프로젝트를 생성할 수 없도록 강제하며, 이미 외부 존재가 확인된 프로젝트만 다룰 수 있게 합니다).

이러한 분리는 두 가지 이유로 중요합니다. 첫째, LLM 기반의 합성을 원하지 않는다면 아카이비스트 없이 인스턴스를 실행할 수 있습니다. 핵심 메모리 저장소 (core memory store)는 단독으로도 잘 작동합니다. 둘째, 아카이비스트는 교체 가능합니다. 합성 프롬프트나 감쇠 휴리스틱 (decay heuristics)이 마음에 들지 않는다면, 직접 작성할 수 있습니다.

정체성과 프로토콜: 밑바닥까지 전부 HTTP

에이전트들은 agent_id 문자열과 API 키를 통해 인증합니다. 이것이 정체성 모델(identity model)의 전부입니다. 프레임워크 결합도 없고, 설치할 SDK도 없으며, 전송 방식(transport)이 고정되어 있지도 않습니다. 서버는 상단에 MCP 어댑터를 얹은 순수 HTTP로 통신합니다.

Claude Code 세션은 MCP 플러그인을 통해 연결되며, API를 일련의 도구들(memory_write, memory_search, task_claim, message_send 등)로 인식합니다. AutoGen 스크립트는 단순히 httpx.post("/memory", ...)를 호출할 뿐입니다. curl을 사용한 bash 한 줄 명령어(one-liner)도 이 플릿(fleet)의 일원으로 참여할 수 있습니다. 서버는 반대편에 무엇이 있는지 알 필요도 없고 상관하지도 않습니다.

이러한 결정은 모든 멀티 에이전트 프레임워크(multi-agent framework)가 스스로가 '그 자체(the thing)'가 되려고 하는 방식에 대한 좌절감에서 비롯되었습니다. CrewAI, LangGraph, 또는 MemGPT 내부에서 에이전트를 구축하면, 해당 프레임워크가 조율(coordination)을 담당하게 됩니다. 그러면 이들을 쉽게 혼합하거나, 직접 만든 스크립트와 함께 사용하거나, 나중에 프레임워크를 교체하는 것이 어려워집니다. 저는 조율 계층이 그 반대이기를 원했습니다. 즉, 에이전트에 대해 아무것도 모르는 HTTP 서버가 되기를 원했습니다.

내가 다르게 했을 것들

몇 가지 솔직한 사후 분석(postmortems)입니다.

버전 필드 (The version field). 단조 카운터(monotonic counter) 대신 벡터 시계(vector clock)를 사용했어야 했습니다. 현재 방식이 작동하는 이유는 서로 다른 두 기원(origin)에서 동일한 항목에 대해 발생하는 실제 버전 충돌이 드물기 때문입니다. 하지만 "드물다"는 것이 "결코 없다"는 뜻은 아니며, 벡터 시계를 사용했다면 예외적인 상황(edge case)을 완전히 없앨 수 있었을 것입니다.

임베딩은 인스턴스별로 적용됩니다 (Embeddings are per-instance). 각 인스턴스는 자신만의 모델로 자체적인 임베딩(embeddings)을 계산합니다. 서로 다른 모델을 사용하는 두 인스턴스는 호환되지 않는 임베딩을 갖게 되며, 메시(mesh) 전체에 걸친 시맨틱 검색(semantic search)이 깨지게 됩니다. 현재 이는 암묵적인 계약(implicit contract) 상태입니다. 즉, 모두가 동일한 기본 모델을 사용한다는 전제입니다. 제대로 된 해결책은 임베딩을 피드(feed)의 일부로 공유하거나, 모델을 인스턴스별 명시적 설정으로 만들어 불일치 시 경고를 보내는 방식이 될 것입니다.

아카이비스트 (archivist)는 단일한 의견 지점 (single point of opinion)입니다. 감쇠율 (Decay rates), 승격 임계값 (promotion thresholds), 합성 프롬프트 (synthesis prompts) 등이 모두 하드코딩되어 있습니다. 이들은 인스턴스별로 설정 가능해야 하지만, 현재로서는 이를 변경하려면 Python 코드를 직접 수정해야 합니다. 다음번에는 이 부분을 더 플러그인 방식으로 (pluggable) 만들고 싶습니다.

마무리하며

이 모든 것은 오픈 소스이며, MIT 라이선스를 따르고, 단 하나의 Docker 컨테이너에서 실행됩니다. 지난 몇 달 동안 저의 멀티 에이전트 (multi-agent) 설정의 중추 역할을 해왔으며, 제가 가장 자랑스럽게 생각하는 부분은 그 누구에게도 특정 프레임워크를 강요하지 않는다는 점입니다. 여러분의 에이전트가 HTTP를 사용한다면, 바로 이 플릿 (fleet)에 합류할 수 있습니다.

프로젝트 이름은 Artel입니다. 코드를 확인하고 싶으시다면 github.com/NicolasPrimeau/artel에서 보실 수 있습니다. 설치하기 전에 UI를 직접 만져보고 싶다면 artel.run (비밀번호 artel)에서 라이브 샌드박스 (sandbox)를 이용할 수 있습니다.

특히 LLM을 위한 분산 메모리 시스템 (distributed memory systems)을 구축해 보았고 제가 아직 겪지 못한 문제 상황에 직면했던 분들로부터 CRDT 설계에 대한 진심 어린 피드백을 받고 싶습니다.

참고: 이 포스트의 초안 작성을 도와준 에이전트는 다른 에이전트들이 읽을 수 있도록 라이브 샌드박스에 이전 버전을 메모리 항목 (memory entry)으로 저장했습니다. 이것이 바로 우리가 지향하는 개념입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0