Engineering · Agent Engineering

재시도 루프의 종점 오판 다섯 건: 텍스트 휴리스틱에서 실행 상태 신호로

제출 후 90회 반복, 빈 최종 출력, 조용히 0.7을 내던 Judge, 재시도율 80%의 기준 설계, 같은 답을 세 번 채점한 retry-death를 차례로 추적했습니다. 모두 판정이 이번 회차의 실행 상태 대신 텍스트 길이나 이전 회차의 값을 읽은 결과였고, 종료와 채점의 근거를 실행 상태로 옮겼습니다.

기간
2026.06 – 2026.07
소속
플래티어 · XGEN
역할
실행 루프·판정 로직 설계

Key Results

80%
기준 설계 결함 당시 Judge 재시도율대형 유통 QA 워크플로우 재실행 5개 런, 채점 420회 중 339회
0.70~0.98
점진 채점 후 점수 분포같은 strict citation 설정, 이전에는 0.00과 만점 양극단. 로컬 스택 라이브
1회
retry-death 수정 후 재시도프로브 3회, 0.70 미달 뒤 새 후보 채점으로 1.00 통과. 수정 전에는 같은 답 3연속 0.30으로 소진

XGEN의 하네스 노드는 LLM을 한 번 호출하고 끝나는 노드가 아닙니다. 도구를 쓰고, 별도의 Judge가 답을 채점하고, 기준에 못 미치면 미달 사유를 담아 다시 쓰게 하는 루프를 엔진 안에 품은 에이전트 실행기입니다. 이 루프가 제대로 동작하려면 매 회차 끝에서 계속할지, 다시 쓸지, 끝낼지를 정확히 골라야 합니다. 2026년 6월부터 7월 사이에 이 선택이 다섯 가지 방식으로 틀렸습니다.

대형 유통 고객의 품질검사 워크플로우에서는 제출 도구를 부르고도 90회를 반복했고, 재시도를 다 쓴 실행은 빈 값을 다음 노드로 넘겼습니다. 설정 탐색 기능을 실제 모델로 처음 실행했을 때는 Judge가 채점을 한 번도 하지 않고 0.7을 내고 있었다는 것이 드러났습니다. 고객 개발 환경의 재실행 로그에서는 Judge 채점 420회 중 339회가 재시도로 이어졌고, 7월 데모 준비 중에는 Judge가 같은 답을 세 번 채점하고 실패로 끝나는 실행이 나왔습니다.

처음에는 각각 다른 버그로 보였습니다. 추적을 마치고 보니 공통점이 하나 있었습니다. 판정이 이번 회차에 실제로 일어난 일, 즉 어떤 도구가 실행됐고 어떤 새 후보가 만들어졌는지를 보지 않고, 텍스트 길이나 이전 회차에서 남은 값을 보고 있었습니다. 이 글은 다섯 건을 발견 순서대로 따라가며 원인을 좁힌 과정과, 판정 근거를 실행 상태로 옮긴 설계 판단을 정리합니다.

하네스의 작업 루프와 판정표

하네스 엔진은 10개 단계로 이뤄져 있습니다. 입력, 메모리, 프롬프트, 도구 카탈로그를 준비하는 앞 단계가 한 번 실행되고, 모델 호출, 도구 실행, 판정과 결정이 루프로 반복되고, 마지막 단계가 최종 출력을 확정합니다. 루프 끝의 결정 단계(Decide)는 아래 순서로 다음 행동을 고릅니다.

1. 가드 차단                       → complete
2. 도구 결과 대기                  → continue
3. Judge 미달 + 재시도 기회 남음   → retry (미달 사유를 담아 재작성)
4. Judge 미달 + 기회 소진          → complete (현재 답 채택)
5. 종료 도구 호출                  → complete
6. 답이 아직 합성되지 않음         → continue (합성 턴 부여)
7. 답 존재                         → complete

