XGEN 15일 무료 체험 — 설치 없이 브라우저에서 바로 시작하세요체험 신청
PlateerAI Labs
·Reading Time | 4 min
작성 | 김진수

재시도 횟수로는 실행을 설명할 수 없었습니다(1편)

도구를 세 번 쓴 실행과 품질 검증에 세 번 실패한 실행이 같은 '재시도 3회'로 남았습니다. 생성·검증·도구 실행·재시도를 하나의 반복문에서 꺼내 명시적인 상태와 전이로 나눈 과정입니다.

재시도 횟수로는 실행을 설명할 수 없었습니다(1편) — 커버 일러스트Series · 하네스 개발기시리즈 1번째 글 · 전체 10편 보기

에이전트가 같은 오류를 반복할 때 저희가 먼저 고친 것은 프롬프트였습니다. 몇 번 고쳐도 오류가 형태만 바꿔 돌아왔고, 실행 기록을 열어보니 서로 다른 세 종류의 실행이 전부 '재시도 3회'라는 한 줄로 남아 있었습니다. 무엇을 잘못 세고 있었는지, 그리고 그 발견이 왜 실행기를 새로 만드는 일로 이어졌는지 정리합니다.


프롬프트를 고쳐도 같은 오류가 형태만 바꿔 돌아왔습니다

시작은 워크플로우 자동 생성이었습니다. 사용자가 하고 싶은 일을 문장으로 적으면 모델이 캔버스 워크플로우를 만들어주는 기능입니다.

모델은 종종 존재하지 않는 노드를 만들었습니다. 서로 연결할 수 없는 포트를 이어놓기도 했습니다.

처음 대응은 지시문을 고치는 것이었습니다. 사용할 수 있는 노드 목록을 프롬프트에 넣고, 포트 규칙을 문장으로 설명하고, 하지 말아야 할 것을 열거했습니다.

그런데 같은 오류가 계속 돌아왔습니다. 노드 이름을 바로잡으면 이번에는 포트가 어긋났고, 포트를 설명해두면 존재하지 않는 파라미터가 나왔습니다.

여기서 저희가 다시 본 것은 지시문의 품질이 아니라 통과 여부를 누가 정하고 있는가였습니다. 프롬프트에 규칙을 적어두는 것은 모델에게 부탁하는 일입니다. 부탁은 대체로 지켜지지만 항상 지켜지지는 않고, 지켜지지 않았을 때 그것을 알아채는 주체가 없었습니다.

그래서 4월 5일, 생성 결과를 노드 레지스트리와 대조하는 일곱 가지 검사를 만들었습니다. 노드가 실제로 존재하는지, 포트 타입이 맞는지, 필수 파라미터가 채워졌는지를 코드가 확인합니다. 실패하면 그 사유를 다음 생성 요청에 실어 보내고, 최대 세 번까지 다시 만들게 했습니다.

생성은 여전히 모델이 합니다. 다만 통과 여부는 모델이 정하지 않습니다.

이 구분은 생성형 기능을 업무에 붙이는 조직이라면 어디서든 다시 마주칩니다. 지시문으로 관리할 수 있는 것은 모델의 경향이고, 보장해야 하는 조건은 지시문 바깥에 있어야 합니다. 둘을 같은 곳에 적어두면 무엇이 보장이고 무엇이 부탁인지 구분할 수 없게 됩니다.

판정을 미들웨어로 끼웠다가 하루 만에 되돌렸습니다

비슷한 문제가 다단계 업무 검토 워크플로우에서도 나타났습니다. 실행이 끝난 뒤 기록을 규칙으로 재생하면 결과가 잘못됐다는 사실은 찾을 수 있었습니다. 문제는 실행 중이었습니다. 진행되는 동안 같은 규칙을 일관되게 적용할 자리가 없었습니다.

가장 자연스러운 해법은 이미 있는 워크플로우 실행기에 판정 미들웨어를 끼우는 것이었습니다. 실행 경로 중간에 훅을 걸고, 규칙을 통과하지 못하면 막습니다. 4월 8일 런타임 판정 미들웨어를 그렇게 넣었습니다.

같은 주에 그 변경을 전부 되돌렸습니다. 판정기, 레지스트리, 추적 어댑터를 포함해 다섯 개 파일 900여 줄이 한 번에 사라졌습니다.

