Engineering · Agent Engineering

에이전트 전용 상태 머신 실행기: 실행기 내장 철회에서 10단계 Python 엔진까지

기존 워크플로우 실행기에 검증 미들웨어를 넣는 시도가 하루 만에 되돌려진 뒤, 에이전트 루프를 별도 상태 머신 실행기로 분리했습니다. Rust 12단계 프로토타입을 Python으로 다시 쓰고 책임이 모호한 단계를 전략과 확장점으로 흡수해 10단계 계약을 확정했으며, 이 단계 구성은 7월까지 바뀌지 않았습니다.

기간
2026.04 – 2026.05
소속
플래티어 · XGEN
역할
엔진 설계·구현

Key Results

10단계
스테이지 구조 확정v1.0.0(2026-04-30), 첫 커밋 후 20일. 이후 7월까지 단계 구성 유지
225줄 → 40줄
플랫폼 쪽 하네스 실행 진입점2026-04-16, 내장 엔진을 PyPI 의존으로 바꾸며 어댑터로 교체한 커밋 기준
25/26
전략 변형 SSE 매트릭스 통과v1.0.0 릴리즈 기록, 미해결 1건을 함께 명시

XGEN 캔버스는 노드를 DAG로 연결해 실행합니다. 에이전트처럼 "답이 충분해질 때까지 도구를 쓰고, 검사하고, 다시 쓰는" 동작을 만들려면 LLM 호출, 결과 검사, 재호출 노드를 수십 개 엮어야 했습니다. 그 과정에서 품질 검사와 안전 로직이 워크플로우마다 복사되고 조금씩 변형됐고, 종료 조건과 재시도 정책을 플랫폼 수준에서 일관되게 통제할 실행 단위가 없었습니다.

2026년 4월 이 문제를 별도 실행기로 풀었습니다. 처음부터 그렇게 결정한 것은 아닙니다. 기존 실행기에 검증 로직을 넣는 시도가 먼저 있었고, 하루 만에 되돌려졌습니다. 그 뒤 Rust로 12단계 상태 머신을 만들었다가 나흘 만에 Python으로 다시 썼고, 단계 디렉터리가 13개까지 늘었다가 4월 30일 v1.0.0에서 10개로 정리됐습니다.

이 글은 그 3주 동안 무엇을 근거로 실행기의 경계를 정했는지, 단계를 늘리고 줄인 기준이 무엇이었는지, 그리고 어떤 계약이 남았는지를 다룹니다. 4월 기록의 상당 부분은 7월에 git 이력과 코드 diff로 소급 정리한 것이라, 당시의 정량 측정보다는 코드 변화가 근거입니다.

검증을 기존 실행기 안에 넣었다가 하루 만에 되돌렸습니다

출발점은 LLM이 자동 생성하는 워크플로우의 품질이었습니다. 생성 결과에 고아 노드, 포트 타입 불일치, 도구 미연결이 섞여 나왔습니다. 4월 5일 생성 결과를 노드 레지스트리와 대조하는 결정론적 검증기를 만들었습니다. start/end 존재, 노드 ID의 레지스트리 등록 여부, 엣지 포트의 존재와 타입 호환, 고아 노드, 에이전트 텍스트 입력, 도구 연결의 7개 규칙입니다. 실패하면 구체적인 오류 메시지를 다음 LLM 호출에 주입해 최대 3회 다시 생성하게 했습니다. 커밋 본문에 적힌 "프롬프트가 아니라 시스템이 강제한다"는 문장은 이후 엔진 전체의 원칙이 됐습니다.

같은 주에 대형 유통 고객의 상품 검수(QA) 워크플로우를 대상으로 회귀 테스트 실험도 진행됐습니다. 에이전트 실행 트레이스, 도구 호출, 위반 항목을 고정된 인터페이스로 정의하고, 검수 단계별 판정 조건(predicate) 65개로 운영 트레이스를 읽기 전용으로 재생하는 구조였습니다. 잘린 트레이스에서는 내용 판정을 건너뛰고 형식 판정(호출 횟수)만 실행하는 등 계약 개념이 처음부터 분명했습니다.

두 실험이 세 번째 시도로 합쳐졌습니다. 테스트 시점이 아니라 실행 시점에 규칙을 강제하려고, 판정을 집행하는 enforcer를 캔버스 에이전트 노드의 런타임 미들웨어로 편입했습니다. 같은 날 enforcer 관련 5개 파일, 909줄이 전부 되돌려졌습니다. 성능이나 정확도 지표 때문이 아니라, 팀 내부에서 "기존 실행기 안에 하네스 로직을 심는 방식" 자체를 반려한 것입니다.

