AI 코딩 에이전트에 루프를 씌우는 건 단발 프롬프트가 막히면 누구나 떠올리는 다음 수입니다. 돌리고, 채점하고, 점수가 낮으면 다시 돌립니다.
그런데 바로 벽 두 개에 부딪힙니다.
- 점수는 오르는데 결과물은 여전히 틀립니다. 에이전트가 과제가 아니라 채점기를 만족시키는 법을 배운 겁니다.
- 실패가 막다른 길입니다. 루프의 부품은 다 있는데 실패한 실행이 다음 실행으로 안 이어집니다.
둘 다 모델 문제가 아니라 환경 설계 문제입니다. 모델이 강해진다고 사라지지 않아요. 강한 모델은 지름길을 더 빨리 찾을 뿐입니다.
오픈소스 Agent OS인 Ouroboros에 최근 합류한 RFC로 이 두 벽을 짚어보려고 합니다. 설계는 이슈 #1917에, 구현은 #1916에 있습니다. 전문을 직접 읽을 수 있다는 게 이 사례를 고른 이유입니다.
벽 1: 수험생에게 답안지를 쥐여줬다
대부분의 에이전트 하네스는 수용 기준을 워커 프롬프트에 그대로 렌더링합니다. 채점에 쓸 명령과 채점 기준이 될 단정문까지요.
동기는 그럴듯합니다. 에이전트가 어떻게 검사받을지 알면 과녁을 제대로 겨눌 수 있다는 거죠.
Ouroboros도 그렇게 했었습니다.
_build_success_contract_block이 verify_command와 Expected output: <단정문>을 워커 지시문에 렌더링했어요.
두 번째 누출은 더 찾기 어려웠습니다. 재시도 때 실패 사유에 단정문의 repr()이 실려서 result.error를 타고 다음 프롬프트로 되돌아왔습니다.
에이전트가 단정문을 보는 순간, 단정문을 맞추는 게 요구사항을 맞추는 것보다 싸집니다.
RFC는 이걸 직설적으로 부릅니다. 궁지에 몰린 워커의 가장 싼 경로는 수용 기준을 구현하는 게 아니라 단정 문자열을 갖고 노는 것이고, 포스트모템(seed_2be2907edc07)이 붙어 있습니다.
교과서적인 보상 해킹입니다. 능력을 본다고 생각했는데 실제로 본 건 답을 베끼는 능력이었어요.
해법: 프롬프트에 넣지 않습니다, 되돌릴 스위치도 없이
두 경로를 다 막아야 합니다. 앞문만 막으면 아무것도 안 달라집니다.
- 앞문:
_build_success_contract_block은 이제 수용 기준 설명과expected_artifacts만 렌더링합니다. 검증은 하네스가 따로 하니까 채점 로직이 워커의 성공 계약에 안 들어갑니다. - 뒷문: 검증 게이트의 실패 사유에 단정문
repr()이 더는 안 실립니다. 재시도 힌트는 단정문을 걸러내는 전용 빌더(orchestrator/retry_hints.py)가 만듭니다. 명령 출력의 마지막 2,000자 꼬리까지요.
그 2,000자 꼬리가 베껴갈 만한 대목입니다. 앞문을 막고 로그 꼬리를 잊으면 누출은 그대로 열려 있습니다.
아직 구멍은 있어요. 지금은 문자열을 그대로 비교하는 방식이라 줄바꿈된 사본은 통과합니다. 공개 이슈(#2020)로 열어뒀습니다.
RFC에는 "공개 수준" 설정 노브가 제안됐다가 기각된 기록도 남아 있습니다. 끌 수 있는 차단벽은 누군가 일정에 쫓기는 오후에 꺼집니다. 그리고 점수가 더 좋아 보이니까 아무도 못 알아챕니다.
막힌 에이전트가 대신 받는 것
전부 숨기기만 하면 워커는 허우적댑니다. 그래서 RFC는 숨기는 것과 힌트 루프를 짝으로 묶습니다.
다음 라운드 지시문은 세션이 실제로 한 일에서 재구성됩니다. 툴 호출 궤적, 증거 목록(deliver_gate.load_ac_evidence_manifest 재사용, 읽기 전용), 검증기의 판정 결과요. 단정문에서가 아니고요.
| 답을 공개하면 | 궤적 기반 힌트면 | |
|---|---|---|
| 에이전트가 보는 것 | "단정문이 요구하는 출력은 X" |
"A와 B를 호출했고, 산출물 C가 없고, 3단계 검증에서 실패했다" |
| 가장 싼 경로 |
X를 지어낸다 |
실제로 C를 만든다 |
| 점수 상승 = 능력 상승인가 | 아니오 | 예 |
사람 시험이 쓰는 것과 같은 배치입니다. 출제자는 답을 알고, 수험생은 자기가 어디서 틀렸는지만 배웁니다.
벽 2: 실패가 막다른 길이다
RFC에 따르면 부품은 전부 이미 있었습니다. 검증 게이트, 실행-평가 체인, evolve_step, Ralph 드라이버, focus.select_evolution_focus. 그런데 아무것도 연결이 안 돼 있었어요.
- 실패한 실행은 정식 평가에 안 들어갔습니다. 실패는 거기서 끝이었고 BLOCKED로 표시될 뿐이었어요.
- 기각된 평가는 진화에 안 들어갔습니다. 역시 거기서 끝이었고요.
루프가 끊어진 세 토막으로 있었습니다. 실패는 보고만 되고 다음 실행에 안 쓰였습니다.
해법: 실행을 평가로, 평가를 진화로 잇습니다
제약 세 개가 핵심입니다.
첫째, 실패한 실행도 평가로 이어집니다. _run_succeeded 게이트를 완화해서 세션을 만든 실행이면 정식 평가로 체인됩니다.
fail-open은 유지돼요. 큐 등록이 실패해도 실행 결과가 뒤집히진 않습니다.
둘째, 기각된 평가가 예산 있는 진화 루프를 발동합니다. 여기서 수렴 루프를 새로 구현한 사람은 없습니다. 평가 잡의 종결 경로가 final_approved is False일 때 기존 진화 기계를 큐에 넣을 뿐이에요.
새로 만든 건 1세대 다리입니다. 실행의 시드와 체인된 평가의 다중 수용 기준 체크리스트를 계보 이벤트로 옮겨서, evolve_step이 평범한 실행을 1세대로 재생하고 2세대를 이미 초점 맞춰진 상태로 시작합니다.
셋째, 실패한 수용 기준만 다음 세대로 가고 통과한 건 동결됩니다. 이 제약의 효과가 제일 큽니다.
작동시키려면 체크리스트를 ACResult로 바꾸는 변환기가 엄격한 기준을 만족해야 했습니다. 인덱스 전체 커버리지, 글자 그대로의 ac_content, semantic_ac_key 동일성이요.
이미 통과한 걸 동결하지 않는 루프는 맞게 한 일을 또 하면서 토큰을 태웁니다. 그러다 맞던 구현을 부숩니다. 증상은 세대 사이를 오가는 점수예요.
루프는 멈출 수 있어야 합니다
루프는 스스로 안 멈추니까, Ouroboros는 Ralph의 기존 정지 조건을 재사용합니다. QA 통과, 수렴, 진동 감지, 등급 퇴행, 실제 경과 시간이요.
execution.auto_evolve_max_generations의 기본값은 3이고 1~10으로 제한됩니다. BLOCKED는 예산이 소진된 뒤에만 일어납니다.
가짜 수렴을 잡는 건 진동 감지와 등급 퇴행 둘입니다. A에서 B로 다시 A로 튀는 점수, 또는 부모보다 나쁜 세대요. 둘 다 토큰을 더 태우는 대신 루프를 세웁니다.
하나 더 고쳤습니다. evolution/loop.py에 맨 except가 있어서 evaluation_summary=None으로 조용히 빠지는 경로가 세 개 있었어요. 지금은 실패 사유를 실은 기각 요약을 기록합니다.
단일 실행에서 예외를 삼키면 한 번 틀립니다. 루프에서 삼키면 오류가 N세대에 걸쳐 증폭되는 동안 로그에는 아무것도 없습니다.
싼 게이트를 비싼 게이트 앞에
같은 패턴이 설계 나머지에도 이어집니다.
평가는 층으로 나뉩니다. 기계 검증(공짜, 결정론적 검사), 그다음 의미 검증, 그다음 다중 모델 합의요. 1층에서 기각된 건 LLM 심판에게 안 갑니다.
인터뷰 단계는 층 대신 숫자를 씁니다. 얼마나 분명한지에 가중치를 매기고, 그 값을 1에서 뺀 게 모호도입니다.
Ambiguity = 1 - Sum(clarity_i * weight_i)
0.2를 넘는 점수는 스펙 생성을 막습니다. 단, force를 명시하면 통과할 수 있어요. CLI는 그 선택지를 계속·취소 옆에 띄웁니다.
루프는 마지막 두 세대 사이 온톨로지 유사도가 0.95에 도달하면 수렴합니다. 그 유사도가 진전 없이 세 세대째 평평하면 별도의 정체 감지기가 루프를 세웁니다.
README가 두 게이트의 발상을 이렇게 적습니다. 명확해지기 전에는 쓰지 말고, 안정되기 전에는 멈추지 말 것.
자기 루프에서 확인할 것
- 채점 기준을 수험생에게 보여주지 마세요. 하네스가 에러 메시지, 로그 꼬리, 재시도 프롬프트로 단정 문자열을 흘리는지 보세요. 앞뒤를 다 막고, 설정 옵션으로 만들지 마시고요.
- 실패에는 다음 단계가 있어야 합니다. 실패한 실행은 평가에 닿고, 기각된 평가는 진화에 닿고, 통과한 작업은 동결돼야 합니다.
- 루프는 멈춰야 하고 가짜 수렴을 감지해야 합니다. 진동, 등급 퇴행, 경과 시간, 세대 예산이요.
RFC 전문은 Q00/ouroboros#1917, 구현은 #1916입니다. 설계 문서는 트리 안 docs/hidden-checklist-convergence/에 있어요.
프로젝트는 github.com/Q00/ouroboros입니다. MIT이고, 로컬 우선이고, 13개 런타임 패밀리 앞단에서 돕니다.
직접 돌려보기
설치는 한 줄입니다.
curl -fsSL https://raw.githubusercontent.com/Q00/ouroboros/main/scripts/install.sh \
| OUROBOROS_INSTALL_REF=devto-ko bash
파이프로 받는 게 꺼려지면 pipx install 'ouroboros-ai[mcp]'로 CLI만 받아도 됩니다. 스크립트가 더 하는 일은 MCP 서버를 호스트에 등록하는 것뿐이고, 그건 ouroboros setup으로 나중에 해도 돼요.
uv가 없으면 설치가 끝나도 MCP 서버가 안 붙습니다. 플러그인 쪽 서버를 uvx로 띄우기 때문이에요. pipx install uv나 brew install uv 아무거나 됩니다.
그다음:
ouroboros init start --orchestrator "아까 에이전트한테 시키려던 그 문장"
인터뷰가 그 문장에 남은 모호함을 0~1로 매깁니다. 0.2를 넘으면 위에서 말한 게이트에 걸리고, force도 같은 화면에 뜹니다.
재밌는 건 점수가 아니라 대답 못 한 질문입니다. 원래 안 정해두고 시작하던 게 거기서 드러나요.
한국어 문서는 ouroboros.page/learn에 있습니다. MCP에서 막히면 11장부터요.
멀티 세대 루프 돌리고 계시다면, 가짜 수렴은 어떻게 잡고 계신가요?
이 글의 영문판은 게시 후 과장 4건을 정정했습니다. 무조건 숨기는 게 아니라 글자 그대로 비교하는 것뿐이라는 것, 모호도 게이트는 force로 통과할 수 있다는 것 등이요.
이 한국어판은 그 정정이 반영된 상태로 썼습니다. 정정 이력은 영문판 말미에 있습니다.
Top comments (0)