되돌린 이유는 미들웨어가 동작하지 않아서가 아닙니다. 동작하게 만들려면 제품의 노드 실행 코드와 업무 규칙이 같은 계층에 붙어야 했고, 그러면 규칙 하나를 바꿀 때마다 노드 실행 코드를 건드려야 했습니다. 반대도 마찬가지였습니다. 노드 하나를 고치면 규칙이 따라 흔들렸습니다.

그때 알게 된 것은 저희에게 필요한 것이 검증 코드가 아니라 검증을 소유한 실행 계층이었다는 점입니다. 검증 로직은 이미 있었습니다. 그 로직이 어디에 살고, 실패했을 때 다음에 무엇을 할지 누가 결정하는가가 비어 있었습니다.

끼워 넣기          제품 실행기 ─┬─ 노드 실행 코드
                              └─ 업무 판정 규칙     같은 계층 → 변경이 서로를 흔든다

나눠 놓기          제품 실행기 ── 노드 실행 코드
                  실행 계층   ── 검증·재시도·종료 소유

기능이 아니라 소유권이 문제일 때가 있습니다. 같은 코드를 어디에 두느냐가 앞으로의 변경 비용을 정합니다.

'재시도 3회'는 서로 다른 세 가지 실행을 같은 이름으로 불렀습니다

별도 실행 계층을 만들기로 하고 처음 정한 것은 모델 호출 방식이 아니라 상태와 전이였습니다. 그 결정을 하게 만든 것은 실행 기록이었습니다.

당시 기록에는 재시도 횟수가 남았습니다. 숫자 하나면 충분해 보였습니다. 몇 번 더 돌았는지 알면 비용도 알 수 있으니까요.

기록을 실제로 열어보니 그렇지 않았습니다.

실행 A   도구를 세 번 호출하고 답을 완성        → 기록: 재시도 3회
실행 B   답을 세 번 만들었지만 전부 품질 미달    → 기록: 재시도 3회
실행 C   공급자 오류로 세 번 다시 보냄          → 기록: 재시도 3회

세 실행은 호출 수만 같습니다. 비용을 쓴 이유가 다르고, 다음 회차에 넘겨야 할 값이 다르고, 사람이 봐야 할 조치가 다릅니다.

A는 정상입니다. 도구를 쓰면서 일이 진행됐습니다. B는 품질 문제입니다. 같은 요청을 다시 보내면 안 되고 반려 사유를 실어야 합니다. C는 아예 상태가 전진하지 않았습니다. 같은 요청을 그대로 다시 보내는 것이 맞습니다.

한 카운터가 세 가지 책임을 떠안고 있었고, 그래서 아무것도 설명하지 못했습니다.

그러니까 저희가 세고 있던 것은 재시도가 아니었습니다. 그냥 반복 횟수였습니다. 재시도라는 이름이 붙어 있어서 재시도를 세고 있다고 믿었을 뿐입니다.

그래서 다음 행동을 세 가지 의미로 나눴습니다.

continue → 새 관측을 더해 현재 작업을 이어 간다
retry    → 검증 피드백을 반영해 새 답 후보를 만든다
complete → 완료 또는 중단 사유를 확정하고 결과를 정리한다

이름을 나누자 각 전이가 무엇을 소비하고 무엇을 남겨야 하는지도 따라 정해졌습니다. continue에는 도구 호출과 관측 결과가 필요합니다. retry에는 반려된 후보와 검증 피드백이 필요합니다. complete에는 최종 출력뿐 아니라 종료 이유가 있어야 합니다.

실행 기록의 단위도 바뀌었습니다. '몇 번 돌았는가'가 아니라 **'왜 어느 상태로 옮겨갔는가'**입니다.

에이전트 비용이 예상보다 크게 나오는 조직이라면 먼저 확인할 것이 있습니다. 지금 남기는 숫자가 서로 다른 이유를 한 칸에 몰아넣고 있지는 않은지입니다. 총 호출 수는 청구서를 설명하지만 무엇을 고쳐야 하는지는 알려주지 않습니다.

도구가 하나도 없을 때 두 단계가 서로를 부르며 멈추지 않았습니다