열흘 뒤에도 같은 판단이 반복됐습니다. 4월 17일 플랫폼 공용 실행 코어에 들어가 있던 하네스 분기를 팀 방침에 따라 제거했고, 같은 날 레거시 실행 경로와 공유하지 않는 독립 스트리밍 엔드포인트로 다시 연결했습니다.

DAG 실행기에는 루프를 소유할 자리가 없었습니다

되돌린 이유를 구조로 정리하면 이렇습니다. DAG 실행기는 노드 단위 실행을 전제로 합니다. 각 노드는 입력을 받아 출력을 내고 끝납니다. 에이전트 루프는 반대로 여러 회차에 걸친 상태를 들고 있어야 합니다. 지금 몇 번째 회차인지, 어떤 도구를 불렀고 결과가 무엇인지, 판정이 몇 점을 줬고 왜 반려했는지, 예산이 얼마나 남았는지를 보고 다음 행동을 골라야 합니다.

이 상태를 미들웨어로 끼워 넣으면 두 가지가 동시에 나빠집니다. 레거시 실행 경로가 하네스 코드에 오염되고, 루프 통제는 여전히 노드 사이의 연결에 흩어집니다. 그래서 에이전트 루프는 명시적인 상태와 전이를 가진 별도 실행기가 소유해야 한다고 결론 내렸습니다.

이 판단을 뒷받침하는 결함은 별도 실행기의 첫날에 나왔습니다. 4월 10일 첫 커밋 후 13시간 만에, 도구가 0개인 워크플로우에서 LLM이 tool_calls를 생성해 무한 루프에 빠지는 것을 발견했습니다. LLM 호출 단계는 도구 목록이 비어 있어도 tool_calls 응답을 그대로 신뢰했고, 도구 실행 단계는 실행할 도구가 없다고 돌려줬습니다. 각 단계는 자기 일을 했지만 이 조합을 종료로 판정할 주체가 없었습니다. 도구가 없으면 tool_calls를 무시하고, 실행된 도구가 0개면 루프를 빠져나오는 조건을 넣었습니다. 종료는 개별 단계의 부수 효과가 아니라 상태 머신이 판단해야 하는 일이었습니다.

12단계 Rust 프로토타입에서 남긴 것과 버린 것

첫 실행기는 Rust였습니다. 4월 10일 첫 커밋에 Bootstrap부터 Complete까지 12단계 상태 머신이 들어 있었습니다.

Bootstrap → MemoryRead → ContextBuild → Plan → ToolDiscovery → ContextCompact
  → (LLMCall ↔ ToolExecute 루프) → Validate → Decide → MemoryWrite → Complete
 
프리셋: minimal(4단계) / claude_code(7) / anthropic(11) / full(12)

단계 enum의 주석에는 "사용자가 체크리스트로 선택하고, 포함된 단계만 실행한다"는 설계가 이미 적혀 있었습니다. 컨텍스트 자동 압축과 도구 결과 예산 같은 실행 인프라는 외부 에이전트 CLI의 컨텍스트 관리 방식을 참고해 구현했고, Plan·Validate·Decide 세 평가 게이트는 자체 설계로 구분했습니다.

이틀 사이에 두 가지를 걷어냈습니다. Python 플랫폼과 연결하던 PyO3 네이티브 바인딩은 하루 만에 제거하고 HTTP 브리지만 남겼습니다. Plan 단계는 프리셋에서 먼저 빠졌다가 12분 뒤 코드까지 삭제됐습니다. 핵심은 Decide의 재시도 대상이 Plan에서 LLMCall로 바뀐 것입니다. "계획으로 돌아가 다시"라는 경로는 재시도를 무겁게 만들 뿐 새로운 상태 전이를 만들지 않았습니다.

4월 14일 Rust 코드를 전부 지우고 Python으로 다시 썼습니다.

선택지 장점 단점 판단
Rust + PyO3 바인딩 타입 안정성, 같은 프로세스 호출 언어 경계 비용, 매일 바뀌는 단계마다 바인딩 수정 하루 만에 철회
Rust + HTTP 브리지 경계가 명확 플랫폼이 라이브러리로 import할 수 없음, 별도 프로세스 운영 과도기
Pure Python 라이브러리 소비자인 플랫폼과 같은 언어, PyPI로 배포하고 의존성으로 소비 런타임 타입 보장이 약함 채택

