에이전트의 메모리가 제대로 작동하는지 측정하는 방법
요약
코딩 에이전트의 메모리 시스템 구축 중 발생한 임베딩 누락 문제를 분석하고, 이를 측정하고 증명하는 방법을 다룹니다. 임베딩 라이브러리의 토큰 제한으로 인해 데이터가 유실되는 현상을 사례로 들어 측정의 중요성을 강조합니다.
핵심 포인트
- 임베딩 라이브러리의 기본 토큰 제한(512)으로 인한 데이터 절단 위험
- 데이터가 늘어날수록 성능이 저하되는 단조 증가 버그 특성
- 상관관계가 아닌 인과관계를 증명하기 위한 정밀한 측정 방법론
- 에이전트 메모리 구축 시 발생할 수 있는 측정의 함정 주의
원문은 olund.dev에 게시되었습니다.
저는 제가 직접 구축한 개인 메모리 시스템 위에서 코딩 에이전트 (coding agents)를 실행합니다. 지속적인 사실 (durable facts)은 마크다운 (markdown) 파일에 저장되고, 인덱서 (indexer)가 로컬 모델 (bge-m3)을 사용하여 이를 임베딩 (embed)하며, recall 명령어가 결과에 대해 시맨틱 검색 (semantic search)을 수행합니다. 세션 (sessions)은 이를 끊임없이 쿼리 (query)합니다. 몇 달 동안은 건강해 보였습니다.
그러다 제가 그 안에 있다는 것을 알고 있는 질문을 던졌습니다. 제 시스템이 Windows를 지원하는지 여부였습니다. 해당 사실은 지속적인 파일 안에 토씨 하나 틀리지 않고 존재했습니다. 하지만 recall은 결과가 하나도 없다고 반환했습니다. 순위가 낮은 것이 아니라, 아예 없었습니다.
이 포스트는 왜 그런 일이 발생했는지 찾아가는 과정, 그리고 더 유용하게는, 모든 단계를 그럴듯한 (plausible) 수준이 아닌 증명 가능한 (provable) 수준으로 만든 방법들에 대한 이야기입니다. 만약 여러분이 에이전트 메모리 (agent memory)를 구축하고 있다면, 구체적인 버그는 여러분의 것이 아닐 수도 있습니다. 하지만 측정의 함정 (measurement traps)은 여러분의 것이 될 것입니다.
버그: 텍스트가 한 번도 임베딩되지 않았다
제 인덱서는 마크다운 섹션 (markdown section) 단위로 메모리 파일을 청킹 (chunked)했습니다. 한 섹션이 904 토큰 (tokens)까지 커졌습니다. 제가 사용하는 임베딩 라이브러리 (fastembed)는 사용자가 오버라이드 (override)하지 않는 한 기본값인 512 토큰에서 입력을 조용히 잘라버리는데 (truncates), 제 코드에는 아무런 설정도 되어 있지 않았습니다. 상위 단계에서도 청크 크기 (chunk size)를 제한하는 것이 없었습니다.
결국 인코더 (encoder)는 3090자 섹션 중 처음 1687자만 보고 나머지는 버렸습니다. 전체 텍스트의 45.4%가 사라졌으며, 여기에는 세 개의 완전한 사실이 포함되어 있었습니다. 에러도, 경고도, 로그 라인 (log line)도 없었습니다. 저장된 벡터 (vector)에는 단순히 그들의 의미가 포함되지 않았을 뿐입니다. 검색 (retrieval)이 그것들을 찾는 데 실패한 것이 아니었습니다. 찾을 내용 자체가 없었던 것입니다.
지독한 특성: 이 버그는 성장에 따라 단조 증가 (monotone with growth)합니다. 섹션에 새로운 사실이 추가될 때마다 더 많은 콘텐츠가 절단 지점을 넘어가게 됩니다. 메모리 시스템은 사용되고 있기 때문에 성능이 저하되었습니다.
상관관계가 아닌 인과관계로 증명하기
"섹션이 길고 recall 성능이 나쁘다"는 것은 상관관계 (correlation)입니다. 코드를 건드리기 전에 저는 메커니즘 (mechanism)을 확실히 고정하고 싶었습니다. 왜냐하면 저의 첫 번째 진단이 틀렸기 때문입니다 (이에 대해서는 아래에서 더 자세히 다룹니다). 이를 해결한 조사 방법은 다음과 같습니다:
| query text | distance to the stored chunk |
|---|---|
| the full 3090-char section | 0.0000 |
| ... | |
| 전체 섹션과 잘린 접두사(prefix)가 _동일한 벡터(same vector)_로 임베딩됩니다. 즉, 거리가 정확히 0입니다. 뒷부분(tail)은 전혀 기여하지 않습니다. 이것은 희석(dilution)의 모습이 아니라, 계단 함수(step function)의 모습입니다. 각 사실(fact)의 문자 오프셋(character offset)과 자체 검색 거리(self-retrieval distance)를 상관 분석한 결과, Spearman rho 값이 +0.952로 나타났습니다. 이는 경사(gradient)가 아니라, 절단 위치에서의 절벽(cliff)을 의미합니다. |
이것이 중요한 이유는 저의 원래 가설이 중심점 희석(centroid dilution)이었기 때문입니다. 즉, 하나의 임베딩이 여러 사실을 평균 내어, 수많은 사실에 의해 지배된다는 가설이었습니다. 희석이 문제라면 재순위화(re-ranking)나 긴 섹션을 더 세밀하게 나누는 등의 해결책이 적절합니다. 하지만 절단(truncation)은 인덱스에서 데이터가 완전히 누락되었음을 의미하며, 아무리 영리한 순위 지정(ranking) 기술을 써도 계산된 적이 없는 벡터를 검색해낼 수는 없습니다. 잘못된 메커니즘에 잘못된 해결책을 적용하는 셈입니다. 10분간의 조사(probe)는 잘못된 수리책을 배포하는 비용보다 훨씬 저렴했습니다.
그럴듯해 보이는 해결책과 진짜 해결책
한 줄로 끝나는 해결책은 존재합니다. 모델이 실제로 8192 토큰 윈도우(window)를 지원하므로, .with_max_length(8192) 한 줄이면 복구됩니다. 하지만 저는 세 가지 이유로 이를 거부했습니다.
- 검증된 결함을 검증되지 않은 결함으로 교체하는 일이기 때문입니다. 즉, 이제 실제로 측정할 수 있게 된 '다중 사실 중심점(multi-fact centroid)' 문제입니다. 윈도우 안에 완전히 들어가는 5개 사실 섹션에서도, 단일 사실의 원문 텍스트로 쿼리를 날리면 해당 섹션이 0.21-0.34의 거리로 반환되었습니다. 이는 전체 청크 자체 쿼리의 하한선인 0.0000과 대조적입니다. 묶음(bundling)은 절단이 없더라도 검색의 선명도(sharpness)를 떨어뜨립니다.
- 단 하나의 거대한 섹션을 위해, 인덱싱된 모든 청크에 대해 16배 길이의 시퀀스에 대한 이차 복잡도(Quadratic) 어텐션(attention) 비용을 지불해야 합니다.
- 입도(granularity)가 여전히 잘못된 상태로 남습니다. 인용(citations), 품질 보고서(quality reports), 인젝션 페이로드(injection payloads)는 모두 개별 사실을 다루어야 하지만, 그럴 수 없었습니다.
따라서 해결책은 _사실(fact)_을 임베딩의 단위로 만드는 것이었습니다. 이제 인덱서(indexer)는 메모리 파일의 사실별 구조를 파싱하여 사실당 하나의 청크를 생성합니다. 모든 사실은 58-335 토큰이며, 어떤 윈도우 안에도 여유롭게 들어갑니다.
그리고 별도로 — 이 부분은 재발을 방지하는 부분입니다 — 이제 인덱서(indexer)는 인코더(encoder)의 실제 윈도우(window) 크기에 맞춰 모든 청크(chunk)의 토큰 수를 계산하며, 기준을 벗어나는 대상에 대해 경고를 보냅니다. 또한 저의 헬스 커맨드(health command)는 해당 수치를 보고합니다. 이 버그 클래스가 다시 나타나는 것을 막는 것은 윈도우 레이즈(window raise)가 아니라 윈도우 가드(window guard)입니다. 오늘 네 개의 위키(wiki) 문서가 한도를 초과했습니다. 탐지기가 이를 명시하므로, 이러한 기술 부채는 조용히 숨겨지는 대신 가시화됩니다.
논쟁하는 대신 수정 사항을 측정하기
이 설계에는 제가 논리적으로 배제할 수 없었던 한 가지 미결된 리스크가 있었습니다. 바로 사실별(per-fact) 청크가 "내가 진행 중인 사이드 프로젝트는 무엇인가?"와 같은 광범위한(broad) 쿼리에 대해 불리할 수 있다는 점이었습니다. 이런 경우 더 많은 주제를 다루는 큰 섹션(section) 단위의 청크가 승리할 가능성이 높기 때문입니다. 저는 이를 설계 검토(design review) 과정에서 하나의 트레이드오프(trade-off)로 받아들였습니다.
하지만 저는 이를 그냥 받아들이는 대신 측정했습니다. 동일한 코퍼스(corpus)를 대상으로 두 개의 인덱스를 만들었습니다: A안은 섹션별로 청크를 나누었으며(현재 라이브 스토어를 정확히 재현), B안은 사실별로 청크를 나누었습니다. 두 안 모두에 대해 20개의 쿼리를 실행했습니다. 메모리 지향적 쿼리, 광범위한 쿼리, 그리고 대조 확인을 위한 8개의 위키 지향적 쿼리를 포함했습니다. 코퍼스의 나머지 부분을 밀어냄으로써 얻는 개선은 진정한 개선이 아니기 때문입니다.
결과적으로 받아들였던 트레이드오프는 뒤집혔습니다:
- Rank-1 거리는 결코 나빠지지 않았습니다: 12개 중 12개의 메모리 지향 쿼리 모두 해당.
- 기본 상위 5개(top-5) 내에 존재하는 메모리 사실(memory facts)의 수가 쿼리 전반에 걸쳐 9개에서 27개로 증가했습니다.
- 6개의 광범위한 쿼리 모두 성능이 향상되었거나 동일했습니다. 핵심적인 광범위 쿼리의 경우, d=0.4763에서 1개의 메모리 히트(hit)를 기록하던 것이 d=0.3537을 선두로 3개의 히트를 기록하는 것으로 개선되었습니다.
- 4개의 좁은 탐색(narrow probes)은 상위 5개 내에 메모리 청크가 0개였던 상태에서 1위(rank 1)로 올라섰습니다.
- 8개의 위키 쿼리는 8개 모두 1위(rank 1)를 유지했습니다.
광범위한 쿼리에 대해 섹션 청크가 가질 것이라 예상했던 이점은 존재하지 않았습니다. 그것은 인덱스에 메모리 청크가 단 두 개뿐이었기 때문에 발생한 착시 현상이었습니다. 당연히 그중 하나가 "광범위한 쿼리에서 승리"할 수밖에 없었습니다. 반환할 다른 것이 없었기 때문입니다. 이것이 제가 논쟁하는 대신 측정해야 한다고 주장하는 가장 강력한 근거입니다. 설계 문서에서 공식적으로 수용했던 리스크는 실재하지 않았습니다.
엔드 투 엔드(End to end)로, 6개의 프로브(probe) 수락 세트(acceptance set)는 0/6 정답에서 6/6 정답으로 변했으며, 12개의 사실(fact) 각각이 이제 정확히 d=0.0000에서 스스로 검색(self-retrieves)됩니다. 이는 더 이상 어떤 사실도 잘리지(truncated) 않는다는 입증된 증거이기도 합니다.
나의 "이전(before)" 측정이 거의 거짓을 말할 뻔했던 두 가지 방식
정직한 전/후(before/after) 비교표를 작성하려면 "이전" 단계가 실제로 과거의 동작을 측정해야 합니다. 나의 경우, 두 번이나 조용히 그 기능을 멈췄으며, 두 번의 실패 모두 동일하게 독이 되는 결과물, 즉 변화가 전혀 없는 것처럼 보이는 표를 만들어냈습니다.
함정 하나: 새 바이너리(binary)에 의해 기존 저장소가 자동으로 업그레이드됨. 나의 인덱스 저장소(index store)는 스키마 버전(schema version)이 오래되면 스스로를 삭제하고 재구축합니다. 수정 사항이 스키마를 업데이트했습니다. 따라서 "이전" 수치를 얻기 위해 보존된 "기존" 저장소를 "새" 바이너리로 지정했을 때, 첫 번째 쿼리가 사실별 청킹(per-fact chunking)을 적용하여 전체 저장소를 조용히 재구축해 버렸습니다. 이는 나의 "이전" 단계를 두 번째 "이후" 단계로 변질시켰습니다. "이전" 단계는 단순히 오래된 데이터가 아니라, 오래된 "바이너리"에 의해 구동되어야 합니다.
함정 둘: 기존 바이너리가 새 바이너리를 덮어씀. 나는 git 워크트리(worktree)에서 그 오래된 바이너리를 빌드했지만, 빌드 캐시(build cache) 디렉토리를 메인 체크아웃(main checkout)과 공유했습니다. 이로 인해 "이후" 열에서 사용 중이던 릴리스 바이너리가 덮어씌워졌습니다. 결과적으로 두 열 모두 수정 전의 코드를 측정하게 되었습니다.
나는 두 가지 징후 덕분에 이를 잡아낼 수 있었습니다. "이후" 열이 "이전" 열과 정확히 일치했다는 점과, 불과 몇 분 전에 측정했던 검증된 결과가 조용히 퇴보(regressed)했다는 점입니다. 내가 지금 지키고 있는 규칙은 다음과 같습니다: 각 단계를 고유한 바이너리 경로에 고정(pin)하고, 두 바이너리 간의 비교를 신뢰하기 전에 두 바이너리가 서로 다름을 확인(assert)하는 것입니다. "모든 곳에서 이전과 이후가 정확히 일치한다"는 것은 무효 결과(null result)가 아닙니다. 그것은 측정 도구의 실패입니다.
세 번째 관련 함정은 테스트 스위트(test suite)에 존재합니다. 제가 사용한 모의 임베더(mock embedder)는 텍스트 전체 입력을 절단(truncation) 없이 해싱하므로, 모든 절단 테스트가—심지어 결함이 있는 코드에 대해서도—통과해 버립니다. 인과 관계 테스트(causal tests)는 실제 인코더(558MB 크기의 모델 다운로드가 필요하여 CI에서는 제외함)를 필요로 하기 때문에, 대신 CI에서는 수정 사항이 보장하는 구조적 속성(structural properties), 즉 '사실당 하나의 청크(one chunk per fact)'와 '식별 필드 채워짐(identity fields populated)'을 고정하여 확인합니다. 증명(proof)은 게이트에서 수동으로 실행되며, 회귀 방지(regression guard)는 모든 커밋마다 실행됩니다. 그리고 저는 각각이 무엇인지 기록해 두었기에, CI가 통과(green)되었다고 해서 그것을 인과적 증명으로 오해하는 일이 없도록 했습니다.
무엇을 전수할 것인가
이 모든 과정에 특별한 도구는 필요하지 않았습니다. CLI, 각 인덱스를 위한 임시 디렉토리(scratch directory), 그리고 무엇을 증거로 간주할지에 대한 규율만 있으면 됩니다. 제가 어떤 에이전트 메모리 시스템을 구축하더라도 가져갈 요소들은 다음과 같습니다:
- 수정 사항을 선택하기 전에 증명 메커니즘을 갖출 것. 저렴한 인과적 탐사(causal probe, 전체 vs 접두사 vs 꼬리 부분 비교) 덕분에, 그럴듯하지만 무용지물인 수리로 이어졌을 잘못된 진단을 막을 수 있었습니다.
- 누락된 데이터를 관찰 가능하게 만들 것. 제 버그의 가장 나쁜 특성은 침묵이었습니다. 토큰 수 가드(token-count guard)는 다음 발생 시 파일명이 포함된 경고를 띄웁니다. 그리고 "측정되지 않음"은 반드시 _측정되지 않음_으로 나타나야 합니다. 저의 헬스 체크(health check)는 판단할 데이터가 없을 때 의도적으로 녹색 체크 표시를 보여주지 않습니다. 아무것도 인증하지 못하는 녹색 표시가 체크를 하지 않는 것보다 더 나쁘기 때문입니다.
- 수용 가능한 리스크를 측정할 것; 리스크가 존재하지 않을 수도 있습니다. 광범위한 쿼리(broad-query) 트레이드오프(trade-off)는 설계 검토(design review) 단계에서는 합리적인 주의 사항처럼 통과되었습니다. 하지만 20번의 쿼리를 거치며 살아남지 못했습니다.
- 대칭적인 결과를 불신할 것. 별도의 증명이 없는 한, 전후 수치가 동일하다는 것은 측정 도구가 고장 났음을 의미합니다.
- 측정한 것만을 주장할 것, 그 이상은 안 됩니다. 위의 모든 내용은 제 컴퓨터에서 129개의 청크, 하나의 임베딩 모델, 20개의 쿼리로 수행된 하나의 코퍼스(corpus) 결과입니다. 이것은 숫자가 아니라 방법론으로서 일반화됩니다.
진단, 수정, 그리고 두 가지 측정 방식(measurement arms)을 포함한 전체 조사 과정은 프로젝트 문서(docs) 내에 아키텍처 결정 기록(Architecture Decision Record, ADR) 및 작업 계획(work plan)으로 기록되었습니다. 이것이 바로 제가 몇 달이 지난 시점에서도 기억을 되살려 재구성할 필요 없이 이 포스트를 작성할 수 있는 이유이기도 합니다. 하지만 그러한 규율(discipline)에 대한 이야기는 다음 포스트에서 다루겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기