ouroboros — 루프를 언제 멈출지 수학으로 정하기
에이전트를 반복해서 돌리는 도구는 많습니다. 대부분은 언제 그만 돌릴지를 사람 감에 맡기죠.
ouroboros는 그걸 숫자 두 개로 못 박습니다 — 모호도가 0.2 아래로 안 내려가면 코드를 시작하지 못하고,
세대 간 온톨로지 유사도가 0.95를 넘어야 루프를 끝냅니다.
저장소를 클론해 그 두 게이트가 실제 코드 어디에 어떻게 박혀 있는지, 그리고
수렴을 거부하는 다섯 개의 장치가 왜 이 프로젝트에서 제일 배울 만한 부분인지 따라갑니다.
1. 한 장 요약
ouroboros가 파는 문장은 "Stop prompting. Start specifying."입니다. 번역하면 "프롬프트로 부탁하지 말고 명세를 만들어라". 그 명세를 만드는 과정이 루프고, 루프의 각 마디마다 사람의 판단 대신 숫자가 문을 여닫습니다.
소크라테스식 질문으로 숨은 가정을 꺼냅니다. 목표·제약·성공기준(브라운필드는 + 기존 코드 이해)을 각각 0~1로 채점.
여기서 막힙니다. 가중 명료도가 0.8을 못 넘으면 Seed(명세)를 만들 수 없습니다. 질문이 더 나갑니다.
대화가 불변 명세로 굳습니다. 인수조건(AC)·온톨로지·제약이 한 파일에. 이후 모든 판단의 기준선.
더블 다이아몬드로 쪼개 런타임(Claude Code·Codex·Gemini·가재코드 등)에 위임. 모든 행동은 이벤트로 기록.
기계 검증(무료) → 의미 검증 → 다중모델 합의. 싼 것부터 돌려 비싼 판단을 아낍니다.
"아직 우리가 모르는 게 뭐지?"(Wonder) → 반영(Reflect) → 다음 세대 씨앗. 평가의 출력이 다음 입력이 됩니다.
여기서 끝납니다. 연속 세대의 온톨로지가 사실상 같아지면 수렴. 단, 아래 다섯 장치가 이 판정을 거부할 수 있습니다.
가재코드 해부에서 본 것이 "사람 승인으로 문을 여닫는" 방식이었고, senpi 해부가 "프롬프트 문장으로 조이는" 방식이었다면, ouroboros는 세 번째 답입니다 — 임계값과 부등호로 문을 여닫습니다. 같은 문제(에이전트가 확신 없이 확신한 척한다)에 대한 세 번째 처방이라 나란히 놓고 보면 재미있습니다.
2. 규칙이 놓인 자리
이 글의 모든 수치는 아래 파일에서 직접 확인한 것입니다. 문서가 아니라 코드에서 가져왔습니다.
| 무엇 | 경로 (src/ouroboros/ 기준) |
|---|---|
| 모호도 임계·가중치·바닥값 | bigbang/ambiguity.py |
| 인터뷰 진행·질문 생성 | bigbang/interview.py · bigbang/seed_generator.py |
| 온톨로지 유사도 계산식 | core/lineage.py |
| 수렴 판정과 거부 게이트 | evolution/convergence.py |
| Wonder / Reflect 사이클 | evolution/wonder.py · evolution/reflect.py |
| AC 회귀 탐지 | evolution/regression.py |
| 드리프트 3성분 측정 | observability/drift.py |
| 정체 4패턴 탐지 | resilience/stagnation.py |
| 측면사고 5인격 | resilience/lateral.py |
| 모델 티어 라우팅 | orchestrator/model_routing.py |
| 이벤트 소싱 저장소 | persistence/event_store.py |
| Ralph 지속 루프 | ralph_loop.py |
| 3단 평가 파이프라인 | evaluation/{mechanical,semantic,consensus,pipeline}.py |
읽는 순서 팁. 이 저장소는 소스만 27만 줄이라 처음부터 훑으면 길을 잃습니다.
bigbang/ambiguity.py(윗부분 60줄) → core/lineage.py의 유사도 함수 →
evolution/convergence.py의 evaluate() 하나. 이 셋이면 설계 철학의 90%가 잡힙니다.
나머지는 그 셋을 여러 런타임에서 돌리기 위한 배관입니다.
3. 게이트 ①: 시작을 막는 모호도
인터뷰는 사용자가 "이제 됐어"라고 느낄 때 끝나지 않습니다. 계산이 됐다고 할 때 끝납니다.
모호도 ≤ 0.2 여야만 Seed 생성 가능
| 차원 | 묻는 것 | 그린필드 | 브라운필드 | 자동완료 바닥값 |
|---|---|---|---|---|
| 목표 명료도 | 목표가 구체적인가 | 0.40 | 0.35 | 0.75 |
| 제약 명료도 | 한계·제약이 정의됐는가 | 0.30 | 0.25 | 0.65 |
| 성공기준 명료도 | 결과를 측정할 수 있는가 | 0.30 | 0.25 | 0.70 |
| 맥락 명료도 | 기존 코드베이스를 이해했는가 | — | 0.15 | 0.60 |
여기서 눈여겨볼 설계 결정이 셋입니다.
- 채점 온도를 0.1로 고정했습니다(
SCORING_TEMPERATURE = 0.1). 같은 대화를 두 번 채점하면 비슷한 점수가 나와야 게이트가 게이트 노릇을 하니까요. 재현성을 위해 창의성을 죽인 자리입니다. - 총점만으로는 통과 못 합니다. 가중 합이 0.8을 넘어도 차원별 바닥값(목표 0.75 등)을 하나라도 못 채우면 자동완료가 거부됩니다. 목표만 선명하고 제약이 텅 빈 상태로 코드에 진입하는 걸 막는 장치죠.
- 연속 2회 통과를 요구합니다(
AUTO_COMPLETE_STREAK_REQUIRED = 2). 한 번 우연히 잘 나온 채점으로 문이 열리지 않게.
목표 0.9 × 0.40 = 0.36
제약 0.8 × 0.30 = 0.24
성공 0.7 × 0.30 = 0.21
─────────────────
명료도 = 0.81 → 모호도 = 0.19 ≤ 0.2 → 통과
왜 하필 0.2인가. 저장소의 설명은 이렇습니다 — 가중 명료도 80% 지점부터는 남은 미지가 코드 레벨의 결정으로 해소될 만큼 작다. 그 위는 아직 아키텍처를 찍고 있는 상태다. 임의의 숫자인 건 맞지만, 임의의 숫자라도 적어두면 팀이 같은 기준으로 싸울 수 있습니다. 느낌으로 "이 정도면 됐지"를 반복하는 것보다 낫다는 게 이 설계의 베팅이에요.
덧: 점수 구간마다 이름(마일스톤)을 붙여 "지금 어디쯤이고 다음 단계까지 뭐가 필요한지"를 보여줍니다. 수치 게이트를 UX로 번역한 부분인데, 이걸 안 하면 사용자는 "왜 아직도 질문해?"만 느끼게 되죠.
4. 게이트 ②: 끝을 정하는 유사도
진화 루프는 세대마다 온톨로지(이 프로젝트가 다루는 개념들의 스키마)를 다시 뽑습니다. 연속한 두 세대가 사실상 같은 것을 말하기 시작하면 더 돌 이유가 없죠. 그 "사실상 같음"을 이렇게 잽니다.
※ 세 항 모두 분모는 두 세대 필드 이름의 합집합
유사도 ≥ 0.95 → 수렴
| 성분 | 가중치 | 재는 것 |
|---|---|---|
| 이름 중복 | 50% | 두 세대에 같은 필드 이름이 존재하는가 |
| 타입 일치 | 30% | 공통 필드의 타입이 같은가 |
| 완전 일치 | 20% | 이름·타입·설명까지 전부 같은가 |
분모가 합집합이라는 점이 중요합니다. 새 필드가 하나 추가되면 이름중복 점수가 즉시 떨어져요. 즉 "개념이 하나라도 새로 생겼으면 아직 이해가 안 끝난 것"으로 취급합니다.
Gen 2: {Task, Priority, Status, DueDate} → 유사도 0.78 → CONTINUE
Gen 3: {Task, Priority, Status, DueDate} → 유사도 1.00 → CONVERGED
여기에 안전장치가 붙습니다. 최소 2세대는 돌아야 판정을 시작하고, 유사도가 임계 위로 3세대 연속이면 정체로 보고 끝내며, 30세대에서는 무조건 강제 종료합니다. 진동(홀수 세대끼리·짝수 세대끼리 같은 값이 반복되는 주기-2 패턴)도 별도로 탐지합니다 — 두 설계안 사이에서 왔다 갔다 하는 상태를 "수렴 중"으로 착각하지 않으려는 거죠.
5. 수렴을 거부하는 다섯 장치 — 이 글의 핵심
여기가 제가 이 저장소에서 제일 배울 만하다고 본 부분입니다.
보통 이런 루프를 짜면 "임계 넘었네 → 끝"으로 끝납니다. ouroboros의 evaluate()는
임계를 넘긴 뒤에 다섯 번 더 의심합니다. 하나라도 걸리면 converged=False로 되돌아가요.
- 평가 게이트
유사도가 0.95를 넘겨도 최종 승인이 없거나 평가 점수가 최소치 미달이면 수렴 거부. "안정됐지만 좋진 않다"를 완료로 인정하지 않습니다.
- 개별 AC 게이트
총점이 아니라 인수조건 하나하나를 봅니다. 모드가
all이면 전부 통과해야 하고,ratio면 최소 통과 비율을 요구합니다. 평균으로 실패를 덮는 걸 막는 장치죠. - 회귀 게이트
전 세대에 통과하던 AC가 이번 세대에 깨졌으면, 다른 게 다 좋아도 수렴 거부. 실패한 AC 번호를 signal에 실어 보냅니다. "고치면서 다른 걸 부쉈다"를 잡는 자리.
- 진화 게이트 — 제일 영리한 부분
온톨로지가 한 번도 안 바뀐 채 유사도만 높으면 수렴을 보류합니다. 코드 주석이 이유를 이렇게 적어놨어요: 안 변한 건 (a) 이미 좋은 온톨로지를 보수적으로 지킨 것이거나 (b) Wonder/Reflect가 그냥 에러난 것인데, 어느 쪽인지 모르면 수렴으로 쳐주지 않는다. "가만히 있어서 안정된 것"과 "다퉈보고 안정된 것"을 구분하는 겁니다.
- 검증 게이트
검증 출력에
skipped나error문자열이 있으면 수렴 거부. 투박하지만 확실한 방식이고, 검증을 건너뛴 채 초록불을 켜는 최빈 실패를 정면으로 막습니다.
"수렴 보류: 유사도 {값}이지만 온톨로지가 {n}세대 동안 변하지 않았음 (수렴을 인정하려면 실제 진화가 선행되어야 함)"
evolution/convergence.py — 진화 게이트의 실제 사유 문자열가져다 쓸 교훈. 자동 루프를 짤 때 우리는 보통 "종료 조건"을 하나만 만듭니다. 그런데 자동화의 진짜 위험은 안 끝나는 것이 아니라 틀린 채로 끝나는 것이에요. 그래서 종료 조건은 하나로 두되, 종료를 거부하는 조건은 여러 개 두는 이 비대칭이 루프를 짜는 사람에게 그대로 옮겨 적을 만한 패턴입니다. 우리 저장소로 치면 insane-research의 검증 게이트가 같은 계열이고요.
6. 궤도 이탈: 드리프트 측정
긴 작업에서 에이전트가 처음 목표에서 슬금슬금 벗어나는 현상을 이 프로젝트는 드리프트라 부르고 수치화합니다.
허용 임계 ≤ 0.3 · 제약 위반 1건당 페널티 0.1
목표·온톨로지 이탈은 토큰 집합 유사도(1 − 유사도)로, 제약 이탈은 위반 건수 × 0.1로 계산합니다.
임베딩도 LLM 호출도 아니고 순수 문자열 연산이라 비용이 0이고 결정론적이에요.
임계를 넘으면 DriftThresholdExceededEvent가 이벤트 저장소에 남습니다.
평가: 토큰 중복으로 "목표에서 벗어났는지"를 재는 건 거칠긴 합니다. 같은 뜻을 다른 단어로 쓰면 이탈로 잡히죠. 다만 싸고 재현 가능하다는 게 이 자리에선 더 중요한 성질입니다 — 매 이터레이션마다 도는 계측이니까요. 비싼 판단(의미·합의 평가)은 뒤에 따로 있습니다.
7. 막혔을 때: 정체 4패턴 · 측면사고 5인격
루프가 돌긴 도는데 나아가지 않는 상태를 네 가지로 나눠 탐지합니다. 넷 다 서로 다른 처방이 필요하기 때문이에요.
| 패턴 | 증상 | 탐지 방식 |
|---|---|---|
| spinning | 같은 출력·같은 에러 반복 | 최근 N회 출력 해시가 전부 동일 |
| oscillation | 두 상태 사이 왕복 | 짝수 회차끼리·홀수 회차끼리 각각 동일(주기-2) |
| no_drift | 변화 자체가 없음 | 드리프트 변화량이 엡실론 미만 |
| diminishing_returns | 나아지긴 하는데 미미함 | 개선율이 전부 임계(기본 0.01) 미만 |
탐지되면 측면사고 인격 5종을 순서대로 투입합니다 —
해커(진짜 제약이 뭔데?), 연구자(코딩 멈추고 근거부터), 단순화가(제일 단순한 게 뭔데?),
아키텍트(처음부터 다시 짜도 이렇게 할까?), 반대자(반대가 참이면?).
전부 실패하면 AllPersonasExhaustedEvent를 남기고 손을 듭니다.
정체를 탐지하는 코드는 순수 함수(해시 비교·수열 비교)라 테스트가 쉽고, 탈출하는 코드만 LLM을 씁니다. 이 분리가 깔끔합니다 — 비결정적인 부분을 최대한 얇게 밀어낸 설계.
8. 돈: 모델 티어 사다리
비싼 모델을 항상 쓰면 파산하고, 싼 모델만 쓰면 결과가 안 나옵니다. ouroboros는 사다리를 하나 정의하고 실패하면 한 칸 올리고 성공하면 한 칸 내리는 방식으로 다닙니다.
바닥은 frugal, 천장은 frontier. 재시도 임계를 넘으면 자동 승급.
여기서 배울 만한 디테일이 둘입니다.
- enforced와 advised를 구분합니다. 런타임이 모델 지정을 실제로 받아주면
enforced, 받아줄 수 없으면(예: 모델 오버라이드 손잡이가 없는 CLI) 결정은 남기되advised로 기록합니다. "우리가 지정했다"와 "실제로 그 모델로 돌았다"를 섞지 않는 것 — 관측 가능성의 기본인데 잘 안 지켜지는 부분이죠. - 해결된 라우팅을 세션에 저장합니다. 설정값이 아니라 확정된 백엔드-모델 지도를 버전과 함께 저장해서, 나중에 세션을 재개할 때 설정이 바뀌었더라도 중간에 조용히 다른 모델로 갈아타지 않게 막습니다. 재현성을 진지하게 생각한 흔적입니다.
"opencode는 의도적으로 빠져 있다 — 모델이 provider/model 복합 id로 지정돼 이 평면 맵으로 표현할 수 없어
라우팅을 휴면 상태로 둔다. gemini_cli는 관측용으로만 매핑한다 — 호출별 모델 오버라이드 손잡이가 없어
결정이 enforced가 아니라 advised로 남는다."
이런 주석을 쓰는 저장소는 신뢰가 갑니다. "지원 안 함"을 왜 안 하는지, 지금 상태가 어떤 한계인지를 코드 옆에 적어두면 다음 사람이 같은 함정을 안 팝니다.
9. 규율을 CI로 굳히기
이 프로젝트에서 두 번째로 인상적이었던 게 워크플로 구성입니다. 사람이 리뷰에서 잔소리할 항목을 기계 게이트로 옮겨놨어요.
| 워크플로 | 막는 것 |
|---|---|
module-size | 모듈이 커지는 것. 캡을 이미 넘은 모듈은 줄어들 수만 있고, 아래 있던 모듈은 캡을 넘을 수 없음 |
auto-boundary | 레이어 경계 침범 |
max-turns-envelope | 에이전트 실행 턴 수 폭주 |
auto-perf-budget | 성능 예산 초과 |
pr-hygiene | PR 형식·설명 누락 |
"이게 존재하는 이유: 2,000줄을 넘는 26개 모듈이 전체 소스의 35.3%를 쥐고 있는데, 더 커지는 걸 막는 게 아무것도 없었다.
#1769는 리뷰 중에 runner.py에 ~324줄을 더했고 어떤 게이트도 눈치채지 못했다.
모듈을 쪼개는 작업은 PR 사이에 다시 부풀어 오르면 값어치가 거의 없다. 이 게이트가 그 아래 깔리는 바닥이다."
이게 "래칫(ratchet)" 패턴입니다. 이상적인 상태를 강요하지 않고, 지금보다 나빠지는 것만 막습니다. 레거시가 큰 저장소에서 코드 품질 규칙을 도입할 때 제일 현실적인 방법이에요 — "전부 2,000줄 이하로 만들자"는 실패하지만 "오늘보다 커지지만 말자"는 통과합니다.
공급망 쪽도 한 줄 있습니다. 선택 의존성을 범위가 아니라 정확한 버전으로 못박아 뒀는데, pyproject 주석에 이유가 적혀 있습니다 — 2026-03 litellm 공급망 사고 이후 "미래의 오염된 latest가 신규 설치에 자동으로 딸려오면 안 된다"는 판단. 업데이트는 리뷰를 거친 Dependabot PR로만 들어옵니다.
10. 직접 돌려본 실측
M5 Max 맥에서 클론 → 의존성 동기화 → 유닛 테스트 전량 실행까지 해봤습니다.
테스트가 소스보다 33% 많습니다. 이 규모에서 이 비율은 흔치 않아요.
실패한 18개는 전부 mcp 패키지 메이저 버전 충돌([mcp] 프로필과 [claude] 프로필은
같이 설치될 수 없습니다)에서 온 임포트 에러였고, 로직 실패는 하나도 없었습니다.
다만 이건 문서에 적혀 있는 제약을 CONTRIBUTING의 명령이 그대로 밟는 상태이기도 합니다.
README는 두 프로필의 공존 불가를 명시하는데, CONTRIBUTING은 uv sync --all-groups를 안내하죠.
처음 기여하는 사람이 빨간 글씨 18줄을 보고 "내가 뭘 잘못했나" 하게 되는 자리입니다.
git clone --depth 1 https://github.com/Q00/ouroboros.git
cd ouroboros
uv sync --python 3.13 --all-groups
# MCP 프로필 충돌 모듈 3개만 제외하고 전량 실행
uv run --python 3.13 --no-sync pytest tests/unit -q -n 8 \
--ignore=tests/unit/mcp/client/test_adapter.py \
--ignore=tests/unit/mcp/test_sdk_mapping.py \
--ignore=tests/unit/mcp/test_sdk_v2_contract.py
11. 비판
- 분해를 설교하면서 본인이 제일 큰 모듈을 갖고 있습니다.
parallel_executor.py13,158줄,runner.py12,144줄. 래칫 CI로 "더 커지지만 말자"를 걸어둔 건 정직한 대응이지만, 지금 이 저장소에 기여하려면 만 줄짜리 파일을 읽어야 한다는 사실은 그대로입니다. - 수치 게이트는 채점자가 LLM입니다. 모호도 0.19와 0.21의 차이는 결국 모델의 판단이에요. 온도를 0.1로 고정하고 바닥값·연속 통과로 방어했지만, 수학처럼 보이는 것과 수학인 것은 다릅니다. 반대로 온톨로지 유사도·드리프트·정체 탐지는 진짜 결정론적 계산이라, 같은 글에서 두 종류의 "숫자"가 섞여 있다는 점은 알고 읽어야 합니다.
- 권장 설치가
curl | bash이고, 그 스크립트가 남의 설정을 고칩니다. 감지한 CLI에 MCP 서버를 등록하고 설정 파일을 씁니다. 하는 일 자체는 합리적이지만, 파이프로 바로 실행하는 방식은 스크립트를 읽을 기회를 없앱니다.pipx install경로가 있으니 그쪽을 권합니다. - 토큰 비용이 설계에 내장돼 있습니다. 인터뷰마다 채점 호출, 세대마다 Wonder/Reflect, 평가마다 다중모델 합의. 티어 사다리로 아끼려는 노력은 보이지만 근본적으로 비싼 워크플로예요. "한 번 제대로 만들어 재작업을 줄인다"는 베팅이 성립하는 크기의 일에만 값을 합니다.
- 얹혀 도는 층이라는 점. 코드를 실제로 쓰는 건 Claude Code·Codex고, ouroboros는 그 앞뒤에 명세와 게이트를 붙입니다. 밑의 에이전트가 형편없으면 게이트는 "형편없음을 정확히 측정"할 뿐입니다.
그럼에도 이 저장소는 읽을 값어치가 있습니다. 자동 루프를 한 번이라도 짜본 사람이면 5절(수렴 거부 장치)과 9절(래칫 CI)에서 바로 가져다 쓸 게 나옵니다. 도구로 채택하지 않더라도 설계 참고서로서 값을 합니다.
12. 직접 확인해보기
이 글의 모든 수치는 아래 명령으로 자기 화면에서 확인됩니다.
git clone --depth 1 https://github.com/Q00/ouroboros.git && cd ouroboros
# 게이트 ① 모호도: 임계·가중치·바닥값·채점 온도
sed -n '35,56p;140,142p' src/ouroboros/bigbang/ambiguity.py
# 게이트 ② 온톨로지 유사도 계산식 (0.5 / 0.3 / 0.2)
sed -n '310,332p' src/ouroboros/core/lineage.py
# 수렴 판정 + 거부하는 다섯 게이트 (이 글 5절)
sed -n '38,205p' src/ouroboros/evolution/convergence.py
# 드리프트 가중치와 임계
sed -n '50,60p' src/ouroboros/observability/drift.py
# 래칫 CI가 존재하는 이유
head -14 .github/workflows/module-size.yml
공부 순서 추천. ① ambiguity.py 상단 상수 20줄 —
"시작을 막는 조건을 숫자로 적는다"가 어떤 모양인지. ② lineage.py의 유사도 함수 —
"같아졌다"를 코드로 정의하는 법. ③ convergence.py의 evaluate() —
종료 조건보다 종료를 거부하는 조건을 더 많이 두는 비대칭.
이 셋을 읽고 나면 자기 자동화 루프에 무엇이 빠졌는지 바로 보입니다.
같은 문제(에이전트의 근거 없는 확신)를 다르게 푼 기록들과 나란히 읽으면 더 남습니다 — 가재코드는 사람 승인 게이트로, senpi는 프롬프트 문장으로, ouroboros는 임계값으로 같은 문을 잠급니다.