이 판단은 성능 측정이 아니라 재작성 비용에 근거했습니다. 단계 구성이 매일 바뀌는 탐색 시기에 언어 경계는 반복 재작성 비용으로 돌아왔습니다. 재작성 첫 커밋에도 단계 번호 체계(s01~s12)와 상태 머신 계약은 그대로 옮겨졌고, Rust에서 삭제한 Plan은 s05_plan으로 다시 들어왔습니다.

배포 원칙도 같은 시기에 정해졌습니다. 4월 14일 플랫폼 쪽 첫 이식 커밋은 엔진 패키지를 통째로 내장했는데, 이틀 뒤 내장한 92개 파일, 약 11,000줄을 모두 지우고 PyPI 의존으로 바꿨습니다. 같은 날 플랫폼의 하네스 실행 진입점은 225줄에서 어댑터 40줄로 줄었습니다. 4월 16일 하루에 PyPI 첫 배포부터 v0.5.3까지 태그 9개가 나왔고, DB·설정·MCP·문서 서비스를 Protocol로 추상화한 ServiceProvider, entry_points 플러그인 로딩, ToolSource Protocol이 이때 들어왔습니다.

13개로 늘어난 단계를 10개로 줄인 기준

v0.x 동안 단계는 늘었습니다. 4월 20일 단계 ID를 의미 중심으로 바꾸고(s02_memory → s02_history, s08_execute → s08_act, s12_complete → s12_finalize 등) 구 ID로 저장된 워크플로우는 별칭으로 변환했습니다. 같은 날 쓴 PHILOSOPHY 문서는 단계마다 담당하는 것과 담당하지 않는 것을 선언했습니다. 4월 22일에는 LLM이 파이프라인 구성을 직접 고르는 s00_harness 메타 단계가 생겼고, 13개 단계가 모두 디렉터리로 분리됐습니다. 같은 날 본문 LLM 호출 단계(s07_llm)를 삭제하고 본문 호출을 s00이 소유하게 했습니다.

책임이 겹친 단계가 실제 결함을 만든 사례도 있었습니다. s03(프롬프트)과 s06(컨텍스트)이 각자 RAG 조회 로직을 갖고 있어, 같은 쿼리로 문서 API를 두 번 호출하고 있었습니다. s03의 조회 로직을 삭제하고 검색은 s06만 하게 했습니다. 단계를 나누는 기준이 이름이 아니라 책임이어야 한다는 것을 보여 준 사례였습니다.

4월 30일 v1.0.0에서 책임이 모호한 4개 단계를 정리했습니다. 처리 방식은 단계마다 달랐습니다.

s05_strategy → 3분할 흡수: thinking_mode는 s03_prompt, capability_auto는 s04_tool, 의도 라우팅은 s06_context
s08_judge    → s08_decide의 judge_then_loop 전략으로 격하
s10_save     → s09_finalize의 persist 전략으로 격하
s12_publish  → 빈 디렉터리라 삭제

판정은 독립 단계가 아니라 결정 단계 안의 전략이 됐습니다. Rust 시절의 Validate → Decide 두 단계가 최종적으로 한 단계 안의 전략이 된 것입니다. 단계를 줄이면서 하드코딩이 될 뻔한 부분은 entry_points 확장점으로 열었습니다. 프롬프트 템플릿, 평가 기준, 출력 포매터를 플러그인으로 등록할 수 있습니다. 필수 단계는 s01_input, s08_decide, s09_finalize 3개로 고정했고, 저장된 워크플로우는 단계 ID 별칭으로 중단 없이 이관했습니다. "단계는 줄이고 확장점은 늘린다"가 v1.0의 실체였습니다.

Plan은 Rust(4월 11일), v0.14(4월 22일), v1.0(4월 30일)에서 세 번 삭제됐습니다. 5월 7일 v1.1에서는 LLM이 파이프라인을 구성하는 planner 노선도 걷어냈습니다. 자율성이 필요한 곳은 파이프라인 구성이 아니라 도구 호출이라는 판단이었고, 이후 s00은 본문 LLM 호출의 소유자로만 남았습니다.

단계를 늘리는 대신 판정표와 검문소로 루프를 통제했습니다

10단계는 준비(s00~s04), 루프(s05~s08), 마감(s09)으로 나뉩니다. 5월 8일 v1.4에서는 s04_tool과 s03_prompt의 실행 순서를 바꿨습니다. 프롬프트에 도구 목차가 들어가야 하므로 도구 카탈로그가 먼저 채워져야 했기 때문입니다. 단계 구성은 그대로 두고 순서만 바꾼 변경입니다.

