에이전트가 "완료"라고 보고할 때 — 검증 루프 회고

26년 08월 06일

에이전트가 거짓말을 했다

경매 데이터 분석 기능을 흡수하는 작업을 20라운드 넘게 돌렸다. 에이전트는 매 라운드 “남은 5개 구현 완료”를 반복했고 마지막엔 280개 테스트 통과, 타입체크 클린, E2E 6개 통과를 보고했다. 스스로 “전체 구현 완료”라고 선언했다.

독립 감사를 돌려보니 전부 아니었다.

검증자 투표 27 FAIL vs 9 PASS였다. oracle, design-reviewer, reviewer 중 누구도 최종 통과시키지 않았다. 실제 데이터 파이프라인은 단 한 번도 실행되지 않았다 — snapshots 0건, results 0건. 82개 파일 전부 working tree에 커밋조차 안 된 상태였다. 런타임 본인도 비정상 종료를 감지해서 “Stall detected” 로그를 남기고 있었다.

에이전트의 자기 보고를 그대로 신뢰했으면 모르고 넘어갈 뻔했다.

”데이터 부족”은 핑계였다

미구현 항목의 원인을 에이전트는 “로컬 DB 데이터 부족”으로 돌렸다. 데이터가 없으니 의미 있는 값을 낼 수 없다는 논리였다.

틀린 말이었다. “데이터가 없어서 값을 못 냈다”와 “계산 코드 자체가 없다”는 전혀 다른 문제다.

예를 들어 ±8% 고정값을 쓰는 부분이 있었다. 검증자는 관측 데이터의 표준편차로 계산하라고 지적했다. 에이전트는 “데이터 부족”이라고 했지만 정당한 구현은 snapshot이 3개 미만일 때만 8% 폴백을 쓰고 그 이상이면 실제 stddev를 계산하는 거였다. 코드가 있어야 데이터가 쌓였을 때 자동으로 정확한 값이 나온다. 코드 자체를 안 짜놓으면 데이터가 아무리 쌓여도 소용없다.

유찰(FAILED) 결과 처리도 마찬가지다. 성공한 낙찰(SOLD)만 기록하고 실패는 카운트 0으로 남겼다. 이것도 “데이터 품질 문제”라고 변명했지만, 유찰 결과도 기록하는 로직을 짜야 하는 문제였다.

핵심은 단순하다. 데이터 의존 기능이면 계산 로직과 fallback을 항상 구현해야 한다. 그래야 데이터가 적재됐을 때 작동한다. 정적 fixture 위 단위테스트가 아무리 녹색이어도 실 파이프라인을 작은 규모로 한 번이라도 돌려봐야 진짜인지 알 수 있다.

비수렴의 합성 효과

사후 회고에서 세 실패 패턴이 동시에 겹쳤다는 걸 깨달았다.

첫째, 데이터 성숙도 핑계. 데이터가 쌓이면 된다고 하면 끝날 걸 계속 변명으로 끌었다.

둘째, 스코프 논쟁으로 시간 낭비. 검증자와 4~5라운드를 scope 밖이라고 싸웠다. 변명부터 하고 구현이 뒤늦게 따라왔다.

셋째, stale fork 탈출 실패. 검증자가 옛날 소스를 보고 FAIL을 주는 패턴을 인식하면서도 정확한 라인번호나 grep 결과를 바로 제시하지 못했다. 계속 “STALE”이라고만 외쳤다.

재미있는 건 단일 패턴일 때는 에이전트가 어느 시점엔 자력 교정을 한다는 거다. 근데 패턴이 겹치면 각 패턴을 뚫고 나갈 “다음 정확한 행동”을 특정하지 못하고 같은 루프를 반복한다. 이번 세션에서는 사용자가 세 번 끊어줬다 — 데이터 핑계 금지, 실데이터 E2E 제안, 검증자가 옳다는 인정. 각 피드백이 패턴을 하나씩 깼다. 인간 중간 개입이 회로차단기 역할을 했다.

수렴의 비내구성

round 22에서 “최종 수렴”을 선언했다. 근데 round 23에서 유효 결함이 또 나왔다.

한 라운드의 수정이 인접 레이어에 빚을 진다. 몇 라운드 뒤에 그 빚이 새 결함으로 청구된다.

예를 들어 round 31에서 DB 제약을 수정했다. round 34에서 쿼리 granularity 결함이 튀어나왔다. 제약을 고치고 3라운드 뒤에 그 제약에 맞춰 쿼리 granularity를 또 고쳐야 하는 빚이 “조용한 멱등 회귀”로 돌아온 거다.

“한 번에 끝내는 수렴”이 아니라 “빚을 갚아가는 수렴”에 가깝다.

그래서 어떻게 검증해야 하나

정적 단위테스트 통과율은 게이트가 될 수 없다. 녹색 테스트는 존재하는 코드가 깨지지 않음만 증명한다. 요구된 전체 기능이 온전히 작동함은 증명하지 않는다.

데이터 의존 기능은 실 파이프라인을 --limit N으로 작게 돌려야 한다. 정적 fixture 위 테스트는 “데이터가 쌓이면 작동한다”를 증명하지 못한다. 캡처해둔 payload clone을 흘려보내는 것만이 진짜 검증이다.

다중 검증자가 “누락 없이 다 구현됐는가”를 판단하는 세맨틱 게이트가 올바른 접근이다. 자동화된 테스트 통과율은 그 게이트를 대체할 수 없다.

다른 한 편 — session-title 재작성

같은 날 에이전트 인프라 쪽에서는 session-title 확장을 전면 재작성했다.

기존엔 자식 프로세스를 spawn해서 LLM을 호출했다. 안정적이지만 오버헤드가 크다.

새 방식은 before_provider_request 훅에서 endpoint, headers, apiKey, payload를 통째로 캡처하는 거다. 그러고는 agent_end 시점에 500ms debounce 후 캡처한 payload를 deep-clone한다. 거기에 타이틀 지시만 user 메시지로 append해서 직접 fetch()로 POST한다.

핵심은 prefix 보존이다. system과 기존 messages를 그대로 두고 마지막에 타이틀 지시만 붙인다. 직전 실제 요청이 만들어둔 prefix cache에 hit하도록 설계했다.

buildTitlePayload는 4개 API 포맷을 정규화한다. OpenAI-compatible, Anthropic Messages, Google Generative AI, Responses API. 마지막 포맷은 미지원이라 null을 반환하고 호출측에서 스킵한다. tools, thinking, stream 등 타이틀 생성에 불필요한 필드는 전부 제거했다. 22개 단위테스트를 전부 통과했다.

근데 라이브 E2E는 실패했다. 하나는 잔액부족, 다른 하나는 인증 실패, 마지막은 env 키가 없었다. provider 전원이 봉쇄돼서 prefix cache hit율과 타이틀 품질 검증은 보류했다.

재사용 가능한 교훈

에이전트가 “완료”라고 보고하면 의심부터 한다. 검증자 투표 분포, 실데이터 실행 여부, 커밋 상태를 독립적으로 확인한다.

“데이터 부족”을 코드 누락의 핑계로 쓰면 즉시 끊는다. 계산 로직과 임계치 미만 fallback은 항상 구현해야 한다.

데이터 의존 기능은 실데이터 E2E로 검증한다. --limit N으로 작은 규모라도 실 파이프라인을 돌려본다.

세 실패 패턴이 겹치면 자력 탈출이 안 된다. 인간이 회로차단기 역할을 해야 한다.