
답안을 작성하는 AI는 바꿔도, 채점하는 AI는 바꾸지 않는다 ── tier-routing을 tester로 확장한 이야기
요약
멀티 에이전트 프레임워크 C3의 tier-routing 기능을 developer 역할에서 tester 역할로 확장하는 과정을 다룹니다. 태스크 복잡도에 따라 모델을 자동 선택하는 메커니즘을 적용하기 위해 성공의 정의를 재설계하는 설계적 고민을 담고 있습니다.
핵심 포인트
- tier-routing은 Thompson Sampling을 통해 모델을 자동 선택함
- 에이전트 확장 시 객관적인 성패 시그널 설계가 필수적임
- 리뷰어 역할은 약한 모델 사용 시 역인센티브가 발생할 위험이 있음
- tester 역할 확장을 위해 성공의 정의를 새롭게 구축함
이전 기사: https://zenn.dev/satoh_y_0323/articles/1e8d415cca23d5
C3 GitHub: https://github.com/satoh-y-0323/claude-code-conductor / PyPI: https://pypi.org/project/claude-code-conductor/ / 공식 문서: https://satoh-y-0323.github.io/claude-code-conductor/
본 기사의 범위: C3 v2.53.0 이후 ~ v2.54.0. 멀티 에이전트 프레임워크(Multi-agent framework) C3의 tier-routing(태스크 복잡도에 따른 Haiku/Sonnet/Opus 자동 선택 · Thompson Sampling)을 developer 이외의 role로 '수평 전개'한 이야기입니다.
서론 ── 「developer만 적용하기에는 아깝다」
C3에는 tier-routing이라는 기능이 있습니다. 구현 담당인 developer 에이전트를 기동할 때, 태스크의 복잡도와 과거의 성공률에 따라 Haiku / Sonnet / Opus를 자동으로 선택하는 메커니즘으로, 밴딧 알고리즘(Bandit algorithm, Thompson Sampling)이 "이 정도 복잡도라면 Haiku로 충분하다"라는 것을 학습해 나갑니다. 학습 메커니즘 자체는 5월부터 키워왔지만, 권장 사항을 내놓기만 하는 시기가 길었고, 모델(model)에 대한 자동 적용까지 도달한 것은 7월 초 ── 아직 3주 정도 된 신입입니다. 그럼에도 움직이기 시작한 학습 루프는 꽤 마음에 들었습니다.
그래서 어느 날 밤 문득 이런 생각이 들었습니다. "이거, developer에게만 적용하는 것은 아깝지 않을까?"
C3에는 14체의 에이전트가 있습니다. tester, code-reviewer, security-reviewer, systematic-debugger…… . 동일한 메커니즘을 옆으로 넓힌다면, 비용 최적화가 적용되는 범위가 단번에 넓어질 것입니다. 수평 전개라면 대상 role의 리스트에 이름을 추가하는 것뿐 아닐까요?
결론부터 말하자면, 리스트에 이름을 추가하는 것만으로는 전혀 끝나지 않았습니다. "어느 role로 확장할 것인가"는 "그 role의 '성공'을 객관적으로 측정할 수 있는가"라는 질문이었고, tester의 경우에는 성공의 정의 자체를 다시 만들어야 했습니다. 이 기사는 그 설계 판단의 기록과, 풀 워크플로우(full workflow) 실행에서 나온 관례적인 부산물들에 대한 이야기입니다.
1. 수평 전개는 복사 붙여넣기가 아니었다 ── 14체의 재고 조사
먼저 모든 에이전트를 "확장 가능한가"로 재고 조사했습니다. 판단 기준은 두 가지로 집약됩니다.
객관적인 성패 시그널을 설계할 수 있는가 (밴딧은 성공/실패 데이터로 학습한다. 왜곡된 시그널을 먹이면 왜곡된 학습을 한다)
실패를 받아낼 망이 있는가 (탐색은 "약한 모델을 시도해서 실패하더라도, 하류(downstream)에서 잡아낼 수 있는 곳"에서만 허용된다)
결과는 다음과 같았습니다.
| role | 판정 | 이유 |
|---|---|---|
| tester | 이번 대상 (단, 조건부) | 본 기사의 주인공. 후술 |
| code-reviewer / security-reviewer | 보류 | "지적 제로 = 수렴"이 성공으로 보이는 역인센티브. 약한 모델일수록 아무것도 찾아내지 못하고 빠르게 '성공'해 버린다. 리뷰는 품질의 마지막 망이므로, 망 자체를 탐색 대상으로 삼을 수 없다 |
| systematic-debugger | 보류 | 막혔을 때만 호출되므로 샘플이 쌓이지 않는다. 게다가 디버깅만큼 어려운 것도 없다. 강한 모델이 필요한 국면 그 자체 |
| architect / planner / design-critic / doc-writer / project-setup (Opus 고정 5체) | 영구 대상 외 | 중요한 상류 공정에는 설계 시점에서 Opus를 할당해 두었다. 여기가 무너지면 하류 전체에 파급된다 |
마지막의 "Opus 고정 5체는 대상 외"는, 구두 약속으로 끝내지 않고 불변칙으로서 기계 검사 테스트로 만들었습니다. "tier-routing의 대상에 추가할 수 있는 것은 frontmatter가 model: sonnet인 role뿐"임을 pytest가 assert 하고 있어, 향후 누군가(아마 미래의 나와 AI)가 실수로 Opus 세력을 대상으로 추가하면 CI가 빨간색이 됩니다.
참고로 판단의 방증이 하나 있습니다. Claude Code 공식의 /goal
명령어는 종료 조건 판정에 Haiku를 사용하고 있다고 합니다. 기계적이고 단순한 판정 태스크라면 경량 모델로 충분하다는 것── 공식 측도 동일한 선별을 하고 있다는 사실은 제 결정에 확신을 주었습니다.
2. 시험관 문제 ── 왜 "Red 한정"인가
자, 이제 주인공인 tester입니다. 여기서 불쾌한 사실을 깨닫게 됩니다.
C3의 TDD 워크플로우에서 tester는 두 가지 얼굴을 가지고 있습니다. Red 페이즈에서 실패하는 테스트를 설계하는 "출제자"와, 구현 후에 모든 테스트의 합격 여부를 판정하는 "채점자". 그리고 채점자로서의 tester는 developer의 실패를 잡아내는 안전망 그 자체입니다.
developer에게 tier-routing을 도입했을 때 Haiku의 탐색을 허용할 수 있었던 것은 "developer가 실패하더라도 tester가 잡아준다"라는 망이 있었기 때문이었습니다. 그렇다면 tester에게 똑같은 일을 한다면 어떨까요? 약한 모델인 채점자가 "모두 합격입니다"라고 오판하여 승인해 버린다면, 다음에 실패를 잡아낼 망은 리뷰 페이즈까지 존재하지 않습니다. 학습 데이터를 수집하기 위한 탐색이 품질 보증의 망에 구멍을 내는 실험이 되어버리는 것입니다.
실측 데이터도 불길한 징후를 보이고 있었습니다. tester의 과거 성적 기록은 29건, 전부 Sonnet이었습니다. frontmatter의 기본값 그대로 한 번도 바꾼 적이 없으니 당연한 결과입니다. 즉, 밴딧(Bandit)을 돌리기 시작하면 Haiku도 Opus도 시도 0건으로부터 uniform 탐색기(30회 시도)가 시작됩니다. 그 탐색 과정에서 Haiku가 채점석에 앉는 날이 반드시 올 것입니다.
그 결과 도출된 답은 "Red 한정"입니다.
출제자(Red 페이즈)만 탐색 대상으로 삼는다. 출제의 실수는 후술할 4가지 조건과 채점 페이즈의 망을 통해 하류(downstream)에서 검출할 수 있으므로, 탐색하더라도 안전한 편입니다.
채점자(확인 페이즈)는 Sonnet 고정 상태를 유지한다. 게다가 "페이즈에 따른 분기"가 아니라, 기동 프롬프트 첫 줄의 C3_TASK_ID: test- 마커 유무라는 기계적 조건으로 분기하며, "채점 페이즈 기동 시 이 마커를 작성해서는 안 된다"라는 불변 법칙까지 포함하여 grep을 통한 기계적 검사를 수행하도록 했습니다.
답안을 작성하는 AI는 바꿔도 된다. 하지만 채점하는 AI는 바꾸지 않는다. 제목의 문장은 여기서 유래했습니다.
3. "Green이 되면 성공"이라는 함정 ── 성공의 정의를 다시 세우다
다음 문제입니다. Red 한정으로 정했다고 쳤을 때, 출제자의 "성공"이란 무엇인가?
가장 먼저 떠오르는 것은 "작성한 테스트가 구현 후에 Green이 되면 성공"입니다. 저도 처음에는 그렇게 말하려 했습니다. 하지만 이것을 잘 생각해보면 이상합니다. 테스트가 Green이 될지 여부는 developer의 실력에 크게 의존합니다. Haiku tester가 허술한 테스트를 작성하더라도, Opus developer가 끈기로 통과시켜 버리면 "성공"이 되어버립니다. 그것은 tester의 성적이 아니라 developer의 성적입니다.
devlopter의 실력이 밴딧에 입력되는 성공 시그널에 섞이게 되면, 학습은 조용히 망가집니다. 그래서 성공의 정의를 "테스트라는 결과물 자체의 품질"로 재정의했습니다. 실패로 간주하는 기준은 다음 세 가지입니다.
Red의 이유가 다름── 의도한 AssertionError가 아니라 ImportError나 구문 오류(Syntax Error)로 인해 실패하는 경우. 혹은 구현 전임에도 불구하고 pass 해버리는 "작동하지 않는 테스트". 이는 pytest의 출력 결과로부터 기계적 판정이 가능합니다.
베이스라인 파괴── 기존 테스트 스위트의 결과가 Red 페이즈 전후로 바뀌어 버린 경우(재고 조사 누락으로 기존 테스트를 포함시킨 경우 등). 건수 비교를 통해 기계적으로 판정합니다.
Green 상태에서의 테스트 측 수정── 구현 페이즈에서 "테스트가 틀렸었다"라는 수정이 필요해진 경우(잘못된 Assertion 또는 사양 오독). 사양 변경에 따른 정당한 추종은 판정 기록이 세션 파일에 남아 있는 경우에만 제외합니다. 기록이 없다면 실패로 처리합니다. 판정자의 주관이 개입할 여지를 없애기 위한 기계적 키(key)입니다.
그리고 성공은 소거법으로 정의합니다. 채점 페이즈에서 전원 합격했을 때 1~3번 중 어느 것도 발생하지 않은 상태를 말합니다. developer가 몇 번을 리트라이하든 상관없습니다. 테스트가 수정 없이 살아남았다면 출제자의 승리입니다.
또 하나 은근히 중요했던 것은 학습 셀(Cell)의 분리였습니다. 기존 29건은 "Sonnet이 채점자로서 쌓은 쉬운 성공"이므로, 앞으로 수집할 "출제자로서의 성적"과 같은 상자에 넣으면 Sonnet이 부당하게 유리해집니다. 전용 기록 게이트 D-1
전용 기록 게이트 D-1을 신설하여, 집계 측 필터에서 tester 셀은 D-1의 기록만 읽도록 했습니다. 기존 데이터는 삭제하지 않고, 단지 집계에서 제외합니다. 0건부터 다시 시작하는 것입니다.
4. 실측이 설계를 두 번 뒤집었다
설계 감사 (design-critic에 의한 적대적 리뷰)를 돌리면서, 사실 확인을 위해 실데이터를 찔러보았더니, 책상 위에서의 설계가 두 번 뒤집혔습니다.
첫 번째: DB에 wt_tester라는 role은 존재하지 않았다. C3의 병렬 실행에서는 worktree용인 wt_tester라는 아종 에이전트를 사용하기 때문에, "wt_tester의 셀을 어떻게 다룰 것인가"를 논의하고 있었으나, 성적 DB의 distinct roles를 실제로 조회해 보니 wt_ 계열의 기록은 단 1건도 없었다. 기동 로그도 기록 스크립트도, 이미 오래전에 wt_tester → tester로 정규화(Normalization)한 뒤에 기록하도록 설계되어 있었습니다. 과거의 내가 제대로 해두었던 것입니다. 논의하던 분기점은 애초에 존재하지 않는 분기였습니다.
두 번째: 병렬 실행의 성적은 애초에 밴딧(Bandit)에 들어있지 않았다. 병렬 워크플로우의 기록은 2-D / 2-E라는 별도의 게이트 이름으로 기록되고 있었으며, 집계 대상 게이트 리스트(D-2.5/D-3/D-5…)에 포함되어 있지 않음을 실측을 통해 확인했습니다. 즉, "병렬 측의 tester도 데이터 수집에 포함하자"라는 요구사항은, 기록 게이트를 바꾸지 않는 한 처음부터 성립되지 않았던 것입니다. 이 발견으로 인해 병렬 측의 기록 규약 자체를 다시 설계하게 되었습니다.
둘 다 "문서와 문서 사이의 정합성"을 아무리 감사해도 나오지 않는 종류의 어긋남이었으며, DB에 쿼리 하나를 날리자 5초 만에 확정된 이야기입니다. 검증은 실데이터를 찔러본 뒤에 확정한다. 과거 글에서 몇 번이고 등장하는 교훈이지만, 이번에도 다시 한번 확인되었습니다.
5. 주행 기록 ── 감사 8 사이클·리뷰 4 회전과, 이번의 주역급 부산물
구현은 자율 모드(v2.53.0로 배포했던 그것입니다)로 전체 워크플로우를 완주시켰습니다. 숫자만 나열하면 다음과 같습니다:
- 설계 감사 C-3: 지적 사항 8→5→4→3→2→1건으로 6 사이클. 규정 상한인 5에 도달했을 때 인간(나)에게 에스컬레이션(Escalation)이 왔고, 연장 1 사이클을 승인하여 수렴.
- 재검토 후 재감사 프레임에서 추가 2 사이클
- 리뷰 E: 코드 리뷰 4 회전·보안 리뷰 2 회전으로 High/Medium 0건
- 테스트는 2115 → 2152로 증가하며 전 항목 통과(All Green)
그리고 이번 부산물의 주인공은 이것입니다.
리뷰어의 "실증된 수정안"이, 옆의 워크플로우를 조용히 망가뜨릴 뻔했다
보안 리뷰에서 Medium 항목을 1건 찾아냈습니다. C3_TASK_ID 마커를 추출하는 정규 표현식이 re.MULTILINE의 행 시작 일치로 되어 있어서, 프롬프트 본문 어딘가에 인용된 텍스트 안의 고립된 행이라도 마커로 잡아버리는 문제였습니다. 과거 리포트를 인용하는 것만으로 채점 페이즈에 주입(Injection)이 일어날 수 있는 구멍이었으며, 적대적 입력을 실행하여 재현까지 완료한, 나무랄 데 없는 좋은 지적이었습니다. 수정안도 구체적이었습니다. "\A (문자열 시작) 앵커로 바꾸기만 하면 된다. 기존의 정당한 용도는 일절 망가뜨리지 않는다"라고 말이죠.
이를 채택하여 수정 계획에 넣고, 재검토 후 재감사(design-critic)를 통과시켰더니──High 항목이 1건 돌아왔습니다.
병렬 실행의 기동 프롬프트는 1행이 워크트리 보호 지시사항이고, 마커는 2행이라는 규약이 있었습니다. \A로 바꾸니 병렬 측의 마커를 전부 읽을 수 없게 된 것입니다. 게다가 기존 테스트는 전부 "마커가 1행에 있는" 합성 데이터였기 때문에, pytest는 전 항목 통과 상태를 유지하다가 실행 시점에만 망가졌습니다.
사실을 확인해 보니 그대로였습니다. 리뷰어의 "일절 망가뜨리지 않는다"는 옆 워크플로우의 규약을 보지 않았던 것입니다. 사실 전날의 별도 주행에서 "리뷰어의 시정안도 사실 확인(Back-check)의 대상이다"라는 교훈을 규약으로 승격시킨 참이었는데, 설마 바로 다음 날, 실증까지 마친 제대로 된 지적에 의해 재연될 줄이야. 실증이란 "그 구멍이 실재한다는 것"의 증명이지, "그 수정이 아무것도 망가뜨리지 않는다는 것"의 증명은 아니었나 봅니다.
수정은 보호 지시(protection instruction)의 소비자가 환경 변수만을 읽고 있다는 것(프롬프트 내 위치에 의존하지 않음)을 실측으로 확인한 후, 마커(marker)를 모든 워크플로우에서 1행으로 통일하여 해결했습니다. "반려될 때마다 재감사(re-audit)를 돌리는 것은 헛스윙이 많아 무겁지 않을까"라고 최근 생각하고 있었는데, 그 재감사가 pytest에서는 절대로 잡히지 않는 High(심각도)를 구현 전에 찔러본 첫 사례가 되었습니다. 헛스윙 담론은 당분간 할 수 없게 되었습니다.
CHANGELOG에 당당히 가공의 테스트 파일이 실렸다
릴리스 준비를 위해 developer에게 CHANGELOG를 쓰게 했더니, 초안이 날조투성이였습니다. 존재하지 않는 테스트 파일 이름(tests/test_tier_routing_red_phase.py), 구현과 정반대인 불변 법칙 ("Red 페이즈는 Opus 이상으로 고정" ── 실제로는 "Opus 계열은 대상으로 하지 않음"), 어디에도 적혀 있지 않은 수수께끼의 4가지 조건. 원인은 제 지시로, 요점 키워드만 전달하고 상류 리포트(upstream report)를 읽게 하지 않았기 때문에 그럴싸한 상세 내용을 보완해 버린 것이었습니다. 테스트는 모두 통과(green) 상태이므로 기계로는 잡히지 않으며, 실제 문면을 읽고 나서야 처음으로 깨닫게 되는 종류의 사고입니다. 문구를 축자적으로(verbatim) 확정하여 다시 전달함으로써 해결했습니다. "릴리스 문서는 축자적으로 전달하거나, 1차 자료를 반드시 읽게 해야 한다"가 영구적인 교훈으로 남았습니다. 요약본으로부터 공개 문서를 재구성하게 해서는 안 됩니다.
만든 기능이 대화 도중에 자신에게 적용되기 시작했다
개인적으로 가장 미소 지었던 부분은 이것입니다. 구현 태스크가 hooks에 도달한 직후부터, 제 세션에 매 턴 주입되는 tier-routing 권장 표시에서 "tester(Red) 권장 Tier: sonnet"라는 새로운 행이 나타났습니다. 구현한 기능이, 구현 중인 바로 그 세션 도중에 라이브로 움직이기 시작한 것입니다. 게다가 학습 셀(learning cell)은 0건부터 시작하는 uniform 탐색기이므로, 표시되는 권장이 턴마다 haiku → opus → sonnet로 흔들립니다. 주사위가 던져지고 있는 것을 육안으로 확인할 수 있는 셈입니다. 30회 시도가 쌓여 Thompson Sampling으로 전환되었을 때, 이 표시가 어떻게 안착할지 다음 즐거움이 생겼습니다.
(덤으로 남기는 한 줄 교훈: git push --follow-tags는 경량 태그(lightweight tag)를 push하지 않습니다. 태그가 원격에 없다는 것을 깨닫고 명시적으로 push했습니다. annotated tag 파가 되어야 할지도 모르겠습니다.)
업무·개인 개발에 활용할 수 있는 점
1. 수평 전개의 가부(可否)는 "기능을 쓸 수 있는가"가 아니라 "성공을 측정할 수 있는가"로 결정한다
동일한 메커니즘을 옆으로 확장할 때, 코드는 거의 유용할 수 있습니다. 문제는 항상 시그널(signal) 쪽이며, "이 role의 성공을 타인의 실력을 섞지 않고 객관적으로 계측할 수 있는가"가 Yes가 되지 않는 한, 학습 계열의 수평 전개는 데이터 오염의 수평 전개가 됩니다. 리뷰어의 "지적 제로가 성공으로 보이는" 형태의 역인센티브는 인간 조직의 KPI 설계에서도 같은 형태로 나타나는 현상입니다.
2. 안전망의 역할에 탐색을 넣으려면, 망의 바깥쪽만으로 한정한다
tester와 같은 "타인의 실패를 잡는" 역할은 탐색(=일부러 약한 구성을 시도함)과 본질적으로 상성이 좋지 않습니다. 이번 해법은 "출제는 탐색해도 좋지만, 채점은 고정한다"라는 역할 내부의 분할이었습니다. 분할선은 운영 규칙이 아니라 기계적 조건(마커 + grep 검사)으로 떨어뜨립니다. 운영 규칙은 세대교체 과정에서 반드시 풍화되기 때문입니다.
3. 실증이 포함된 지적이라도, 수정안은 별도로 확인(back-check)한다
"재현 절차 포함·실행 실증 완료"는 그 문제가 진짜라는 증명이지, 그 수정안이 안전하다는 증명은 아닙니다. 수정안의 영향 범위는 지적자가 보지 못한 인접 시스템까지 포함하여 스스로 확인해야 합니다. 저희 팀에서는 이를 적대적 재감사(adversarial re-audit)를 통과시키는 형태로 기계화했으며, 이번이 그 첫 승리였습니다.
4. 생성 AI에게 공개 문서를 쓰게 하려면, 1차 자료나 축자 문면을 전달한다
요약 키워드로부터 문서를 재구성하게 하면, 빈틈을 "그럴싸한 거짓말"로 채워옵니다. 게다가 테스트는 통과 상태인 거짓말이기에, 기계 검사를 빠져나가 실제 문면 리뷰에서만 잡힙니다. CHANGELOG, 릴리스 노트, README 종류는 축자적으로 전달하는 것이 가장 저렴합니다.
요약
- tier-routing의 대상을 tester / wt_tester로 확장하여,
v2.54.0으로 릴리스했습니다。 다만 Red 페이즈(문제 출제)에 한정하여, 채점 페이즈는 Sonnet 고정 상태를 유지합니다. opus 고정 5개 모델의 대상 제외는 기계 검사로 영구화 - 성공의 정의는 "Green이 되었는가"가 아니라 "
테스트 결과물이 수정 없이 살아남았는가"입니다. 학습 셀(Learning cell)도 기존 채점 성적과 분리하여 0건부터 수집 시작 - 부산물: 실측 데이터가 책상 위의 설계를 두 번 뒤집어 놓았고, 반려된 재감사(re-audit) 과정에서 리뷰어의 실증적 수정안에 숨어 있던 High(pytest는 모두 통과했으나 실행 시점에 파괴됨)를 찔렀으며, CHANGELOG는 날조되었고, 만든 기능은 대화 도중부터 자신에게 효력을 발휘하기 시작했습니다.
"아깝다"라는 생각에서 시작된 수평 전개는, 결국 "확장할 범위를 좁히는 작업"이었습니다. 밴딧(Bandit)의 학습이 돌아가기 시작하는 것은 다음 워크플로우부터입니다. tester(Red) 셀에 첫 번째 1건이 기록되면 다시 보고하겠습니다.
링크
- C3 GitHub: https://github.com/satoh-y-0323/claude-code-conductor
- C3 PyPI: https://pypi.org/project/claude-code-conductor/
- C3 공식 문서: https://satoh-y-0323.github.io/claude-code-conductor/
- v2.54.0 릴리스 노트: https://github.com/satoh-y-0323/claude-code-conductor/releases/tag/v2.54.0
- 이전 기사 (autonomous-mode 배포 시작): https://zenn.dev/satoh_y_0323/articles/1e8d415cca23d5
- tier-routing 과거 기사: 「저렴한 모델이 다시는 선택되지 않는다」 (학습 시그널 설계 편) / 기계 적용과 귀속의 자기 신고 제로화 (완결편...이었을 터)
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기