컴파일된 에이전트가 밖에서도 동작하는가: 빈 도구 정의, 빠진 패키지 초기화 파일, 0자로 잘린 RAG
캔버스에서 만든 에이전트를 wheel·npm·MCP 도구로 내보내 환경변수만으로 실행되게 하면서, 빌드와 설치는 성공하는데 실제로는 동작하지 않던 세 경로를 추적했습니다. 비어 있던 도구 정의는 서브파이프라인 동결로, PyPI 네 버전의 import 실패는 fresh-clone 빌드 검증으로, 한 번도 동작하지 않던 standalone RAG는 기본값 세 곳을 고쳐 바로잡았습니다.
Key Results
- 1.18.1~1.18.4
- import가 실패하던 PyPI 배포 대역fresh-clone 빌드 wheel에서 memory 패키지 초기화 파일 누락, 레지스트리 실물 검사로 확인
- 5,514자 → 0자
- standalone RAG가 찾은 본문 중 모델에 전달된 양수정 전 컴파일 산출물, 도구 결과 미리보기 임계·크기 미설정(0/0)
- 151건
- 추적 거부 해제로 저장소에 들어온 테스트.gitignore가 test_*.py와 tests/를 막던 것을 해제, 같은 날 190건 통과
XGEN의 하네스 노드로 구성한 에이전트는 "컴파일"을 거쳐 플랫폼 밖으로 나갈 수 있습니다. Python wheel이나 npm 패키지로 내보내 다른 서버에 설치하거나, MCP 서버로 실행해 Claude Code 같은 MCP 클라이언트가 도구로 호출하게 하는 기능입니다. 요구 조건은 하나였습니다. 산출물이 클러스터로 콜백하지 않고, 환경변수만 주면 플랫폼 안에서와 같은 동작을 해야 합니다.
이 조건을 검증하는 동안 같은 모양의 실패를 세 번 만났습니다. 빌드는 성공하고 설치도 되고 MCP 연결도 되는데, 실제 호출에서는 도구를 부르지 못하거나, import 단계에서 패키지 전체가 실패하거나, 검색 결과를 찾고도 모델에게 한 글자도 전달하지 않았습니다.
이 글은 2026년 5월 말부터 6월 중순까지 이 세 경로를 어떻게 좁혀 갔는지, 그리고 검증 기준을 "빌드가 된다"에서 "플랫폼 밖에서 실제로 답한다"로 옮긴 과정을 다룹니다.
산출물에 요구한 조건: 환경변수만으로 실행된다
컴파일 채널은 몇 차례 바뀌었습니다. 4월 20일 wheel을 만드는 첫 컴파일러가 나왔고, 4월 29일 하네스 설정(HarnessConfig)과 1:1로 대응하는 spec.json을 담는 npm 채널로 교체됐습니다. 5월 14일에는 Python 채널이 바이너리가 아니라 읽을 수 있는 소스 트리를 생성하는 컴파일러로 다시 들어왔습니다. 생성된 소스에는 노드 설정을 담은 기본값과 CLI, MCP 진입점이 있고, 설정은 코드, 환경변수, 파일, 클러스터, SDK 기본값의 5단계 우선순위로 해석됩니다.
standalone 실행을 위해 컴파일 시점에 도구 정의를 동결(freeze)합니다. 외부 HTTP 도구는 환경변수를 헤더에 주입하고 {{name}} 자리를 치환하는 호출 명세로 굳히고, RAG와 MCP 세션도 각자의 동결 형식으로 굳힙니다. npm 엔진과 Python 엔진의 도구 디스패치가 따로 구현되면 동작이 갈라질 수 있어, npm 쪽 디스패처를 Python으로 포팅한 433줄 모듈을 두고 동작을 맞췄습니다.
반대 방향도 시도했습니다. 5월 12일 npm 엔진에 실행 시점에 클러스터로 요청을 전달하는 프록시 모드를 넣었습니다. 플랫폼 환경을 그대로 재현할 수 있지만 산출물이 클러스터 없이는 동작하지 않게 되므로 같은 날 되돌렸습니다.
캔버스 워크플로우
│ 컴파일: 노드 설정·도구 정의 동결
▼
wheel / npm 패키지 ◄── 환경변수(API 키, 벡터 저장소 주소 등)
│
▼
stdio MCP 서버 ──► MCP 클라이언트의 tools/call ──► 하네스 10단계 실행첫 번째: 도구 정의가 빈 배열로 컴파일됐습니다
5월 26일 QA 데모용으로 컴파일한 wheel에서 모델이 도구를 부르지 않고 텍스트만 출력했습니다. 산출물을 열어 보니 동결된 도구 정의 배열이 비어 있었습니다. MCP 연결은 되고 실행도 끝나지만 도구가 없으니 "연결만 되고 실행은 안 되는" 상태였습니다.
동결 빌더를 확인하자 원인이 보였습니다. RAG, MCP 세션, 외부 HTTP 호출 세 종류만 동결하고, 다른 워크플로우를 도구로 감싸는 경우의 분기가 아예 없었습니다. QA 데모는 하위 QA 워크플로우들을 도구로 묶은 구조였기 때문에 동결할 도구가 하나도 잡히지 않았습니다.
추적하면서 두 가지가 더 나왔습니다. 동결 시점에는 요청 컨텍스트에 사용자 정보가 없어 하위 워크플로우 목록이 빈 리스트로 조회됐습니다. 그래서 user_id로 워크플로우 메타를 직접 조회해 재귀적으로 동결하고, 이미 방문한 워크플로우 ID를 기록해 순환을 막았습니다. 또 실제 QA 워크플로우는 하네스 설정이 없는 일반 캔버스 플로우였습니다. 캔버스의 에이전트 노드를 모델·프롬프트로, 문서 로더를 RAG로 바꾸는 변환 함수를 만들고 변환 후 동결하게 했습니다.
하위 워크플로우를 어떻게 실행할지가 설계 판단이었습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 실행 시 클러스터 콜백·HTTP loopback | 구현이 간단 | 산출물이 클러스터에 종속 | 기각 |
| 하위 워크플로우마다 stdio 프로세스 기동 | 프로세스 단위 격리 | 산출물이 별도 프로세스 기동에 의존 | 기각 |
| 하위 설정을 산출물에 동결하고 같은 프로세스에서 중첩 파이프라인 실행 | 환경변수만으로 실행 | 재귀와 도구 충돌을 관리해야 함 | 채택 |
중첩 실행에는 가드가 필요했습니다. 재귀 깊이는 ContextVar로 최대 4로 제한했습니다. 부모와 자식의 도구 카탈로그가 섞이지 않도록 하위 도구를 전역 레지스트리에 등록하지 않고, 파이프라인 상태 범위의 extra_tool_sources로만 주입했습니다. 에이전트 노드 없이 workflow_tool과 foreach로 구성된 다중 포트 그래프를 위해서는 call, transform, foreach, router, passthrough를 해석하는 그래프 인터프리터를 Python과 npm 양쪽에 구현했습니다.
배포 직후 회귀도 하나 있었습니다. strict 도구 모드의 허용 목록이 도구 소스 ID를 키로 하고 있어 동결된 도구 소스를 찾지 못했습니다. 동결된 도구 이름을 평탄한 허용 목록으로 함께 넣어 해결했습니다. 동결 정의를 JSON으로 직렬화하지 못해 실패하던 잠재 버그도 같은 날 고쳤습니다.
두 번째: 로컬에서는 정상인 wheel이 개발 서버의 하네스를 멈췄습니다
6월 10일 제품 기본 라인을 재배포한 뒤 개발 서버에서 컴파일 배포 API가 500을 반환했습니다. 오류 메시지는 cannot import name 'ProgressStatus' from 'xgen_harness.memory' (unknown location)였습니다. 더 심각한 것은 오전에 정상 실행되던 하네스 노드가 같은 오류로 실행 자체에 실패했다는 점입니다.
unknown location은 이름을 찾을 모듈 파일이 없다는 뜻이고, 패키지가 __init__.py 없는 namespace 패키지로 인식될 때 나타나는 형태였습니다. 엔진 저장소를 확인했습니다.
$ git check-ignore -v <엔진 패키지>/memory/__init__.py
.gitignore:24:_*.py <엔진 패키지>/memory/__init__.py
저장소 전수 비교: 디스크 29 / git 추적 28 → 누락은 이 파일 1개원인은 두 조건의 결합이었습니다. 첫째, 스크래치 파일을 걸러 내는 .gitignore 패턴 _*.py가 __init__.py까지 매칭했습니다. 전날 테스트 파일 추적을 막던 패턴을 걷어내면서 스크래치 파일은 _*.py 접두로 구분하기로 했고, 같은 날 장기 실행 메모리 패키지를 새로 만들었습니다. 그 패키지의 __init__.py가 한 번도 커밋되지 않은 것입니다. 둘째, pyproject의 패키지 탐색이 namespace 패키지를 허용했습니다. 그래서 fresh clone에서 빌드하면 오류 없이 __init__.py 없는 memory 디렉터리를 wheel에 담았고, 엔진 최상위 __init__.py의 from .memory import ProgressStatus에서 패키지 import 전체가 실패했습니다.
로컬 빌드가 정상이었던 이유도 여기에 있었습니다. 디스크에 추적되지 않은 __init__.py가 있어 로컬 wheel에는 들어갔고, 그래서 발견이 늦었습니다. PyPI에 올라간 실물을 내려받아 확인하니 1.18.2와 1.18.4에는 파일이 없었고 로컬 dist의 1.18.3에는 있었습니다. 1.18.1부터 1.18.4까지가 import에 실패하는 배포 대역이었고, 재배포된 컨테이너는 새 의존성 핀(1.18.2 이상)에 따라 1.18.4를 설치했습니다.
수정은 간단했지만 검증 순서를 바꿨습니다.
1. .gitignore에 !__init__.py 재포함, memory/__init__.py 커밋, 버전 1.18.5
2. git archive로 커밋 대상 트리만 꺼내 fresh clone과 같은 조건에서 빌드
3. 그 wheel을 새 환경에 설치해 from <엔진 패키지> import ProgressStatus 확인
4. 업로드 전 원격 최신 이력 확인(fetch)
5. 업로드 후 pip download --no-cache-dir로 레지스트리 실물을 다시 받아 파일 확인PyPI에 1.18.5를 올린 뒤에도 개발 서버는 여전히 실패했습니다. 컨테이너 site-packages에 1.18.4가 설치돼 있었기 때문입니다. 서비스를 재빌드했는데 Docker의 pip 설치 레이어 캐시가 1.18.4를 재사용했고, 캐시를 쓴 첫 재배포는 실제로 그대로 실패했습니다. 캐시 없이 재빌드한 뒤에야 하네스 실행과 컴파일 배포가 정상으로 돌아왔습니다. 재발 방지로 플랫폼의 엔진 의존성 하한을 1.18.5로 올려 실패하는 대역을 차단했습니다.
이 사고에는 앞선 사건이 있었습니다. 전날 엔진 코드를 점검하면서 "테스트가 0건"이라는 진단이 나왔는데, 테스트를 작성하지 않은 것이 아니라 같은 .gitignore가 test_*.py와 tests/의 추적을 막고 있었습니다. 이 패턴을 걷어내자 테스트 151건이 저장소에 들어왔고 같은 날 190건까지 늘었습니다. 같은 날 PyPI 1.18.3 업로드는 다른 작업이 먼저 같은 번호로 발행해 실패했고, wheel 내용을 직접 확인한 뒤 1.18.4로 다시 발행했습니다. 이후 _*.py 패턴은 __init__.py뿐 아니라 _decide.py 같은 실제 소스도 삼키고 있었다는 것이 확인돼 제거했고, 테스트는 별도 tests 디렉터리로 옮겼습니다.
세 번째: 한 번도 동작한 적 없던 standalone RAG
같은 날 저녁, 컴파일 산출물을 완전히 다른 환경에서 실행하는 검증을 했습니다. 개발 서버의 캔버스에서 컴파일한 wheel을 새 가상환경에 설치하고, API 키와 로컬 벡터 저장소(Qdrant) 주소를 환경변수로 주고, RAG 컬렉션을 로컬 컬렉션으로 바꿨습니다. 임베딩 모델도 확인했습니다. 두 임베딩 모델로 실측한 유사도가 text-embedding-3-small은 0.39, 다른 모델은 0.04로, 컬렉션을 만든 모델과 일치하는 쪽을 골랐습니다. 이 설정으로 stdio MCP 서버를 실행했습니다.
모델은 검색 도구를 호출했는데 "데이터가 없다"고 답했습니다. 검색을 직접 호출하는 스크립트와 이벤트 트레이스 스크립트로 단계별 값을 확인하자, 검색은 5,514자를 찾았는데 모델에게 전달된 본문은 0자였습니다. 원인은 세 겹이었습니다.
| 결함 | 증상 | 원인 | 수정 |
|---|---|---|---|
| 문서 서비스 고아 | "DocumentService is not available" | 생성 시 받은 문서 서비스를 상태에만 저장, 정작 읽는 단계는 0개 | 파이프라인 실행 시 ServiceProvider로 연결(기존 주입은 그대로) |
| 검색 개수 0 | 벡터 저장소는 200 응답, 결과는 빈 목록 | rag_top_k 미설정 시 값 or 0으로 limit 0 |
런타임 하한 4, 미설정·0이면 폴백 |
| 도구 결과 0자 | 찾은 본문을 모델이 보지 못함 | 미리보기 임계·크기 미설정 시 0/0, 모든 결과가 [:0]으로 잘림 | 둘 다 명시값(>0)일 때만 압축 |
세 결함 모두 클러스터 실행에서는 드러나지 않았습니다. 클러스터는 단계 파라미터와 상태를 직접 채워 넣으므로 이 기본값 경로를 타지 않습니다. 그래서 컴파일 산출물의 RAG는 그때까지 한 번도 동작한 적이 없었습니다.
falsy 값을 기본값으로 대체하는 패턴이 결함이 된 것은 처음이 아니었습니다. 전날 코드 점검에서도 max_retries를 명시적으로 0으로 줘도 or 기본값 때문에 무시되고, max_history 0이 슬라이스 [-0:]가 되어 전체 이력을 주입하는 결함을 고쳤습니다. 이번에는 반대 방향으로, 값이 없을 때 0이 들어가 기능이 꺼졌습니다.
결과: 검증 기준을 "플랫폼 밖에서 답한다"로 옮겼습니다
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 동결된 도구 정의 | 빈 배열 | 서브파이프라인 도구 포함 | 로컬 컴파일 API로 생성한 wheel |
| 중첩 서브파이프라인 실행 | 불가 | 새 가상환경 + API 키만으로 정답 | PyPI 1.16.1, npm 엔진도 동일 |
| PyPI wheel import | 1.18.1~1.18.4 실패 | 1.18.5 통과 | fresh-clone 빌드, 레지스트리 재다운로드 |
| 개발 서버 하네스 실행 | 전면 실패 | 정상 완료 | 캐시 없이 재빌드 후 |
| standalone RAG 전달 본문 | 0자(5,514자 검색) | 원문 인용 응답 | Claude Code를 stdio MCP 클라이언트로 연결 |
| 엔진 테스트 | 0건(추적 차단) | 190 passed | 2026-06-09~10 pytest |
최종 체인은 UI의 컴파일 버튼, wheel 다운로드, 새 가상환경 설치, stdio MCP, 외부 JSON-RPC의 tools/call, 실제 LLM 호출까지 이어졌습니다. Claude Code에 MCP 서버로 등록해 Claude가 직접 도구를 호출하게 했을 때 RAG 검색이 실제로 히트했고, 리뷰 데이터의 원문 필드를 인용한 답을 받았습니다. npm 패키지의 serve-mcp 경로와 플랫폼에 호스팅된 HTTP MCP 경로도 같은 날 검증해, 외부 연동 세 경로를 모두 확인했습니다.
회고와 남은 과제
세 경우 모두 앞 단계의 성공이 다음 단계를 보장하지 않았습니다. 빌드는 됐지만 도구가 비어 있었고, 로컬 wheel은 됐지만 fresh clone wheel은 import에 실패했고, 검색은 됐지만 모델은 0자를 받았습니다. 그래서 검증을 빌드, 커밋된 트리 기준 빌드, 레지스트리 실물, 새 환경 import, 외부 클라이언트의 실제 답으로 나눴습니다. 특히 "로컬에서 된다"는 추적되지 않은 파일과 캐시 때문에 근거가 되지 못했습니다.
남은 과제도 있습니다. 그래프 인터프리터의 router 조건, jmespath, s3 처리 같은 세부 동작은 클러스터 실행 결과와 대조하지 못했습니다. 기존의 복잡한 워크플로우를 컴파일했을 때 런타임에는 도구 10개가 바인딩됐지만 동결 명세에는 RAG 검색 하나만 들어간 관찰도 남아 있습니다. 당시 권한이 해제돼 403을 반환하던 API 도구들이 동결에서 빠진 것으로, 무엇을 동결 대상으로 삼을지의 기준은 따로 정리해야 합니다.