continue와 retry를 별도 루프로 나눈 것은 의도한 설계입니다. 둘을 같은 카운터로 세면 도구를 많이 쓰는 정상 실행이 재작성 기회를 소모하거나, 품질 기각이 무제한 반복됩니다. 거꾸로 말하면 이 설계는 결정 단계가 "지금 회차가 도구 작업 중인지, 새 답 후보가 나온 회차인지"를 정확히 구분할 수 있다는 전제 위에 서 있습니다. 다섯 건의 결함은 모두 이 전제가 무너진 자리에서 나왔습니다.

Judge는 본체와 다른 provider와 모델로 설정할 수 있습니다. 같은 모델이 자기 답을 후하게 채점하는 편향을 줄이기 위해서입니다. 절대 규칙도 본체 프롬프트가 아니라 Judge의 채점 기준으로 들어갑니다. 규제는 프롬프트 문장이 아니라 검증 게이트로 강제한다는 원칙입니다.

첫 결함들이 나온 곳은 대형 유통 고객의 품질검사 워크플로우였습니다. 상품 서류를 파일별로 판독해 판정 코드를 제출하는 에이전트로, 부모 워크플로우 아래에 진위 확인, 파일 판정, 결과 병합의 세 하위 워크플로우가 있었습니다. 파일 판정 단계는 필수(hard) 기준 3개, 통과 임계값 0.8, 재시도 5회로 설정돼 있었고, 고객 내부망의 개발 환경 쿠버네티스에서 실행됐습니다. 판정 결과는 submit_result라는 제출 도구로 넘깁니다.

서로 다른 경로로 보고된 다섯 가지 증상

증상은 서로 다른 시기에 서로 다른 경로로 보고됐습니다.

시점 증상 처음 관찰한 곳
6월 9일 제출 도구 실행 후 continue 무한 반복, 90회 호출 고객 개발 환경 로그
6월 9일 재시도 소진 후 빈 최종 출력 고객 개발 환경 로그
6월 11일 파일이 계속 보류로 남음, Judge 재시도율 80% 재실행 5개 런 로그
6월 17일 Judge가 채점 없이 0.7 폴백 점수 반환 설정 탐색 기능의 실모델 실행
7월 10일 같은 답을 3회 채점하고 재시도 소진 실패 데모용 로컬 스택 실행

첫 번째 로그는 이랬습니다. 제출 도구가 실행됐는데 submit_result 미검출 → pass가 찍히고, 이어서 decision=continue · reason=도구 결과 합성 필요 (실행=N, 인트로=M자)가 반복됐습니다. 한 실행이 90회 반복하며 모델을 90회 호출했고, 그 사이 provider의 assistant message prefill 거부 예외가 70번 연달아 났습니다. 최종 출력은 인트로 같은 짧은 문장이었습니다.

두 번째는 Judge 재시도를 다 쓰거나 마지막 턴이 prefill 거부로 실패했을 때, 하네스가(하위 에이전트 포함) 빈 값을 다음 노드로 넘기는 증상이었습니다. 앞 회차에서 만든 부분 결과까지 함께 사라졌습니다.

제출 루프, 빈 출력, 침묵하는 Judge

제출 루프. 처음 확인한 것은 버전이었습니다. 배포된 엔진과 로컬 엔진은 버전 번호가 달랐지만 결정 단계 코드는 같았기 때문에 버전 문제는 아니었습니다. 줄 단위로 따라가니 결정 단계에 이런 규칙이 있었습니다.

# 수정 전 결정 규칙(의사 코드)
if tools_run > 0 and not final_output and len(last_assistant_text) < 200:
    return CONTINUE  # "도구 결과 합성 필요"

도구를 실행했는데 최종 답이 없고 마지막 텍스트가 200자 미만이면, 모델이 "조사하겠습니다" 같은 인트로만 쓰고 멈춘 것으로 보고 합성 턴을 한 번 더 주는 규칙입니다. 문제는 제출 도구를 호출한 뒤 모델이 남기는 짧은 확인 문구도 200자 미만이라는 점이었습니다. 결정 단계는 제출이 끝난 실행을 "아직 합성 전"으로 판정했고, 그 확인 문구가 대화의 마지막 assistant 메시지로 남은 채 다시 호출하니 provider가 prefill로 보고 거부했습니다. 거부된 턴은 또 짧은 텍스트를 남기고, 결정 단계는 또 continue를 냈습니다.