전이를 나누고 나서 종료는 자연히 따라올 줄 알았습니다. 답이 나오면 끝나고, 한도에 닿으면 멈추면 되니까요.

4월 11일, 도구 목록이 비어 있는 실행에서 모델 호출 단계와 도구 실행 단계가 서로를 부르며 멈추지 않는 것을 발견했습니다.

모델 호출 ──"도구를 쓰겠다"──> 도구 실행 ──"쓸 도구가 없다"──> 모델 호출 ──> …

각 단계는 자기 일을 정확히 했습니다. 모델은 도구를 요청했고, 도구 단계는 요청받은 도구가 없다고 정직하게 답했습니다. 어느 쪽도 버그가 아니었습니다.

없었던 것은 이 상황을 종료로 판정할 주체였습니다. 종료를 각 단계의 부수효과로 두면, 모든 단계가 정상 동작하는데도 실행이 끝나지 않는 조합이 생깁니다.

이 사건 이후 종료는 별도의 결정 단계가 소유하도록 옮겼습니다. 현재 답 후보, 도구 상태, 정책 결과, 실행 한도를 한자리에서 보고 다음 전이를 고릅니다. 모델 호출 단계는 응답을 기록하지만 종료를 정하지 않습니다. 도구 단계는 관측 결과를 더하지만 품질 판단을 바꾸지 않습니다.

루프를 도는 시스템을 만들 때 정상 경로만 보면 종료 조건은 사소해 보입니다. 실제로 문제가 되는 것은 모든 구성 요소가 각자 옳게 동작하는 조합입니다. 그 조합은 어느 한 구성 요소의 버그로 잡히지 않습니다.

Rust로 단단하게 만들었는데 정작 기다린 것은 모델이었습니다

4월 10일에 만든 첫 독립 실행기는 Rust였습니다. 실행 코어이니 상태와 자료형을 컴파일러가 붙잡아주는 언어가 맞다고 봤습니다. 1만 4천 줄이 들어갔습니다.

4일 뒤 전부 지우고 Python으로 다시 썼습니다.

성능이 안 나와서가 아닙니다. 실행 시간을 재보니 대부분이 모델 응답과 외부 도구를 기다리는 데 쓰이고 있었습니다. 상태 전이를 계산하는 시간은 그 옆에서 반올림하면 사라지는 수준이었습니다.

반면 빠르게 늘어난 것은 다른 쪽 코드였습니다. 공급자를 바꾸고, 전략을 갈아끼우고, 평가 방식을 붙이는 코드입니다. 실제 병목은 상태 전이의 실행 속도가 아니라 변경을 연결하고 확인하는 시간이었습니다.

믿었던 병목      상태 전이 계산      → 실측하니 전체 실행 시간에서 무시 가능
실제 병목        변경을 붙이고 확인   → 여기가 매일 늘어나고 있었다

그러니까 Rust가 틀린 선택이었다기보다, 저희가 단단하게 만들려던 대상이 애초에 위험한 곳이 아니었습니다. 지킬 것이 없는 자리에 방벽을 세우고 있었던 셈입니다.

기술 선택을 검토할 때 흔히 "무엇이 더 빠른가"를 먼저 묻습니다. 그보다 먼저 확인할 것은 지금 시간을 쓰고 있는 곳이 어디인가입니다. 두 질문의 답이 다르면 최적화는 비용만 남깁니다.

언어를 갈아엎었는데 설계는 그대로 남았습니다

전면 재작성이 4일 만에 가능했던 이유는 따로 있습니다. 다시 만들 때 처음부터 새로 설계하지 않았기 때문입니다.

단계 식별자, 상태 필드, 이벤트 형식, 도구 인터페이스를 그대로 유지했습니다. 언어를 바꾼 뒤에도 같은 상황에서 같은 전이를 고르는지 나란히 비교할 수 있었습니다.

여기서 확인한 것은 저희가 Rust로 만든 자산이 Rust 코드가 아니었다는 점입니다. 실행의 의미를 언어 바깥의 계약으로 먼저 잡아둔 것이 자산이었고, 그 계약이 있었기 때문에 구현체는 갈아엎어도 됐습니다.