s00 harness   본문 LLM 호출 소유
s01 input     입력 정규화              (필수)
s02 history   이력·기억 로드
s04 tool      도구 카탈로그            ← v1.4부터 s03보다 먼저 실행
s03 prompt    시스템 프롬프트 구성
s05 policy    정책 게이트
s06 context   컨텍스트 윈도우 관리
s07 act       도구 실행
s08 decide    판정과 다음 행동 결정    (필수)
s09 finalize  최종 출력·저장          (필수)

루프 안의 판단은 s08이 소유합니다. continue와 retry는 의도적으로 분리했습니다. 하나로 합치면 도구를 많이 쓰는 정상 실행이 재작성 횟수를 소모하거나, 품질 기각이 무제한 반복될 수 있습니다. 7월 아키텍처 문서에 정리된 판정 순서는 다음과 같습니다.

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

비용과 반복 한도는 처음에 결정 단계 내부에 하드코딩돼 있었는데, Python 재작성 당일 GuardChain(토큰 예산, 비용 예산, 반복 횟수, 콘텐츠)으로 옮겼습니다. 4월 23일 v0.17에서는 가드가 LLM 호출 직전, 응답 직후, 도구 실행 직전, 루프 회차 종료 시의 4개 훅에서 발동하도록 재설계했습니다. 위험이 생기는 시점이 네 군데라는 것이 근거였습니다. 단계를 하나 더 만드는 대신, 이미 있는 단계 사이의 시점에 검사를 거는 방식입니다.

결과: 단계는 고정되고 기능은 전략으로 들어왔습니다

지표 이전 이후 측정 조건
단계 수 12(Rust), 13(v0.x 디렉터리) 10 v1.0.0, 2026-04-30
플랫폼에 내장한 엔진 코드 92파일, 약 11,000줄 0, PyPI 의존 2026-04-16 이식 커밋
플랫폼 하네스 실행 진입점 225줄 어댑터 40줄 2026-04-16 커밋
전략 변형 SSE 매트릭스 해당 없음 26개 중 25개 통과, 1건 미해결 v1.0.0 릴리즈 기록
제품 기본 라인 합류 변경 해당 없음 2개 파일(의존성 핀, 3줄) 2026-05-01 합류

v0.1.0(4월 16일)에서 v1.0.0까지 14일 동안 약 60개 릴리즈가 나왔습니다. 제품 기본 라인 합류가 의존성 핀 하나로 성립한 것은 엔진을 라이브러리로만 소비한다는 원칙의 결과입니다. 실질 코드는 이미 엔진 쪽에서 완결돼 있었습니다. 릴리즈에는 UI 테스트 매트릭스 90케이스 문서도 함께 들어갔습니다.

10단계 구성은 7월까지 바뀌지 않았습니다. 그 사이의 기능인 점진적 도구 공개, strict 도구 모드, 자가 단조(self-forging)는 단계 추가가 아니라 각 단계에 꽂는 전략과 확장점으로 들어왔습니다. 각 단계가 교체 가능한 전략의 슬롯이 되면서, 이후 자가 개선 루프가 설정을 바꿀 좌표계 역할도 하게 됐습니다.

회고: 단계의 존재 이유는 고유한 상태 전이였습니다

이 3주의 기록은 대부분 소급 정리라 성능 비교 같은 정량 근거가 없습니다. Rust에서 Python으로 옮긴 판단도 측정이 아니라 재작성 비용에 근거했고, 이 점은 한계로 남깁니다. v1.0 릴리즈의 SSE 매트릭스에서 미해결로 남은 1건(stage_params 디스패치)도 릴리즈 기록에 그대로 적었습니다.

돌아보면 단계를 남기고 지운 기준은 하나였습니다. 그 단계가 다른 단계와 구분되는 상태 전이를 만드는가입니다. Plan은 재시도 경로를 무겁게 했을 뿐 새로운 전이를 만들지 않아 세 번 삭제됐습니다. 판정은 독립 단계일 때보다 결정 단계 안의 전략일 때 continue와 retry를 한자리에서 구분할 수 있었습니다. 도구 0개 무한 루프처럼 각 단계가 옳게 동작하는 조합에서 생기는 문제는, 전이를 소유하는 주체를 하나로 정해야만 막을 수 있었습니다.