한 겹이 더 있었습니다. 제품 쪽 판정 보조 로직은 제출 여부를 대화 메시지를 파싱해 찾고 있었는데, 메시지 형식이 맞지 않아 실패하고 있었습니다. 로그의 submit_result 미검출이 그 결과였습니다. 엔진의 도구 실행 단계는 호출마다 도구 이름, 호출 ID, 입력을 실행 이력(tool call history)에 따로 쌓고 있었습니다. 제출 여부의 정본은 메시지 텍스트가 아니라 이 이력이었습니다.

빈 출력. 마지막 단계는 final_output = last_assistant_text or ""로 최종 답을 정하고 있었습니다. 마지막 턴이 비면 앞에서 만든 텍스트나 제출 내용이 있어도 빈 문자열이 확정됩니다. 재시도 소진이나 prefill 실패처럼 마지막 턴이 비는 경로가 실제로 있었으니, 빈 출력은 언젠가 나올 일이었습니다.

침묵하는 Judge. 세 번째 결함은 다른 작업에서 드러났습니다. 6월 17일, 하네스 설정을 실행 기록 기반으로 개선하는 기능을 만들면서 처음으로 실제 파이프라인과 실제 모델(Qwen3.6-27B)로 전체 흐름을 실행했습니다. 이 실행에서 Judge 보조 호출이 매번 예외를 내고 있다는 것이 처음 노출됐습니다. 보조 호출의 max_tokens 설정 기본값이 None이었고, 기본값을 대신 넣는 처리가 None 앞에서 발동하지 않아 매 호출 int(None) 예외가 났습니다. Judge는 예외가 나면 0.7 폴백 점수를 냈습니다. 채점은 한 번도 일어나지 않았는데 점수는 그럴듯한 값으로 나오고 있었던 것입니다.

엔진에는 유닛 테스트가 225개 있었고 전부 통과하고 있었습니다. 어느 테스트도 실제 provider로 전체 파이프라인을 실행하지 않았기 때문입니다. 6월 9일 엔진 코드 검수에서 "응답 파싱에 실패하면 0.7로 통과시키는" fail-open 경로를 판정 생략으로 바꿨는데, 예외 경로에는 같은 성격의 폴백이 남아 있었습니다. 이 결함은 고객 QA에서 보인 Judge 불안정의 유력한 원인 후보로 따로 기록했습니다. 다만 그 인과를 별도로 검증하지는 못했습니다.

재시도율 80%와 retry-death

계속 보류. 6월 11일에는 "파일이 계속 보류로 떨어진다"는 보고가 왔습니다. 첫 가설은 판정 보조 로직의 오작동이었습니다. 재실행 5개 런의 로그와 워크플로우 정의 4개를 직접 대조했습니다. 판정 보조 로직은 등록 4/4, 제출 감지와 사유 주입 정상, 미호출 가드 발동 0, 코드 정규화 개입 0이었습니다. 가설은 기각됐습니다.

보류의 실체는 두 갈래였습니다. 하나는 라우팅입니다. 적재 시점에 "미처리 → 보류" placeholder가 깔리는데, 일부 문서 유형은 판정 단계로 라우팅되지 않아 제출 시도가 0회였고, 병합 단계의 결정표가 그 placeholder를 그대로 유지했습니다. 다른 하나가 Judge 기준 설계였습니다. 채점 420회 중 339회, 80%가 재시도로 이어지고 있었습니다.

  • 진위 확인 단계의 기준은 "응답에 세션 ID를 포함할 것"을 요구했는데, Judge는 제출 페이로드만 채점하도록 바뀌어 있었습니다. 세션 ID는 채점 대상에 들어오지 않으니 0.76 < 0.8로 영원히 통과할 수 없었습니다.
  • 파일 판정 단계는 필수 기준이 3개라 사소한 형식 지적도 총점 0.00으로 처리됐습니다(174회). Judge가 사실을 잘못 읽은 사례도 있었습니다.
  • 같은 파일의 판정 코드가 실행마다 바뀌었고, 한글 기준 이름은 정규화 과정에서 criterion_N으로 바뀌어 미달 사유를 읽기 어려웠습니다.

