전용 QA 노드를 범용 하네스 노드로 옮기며: 캡슐이 숨기던 기능과 엔진이 읽지 않던 채널
품질검사 전용 캡슐 노드가 암묵적으로 처리하던 이미지 입력, 서브툴 바인딩, 세션 전달, RAG 전달을 범용 하네스 노드로 옮기며 드러난 결함 15종을 고객사 개발 환경에서 실측하며 고쳤습니다. 그중 두 번은 수정값을 엔진이 읽지 않는 채널에 쓰고 있었고, 엔진 소스로 설정 해석을 재현해 살아 있는 채널로 다시 연결했습니다.
Key Results
- 15종
- 이식 과정에서 수정한 결함MR 17건(종료 후 대체 2건 포함), 고객사 개발 환경 실측 기반, 2026-06-04~10
- 5 → 8
- 초기 바인딩 도구 수런타임 서브툴을 on-demand 발견이 아닌 bound로 등록한 뒤의 tools_count, 고객 내부망 실행 로그
- 23 → 8
- 3-way 머지 실질 충돌 파일세 버전을 모두 LF로 정규화해 공급한 뒤
대형 유통 고객의 품질검사 에이전트는 상품마다 제출된 시험성적서, 케어라벨 같은 서류를 파일별로 판독해 판정 코드를 제출합니다. 이 에이전트는 원래 품질검사 전용으로 만든 캡슐 노드 위에서 실행됐습니다. 이미지 입력, OCR, 세션 저장소의 필드를 읽고 쓰는 서브툴, 판정 제출 도구가 노드 안에 들어 있었습니다. 2026년 6월 초, 이 캡슐 노드를 XGEN의 범용 하네스 캔버스 노드로 교체했습니다. 판정을 별도 Judge로 재검증해 답변 흔들림을 줄이려는 것이 목적이었습니다.
교체 자체는 노드 하나를 바꾸는 일처럼 보였지만, 실제 워크플로우를 연결해 보니 캡슐 노드가 암묵적으로 처리하던 기능이 하나씩 빠지거나 다른 경로로 새고 있었습니다. 6월 4일부터 10일까지 고객사 개발 환경에서 실측하며 15종의 결함을 MR 17건으로 고쳤습니다.
이 글은 그 과정을 정리합니다. 결함을 모아 보니 공통 원인은 하나였고, 수정 과정에서도 같은 실수를 두 번 반복했습니다. 하네스가 노드 스펙을 재구성하면서 캡슐의 런타임 객체와 갈라졌다는 것, 그리고 고친 값을 엔진이 실제로 읽지 않는 채널에 쓰고 있었다는 것입니다.
캡슐 노드에서 범용 노드로: 무엇이 달라지는가
캡슐 노드는 품질검사라는 한 가지 일을 위해 필요한 것을 모두 품고 있었습니다. 범용 하네스 노드는 다릅니다. 캔버스에서 연결된 노드를 포트 역매핑으로 엔진의 10단계 슬롯에 넣습니다. 메모리 포트는 메모리 단계로, 도구 포트는 도구 단계로, 컨텍스트 포트는 컨텍스트 단계로, 출력 스키마는 판정과 마감 단계로 들어갑니다. 연결 노드는 실행 중의 객체가 아니라 노드 스펙(기능 ID와 파라미터)으로 수집되고, 하네스가 그 스펙으로 도구 정의와 실행기를 다시 만듭니다.
캡슐 노드 범용 하네스 노드
이미지·OCR 내장 → 이미지 포트 + 비전 전처리
서브툴 내장(조회·저장·제출) → 도구 포트의 런타임 도구를 바인딩
세션 ID 암묵 전달 → 상류 엣지 값을 스펙에 보강
RAG 본문 직접 주입 → 컨텍스트 포트 → 컨텍스트 단계 → 검색 도구판정 워크플로우의 구조도 이 차이를 키웠습니다. 병합 워크플로우가 루프 항목에서 세션 ID, 요청 번호, 파일 패턴을 만들어 파일 판정과 진위 확인 워크플로우에 넘기고, 두 하위 워크플로우는 데이터 엣지가 아니라 같은 세션의 Redis 키를 공유하며 실행 순서만 맞췄습니다. 세션 ID가 한 번 빠지면 판정 결과가 저장되지 않는 구조였습니다.
고객 코드 라인도 제약이었습니다. 4월 말부터 제품 기본 라인과 따로 흘러간 하드 포크라 리베이스가 불가능했고, 실행 환경은 고객 내부망의 쿠버네티스, 모델은 클라우드 provider의 Claude Sonnet 계열이었습니다.
이식: 저장소마다 다른 발산과 CRLF 거짓 충돌
6월 2~3일 하네스 노드를 고객 코드 라인으로 이식했습니다. 백엔드, 프론트엔드, 코어 세 저장소의 발산 양상이 완전히 달라 전략도 각각 정했습니다.
| 저장소 | 발산 양상 | 전략 |
|---|---|---|
| 워크플로우 백엔드 | 하네스 순수 파일은 분기 이후 고객이 손대지 않음, 옛 브리지 일부만 존재 | 신규 16개 복사, 13개는 정본 그대로 채택, 실행 진입점 등 5개 수동 머지 |
| 프론트엔드 | 고객이 하네스 전용 페이지를 크게 바꿈(+399/−735), 캔버스 노드는 통째로 없음 | merge-base, 고객, 정본의 3-way 머지, 범위는 캔버스 노드로 한정 |
| 코어 | 하네스 실행 로그·컴파일 이력 DB 모델과 마이그레이션이 없음 | 신규 4개 복사, 모델 등록과 기동 마이그레이션 2개 수동 머지 |
프론트엔드 3-way 머지에서 함정이 하나 있었습니다. Windows의 autocrlf 설정 때문에 작업 트리 파일은 CRLF, 저장소 객체는 LF였고, 고객 쪽 버전을 작업 트리에서 읽으면 파일 전체가 하나의 거짓 충돌 덩어리가 됐습니다. 세 버전을 모두 저장소 객체에서 LF로 꺼내 공급하자 충돌이 23개 파일에서 실질 8개로 줄었습니다. 충돌 해결 원칙은 "고객 코드 보존 + 하네스 추가분만"이었고, 고객 라인의 아이콘 패키지가 다른 이름 체계를 써서 아이콘 이름을 하나씩 대조해 바꿨습니다. 코어 저장소는 리뷰에서 누락을 잡아낸 경우입니다. 실행 로그와 컴파일 이력 테이블이 없으면 노드가 실행 기록을 남길 때 실패하므로, 코어가 빠지면 노드는 반쪽이었습니다.
검증은 정적 수준이었습니다. 백엔드 33개 파일 컴파일 검사 오류 0, 이식한 하네스 로직 파일 23개가 정본과 blob 해시 단위로 일치했습니다. 프론트엔드 TypeScript 전체 빌드는 개발 컨테이너 구조상 검증하지 못했고, 고객 환경에서의 런타임 확인은 다음 단계로 남겼습니다.
실측에서 드러난 결함 1: 입력, 도구, 스트리밍
6월 4일 실제 품질검사 워크플로우를 연결하자 결함이 차례로 나왔습니다.
이미지 포트가 없었습니다. 이미지 리스트 포트를 새로 만들고 입력 단계에서 비전 콘텐츠로 변환했는데, 처음 넣은 이미지 확대 코드는 에이전트 코어의 정본 처리와 달랐습니다. 긴 이미지 자동 분할과 provider별 크기 설정까지 반영하려고 정본 이미지 처리 함수로 정렬했습니다.
서브툴이 사라졌습니다. 세션 저장소 노드가 만드는 조회, 저장, 제출 서브툴은 런타임 StructuredTool인데, 하네스는 이를 범용 단일 도구 하나로 뭉갰습니다. 서브툴을 노출하는 첫 수정은 on-demand 발견 방식으로 등록해서, 초기 바인딩 목록에 들어가지 않았습니다. 엔진의 도구 정의와 실행기 목록에 직접 bound로 등록하자 초기 바인딩 도구가 5개에서 8개가 됐습니다. 도구 공급 노드마다 제각각이던 도구 평탄화 규칙도 에이전트 코어의 규칙으로 맞췄습니다.
스트리밍 모드에서 도구 인자가 {}로 유실됐습니다. 엔진의 스트리밍 경로가 tool_use 입력을 델타로 나눠 받으면서 텍스트만 누적하고 있었습니다. 엔진을 고치는 대신 하네스의 LLM 생성 시점에 비스트리밍을 강제했습니다. 사용하던 클라우드 클라이언트가 스트리밍 비활성 속성을 False로 고정하고 있어, 생성 후 속성을 덮어쓰는 방식이었습니다.
배포 환경에서는 하위 워크플로우를 찾지 못했습니다. foreach가 사용자 ID와 워크플로우 ID를 함께 조건으로 조회해서, 배포자와 소유자가 다르면 실패했습니다. 워크플로우 ID만으로 조회하게 바꿨습니다.
6월 4일 내부망 로그로 비스트리밍 적용, 도구 8개 바인딩, 필드 조회 정상, 제출 결과의 Redis 저장, 진위 확인 전체 흐름 성공을 확인했습니다.
실측에서 드러난 결함 2: 결과가 틀리거나 사라지는 경로
메커니즘이 동작하자 결과의 정확도 문제가 보였습니다. 이 단계의 결함은 더 조용했습니다.
6월 4일 진단에서는 원인을 배제해 가며 찾은 것이 두 가지 있었습니다. 파일이 많은 요청이 결과 0건으로 끝나는 문제는 컨텍스트 길이(모델 상한에서 잘라내는 처리가 있음), 반복 상한, 도구 라운드를 차례로 배제하고 나니 출력 토큰이 남았습니다. 진위 확인과 병합 단계의 max_tokens가 8,000 안팎이라 모든 파일의 결과를 한 번에 제출하기에 부족했고, 상한을 올리고 파일을 배치로 나누는 방향을 정했습니다. 다른 하나는 Judge가 모델의 마지막 텍스트만 평가한다는 점이었습니다. 결과가 제출 도구에만 있는 워크플로우에서는 Redis에 정확히 저장돼도 Judge가 0점을 줄 수 있었습니다.
세션 ID가 흐르지 않았습니다. 6월 4일 상류 엣지 값을 스펙에 보강하는 수정을 넣었는데, 6월 8일 다시 보니 이 코드는 한 번도 동작하지 않았습니다. 값을 같은 이름의 파라미터에만 주입했는데, 세션 저장소 노드의 세션 ID는 파라미터가 아니라 입력 포트였습니다. 매번 건너뛰는 코드였던 것입니다. 맞는 파라미터가 없으면 합성 파라미터로 추가하도록 고쳤고, 노드의 실행 함수가 시그니처에 있는 키만 통과시키는 것을 확인해 캡슐 노드와 같은 Redis 키를 쓰는지 검증했습니다.
같은 날 내부망 로그에서 "실행 실패"의 진짜 정체도 찾았습니다. 워크플로우 설정의 max_tokens가 1,000,000이라 매 모델 호출이 provider의 검증 예외로 실패했고, 그 빈 호출이 90회 반복됐습니다. 모델 한도를 넘는 값은 상한으로 고정했습니다. 엔진의 Judge 파싱 기본 임계값(0.7)과 파이프라인의 재시도 임계값(0.8)이 달라 반성 주입이 빠지는 경로, 앞서 진단한 대로 Judge가 제출값이 아니라 중간 서술을 채점하던 경로도 제출 페이로드를 채점하도록 함께 고쳤습니다.
판정이 틀렸습니다. 시험성적서 등급 기준을 잘못 가져와 "3급 이상 합격"인 필링을 4급으로 판정했습니다. 캡슐 노드는 문서 검색 도구로 본문 전체를 받는데, 하네스는 RAG를 120자 스니펫의 progressive 모드로 넘기고 있었습니다. 본문 전체 주입은 검색 모드가 always_search일 때만 매핑돼 있었고, 사용자가 판정 단계의 검색 모드를 light로 바꾸자 스니펫으로 떨어졌습니다. RAG가 연결되면 검색 모드와 무관하게 본문 전체를 주도록 했습니다.
데이터가 사라졌습니다. 종합 검토가 색상 값이 비었거나 태그 형식이 다르다는 이유로 제출된 서류를 "누락"으로 판정했고, "완 제품"과 "완제품" 같은 공백 변형도 흡수하지 못했습니다. 색상은 판정에서는 여전히 필수지만 누락 사유에서는 뺐습니다. 설정 병합 함수는 파일 목록을 통째로 덮어써 나머지 파일 결과를 지우고 있어 파일 번호 키로 병합하게 바꿨고, tmpfs에 쌓이던 이미지 캐시는 512MB 상한과 LRU 제거를 두었습니다.
권한 조회 실패를 익명으로 처리하던 fail-open은 fail-fast로 바꿨습니다. 다만 후속 확인에서 고객 라인에는 이미 권한 합성이 동작하고 있었고, 이 환경의 실제 원인이 아니었습니다. 과잉 귀인이었다고 기록하고 방어 코드로만 남겼습니다. 연결한 도구가 스펙 재구성에 실패한 채 조용히 실행되면 오답이 나오므로, 그 경우도 경고 로그가 아니라 실행 중단으로 바꿨습니다.
엔진이 읽지 않는 채널: 두 번의 가짜 수정
이 기간에 가장 아팠던 것은 고쳤다고 생각한 것이 고쳐지지 않았던 두 경우입니다.
첫 번째는 6월 9일입니다. RAG 노드의 세부 설정이 하네스에서 엔진 기본값으로 떨어지는 문제를 고치면서 top_k 등을 컨텍스트 단계 파라미터로 전파했는데, 재검토해 보니 엔진은 그 단계에서 top_k를 읽지 않았습니다. 가짜 수정이었습니다. 같은 커밋에는 노드의 rerank가 엔진의 reranker로 매핑되지 않은 누락, 동작 수정 대신 경고 로그로 덮은 임시방편, 모든 모드에 적용해 list 모드의 메모리를 두 배로 늘린 증분 직렬화도 있었습니다. 넷 다 보정 커밋으로 바로잡고 교훈을 남겼습니다. 전파할 키는 엔진이 그것을 읽는 소스까지 확인하고, 로깅은 동작을 고칠 수 없을 때만 쓴다는 것입니다.
두 번째는 6월 10일, 수정분을 제품 기본 라인으로 옮기는 번들을 만들다 발견했습니다. 6월 9일의 "본문 전체 강제"도 같은 함정에 빠져 있었습니다.
수정 전 노드 → 컨텍스트 단계 파라미터 rag_pd_mode = "eager"
① 엔진 도구 단계는 자기 단계 파라미터만 읽음 → 무시
② 엔진의 폐기 값 정규화가 "eager"를 progressive로 바꿈 → 스니펫 유지
노드의 검색당 문서 수(rag_top_k)도 같은 채널 → 무엇을 넣어도 4
수정 후 노드 → 도구 단계 파라미터 rag_pd_mode = "eager" → 엔진이 읽음 → 본문 전체
연결한 검색 노드의 top_k → 최상위 설정으로 전파 → 엔진이 읽음이중으로 죽은 채널이었습니다. 이번에는 확인 방법을 바꿔, 엔진 소스의 설정 객체와 정책 기본값 처리를 그대로 실행해 값이 어떻게 해석되는지 재현했습니다. 이전 코드는 eager가 progressive로 바뀌어 스니펫이 그대로였고, 수정 코드는 eager가 살아남아 본문 전체가 됐으며, rag_top_k 10은 엔진이 10으로 읽었습니다(이전에는 항상 4). 실제 판정 워크플로우 설정과 대조하다 검색 노드의 top_k 5가 하네스 기본값 4에 밀리는 틈도 하나 더 찾아, 연결 노드의 명시값이 우선하게 보강했습니다. 효과가 없던 rag_top_k 노드 파라미터는 삭제하고, 검색 노드의 설정을 따르게 했습니다.
설계 판단: 엔진에 분기를 넣지 않고 정본에 맞추기
결함마다 고칠 위치를 정해야 했습니다. 선택지는 세 가지였습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 엔진에 캡슐 노드 전용 분기 추가 | 당장 빠름 | 고객 전용 경로가 플랫폼 독립 엔진에 남음 | 기각 |
| 연동 계층에 병렬 구현 작성 | 엔진 무수정 | 이미지 처리, 도구 평탄화가 에이전트 코어와 또 갈라짐 | 기각 |
| 에이전트 코어 정본 함수에 정렬 + 엔진이 읽는 채널로 재배선 | 엔진 무수정, 캡슐 노드와 동작 일치, 같은 버그가 다시 갈라질 여지 감소 | 엔진이 각 값을 어디서 읽는지 소스로 확인해야 함 | 채택 |
엔진은 플랫폼에 독립인 패키지이고 플러그인 진입점으로 확장하도록 설계돼 있어, 고객 도메인 로직을 엔진에 넣지 않는다는 원칙을 지켰습니다. 그렇다고 연동 계층에 새 구현을 쓰면 "캡슐은 되는데 하네스만 틀린다"는 같은 증상이 다른 자리에서 반복됩니다. 그래서 이미지 처리와 도구 평탄화는 에이전트 코어의 정본 함수를 그대로 쓰게 했고, 설정 전달은 새 파라미터를 만들지 않고 엔진이 실제로 읽는 채널로 다시 연결했습니다. 앞의 두 가짜 수정은 이 원칙의 마지막 조건, 즉 "엔진이 읽는지 소스로 확인한다"를 건너뛴 결과였습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 하네스 로직 파일 정합 | 정본과 발산 | 23개 파일 blob 해시 일치, 컴파일 검사 오류 0 | 이식 직후 정적 검증 |
| 3-way 머지 충돌 | 23개 파일 | 실질 8개 파일 | 세 버전 LF 공급 |
| 초기 바인딩 도구 | 5개 | 8개 | 고객 내부망 실행 로그 |
| 제출 후 반복 | 90회 반복 | 소멸 | 재배포 후 실행 로그 |
| RAG 전달 | 스니펫, top_k 항상 4 | 본문 전체, top_k 10 반영 | 엔진 소스로 설정 해석 재현 |
| 이미지 캐시 | 상한 없이 증가 | 512MB 상한 + LRU | 캐시 설정 |
고객 라인에서 검증한 수정 묶음은 공용 코드 라인으로 역이식했습니다. 해당 라인에 없는 클라우드 provider 전용 코드와 고객 라인 전용 모듈, 제품 기본 라인에서 되돌린 이력이 있는 이미지 입력 변경은 제외하고 담았습니다.
운영 사고도 하나 있었습니다. 6월 16일 고객 개발 환경 배포에서 하네스 노드를 포함한 노드 파일 세 곳에 비활성 플래그가 섞여 들어가, 노드 등록이 통째로 건너뛰어지고 모든 하네스 워크플로우가 실행되지 않았습니다. 기동 명령에서 그 줄을 지우는 임시 패치로 복구하고 세 줄을 지우는 수정을 머지했지만, 이미지가 재빌드되지 않아 한동안 임시 패치로 동작했습니다.
남은 과제는 세 가지입니다. RAG 채널 재배선은 제품 기본 라인 번들에는 들어갔지만 고객 라인에서는 보류돼, 판정 단계가 기준 문서를 간헐적으로 스니펫으로 받는 위험이 한동안 남았습니다. 프론트엔드 TypeScript 전체 빌드는 이식 시점에 검증하지 못했습니다. 워크플로우를 복제하면 중첩된 하위 워크플로우 참조가 바뀌지 않는 문제는 결과를 막지 않는 비차단 문제라 남겨 두었습니다.
돌아보면 이식에서 시간을 가장 많이 쓴 것은 기능을 옮기는 일이 아니라, 캡슐이 말없이 해 주던 일을 하나씩 찾아 이름을 붙이는 일이었습니다. 그리고 이름을 붙인 뒤에도 그 값이 실제로 엔진에 도달하는지를 소스로 확인하기 전까지는 고쳤다고 말할 수 없었습니다.