[hermes-agent] [기술 분석] LLM 에이전트의 성능 병목 해결: 비동기 토큰 카운팅과 쓰기 병합(Coalescing) 기법
PR 링크: NousResearch/hermes-agent#73359 상태: Merged | 변경: +1030 / -16
들어가며
LLM 에이전트가 API 호출을 수행할 때마다 발생하는 '토큰 카운팅 및 비용 계산'은 사소해 보이지만, 실제 운영 환경에서는 심각한 성능 병목이 될 수 있습니다. 특히 SQLite와 같은 파일 기반 데이터베이스를 사용할 때, DB 파일의 크기가 수 GB에 달하거나 디스크 I/O가 지연되는 상황(Cold state)에서는 단 한 번의 UPDATE 쿼리가 에이전트의 메인 실행 루프(Turn thread)를 수백 밀리초(ms) 동안 멈추게 만들 수 있습니다.
이번에 분석할 NousResearch/hermes-agent의 PR(#64171 및 후속 강화 작업)은 이러한 동기식 DB 쓰기 문제를 비동기 큐(Background Single-writer Queue)와 델타 병합(Coalescing) 기법을 통해 해결했습니다. 이 최적화를 통해 메인 스레드의 작업 비용은 p50 기준 0.54ms에서 0.0005ms로, 즉 약 1,000배 이상 단축되었습니다.
코드 분석: 무엇이 어떻게 바뀌었나
1. 호출부의 변화: Blocking에서 Enqueue로
기존에는 API 호출이 끝날 때마다 메인 스레드에서 즉시 DB를 업데이트했습니다. 변경 후에는 이를 큐에 넣는 방식으로 전환하여 메인 루프의 정체를 제거했습니다.
Before (agent/conversation_loop.py):
# DB에 직접 쓰기 작업을 수행하며 완료될 때까지 대기함
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
# ... 기타 인자들
)
After (agent/conversation_loop.py):
# 백그라운드 스레드 큐에 작업을 추가하고 즉시 다음 단계로 진행
agent._session_db.queue_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
# ...
)
2. SessionDB의 비동기 인프라 구축
hermes_state.py 내의 SessionDB 클래스에는 비동기 처리를 위한 큐와 전용 스레드 관리 로직이 추가되었습니다.
Before (hermes_state.py):
def __init__(self, db_path: Path = None, read_only: bool = False):
# ... 초기화 로직
self._conn = None
After (hermes_state.py):
def __init__(self, db_path: Path = None, read_only: bool = False):
# ...
self._token_queue: deque = deque()
self._token_queue_cond = threading.Condition(threading.Lock())
self._token_writer_thread: Optional[threading.Thread] = None
self._token_writer_stop = False
self._token_writer_busy = False
여기서 threading.Condition을 사용한 점이 핵심입니다. 이는 큐에 데이터가 들어왔을 때만 스레드를 깨워 CPU 자원 낭비를 방지하며, self._lock(SQLite 쓰기 락)과 별개의 락을 사용하여 큐 삽입 작업이 실제 DB 쓰기 작업과 경합하지 않도록 설계되었습니다.
3. 최적화의 핵심: 델타 병합 (Coalescing)
단순히 비동기로 옮기는 것을 넘어, 이 PR은 여러 개의 업데이트 요청을 하나로 합치는 'Coalescing' 로직을 도입했습니다. 연속된 요청이 동일한 세션과 동일한 모델 경로(Route)를 가진다면, DB에 여러 번 쓸 필요 없이 메모리에서 합산한 뒤 한 번의 UPDATE 쿼리만 날립니다.
_TOKEN_DELTA_SUM_FIELDS = (
"input_tokens", "output_tokens", "cache_read_tokens",
"cache_write_tokens", "reasoning_tokens", "api_call_count",
)
위와 같이 합산 가능한 필드들을 정의하고, 백그라운드 루프에서 큐를 비울 때 이 필드들을 병합합니다. 이는 특히 짧은 시간 내에 수많은 도구 호출(Tool calls)이 발생하는 에이전트 시나리오에서 DB 부하를 획기적으로 줄여줍니다.
4. 일관성 보장을 위한 Flush Barrier
비동기 시스템의 고질적인 문제는 '방금 쓴 데이터를 읽을 때 최신 데이터가 아닐 수 있다'는 점입니다. 이를 해결하기 위해 읽기 작업이 필요한 곳에 flush_token_counts()라는 배리어(Barrier)를 추가했습니다.
agent/insights.py 변경사항:
def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]:
# ...
# 리포트를 생성하기 전 큐에 쌓인 모든 데이터를 DB에 반영하도록 강제함
flush = getattr(self.db, "flush_token_counts", None)
if callable(flush):
flush()
왜 이게 좋은 최적화인가?
1. 지연 시간(Latency)의 획기적 개선
성능 측정 결과에 따르면, 실제 OpenAI 호출 시 Turn-thread의 회계 처리 시간이 2.7~3.3ms에서 0.23ms로 감소했습니다. 마이크로 벤치마크에서는 112ms가 걸리던 작업이 0.3ms로 줄어들었습니다. 이는 사용자 경험 측면에서 에이전트의 반응 속도가 훨씬 빨라짐을 의미합니다.
2. 원자성 및 정확성 유지
비동기 처리를 도입하면서도 byte-exact 함을 유지했습니다. 즉, 결과적으로 DB에 기록되는 값은 동기 방식과 1바이트의 오차도 없이 동일합니다. 이는 금융 비용과 직결되는 토큰 카운팅 시스템에서 타협할 수 없는 중요한 가치입니다.
3. 견고한 예외 처리 (Hardening)
리뷰 과정에서 발견된 여러 엣지 케이스들이 반영되었습니다:
- 스레드 재생성: 백그라운드 스레드가 예기치 않게 종료되었을 경우, 다음
queue호출 시 스레드를 자동으로 다시 살립니다 (not thread.is_alive()체크). - Graceful Shutdown:
atexit을 사용하여 프로세스가 종료될 때 큐에 남은 데이터를 모두 쓰고 종료되도록 보장합니다. - 순서 보장: 모델 설정이 변경되는
update_session_model호출 전에는 반드시flush를 수행하여, 이전 모델의 토큰 카운트가 새 모델의 레코드에 섞이지 않도록 순서를 엄격히 제어했습니다.
결론
이 PR은 "성능을 위해 일관성을 포기하지 않는다"는 원칙을 잘 보여줍니다. 단순히 threading.Thread를 하나 띄우는 것에 그치지 않고, 데이터 병합을 통한 I/O 효율화, 배리어를 통한 읽기 일관성 확보, 그리고 프로세스 종료 시의 데이터 유실 방지까지 고려한 완성도 높은 엔지니어링 사례입니다.
고성능 Python 애플리케이션을 설계할 때, I/O 바운드 작업이 메인 루프를 방해하고 있다면 이와 같은 '비동기 큐 + 병합 + 배리어' 패턴은 매우 유용한 해결책이 될 것입니다.
참고 자료
- https://docs.python.org/3/library/threading.html#condition-objects
- https://docs.python.org/3/library/collections.html#collections.deque
- https://docs.python.org/3/library/atexit.html
⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.
관련 포스트
PR Analysis 의 다른글
- 이전글 [flashinfer] FlashInfer의 Mixture-of-Experts(MoE) 라우팅 성능 최적화 분석
- 현재글 : [hermes-agent] [기술 분석] LLM 에이전트의 성능 병목 해결: 비동기 토큰 카운팅과 쓰기 병합(Coalescing) 기법
- 다음글 [hermes-agent] NousResearch Hermes Agent 성능 최적화: Prompt 캐싱, Reasoning Timeout, Lazy Compressor 초기화
댓글