여기서 원칙을 하나 세웠습니다. Judge 기준은 Judge가 실제로 보는 입력만으로 판정할 수 있어야 합니다. 볼 수 없는 것을 요구하는 기준은 품질 기준이 아니라 재시도 생성기입니다. 이분 채점도 문제였습니다. 필수 기준 하나만 어겨도 0점이 되는 구조에서는 "거의 맞는 답"과 "완전히 틀린 답"을 구분할 수 없습니다. 7월 초 로컬 스택에서도 같은 strict citation 설정이 0.00 아니면 만점으로만 나왔습니다.

retry-death. 7월 10일, 실패 교훈을 다음 실행이 이어받는 경로를 고친 뒤에도 재시도 3회를 다 쓰고 실패하는 실행이 남았습니다. "루프는 답이 나올 때까지 도는 로직이고 Judge와는 별개인데 왜 3회 소진으로 끝나느냐"는 질문을 계기로 다시 조사했습니다. 실행 기록에서 모델은 재시도 후 텍스트 없이 도구만 호출하며 작업을 이어 가고 있었습니다. 루프는 설계대로 동작하고 있었습니다.

결정적인 증거는 채점 기록이었습니다. Judge가 길이 309의 같은 답을 세 번 연속 0.30으로 채점하고 있었습니다. 원인은 두 값의 수명이었습니다.

회차 N    답 후보 생성(길이 309) → Judge 0.30 → retry
회차 N+1  모델이 도구만 호출, 텍스트 없음
          마지막 답 텍스트 미갱신 → 회차 N의 답이 그대로 남음
          "답이 없으면 채점하지 않는다" 게이트를 통과 → 같은 답 재채점 0.30
          이전 점수도 남아 있음 → 점수 분기가 합성 continue보다 먼저 걸려 retry
회차 N+2  반복 → 재시도 3회 소진, 실패

모델 호출 단계는 텍스트가 없는 턴에서 마지막 답 텍스트를 갱신하지 않았고, 검증 점수는 회차를 넘어 남았습니다. 결정 단계에서 점수 분기는 합성 continue 분기보다 우선순위가 높기 때문에, 도구 작업 중인 회차에도 남은 점수를 보고 retry를 연달아 냈습니다. 6월에 설정 탐색 설계 문서를 쓰면서 "retry-death"라는 이름으로 경계해 둔 실패 양식이 바로 이것이었습니다.

같은 날 설계 원칙과 코드의 정합을 다시 감사하면서 두 가지를 더 찾았습니다. 하나는 재시도 턴에 주입하는 반성 노트에 "다시 조사하지 말 것(do not re-research)"이라는 문구가 들어 있었던 것입니다. 불필요한 도구 반복을 줄이려고 넣은 문장이었지만, 근거 부족으로 기각된 답까지 다시 검색하지 못하게 막고 있었습니다. 다른 하나는 도구 검색 도구가 카탈로그가 12개 이상일 때만 등록되는 휴리스틱이라, 설정으로 켜도 작은 워크플로우에서는 무시됐던 것입니다.

설계 판단: 판정의 근거를 실행 상태로 옮기기

다섯 건을 놓고 고칠 방향을 세 가지로 비교했습니다.

선택지 장점 단점 판단
프롬프트에 행동 지시 추가 코드 변경 없이 즉시 적용 모델이 따른다는 보장이 없고, 검증 게이트가 강제하는 retry를 끊지 못함 기각
텍스트 휴리스틱 보정(임계값, 형식 규칙) 국소 수정으로 끝남 200자 규칙 자체가 제출 루프의 원인이고, 새 형식마다 예외가 생김 기각
실행 상태 신호로 판정 도구 이력, 회차, 후보가 명시적이고 회귀 테스트로 고정 가능 엔진과 제품 양쪽 수정, 배포 순서 관리 필요 채택

