[hermes-agent] NousResearch Hermes Agent 성능 최적화: Prompt 캐싱, Reasoning Timeout, Lazy Compressor 초기화
PR 링크: NousResearch/hermes-agent#57229 상태: Merged | 변경: +523 / -82
들어가며
최근 NousResearch의 Hermes Agent 프로젝트에서는 AI 모델의 응답 속도 향상을 목표로 하는 중요한 성능 개선 작업이 진행되었습니다. 특히, 에이전트의 핵심 경로(hot-path)에서 발생하는 성능 병목 현상을 해결하기 위한 여러 최적화 기법이 통합된 PR이 주목받고 있습니다. 본 글에서는 해당 PR의 코드 변경 사항을 상세히 분석하고, 각 변경이 왜 성능 향상에 기여하는지, 그리고 이러한 최적화가 가지는 일반적인 교훈은 무엇인지 기술 블로그 형식으로 풀어내고자 합니다.
이 PR은 특히 다음과 같은 세 가지 주요 성능 개선 사항을 통합하고 있습니다:
- Prompt Caching 최적화: Anthropic API 호출 시 전체 메시지 기록을 불필요하게 복사하는 문제를 해결하여 복사 비용을 대폭 절감합니다.
- Reasoning Timeout 최적화: 모델별 추론 시간 제한(timeout)을 계산하는 로직을 최적화하여 반복적인 재계산 비용을 제거합니다.
- Lazy Compressor Initialization: 에이전트 초기화 시 동기적으로 발생하는 HTTP 요청을 지연시켜 초기화 시간을 단축합니다.
이러한 개선 사항들은 에이전트의 전반적인 응답 속도와 효율성을 크게 향상시킬 것으로 기대됩니다.
코드 분석
1. agent/prompt_caching.py - 선택적 복사(Shallow Copy)를 통한 Prompt Cache 최적화
기존 코드에서는 Anthropic API 호출 시 apply_anthropic_cache_control 함수 내에서 메시지 기록 전체를 deepcopy하는 비효율적인 작업이 수행되었습니다. 이는 특히 대화 기록이 길어질수록 상당한 성능 저하를 유발하는 병목 지점이었습니다.
Before:
# (기존 코드에서는 메시지 기록 전체를 deepcopy하는 로직이 존재했음)
# 예시: deepcopy(message_history)
After:
PR에서는 이 부분을 개선하여, 전체 메시지 기록을 deepcopy하는 대신 리스트 자체를 shallow copy하고, cache_control 마커가 적용된 최대 4개의 메시지만 deepcopy하도록 변경했습니다. 또한, 최신 static_system_prefix 시스템 메시지 처리를 통합하여 시스템 메시지는 _apply_system_cache_markers 호출 전에 deepcopy되도록 했습니다.
# (개선된 로직은 메시지 기록 전체를 shallow copy하고, 필요한 부분만 deepcopy)
# 예시: shallow_copied_history = message_history[:]
# for msg in messages_to_deepcopy:
# deepcopy(msg)
왜 이게 좋은가?
이 변경은 메모리 복사 비용을 획기적으로 줄여줍니다. 기존의 전체 deepcopy는 메시지 기록의 크기에 비례하여 시간 복잡도를 가졌지만, 개선된 방식은 필요한 부분만 복사하므로 훨씬 효율적입니다. 실제 벤치마크 결과, 401개의 메시지(약 3MB)를 가진 기록에서 응답 시간이 0.78ms에서 0.02ms로 약 39배 향상되었습니다. 이는 매 턴마다 발생하는 비용이므로, 전체적인 에이전트 성능에 상당한 영향을 미칩니다.
2. agent/reasoning_timeouts.py - 사전 계산된 정렬된 Reasoning Timeout Floor
기존 코드에서는 _match_any() 함수가 호출될 때마다 floors 테이블을 재정렬하고 정규 표현식을 컴파일하는 작업을 반복했습니다. 이는 불필요한 계산 비용을 발생시키는 또 다른 병목 지점이었습니다.
Before:
# (기존 코드에서는 매 호출마다 floors 테이블 재정렬 및 regex 컴파일)
# 예시: sorted_floors = sorted(floors_table)
# compiled_regex = re.compile(pattern)
After:
개선된 코드에서는 정렬된 (slug, floor, pattern) 튜플을 모듈 로드 시점에 한 번만 미리 계산하고 컴파일하여 사용합니다. 이렇게 하면 매번 반복되는 계산을 피할 수 있으며, 결과적으로 더 빠르고 스레드 안전한(thread-safe) 방식으로 동작합니다.
# (개선된 로직은 모듈 로드 시점에 한 번만 계산)
# 예시: PRECOMPUTED_FLOORS = [
# (slug, floor, re.compile(pattern))
# for slug, floor, pattern in floors_table
# ]
왜 이게 좋은가?
반복적인 계산을 제거함으로써 CPU 사용량을 줄이고 응답 시간을 단축합니다. 특히, _match_any() 함수가 자주 호출되는 시나리오에서 그 효과가 두드러집니다. 이 변경은 162개의 프로브 모델 이름에 대해 기존 구현과 동일한 결과를 보장하면서 성능을 개선했습니다.
3. agent/context_compressor.py - Lazy Compressor 초기화 및 동기 HTTP 요청 지연
기존 ContextCompressor.__init__ 메서드는 모델의 컨텍스트 길이를 얻기 위해 get_model_context_length() 함수를 즉시 호출했습니다. 이 함수는 내부적으로 동기적인 /models HTTP 요청을 발생시킬 수 있으며, 이는 에이전트 초기화 과정 자체를 차단하는 심각한 성능 문제를 야기했습니다.
Before:
# agent/context_compressor.py
class ContextCompressor:
def __init__(self, ...):
# ...
self.context_length = get_model_context_length(
model, base_url=base_url, api_key=api_key, ...
)
# ... (이하 컨텍스트 길이 기반 계산 로직)
After:
PR에서는 get_model_context_length() 호출을 __init__ 메서드 밖으로 빼내어, 실제 컨텍스트 길이가 필요할 때 (즉, context_length 프로퍼티에 처음 접근할 때) 호출되도록 지연시켰습니다. 또한, context_length, threshold_tokens, tail_token_budget 등의 속성 접근 시 지연 초기화(lazy initialization)를 적용했습니다. 이는 __init__ 메서드가 더 이상 동기 HTTP 요청으로 인해 차단되지 않도록 보장합니다.
# agent/context_compressor.py
class ContextCompressor:
def __init__(self, ...):
# ...
self._resolved_context_length: int | None = None
self._threshold_tokens: int | None = None
# ...
self._log_init_summary = not quiet_mode # 초기화 로그 출력 여부 플래그
def _resolve_context_length(self) -> int:
"""Resolve and cache the model's context length on first access."""
if self._resolved_context_length is None:
self._resolved_context_length = get_model_context_length(
self.model, ...
)
# ... (컨텍스트 길이 기반 계산 로직)
self._emit_init_summary_once()
return self._resolved_context_length
@property
def context_length(self) -> int:
return self._resolve_context_length()
@context_length.setter
def context_length(self, value: int) -> None:
# ... (setter 로직)
self._emit_init_summary_once()
# ... threshold_tokens, tail_token_budget 등도 유사한 lazy property로 구현
def _emit_init_summary_once(self) -> None:
"""Emit the informative startup line once, on first resolution."""
if not getattr(self, "_log_init_summary", False):
return
self._log_init_summary = False
logger.info(
"Context compressor initialized: ...",
self.model, self._resolved_context_length, ...
)
왜 이게 좋은가?
이 변경은 에이전트 초기화 시간을 크게 단축시킵니다. 특히 네트워크 지연이 발생하거나 /models API 응답이 느린 환경에서 그 효과가 극대화됩니다. 초기화가 비동기적으로 이루어지므로, 에이전트는 준비가 되는 대로 더 빠르게 작업을 시작할 수 있습니다. 또한, quiet_mode와 관계없이 __init__에서 동기 호출이 발생하지 않도록 보장하여 일관된 동작을 유지합니다. fix(compression): keep ContextCompressor init non-blocking when quiet_mode=False 커밋은 이 지연 초기화 로직이 quiet_mode=False일 때도 제대로 동작하도록 보완하는 역할을 합니다.
4. agent/conversation_compression.py - Copy-on-Write (COW)를 이용한 Image Shrink 복구
이 변경은 fix(compression): copy-on-write in image-shrink recovery 커밋에서 이루어졌으며, 이미지 크기 축소(shrink) 과정에서 발생할 수 있는 데이터 무결성 문제를 해결합니다. 기존에는 이미지 크기 축소 시 원본 메시지의 content 부분을 직접 수정하는 방식이었습니다. 만약 이 과정에서 원본 이미지 데이터가 손상되거나, 다른 메시지에서 해당 이미지 데이터를 참조하고 있었다면 예상치 못한 부작용이 발생할 수 있었습니다.
Before:
# (기존 코드에서는 source 딕셔너리를 직접 수정)
def _write_data_url_to_source(source: dict, data_url: str) -> None:
# ...
# source 딕셔너리 내부를 직접 수정
source['content'] = ... # 예시
After:
개선된 코드에서는 Copy-on-Write (COW) 패턴을 적용했습니다. _write_data_url_to_source 함수는 이제 원본 source 딕셔너리를 수정하는 대신, 새로운 source 딕셔너리를 생성하여 반환합니다. 이를 통해 원본 데이터는 불변(immutable)하게 유지되며, 이미지 크기 축소와 같은 작업은 이 복사본에 대해서만 수행됩니다. 이는 특히 여러 메시지가 동일한 이미지 데이터를 참조하고 있을 때, 한 메시지의 이미지 처리 결과가 다른 메시지에 영향을 미치는 것을 방지합니다.
# agent/conversation_compression.py
def _write_data_url_to_source(source: dict, data_url: str) -> dict:
"""Return a NEW source dict carrying the re-encoded payload.
Copy-on-write: ...
"""
# ...
new_source = source.copy() # 원본 복사
# ... (새로운 source 딕셔너리에 데이터 적용)
new_source['content'] = ... # 예시
return new_source
# 호출하는 쪽에서는 반환된 새 딕셔너리로 교체
# msg["content"] = _write_data_url_to_source(original_content_part, data_url)
왜 이게 좋은가?
Copy-on-Write 패턴은 데이터의 무결성을 보장하고 예상치 못한 부작용을 방지하는 데 매우 효과적입니다. 특히 공유 자원을 다루는 복잡한 시스템에서 중요합니다. 이 변경은 이미지 처리 과정에서 발생할 수 있는 잠재적인 버그를 수정하고, 코드의 안정성을 높였습니다. 회귀 테스트 결과, 이 변경으로 인해 발생하는 문제가 없음을 확인했습니다.
왜 이게 좋은가? (종합)
이 PR에 포함된 여러 최적화는 Hermes Agent의 핵심 성능을 크게 향상시키는 데 기여했습니다. 주요 이점은 다음과 같습니다:
- 응답 속도 향상: Prompt 캐싱 최적화와 Lazy Initialization을 통해 에이전트의 응답 시간이 획기적으로 단축되었습니다. 특히, 39배의 속도 향상을 보인 Prompt 캐싱 최적화는 매 턴마다 발생하는 비용을 줄여 전체적인 사용자 경험을 개선합니다.
- 초기화 시간 단축: 동기 HTTP 요청을 지연시킴으로써 에이전트가 더 빠르게 준비 상태가 됩니다. 이는 실시간 서비스나 빠른 시작이 중요한 애플리케이션에 매우 유리합니다.
- 자원 효율성 증대: 불필요한 데이터 복사 및 반복 계산을 제거하여 CPU 및 메모리 사용량을 줄였습니다.
- 안정성 및 견고성 강화: Copy-on-Write 패턴 적용을 통해 데이터 무결성을 보장하고 잠재적인 버그를 예방했습니다.
일반적인 교훈:
- 병목 지점 식별 및 집중: 성능 개선은 가장 비용이 많이 드는 부분(hot-path)을 식별하고 집중할 때 가장 큰 효과를 발휘합니다. 이 PR은
deepcopy, 반복 계산, 동기 I/O 등 명확한 병목 지점을 정확히 타겟팅했습니다. - Lazy Evaluation의 힘: 초기화 시점에 모든 것을 계산하는 대신, 실제로 필요할 때 지연하여 계산하는 Lazy Evaluation은 초기화 속도를 높이고 불필요한 연산을 줄이는 강력한 기법입니다.
- 데이터 복사 전략의 중요성:
deepcopy와shallow copy의 차이를 이해하고 상황에 맞게 사용하는 것은 메모리 사용량과 성능에 큰 영향을 미칩니다. 꼭 필요한 데이터만 복사하는 것이 중요합니다. - 불변성(Immutability)과 Copy-on-Write: 공유 데이터를 다룰 때는 불변성을 유지하거나 Copy-on-Write 패턴을 적용하여 데이터의 무결성을 보장하고 부작용을 최소화해야 합니다.
- AI 코드의 지속적인 최적화: AI 모델을 다루는 시스템은 복잡하고 성능 요구사항이 높으므로, 지속적인 프로파일링과 최적화가 필수적입니다.
References
- get_model_context_length - (참고: 실제 Hermes Agent의
get_model_context_length함수 위치는 다를 수 있으나, 기능적으로 유사한 함수를 지칭합니다. 정확한 위치는 소스 코드 탐색이 필요합니다.) - Context Compressor Logic - (PR에서 수정된
ContextCompressor클래스의 관련 로직) - Prompt Caching Logic - (PR에서 수정된
prompt_caching관련 로직) - Conversation Compression Logic - (PR에서 수정된
conversation_compression관련 로직)
참고 자료
- https://github.com/imartinez/privateGPT/blob/master/src/private_gpt/utils/get_model_context_length.py
- https://github.com/NousResearch/hermes-agent/blob/main/agent/context_compressor.py
- https://github.com/NousResearch/hermes-agent/blob/main/agent/prompt_caching.py
- https://github.com/NousResearch/hermes-agent/blob/main/agent/conversation_compression.py
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [hermes-agent] [기술 분석] LLM 에이전트의 성능 병목 해결: 비동기 토큰 카운팅과 쓰기 병합(Coalescing) 기법
- 현재글 : [hermes-agent] NousResearch Hermes Agent 성능 최적화: Prompt 캐싱, Reasoning Timeout, Lazy Compressor 초기화
- 다음글 [hermes-agent] SQL GROUP BY를 활용한 세션 통계 쿼리 최적화: 575ms에서 1ms 미만으로
댓글