자가단조(Self-Forging) 설계: 지시문 대신 타입 있는 설정을 변이하고 3중 게이트로 채택하기
에이전트가 자기 지시문을 고쳐 쓰는 대신, 엔진 레지스트리가 허용하는 타입 있는 설정 변이만 제안하고 dev 상승, held-out 유지, Judge와 무관한 보조 지표 유지를 모두 통과한 변경만 채택하는 루프를 엔진에 구현했습니다. 실모델로 실행하자 Judge와 실행 중 자가조정이 각각 조용히 꺼져 있었다는 것도 드러났습니다.
Key Results
- 0.5 → 0.96
- 비교셋 벤치 점수 JQwen3.6-27B 실 파이프라인, Goodhart 방어 하드닝 후. 같은 모델의 self-judge이고, 반성 단계가 비교셋 기록을 읽어 엄밀한 held-out은 아님
- 225 → 237
- 엔진 테스트forge 5, 하드닝 4(int(None) 회귀 가드, Goodhart 차단 포함), GEPA 3 신규
- 4 → 8 → 12
- 실행 중 자가조정의 rag_top_kact 모드, 재시도 2회, 로컬 스택 데모 워크플로우. off 기준선은 4 고정
XGEN 하네스의 설정은 조합이 많습니다. 10개 단계마다 꽂을 전략이 있고, 실행 지점마다 거는 가드가 있고, Judge 기준마다 가중치와 필수 여부가 있고, 합격 임계값, 재시도 횟수, 반복 상한, temperature, 검색 폭 같은 스칼라 값이 있습니다. 이 값들은 워크플로우마다 사람이 정하고 있었고, 실패가 반복돼도 설정이 그 실패에서 배우는 경로는 없었습니다.
2026년 6월 17일, 하네스가 자기 실행 기록을 근거로 설정을 진화시키는 자가단조(self-forging) 루프를 엔진에 넣었습니다. 설계의 중심은 무엇을 바꾸게 할지보다 무엇을 바꾸지 못하게 할지에 있었습니다. 점수를 목표로 삼는 순간 점수를 올리는 가장 빠른 길은 평가의 허점을 찾는 것이 될 수 있기 때문입니다.
이 글은 변이 대상을 지시문이 아니라 타입 있는 설정으로 정한 이유, 변이를 합법 수와 역수로 정의한 방식, 과최적화를 막는 3중 게이트, 그리고 같은 좌표계를 쓰는 실행 중 자가조정 루프까지 정리합니다. 두 루프 모두 실모델로 실행하고 나서야 조용히 꺼져 있던 부분이 드러났다는 것도 함께 적습니다.
왜 지시문이 아니라 설정인가
출발점은 그 전에 개인 저장소에서 만든 실험이었습니다. 범용 코딩 에이전트를 실행하고 pytest 통과율 변화로 채점한 뒤, 실패 반성을 바탕으로 LLM이 지시문 마크다운 파일 전체를 다시 쓰게 하는 루프였습니다. 벤치 점수가 0.05 넘게 떨어지면 git revert로 되돌리는 관성 브레이크도 있었습니다. 루프는 돌았지만 한계가 분명했습니다. 쓰기 대상이 자유 텍스트라 무엇이 바뀌어 점수가 변했는지 분리할 수 없었고, 채점은 답변 품질이 아니라 코드 테스트였고, 무엇보다 하네스 엔진과 연결돼 있지 않았습니다.
반면 엔진은 이미 변이의 어휘를 갖고 있었습니다. 20개가 넘는 플러그인 레지스트리가 단계별 전략 구현, 실행 지점별 가드, Judge 기준 스키마, 오케스트레이터 패턴을 등록받고, 설정은 레지스트리 기준으로 타입 검사를 받습니다. 10개 단계 각각이 전략을 꽂는 슬롯이라는 설계는 원래 하드코딩 없이 동작을 바꾸려는 것이었는데, 자가개선 관점에서는 그대로 닫힌 좌표계가 됐습니다.
외부 연구도 조사했습니다. 검색 증강 에이전트, 상태를 외부화한 하네스, 타입 프리미티브의 치환 대수와 실행 기록 기반 진화를 제안한 HarnessX 등에서 공통점은 신뢰성을 모델 한 번의 출력이 아니라 모델 밖의 스캐폴드, 즉 상태, 검증, 제어, 구조의 외부화로 옮긴다는 것이었습니다. 처음에는 온톨로지와 결합한 방향을 제안했다가, 온톨로지와 엮지 않는다는 방침에 따라 기존 자산(이중 provider, 실행 기록, 컴파일 산출물) 위에서 다시 설계했습니다.
| 축 | 이전 실험 | 자가단조 |
|---|---|---|
| 실행기 | 범용 코딩 에이전트 | XGEN 하네스 엔진이 벤치 과제 실행 |
| 쓰기 대상 | 지시문 마크다운 전체 | 타입 있는 하네스 설정(단계별 전략, 가드, 기준, 스칼라) |
| 채점 | pytest 통과율 | Judge 점수 기반 벤치 점수 J |
| 변경 단위 | 자유 텍스트 재작성 | 등록된 프리미티브의 치환 |
치환 대수: 합법 수, 역수, 잠긴 표면
변이 하나를 "수(手)"라고 불렀습니다. 수의 종류는 전략 교체, 가드 토글, 숫자 튜닝(합격 기준, 재작성 한도, 반복 상한, temperature, rag_top_k, 상태 보관 상한), 단계 파라미터 설정, Judge 기준의 가중치와 필수 여부 수정, 그리고 뒤에서 설명할 프롬프트 지침 한 줄 추가입니다. 수는 하드코딩한 목록이 아니라 실행 시점에 레지스트리를 조회해 만들고, 적용 전에 스키마로 검증하며, 각 수는 이전 값을 복원하는 역수를 함께 가집니다. 수를 합성할 수 있고 역수가 있으니 롤백이 결정론적입니다.
class Runner(Protocol): # 실엔진이든 mock이든 같은 계약
def run(self, config: dict, task: dict) -> RunRecord: ... # {score, outcome, trace}
class Algebra: # 등록된 프리미티브에서만 합법 수를 생성
def legal_moves(self, config) -> list[Move]: ...
def apply(self, config, move) -> dict: ... # 새 설정(원본 불변)
def invert(self, move) -> Move: ... # 롤백용 역수바꿀 수 없는 표면도 명시했습니다. 사용자 프롬프트, 엔진 코드, 벤치셋과 채점 기준의 의미, provider 키는 변이 대상이 아닙니다. 자가단조가 점수를 올리는 가장 쉬운 길은 시험지를 고치는 것인데, 그 경로를 구조적으로 막은 것입니다.
탐색은 개체군 방식이 아니라 한 번에 한 수를 적용하는 탐욕적 방식으로 했습니다. 후보 하나를 평가하는 데 실제 파이프라인 전체를 실행해야 하니 비용이 크고, 한 수씩 쌓인 선형 커밋 기록이 감사에 유리했습니다. 결과는 진화한 설정과 commits.jsonl(반성 근거, 변경 전후 벤치 점수, 판정)로 나오고, 그 설정을 실제로 적용할지는 사람과 플랫폼이 정합니다.
루프: 신호, 변이, 재평가, 게이트
한 바퀴는 벤치 실행, 신호 추출, 수 제안, 재평가, 게이트 순서입니다.
base config + 벤치(dev / held-out)
→ 실제 파이프라인으로 평가
→ 실행 기록에서 신호 추출: Judge 미적용, 임계값 대비 낮은 점수, 반복 압력, 정책 차단
→ 신호에 맞는 합법 수 제안
→ 수 적용 후 dev / held-out 재평가
→ 게이트 통과 시 채택, 실패 시 역수로 롤백신호는 실행 상태의 계약에서 데이터로 유도하고, 도메인별 신호는 확장 지점으로 등록하게 했습니다. 오프라인 MockRunner로 만든 개념 검증에서는 의도적으로 나쁘게 만든 초기 설정의 J가 0.318에서 0.996으로 올라가고, 회귀하는 수가 롤백되는 것을 확인했습니다.
게이트가 이 설계의 중심입니다. 벤치 점수만 보면 자가단조는 벤치에 과최적화합니다. 그래서 채택 조건을 세 개로 두고 모두 통과해야 하게 했습니다.
| 게이트 | 막는 것 |
|---|---|
| dev 점수 상승 | 효과 없는 변경 |
| held-out 점수 유지 | dev 과적합 |
| Judge와 무관한 보조 지표(응답률) 유지 | 답을 거부할수록 점수가 오르는 퇴화 |
세 번째 게이트가 필요한 이유는 Judge 점수만으로는 "답하지 않는 설정"을 걸러낼 수 없기 때문입니다. 응답률처럼 Judge가 계산에 관여하지 않는 지표를 함께 지켜야 합니다. 여기에 개선이 멈추면 탐색을 끝내는 early stop과, 과최적화 신호가 보이면 되돌리는 롤백을 더했습니다.
설정 노브만으로는 한계가 있어 같은 날 프롬프트도 진화 대상에 넣었습니다. GEPA 방식의 반성기가 실패 기록의 Judge 피드백을 자연어 반성으로 바꾸고, 시스템 프롬프트 안의 <forge_guidance> 블록에 지침 한 줄을 추가하는 수를 제안합니다. 이 수도 역수가 있고 같은 3중 게이트를 거칩니다.
여기에는 5월에 세운 원칙이 걸려 있었습니다. 당시 확인 요청을 반복하는 회귀를 시스템 프롬프트의 행동 지시로 고치려다 하루 만에 철회하고, "엔진은 환경만 바꾸고 행동은 모델이 정한다"를 원칙으로 정했습니다. 자가단조도 같은 원칙을 따릅니다. 설정을 바꾸지 지시를 박지 않으며, 프롬프트 진화는 전용 블록 안의 한 줄로 범위를 한정하고 게이트를 통과해야만 남습니다.
실모델로 처음 실행했을 때: 침묵하던 Judge
개념 검증은 mock이었으므로, 실제 파이프라인과 실제 모델로 다시 실행했습니다. 내부에서 쓰던 vLLM의 Qwen3.6-27B를 OpenAI 호환 provider로 연결하고 forge를 실행하자, forge는 실행 기록에서 Judge가 꺼진 설정(ungated)을 진단해 Judge 활성화를 채택했고 J가 0.5에서 1.0이 됐습니다.
같은 실행이 숨어 있던 버그를 처음 드러냈습니다. Judge가 쓰는 보조 호출의 max_tokens 기본값이 None이었는데, 기본값을 대신 넣는 처리가 None 앞에서 발동하지 않아 매 호출 int(None) 예외가 났고, Judge는 예외가 나면 0.7 폴백 점수를 냈습니다. 채점은 일어나지 않았는데 점수는 그럴듯했습니다. 유닛 테스트 225개가 모두 통과하는 상태였는데, 실제 provider로 전체 파이프라인을 실행하는 테스트가 없었기 때문입니다. 자가단조는 점수를 연료로 쓰는 장치라, 점수가 가짜면 진화도 가짜가 됩니다. 기본값 해석을 고치고 이 경로에 회귀 가드 테스트를 넣었습니다.
이후 Goodhart 방어를 넣은 하드닝 버전에서도 실모델 비교셋 J는 0.5에서 0.96이었고, 플랫폼 연동 모듈을 직접 불러 실행한 통합 경로에서는 0.5에서 0.92였습니다. 테스트는 225개에서 forge 5개, 하드닝 4개(int(None) 회귀 가드, Goodhart 차단, 합성 게이팅, 보조 지표), GEPA 3개가 늘어 237개가 됐습니다. forge 패키지는 opt-in import라 기본 사용자에게는 로드되지 않게 했습니다.
이 숫자는 조심해서 읽어야 합니다. J는 같은 Qwen 모델이 채점한 값이라 자기 답에 관대한 편향을 배제하지 못합니다. 이 측정이 보여 준 것은 루프가 실제 실행 기록에서 원인을 진단하고 맞는 수를 채택한다는 메커니즘이지, 품질 향상의 크기가 아닙니다. 외부 라벨 벤치로 엄밀하게 재는 것은 다음 단계로 남겼습니다.
실행 중 자가조정: 만들었지만 아무도 부르지 않았던 루프
자가단조는 오프라인 배치입니다. 그런데 지금 처리 중인 요청이 Judge에 기각됐을 때 그 자리에서 설정을 조정하는 일은 시간축이 다릅니다. 둘을 같은 기능으로 만들면 벤치로 검증한 변경과 운영 중 즉흥 변경이 섞이므로, 같은 변이 좌표계(치환 대수의 합법성 검사와 역수)를 공유하되 별도 루프로 분리했습니다.
| 구분 | 실행 중 자가조정 | 오프라인 자가단조 |
|---|---|---|
| 시점 | Judge 기각 후 재작성 직전 | 배치(opt-in) |
| 대상 | 실행 중 설정 몇 개 | base 설정 |
| 효과 | 이번 실행의 다음 시도 | 다음 실행들의 출발점 |
| 안전 장치 | off/observe/act, 롤백 저널, 기본 off | 3중 게이트, 역수 롤백, 사람이 적용 |
실행 중 조정 규칙은 단순합니다. 검증 점수가 낮으면 temperature를 낮추고, 근거 부족 피드백이면 rag_top_k를 올리되 하한을 고정해 에이전트가 임의로 되돌리지 못하게 하고, 그래도 정체하면 최후 수단으로만 합격 기준을 조금 내립니다. 기준을 낮춰 통과시키기보다 근거를 늘려 개선하는 쪽을 우선한 것입니다. observe 모드는 제안만 기록하고, act 모드만 적용합니다.
6월 23일 이 기능을 엔진에 넣었지만, 제품 기본 라인을 실제로 조회해 보니 루프를 닫는 호출자가 하나도 없었습니다. 설정 변경기를 가져오는 호출, forge 결과를 워크플로우에 되쓰는 호출, 상태를 기록하는 호출이 모두 정의만 있고 부르는 곳이 0이었습니다. 7월 2일에는 실행 중 자가조정이 켜져 있는데도 재시도 때 아무 로그도 남기지 않는 것을 보고 원인을 찾았습니다. 두 겹이었습니다. 엔진의 제안기가 None 센티넬과 문자열로 들어온 숫자 설정을 읽지 못했고, 플랫폼 연동 계층이 자가조정 설정을 하네스 설정으로 전달하지 않아 게이트가 항상 꺼져 있었습니다. 둘을 고치자 라이브에서 retry 1/2 뒤 self-govern: tune_scalar(temperature=0.15); tune_scalar(validation_threshold=0.8)이 기록되고, 다음 판정이 조정된 임계값으로 동작했습니다.
7월 3일에는 검색 폭 조정을 실제 검색 도구까지 연결했습니다. 결정적 단위 시험에서 rag_top_k가 4, 8, 12, 16, 20으로 올라가고 하한이 지켜지는 것을 확인했고, 로컬 스택의 데모 워크플로우를 act 모드로 실행하자 첫 재시도에서 4에서 8로, 두 번째에서 8에서 12로 올라갔고 temperature는 0.7, 0.5, 0.3으로 내려갔습니다. 같은 조건의 off 기준선에서는 재시도가 일어나도 top_k가 4로 고정돼 A/B 대조가 성립했습니다. 다만 에이전트가 검색 폭을 스스로 고르는 progressive 모드에서는 효과가 부분적이어서, 하한을 보장하는 데 그칩니다.
설계 판단: 표현력보다 검증 가능성
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 지시문 자유 재작성 | 표현력이 가장 넓음 | 변경 원인을 분리할 수 없고 롤백이 파일 덮어쓰기 | 기각 |
| 개선기가 자기 코드를 수정 | 탐색 공간 최대 | 조사한 사례에서 보상 해킹과 불안정이 보고됨 | 금지 |
| 개체군 기반 대량 탐색 | 지역 최적 탈출에 유리 | 후보마다 실제 파이프라인 실행, 감사 기록이 복잡 | 기각 |
| 타입 있는 설정 변이 + 3중 게이트 | 합법성 검사, 역수 롤백, 한 수 단위의 인과와 감사 기록 | 표현력이 좁고 개선이 느림 | 채택 |
자가단조에서 실제로 설계한 것은 생성기가 아니라 관문이었습니다. 후보를 영리하게 만드는 일은 나중에 바꿀 수 있지만, 무엇을 바꿀 수 있고 무엇을 바꿀 수 없는지, 어떤 조건에서 채택하고 어떻게 되돌리는지는 처음부터 정해 둬야 했습니다. 탐욕적 탐색의 지역 최적 문제는 개인 저장소의 다음 버전에서 따로 실험했습니다. 최적 개선 채택, 깊이 D 빔 미리보기, 거부한 수의 재제안 금지(tabu), 비용을 반영한 목표 함수(J = 품질 평균 − λ·비용 평균)를 넣자, 오프라인 데모에서 같은 최고 품질(0.9956)에 탐욕적 방식보다 한 단계 적은 4단계로 롤백 낭비 없이 도달했고, 비용 항 때문에 더 비싼 설정 대신 한 단계 낮은 설정을 골랐습니다. 이 최적화기를 실엔진에 연결하는 작업은 로컬 스택이 필요해 보류했습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 벤치 점수 J | 0.5 | 1.0 | Qwen3.6-27B 실 파이프라인, Judge 미적용 진단 후 활성화 채택, self-judge |
| 비교셋 J | 0.5 | 0.96 | Goodhart 방어 하드닝 후, 같은 조건 |
| 통합 경로 비교셋 J | 0.5 | 0.92 | 플랫폼 연동 모듈 직접 로드, 엔드포인트 E2E는 미수행 |
| 엔진 테스트 | 225개 | 237개 | forge, 하드닝, GEPA 신규 테스트 포함 |
| 실행 중 rag_top_k | 4 고정(off) | 4 → 8 → 12(act) | 로컬 스택 데모 워크플로우, 재시도 2회 |
배운 것은 두 가지입니다. 하나는 자동 개선에서 점수의 진위가 모든 것의 전제라는 점입니다. Judge가 0.7을 지어내던 동안의 진화는 아무 의미가 없었습니다. 다른 하나는 기능의 존재와 기능의 동작이 다르다는 점입니다. 실행 중 자가조정은 설정 필드와 엔진 코드가 모두 있었는데도, 호출자와 설정 전달이 빠져 한동안 아무것도 하지 않았습니다.
남은 과제도 분명합니다. J는 같은 모델의 self-judge라 외부 라벨 벤치가 필요합니다. 비교셋도 엄밀한 held-out이 아니었습니다. 나중에 구현을 다시 읽어 보니 다음 후보를 제안하는 반성 단계가 비교셋의 실행 기록을 읽고 있었고, 간편 API 경로에서는 개발셋과 비교셋이 같은 것으로 설정됐습니다. 그래서 이 비교셋은 탐색기가 보지 못한 문제에서의 성능을 보장하는 검증이 아니라, 명백히 나빠지는 후보를 거르는 회귀 방어선으로 읽어야 합니다. 위 0.96도 그 조건의 값입니다. 플랫폼 forge 엔드포인트의 E2E 검증은 하지 못했고, 7월 중순 아키텍처 감사에서는 이 엔드포인트의 권한과 쿼터, 그리고 배포 게이트가 held-out 데이터셋, 서버가 소유한 임계값, 산출물·설정 해시를 정본으로 남기지 않는 점이 우선 과제로 지적됐고, 이후 로컬 구현에서 이 값들이 맞지 않으면 승격과 외부 활성화를 막는 fail-closed 계약으로 정리했습니다. 실행 중 조정값을 다음 실행의 시작값으로 고정하는 작업은 rag_top_k만 대상으로 설계했습니다. temperature와 합격 기준은 한 방향으로만 계속 움직이는 래칫 위험이 있어 제외했고, act 게이트와 상한 고정을 두기로 했지만, 설정이 실행 전에 확정되는 구조라 읽어 오는 시점을 옮기는 별도 작업이 필요합니다.