첫 번째 선택지는 실제로 시도해 본 적이 있습니다. 6월 26일, 검색 도구가 빈 결과를 반환하던 실험 워크플로우에서 "빈 검색이면 반복하지 말고 지식으로 답을 완성하라"는 가드를 시스템 프롬프트에 넣고 반복 상한을 3으로 줄였습니다. 결과는 1차 검색 3회 후 Judge 0.00, retry, 2차 회차에서 180초 시간 초과였습니다. Judge가 강제하는 retry와 빈 도구 루프는 프롬프트 문장으로 끊기지 않았습니다.

두 번째 선택지는 원인을 그대로 둔 채 숫자만 바꾸는 일이었습니다. 제출 루프는 200자 규칙이 만든 것이고, 7월 2일에는 반대 방향의 증상도 확인했습니다. 판정 보조 로직이 200자 미만 답변을 인트로로 보고 채점을 생략해, 짧은 답은 Judge를 그냥 통과하고 있었습니다. 같은 길이 규칙이 한쪽에서는 끝난 실행을 붙잡고, 다른 쪽에서는 검증하지 않은 답을 놓아주고 있었던 셈입니다.

그래서 세 번째로 갔습니다. 원칙은 세 가지로 정리했습니다.

  1. 종료는 텍스트가 아니라 이번 회차의 도구 실행 이력으로 판단합니다. 종료 도구가 실행됐으면 끝입니다.
  2. Judge는 이번 회차에 새로 만들어진 후보만 채점합니다. 재시도 1회는 신선한 후보 1회 채점과 같아야 합니다.
  3. 점수는 다음 결정의 근거가 되는 상태입니다. 예외, 파싱 실패, 볼 수 없는 기준이 점수로 위장해서는 안 됩니다.

채점 방식은 부분 점수로 바꿨습니다. 기준별 0.0~1.0 가중 평균을 기본으로 하되, 필수 기준 위반은 총점 0을 강제하고, Judge 응답 파싱에 실패하면 통과가 아니라 판정 생략으로 처리합니다. 필수 기준을 없애지 않은 이유는 절대 규칙을 Judge 기준으로 강제한다는 원칙 때문입니다. 7월 2일 실험에서도 약한 Judge 모델은 절대 규칙 문장만으로는 오판했고, 같은 내용을 필수 기준 축으로 등록하자 6건 모두 정확했습니다.

구현: 종료, 출력, 후보와 점수의 수명

수정은 엔진과 제품 연동 계층 양쪽에 들어갔고, 결함마다 회귀 테스트를 함께 넣었습니다.

# 종료: 이번 회차의 마지막 도구가 종료 도구면 루프 완료(미지정 시 기존 동작 유지)
if terminal_tools and current_iteration_last_tool(state) in terminal_tools:
    return LOOP_COMPLETE
 
# 최종 출력: 마지막 턴이 비어도 앞의 결과를 잃지 않는다
final = (last_assistant_text
         or last_nonempty_assistant_text(state.messages)
         or last_submit_payload(state.tool_call_history))
 
# retry-death: 후보와 점수의 수명을 한 회차로 묶는다
state.last_assistant_text = response.text or ""   # 텍스트 없는 턴은 빈 값
if decision == RETRY:
    state.validation_score = None                  # 새 후보가 채점되기 전엔 점수 없음

제품 쪽 판정 보조 로직은 제출 감지를 실행 이력 기준으로 바꾸고, 판정 통과와 제출이 함께 확인되면 최종 출력을 바로 세팅해 엔진이 즉시 완료하게 했습니다. 연동 계층의 결과 수집도 마지막 제출 내용을 보존해, 최종 출력이 비면 그것으로 폴백합니다. Judge 보조 호출은 기본값 해석 함수로 설정을 읽게 바꾸고(max_tokens 기본 500), 이 경로에 int(None) 회귀 가드 테스트를 추가했습니다. 반성 노트 문구는 "필요하면 도구를 다시 쓸 것"으로 바꿨고, 도구 검색 도구는 명시적으로 요청하면 카탈로그 크기와 무관하게 등록하도록 설정이 휴리스틱보다 우선하게 했습니다.

