레슨 6: 실습: 에이전트에 영속 메모리 계층 추가하기
학습 목표:
- 에이전트에 안전한 메모리 읽기/쓰기 도구 세트를 연결하고, 새 세션이 시작될 때 메모리를 히스토리에 백필하기
- 단순화한 압축 함수와 도구 결과 정리 로직을 직접 손으로 작성하고, 그것이 네이티브 메커니즘과 어떻게 다른지 이해하기
- 메모리 읽기/쓰기와 히스토리 트리밍 조각을 '에이전트 도구 호출: 에이전트가 실제로 일하게 만들기'의 실행 루프에 맞춰 넣어, 기억하면서도 스스로를 줄이는 에이전트를 만들기
전제: 레슨 1-5를 마치고, 기본적인 JavaScript / Node.js를 읽을 수 있을 것 | 이전: 레슨 5 <<
먼저, 그 성과: 메모리는 정말로 두 개의 별개 세션을 넘어 이어진다
이것이 레슨 끝에서 우리가 만들어 낼 결과입니다. 첫 실행에서, 에이전트에게 선호를 말합니다:
프로세스가 종료됩니다. 새 프로세스를 시작해 완전히 무관한 것을 묻습니다:
두 실행 사이에 프로세스는 완전히 재시작됐고 messages 배열은 빈 상태에서 시작했습니다 — 그런데도 두 번째 실행은 여전히 첫 번째 실행의 선호를 '기억'합니다. 그것은 우연이 아닙니다. 이번 레슨에서 우리가 만드는 두 조각의 결합된 효과입니다: 안전한 메모리 읽기/쓰기 도구 세트, 그리고 세션이 시작될 때 메모리를 능동적으로 백필하는 약간의 로직. 그 위에, 이번 레슨은 레슨 2가 기술했지만 '에이전트 도구 호출: 에이전트가 실제로 일하게 만들기'의 실행 루프가 결코 구현하지 않은 나머지 절반을 채웁니다 — 히스토리가 너무 커졌을 때 스스로를 어떻게 줄이는가입니다.
출발점: 도구 호출 코스의 실행 루프
우리는 맨바닥에서 시작하는 것이 아닙니다. '에이전트 도구 호출: 에이전트가 실제로 일하게 만들기'의 레슨 6은 동작하는 도구 실행 루프를 만들었습니다. 핵심 형태는 이렇습니다: 각 도구의 스키마와 구현을 하나의 TOOLS 테이블에 등록한 뒤, 루프를 돕니다 — 요청을 보내고, stop_reason을 확인하고, 그것이 tool_use일 때마다 모든 호출 블록을 훑어 실행하고 결과를 messages에 다시 이어 붙입니다, 모델이 도구 호출을 멈출 때까지.1
이 골격에 두 가지 새로운 것을 더합니다. 첫째, 메모리 읽기/쓰기 도구 — 에이전트가 윈도우 바깥에 간직할 가치가 있는 콘텐츠를 능동적으로 써 낼 수 있게 합니다. 둘째, 약간의 히스토리 트리밍 로직 — 긴 대화가 영원히 부풀지 않게 합니다. 둘 다 앞선 다섯 레슨의 원칙 위에 직접 세워집니다. 이번 레슨은 그것들을 실행되는 코드로 바꿀 뿐입니다.
1단계: 에이전트에 메모리 읽기/쓰기 도구 연결하기
먼저, 메모리 파일을 위한 전용 메모리 루트를 정의하고, 그 주변의 경계 확인도 함께 정의합니다 — 이것은 '레슨 3: 외부 메모리: 파일과 검색'의 경로 경계 패턴을 그대로 가져온 것입니다:
resolveMemoryPath 안의 결합 조건 abs === MEMORY_ROOT || abs.startsWith(MEMORY_ROOT + path.sep)는 레슨 3이 든 바로 그 이유 때문에 있습니다: 벌거벗은 startsWith(MEMORY_ROOT)는 같은 접두사를 가진 형제 디렉터리(가령 memory-evil)에 의해 우회됩니다.
도구 스키마도 '무엇을 저장할 것인가' 경계를 명확히 밝혀야 합니다 — 코드로 강제되는 것이 아니라, description을 통해 모델의 행동을 위해 틀 지워지는 것입니다:
'레슨 5: 메모리의 경계와 안전'은 악성 콘텐츠가 일단 메모리 같은 저장소 — 신뢰되고 몇 번이고 다시 로드되는 — 에 도달하면, 공격자는 더 이상 하나의 응답이 아니라 미래의 추론에 영향을 미친다는 점을 짚었습니다.2 write_memory의 description에 있는 줄 — "작업 중 읽은 가공되지 않은 신뢰할 수 없는 텍스트를 아무런 선별 없이 곧바로 쓰지 않는다" — 은 그 원칙을 모델이 볼 수 있는 명시적 지시로 바꿉니다. 그것이 진짜 콘텐츠 검토를 대신할 수는 없지만, 적어도 '읽은 것은 무엇이든 쓴다'가 기본 동작이 되는 것은 막습니다.
2단계: 세션이 시작될 때 메모리를 히스토리에 백필하기
이제 도구는 메모리 파일을 읽고 쓸 수 있지만, 새 세션이 시작될 때 누군가 능동적으로 읽지 않는 한 preferences.md는 디스크 위의 조용한 파일일 뿐입니다 — 그것은 저절로 이번 요청의 컨텍스트 윈도우에 나타나지 않습니다. 레슨 3은 CLAUDE.md 같은 메모리 파일이 매 세션 시작 시 어떻게 컨텍스트에 로드되는지 다뤘습니다3; 여기서는 같은 발상을 써서 약간의 세션 간 메모리 백필 로직을 직접 손으로 작성합니다:
이 백필 로직은 초기 messages 배열을 만들 때 호출되어, 메모리 콘텐츠가 대화의 맨 첫 메시지로 나타나게 합니다 — 그러면 모델이 read_memory를 호출하지 않고도 첫 턴부터 그것이 윈도우 안에 있게 됩니다. 4단계에서 그것이 전체 루프의 정확히 어디에 끼워지는지 보입니다.
3단계: 압축과 정리 로직을 직접 손으로 작성하기
도구 호출 코스의 실행 루프에서 messages 배열은 오직 덧붙이기만 할 뿐 — 결코 잘리지 않습니다. '레슨 2: 대화 히스토리 관리: 덧붙이기, 잘라내기, 요약하기'는 실제 네이티브 메커니즘에서 요약 압축(compact_20260112, 기본 150K 토큰에서 트리거)과 도구 결과 정리(clear_tool_uses_20250919, 기본 100K 토큰에서 트리거되며 마지막 3개 호출을 유지)가 서로 다른 역할을 가진 두 네이티브 기능임을 다뤘습니다.4 이번 레슨은 각각이 무엇을 하는지 이해하도록 단순화한 버전을 손으로 작성합니다 — 하지만 먼저, 한 가지 경계를 분명히 밝혀야 합니다: 아래 코드는 교육을 위해 맨바닥에서 지은 단순화된 로직이지, Anthropic이 제공하는 네이티브 베타 기능이 아닙니다. 실제 프로젝트에서 SDK가 이미 compact_20260112와 clear_tool_uses_20250919 같은 네이티브 파라미터를 지원한다면, 손으로 쓴 버전을 다시 발명하기보다 공식 구현을 선호해야 합니다.
먼저, 히스토리 부풀기를 측정하는 문제입니다. 실제 토큰 세기는 전용 세기 엔드포인트를 호출한다는 뜻입니다; 여기서는 교육을 단순하게 하려고 조잡한 문자 예산(character budget) 으로 근사합니다 — 이것은 정확한 토큰 세기가 아니라 근사일 뿐임에 유의하세요:
레슨 2의 연습은 함정 하나를 다뤘습니다: 히스토리를 슬라이스하다가 실수로 tool_use / tool_result 쌍을 중간에서 자르면, 프로토콜 구조가 깨집니다. 손으로 쓴 압축은 '어떤 히스토리가 요약에 들어가고 어떤 것이 최근 부분에 남는지'를 결정할 때, 메시지 개수가 아니라 완전한 왕복 경계에서 잘라야 합니다:
여기서 요약을 생성하는 것은 한 번의 추가 요약 호출을 하는 것을 뜻합니다 — 그것이 바로 레슨 2가 언급한 비용입니다: 압축 자체가 추가 모델 호출을 태우고, 그 결과인 요약 메시지는 손실이 있어, 원본의 세부는 사라집니다.
손으로 쓴 도구 결과 정리 버전은 더 가볍습니다: 추가 모델 호출이 없고, 유지 개수를 넘어선 오래된 tool_result 블록의 콘텐츠를 플레이스홀더 콘텐츠로 바꿀 뿐, 호출이 있었다는 기록은 유지합니다(tool_use_id는 그대로 있고, content만 교체됩니다):
4단계: 메모리로 증강된 루프 조립하기
메모리 읽기/쓰기 도구, 메모리 백필, 손으로 쓴 압축, 도구 결과 정리를 같은 루프에 맞춰 넣으면, 이번 레슨의 메모리로 증강된 루프가 나옵니다:
매 턴 시작 시 maybeCompact를 돌리고, 각 턴의 도구 결과가 다시 쓰인 직후에 clearOldToolResults를 돌립니다 — 이것은 레슨 2의 사고 모델에 대응합니다: 압축은 '윈도우 전체가 너무 큼'을 다루고, 정리는 '윈도우 안의 오래되고 다시 가져올 수 있는 데이터'를 다루며, 둘은 충돌하지 않고 동시에 효력을 발휘할 수 있습니다.4 한편 loadMemoryBackfill은 runAgent 맨 위에서 딱 한 번 호출되어, 레슨 3의 '외부 메모리'를 이번 실행의 윈도우로 실제로 옮기는 일을 합니다. 그 세 조각이 함께 이번 레슨 서두의 '프로세스 재시작 후에도 여전히 선호를 기억한다'는 효과의 완전한 근원입니다. 이 루프 이후에 '작업이 어디까지 왔는지'도 기억해야 한다면, '레슨 4: 구조화된 상태: 에이전트는 작업이 어디까지 왔는지 어떻게 기억하는가'의 todo 생명주기를 같은 방식으로 메모리 파일에 쓰이는 체크포인트로 바꿀 수 있습니다 — 접근은 write_memory와 동일하고, 쓰는 콘텐츠만 '선호'에서 '진행 상황'으로 바뀝니다.5
정리
- 메모리 읽기/쓰기 도구는 레슨 3의 경로 경계 패턴(
abs === ROOT || abs.startsWith(ROOT + path.sep))을 재사용하고, write_memory의 description은 '무엇을 저장할 것인가'를 명확히 밝혀야 한다 — 하지만 그것은 프롬프트 수준의 안내일 뿐 진짜 콘텐츠 검토를 대신할 수 없다
- 메모리가 실제로 효력을 발휘하려면, 세션 시작 시의 능동적 백필을 건너뛸 수 없다 — 디스크에 앉아 있는 메모리 파일은 저절로 이번 요청의 컨텍스트 윈도우에 나타나지 않으며, CLAUDE.md처럼 세션 시작 시 명시적으로 읽히고 명시적으로 로드되어야 한다
- 손으로 쓴 압축과 손으로 쓴 정리는 교육을 위한 단순화된 구현으로, 각각 네이티브
compact_20260112와 clear_tool_uses_20250919에 대응한다 — 실제 프로젝트에서 SDK가 네이티브 파라미터를 지원한다면 공식 구현을 선호한다
- 히스토리를 슬라이스하는 것은(압축이든 정리든) 메시지 개수가 아니라 완전한
tool_use/tool_result 왕복 경계에서 해야 하며, 그러지 않으면 프로토콜 구조를 끊는다
- 메모리 읽기/쓰기, 히스토리 백필, 압축/정리는 각각 레슨 3과 2에서 가르친 원칙에 대응한다 — 이번 레슨이 한 것은 그 원칙들을 실행되는 코드로 바꾼 것뿐이다
이제 당신은 에이전트 메모리와 상태의 여섯 레슨을 모두 마쳤습니다. '컨텍스트 윈도우가 에이전트가 가진 메모리의 전부다'에서 시작해, 에이전트에 영속 메모리 계층을 손으로 연결하는 데까지 왔습니다. 가장 값진 다음 걸음은 또 다른 레슨을 읽는 것이 아니라 — 이 메모리로 증강된 루프를 당신 프로젝트의 실제 시나리오에 연결하고, 몇 턴 돌려 보며, 로그를 지켜보는 것입니다. 디버깅하다가 특정 파라미터나 공식 기본값이 불확실할 때는, sources.md로 돌아가 S1-S5 공식 문서와 OWASP 블로그 원문을 확인하세요.