한 AI 에이전트가 자신의 폴링 루프(poll loop)를 '이벤트 기반(event-driven)'이라고 평가했습니다. 저는 대신 코드를
요약
AI 에이전트가 자신의 폴링 루프를 '이벤트 기반'이라고 잘못 정의한 사례를 통해, 코드의 실제 동작과 라벨링 사이의 괴리를 분석합니다. 비용 효율적인 시스템 설계를 위해 저렴한 게이트를 활용한 필터링과 지속적인 상태 유지의 중요성을 강조합니다.
핵심 포인트
- 설명을 믿지 말고 실제 코드를 읽어 동작을 확인해야 함
- 비용이 큰 작업 앞에 저렴한 확인 절차(Gate)를 두어 효율성 극대화
- 매번 프로세스를 재생성하기보다 지속적인 상태(Persistent State) 유지 권장
- 폴링 루프와 이벤트 기반 방식의 비용 및 지연 시간 차이 이해
문맥 (Context)
downbeat (터미널 세션 간에 메시지를 전달하는 저의 도구, github.com/FreddieMcHeart/downbeat)에는 세션이 자신의 수신함(inbox)을 인지하도록 유지하는 두 가지 방법이 있습니다. 하나는 /relay-monitor입니다. 이는 내장된 /loop(N분마다 프롬프트를 재실행)를 수신함 훅(inbox hook)과 결합한 것으로, 새로운 메일 도착 여부와 상관없이 매 틱(tick)마다 전체 모델 턴(model turn)이 발생합니다. 다른 하나는 스스로 나타났습니다. 다른 세션이 백그라운드 "모니터(Monitor)"를 설정했고, 자신의 노트에 이를 "이벤트 기반(event-driven), 즉각적(instant), 저렴함(cheap), 이벤트 발생 시에만 실행됨"이라고 설명했습니다.
어떤 방식이 더 나은지 질문을 받았습니다. 우리는 이미 외부 도구를 평가하기 위한 규칙을 가지고 있었습니다: 설명을 믿지 말고, 코드를 읽어라(do not trust the description, read the code). 저는 그 규칙을 해당 세션의 작업 자체에 적용했습니다.
발견 (The finding)
스스로 "이벤트 기반(event-driven)"이라고 설명한 모니터는 다음과 같았습니다:
while true; do
sleep 90
cur=$(downbeat inbox | grep '^*' | awk '{print $2}' | sort)
...
이것은 폴링 루프(poll loop)입니다. 90초 동안 대기(sleep)하고, 수신함을 나열한 뒤, 이미 확인된 파일(seen-file)과 차이(diff)를 비교합니다. 해당 세션은 "매 턴마다 스스로 재장전(re-arm)하지 않는다"는 점을 "폴링(poll)하지 않는다"는 것과 혼동했습니다. 루프가 아니라 라벨(label)을 기준으로 스스로를 평가한 것입니다. 자신의 비교 표에는 즉각적인 지연 시간(latency)과 이벤트 발생 시에만 비용이 발생한다고 주장했습니다. 현실은: 최대 90초의 지연 시간과 트래픽 발생 여부와 상관없이 하루에 약 960번의 폴링이 발생한다는 것이었습니다.
그럼에도 불구하고 맞았던 두 가지
잘못된 라벨을 넘어 코드를 읽어보니, 그 안에 담긴 두 가지 아이디어는 진정으로 훌륭했습니다.
첫째, 비용이 많이 드는 소비자(consumer) 앞에 저렴한 게이트(gate)를 두는 것입니다. 조용한 채널에서 /relay-monitor는 단지 "새로운 것이 없음"을 읽기 위해 매 간격마다 전체 모델 턴을 소비합니다. 이 스크립트의 틱(tick)은 bash 폴링이며 비용이 거의 들지 않고, comm이 실제로 새로운 메일을 찾았을 때만 모델 턴으로 격상(escalate)됩니다. 저렴한 확인 절차를 핫 패스(hot path)에 두고, 비용이 많이 드는 절차를 희귀한 패스(rare path)에 두십시오. 그것을 무엇이라 부르든, 그것이 진짜 승리입니다. (정확한 비용 차이: 측정되지 않음. 방향성은 명확하며, 규모를 벤치마크하지는 않았습니다.)
둘째, 지속적인 상태(persistent state)가 재생성(re-creation)보다 낫습니다. 이전 버전은 매 턴마다 새로운 "1회성(fire once)" 와처(watcher)를 재장전(re-armed)했는데, 이는 두 번 실행되는 중첩된 와처들을 쌓이게 만들었습니다. seen-set을 가진 하나의 장기 실행 프로세스(long-lived process)는 각 메시지를 정확히 한 번씩 방출합니다. 그것은 bash에서 컨슈머 오프셋(consumer offset)을 조용히 재도출(re-derived)해냈습니다.
핵심 (The kicker)
이 지점은 제 자신의 코드베이스에 대한 교훈으로 변했습니다. downbeat에는 이미 실제 이벤트 기반(event-driven) 와처가 있었습니다: src/downbeat/core/watcher.py에는 watchdog(macOS의 FSEvents, Linux의 inotify)을 기반으로 구축된 FsWatcher와 네트워크 파일 시스템을 위한 PollWatcher 폴백(fallback)이 있었으며, 둘 다 make_watcher() 팩토리(factory) 뒤에 구현되어 있었습니다. 저는 몇 달 전 TUI의 실시간 새로고침을 위해 이것을 작성했으나, 사용되지 않은 채 방치되어 있었습니다.
한편, 제가 더 최근에 추가했던(현재는 삭제됨, 아래 업데이트 참조) downbeat watch 명령은 이를 무시하고 자체적인 while sleep 루프를 돌리고 있었습니다.
결국 두 개의 독립적인 에이전트, 즉 다른 세션과 저의 과거 자아가, 테스트를 거친 이벤트 기반 와처가 단 한 번의 임포트(import) 거리에 있음에도 불구하고 각각 "루프 내에서 인박스(inbox)를 폴링(poll)하기"를 재도출하고 있었던 것입니다.
수정 사항 (The fix)
watch 명령이 팩토리를 사용하도록 다시 연결(Rewire)합니다:
w = make_watcher(on_change=emit, prefer="auto") # FsWatcher, PollWatcher fallback
# --poll은 FS 이벤트가 신뢰할 수 없는 NFS/SMB 환경에서 PollWatcher를 강제합니다
이제 downbeat watch는 유휴 비용(idle cost)이 거의 제로에 가까운 상태로 파일 시스템 이벤트에서 블로킹(blocks)되며, 이를 모니터 하에서 실행하면 다른 세션이 지향했던 바로 그 "저렴한 게이트(cheap-gate) 후 깨어남(wake)" 아키텍처를 정확히 구현하게 됩니다. 차이점은 "이벤트 기반"이라는 주장이 이제 희망 사항이 아니라 마침내 사실이 되었다는 점입니다.
내가 얻은 교훈 (What I took away)
- 메커니즘을 설명(description)이 아닌 코드로 평가하세요. 그 설명이 본인의 것이거나, 특히 AI가 작성한 것이라면 더욱 그렇습니다. "내가 만든 것"과 "내가 만들었다고 말한 것" 사이의 간극이 바로 이 버그가 존재했던 지점이었습니다.
- 새로운 프리미티브 (primitive)를 만들기 전에, 이미 배포한 것이 있는지 확인하세요. 제 저장소(repo)를 10초 동안 grep(검색)만 했어도 "수신함 폴링 (poll the inbox)"을 두 번이나 따로 재발명하는 일은 막을 수 있었을 것입니다.
- 저렴한 술어 (predicate), 그 다음의 비용이 큰 작업. LLM 호출(turn) 앞에 배치된 거의 비용이 들지 않는 파일 시스템 체크는 여기서 실제로 중요했던 패턴이며, 일단 찾아보기 시작하면 코드베이스 전반에서 나타납니다.
이 전체 과정에서 얻은 가장 가치 있는 결과물은 기능(feature) 그 자체가 아니었습니다. 이미 테스트된 프리미티브가 눈앞에서 두 번이나 다시 유도(re-derived)되었다는 사실을 알아차린 것이었습니다.
업데이트: 프리미티브가 더 나은 자리를 찾았습니다
이 글을 쓴 이후, 그 FsWatcher는 다시 한번 승격되었습니다. 이 포스트에서 찬양했던 독립형 watch 명령어를 불필요하게 만들었습니다. 사용자가 루프 내에서 watch를 실행하는 대신, 이제 워처(watcher)는 TUI _내부_에 존재합니다. 앱이 열려 있는 동안, 피어(peer)에게 대기 중인 메일이 있고 해당 피어가 너무 오래 유휴(idle) 상태라면 즉시 네이티브 OS 알림을 발생시킵니다. 이때 하드리스(headless) 전송 훅(send-hook)과 TUI가 동일한 메시지에 대해 중복 알림을 보내지 않도록 하트비트(heartbeat)를 사용합니다. 독립형 watch 서브커맨드는 완전히 제거되었습니다.
이는 이 글 자체가 주는 교훈을 한 단계 더 발전시킨 것입니다. 제가 사용되지 않은 채 놓여 있는 것을 발견한 테스트된 프리미티브는 단순히 두 개의 수동으로 작성된 폴링 루프(poll loops)를 대체하는 데 그치지 않았습니다. 그것이 명백한 빌딩 블록(building block)이 되었을 때, 그것을 사용할 올바른 위치는 제가 처음에 그것을 붙여두었던 명령어가 전혀 아니었다는 사실이 밝혀졌습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기