같은 이유로 단계의 개수 자체는 중요하지 않았습니다. 책임을 나누다 보니 전체 조율, 입력, 이력, 프롬프트, 도구, 정책, 컨텍스트, 실행, 결정, 마무리로 경계가 잡혔고, 4월 말에 열 단계로 정착했습니다.

입력 준비
  → [컨텍스트 구성 → 모델 호출 → 정책 검사 → 도구 실행 → 다음 행동 결정]
  → 결과 정리

한 번만 하면 되는 일과 필요한 만큼 반복하는 일이 갈렸습니다. 단계의 순서는 실행 계약으로 고정하고, 단계 안의 처리 방식은 갈아끼울 수 있게 뒀습니다. 컨텍스트 단계를 절단에서 요약으로 바꿔도 결정 단계의 상태를 건드리지 않습니다. 새 공급자를 붙일 때 전체 루프를 다시 쓰지 않습니다.

단계가 많아 보이는 구조가 오히려 한 번에 바뀌는 범위를 줄였습니다.

마지막 문장이 매번 달라서 답으로는 검증할 수 없었습니다

만들어놓고 나니 어떻게 테스트할지가 남았습니다. 처음에는 출력 문자열을 기대값과 비교했습니다.

같은 입력에 같은 답이 나오지 않았습니다. 모델의 마지막 문장은 매번 조금씩 달라집니다.

문자열이 다르다고 실행이 잘못된 것은 아니었습니다. 검증하려던 대상을 잘못 고른 것입니다. 저희가 보장하려던 것은 답의 표현이 아니라 실행이 상황에 맞는 경로를 골랐는가였습니다.

그래서 답 대신 경로를 확인했습니다. 도구 요청이 오면 관측 결과를 더한 뒤 이어 가는지, 검증에 실패하면 피드백을 실은 새 생성으로 가는지, 정책이 차단하면 추가 호출 없이 끝내는지를 봤습니다.

결과에도 답만 남기지 않았습니다. 어떤 단계를 거쳤는지, 무엇을 관측했는지, 어떤 이유로 계속하거나 멈췄는지, 토큰과 비용이 어느 실행에 속하는지를 함께 붙였습니다. 답의 표현이 달라도 실행의 의미가 같으면 같은 계약을 지킨 것으로 봅니다.

출력이 매번 달라지는 시스템을 검증할 때 문자열 비교는 대체로 막다른 길입니다. 무엇을 보장하려는지 먼저 적어보면, 그 문장 안에 검증해야 할 대상이 이미 들어 있는 경우가 많습니다.

저희가 만든 것은 복잡한 루프가 아니라 이름들이었습니다

돌아보면 이 작업에서 새로 만든 기능은 많지 않습니다.

도구를 쓰는 일과 답을 다시 만드는 일은 원래 달랐습니다. 공급자 오류로 다시 보내는 일도 원래 달랐습니다. 종료도 완료와 중단이 원래 달랐습니다.

달랐던 것들이 코드에서 같은 이름으로 불리고 있었을 뿐입니다. 그래서 실행 기록이 아무것도 설명하지 못했고, 종료가 새는 자리를 찾을 수 없었고, 무엇을 최적화해야 하는지 판단할 수 없었습니다.

상태 머신은 단순한 검증 루프를 복잡하게 만드는 장치가 아니었습니다. 이미 있던 차이에 이름을 붙이는 장치였습니다. 이름이 생기자 그다음 결정들이 따라왔습니다. 전이마다 무엇을 넘길지, 종료를 누가 소유할지, 무엇을 기록할지, 무엇을 테스트할지가 차례로 정해졌습니다.

첫 커밋에서 열 단계 구조가 굳기까지 20일이 걸렸습니다. 그 20일 대부분은 새 기능을 넣는 시간이 아니라 무엇과 무엇이 다른지 가려내는 시간이었습니다.

다만 이 실행기는 아직 제품 안에서만 살 수 있었습니다. 상태와 전이는 범용인데 데이터 접근과 캔버스 객체 변환이 같은 코드에 섞여 있었기 때문입니다. 다음 편에서는 이 실행기가 특정 제품의 데이터베이스와 화면 구조를 몰라도 동작하도록 의존 방향을 나눈 과정을 다룹니다.


다음 편 → 저장소를 나눴는데 의존성은 그대로였습니다 (2편)

#하네스#에이전트 실행#상태 머신
블로그 목록으로