배포에서도 하나 배웠습니다. 빈 출력 수정을 담은 버전을 패키지 저장소에 올리려는데, 다른 세션이 같은 버전 번호로 먼저 발행해 업로드가 실패했습니다. 이미 올라간 wheel을 풀어 보니 수정이 빠져 있어 다음 버전 번호로 다시 발행했습니다. 발행 후 wheel 내용을 직접 확인해야 한다는 것을 그때 교훈으로 남겼습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
제출 후 반복 90회 반복, prefill 거부 70회 제출 감지 후 즉시 완료, 반복과 prefill 거부 소멸 고객 개발 환경 재배포 후 실행 로그
Judge 채점 매 호출 예외, 0.7 폴백 실제 채점, 회귀 가드 테스트로 고정 실제 파이프라인과 Qwen3.6-27B
점수 분포 0.00 또는 만점 0.70~0.98, 필수 기준 위반은 0점 유지 같은 strict citation 설정, 로컬 스택 라이브
retry-death 프로브 같은 답(길이 309) 3연속 0.30, 재시도 소진 실패 0.70 미달 뒤 길이가 다른 새 후보 채점, 1.00 통과, retry 1회 프로브 3회
엔진 테스트 유닛 테스트 225건(실 provider 경로 없음) 237건 통과 7월 10일 retry-death 수정 후 pytest

재시도율 80%는 기준 설계 결함을 찾은 시점의 수치이고, 기준을 고친 뒤 같은 조건으로 다시 잰 재시도율 기록은 없습니다. 이 글에서 재시도율의 개선 폭을 주장하지 않는 이유입니다.

retry-death를 고친 날 실패 교훈 경로도 함께 고쳤습니다. 미달 사유가 담긴 교훈만 남긴 상태로 다음 실행을 시작하자 첫 초안이 1.00으로 통과해 재시도 0회로 끝났습니다. 루프는 안전망이고, 이어받을 상태가 있으면 안전망까지 갈 일이 줄어든다는 것을 보여 준 실행이었습니다.

다섯 건을 돌아보면 판정 로직의 결함이라기보다 판정 입력의 결함이었습니다. 결정 단계는 텍스트 길이로 진행 상태를 추정했고, Judge는 이전 회차의 답과 점수를 이번 회차의 것으로 읽었고, 예외는 0.7이라는 점수로 위장했습니다. 고친 방향은 매번 같았습니다. 이미 실행 상태에 있는 사실(도구 실행 이력, 이번 회차의 후보)을 판정의 근거로 쓰고, 근거가 없을 때는 점수를 만들지 않는 것입니다.

테스트에 대해서도 배운 점이 있습니다. 유닛 테스트 225개는 각 부품이 맞게 동작하는지를 확인했지만, 실제 provider로 전체 루프를 실행했을 때 Judge가 채점을 하는지는 확인하지 않았습니다. 판정 경로처럼 실패가 점수로 흡수되는 곳은 실모델 종단 실행으로 반드시 한 번은 확인해야 합니다.

남은 과제도 있습니다. 첫 초안을 도구 없이 즉답한 실행에서는 재시도가 도구 호출 없이 재작성만 반복해, "뉴스 N건"처럼 도구가 필요한 기준이 0.30으로 세 번 소진되는 경우를 재녹화 중에 확인했습니다. 재시도 회차에서 도구 사용을 다시 유도할지, 기준 설계 가이드로 막을지는 별도 과제로 남겼습니다. 인트로 판정 임계값 200자가 엔진의 두 모듈에 각각 인라인으로 정의돼 있는 점, 품질 보류가 임계값과 같은 점수를 세팅하는 점(현재는 무해)도 파싱 실패와 같은 판정 생략 패턴으로 통일할 후보입니다.