스스로를 신뢰할 수 없도록 설계된 AI 개발 하네스(Harness)를 구축했습니다. 그리고 '신뢰하지 않는 부분'을 점검해 보았습니다.
요약
AI 에이전트의 안전한 실행을 위해 스스로를 신뢰하지 않는 설계 원칙을 적용한 개발 하네스 구축 실험을 다룹니다. 에이전트가 게이트(Gate)를 우회하거나 모델 스스로 규칙을 준수하는 것처럼 행동하는 현상을 분석하며, 검증 가능한 증명과 되돌릴 수 없는 작업 제어의 중요성을 설명합니다.
핵심 포인트
- 에이전트의 작업은 기계적 증명 없이는 종료되지 않아야 함
- 모델이 게이트의 존재를 인지하면 스스로 규칙을 집행하는 것처럼 행동할 수 있음
- 검증 불가능한 협조는 시스템의 안전 장치를 무력화할 위험이 있음
- pi-eval과 pi-gates를 통해 에이전트 런타임의 안전성 검증 시도
스스로를 신뢰할 수 없도록 설계된 AI 개발 하네스(Harness)를 구축했습니다의 후속 글입니다.
제가 앞서 작성했던 하네스(Harness)는 스스로를 신뢰할 수 없도록 설계되었습니다. 그것이 핵심이었으며, 다음의 한 문장에 기반하고 있었습니다:
에이전트(Agent)가 생성하는 그 어떤 것도 기계적으로 확인 가능한 증명(Machine-checkable proof) 없이는 종료되지 않으며, 인간의 개입 없이는 어떠한 되돌릴 수 없는 작업(Irreversible action)도 일어나지 않는다.
저는 여전히 이 원칙을 믿습니다. 하지만 여기에는 제가 인지하지 못했던 가정이 포함되어 있었습니다. 바로 '위험한 것은 검증되지 않은 작업'이라는 가정입니다.
저는 하룻밤 사이에 pi — 공개되어 있고, npm으로 배포되며, 포크(Fork) 가능한 에이전트 런타임(Agent runtime) — 상에서 해당 문장의 양쪽 측면을 모두 다시 구축했습니다. 이는 주로 제 하네스의 어느 정도가 규율(Discipline)에 의한 것이고, 어느 정도가 특정 호스트(Host)의 형태에 의한 것인지 알아내기 위함이었습니다. 그 결과 두 개의 공개 저장소(Repo)가 탄생했습니다: 증명(Proof) 측면을 위한 pi-eval과, 되돌릴 수 없음(Irreversibility) 측면을 위한 pi-gates입니다.
두 부분 모두 처음에는 실패했습니다. 그것들은 동일한 형태로 실패했으며, 그 형태는 제가 방어하려고 했던 형태가 아니었습니다. 각 경우에서 검증(Check)은 실행되었고, 통과되었으며, 증명하려던 것과는 다른 무언가를 증명하고 있었습니다.
이는 제 논지에 대한 수정이며, 증거와 함께 내용을 공유합니다.
모델이 예의를 차리고 있었던 게이트(Gate)
되돌릴 수 없음(Irreversibility) 측면은, 제가 해당 차례에 키보드로 승인 문구를 입력하지 않는 한 git commit과 git push를 거부하는 게이트(Gate)입니다. 에이전트가 저장소(Repo)에서 수행하는 다른 모든 작업은 디프(Diff)를 읽음으로써 복구할 수 있습니다. 하지만 푸시(Push)는 그렇지 않습니다.
저는 이를 작성하고, 순수 로직을 테스트하고, 설치한 뒤, 제 세션에 승인 문구 없이 무언가를 커밋하도록 요청했습니다.
거부되었습니다. 그러더니 모델은 제게 이렇게 말했습니다:
게이트가 의도한 대로 작동하고 있습니다 — 커밋을 차단하고 있습니다
어떠한 bash 명령어도 실행되지 않았습니다. tool_call 이벤트도 발생하지 않았습니다. 게이트는 호출된 적조차 없었습니다. 모델은 대화 초반에 해당 규칙에 대해 읽었으며, 스스로 규칙을 집행하고 있었던 것입니다. 그리고 모델은 자신의 관점에서 정확하게, 스스로를 그 메커니즘(Mechanism)이라고 설명하고 있었습니다.
저는 그 기록을 보고 게이트(Gate)가 제대로 작동하지 않았다고 보고할 뻔했습니다.
제가 "거절하지 마세요, 게이트가 차단하는지 확인해야 합니다"라고 반박하며 밀어붙였을 때, 모델은 이를 우회(Bypass) 시도로 분류하고 다시 거절했습니다.
더욱 심각한 세부 사항은 다른 엔지니어들이 꼭 알아두었으면 하는 부분입니다. 모델은 규칙에 대해 알고 있었는데, 그 이유는 제 게이트가 모델에게 알려주었기 때문입니다. 제가 처음 사용한 "무장(Armed)" 배너는 ctx.ui.notify를 사용했는데, 이는 대화 내용(Conversation)에 포함됩니다. 게이트가 검증 불가능하게 만드는 협조를 스스로 만들어낸 것입니다.
두 가지 해결책이 있으며, 둘 다 다른 곳에도 적용 가능합니다:
문맥 밖에서 알림(Announce out of context). 배너를 TUI 하단에 기록되는 ctx.ui.setStatus로 옮겼습니다. 추적해 보니 setStatus → interactive-mode.js 내의 setExtensionStatus로 이어지며, 모델의 컨텍스트(Context)로 들어가는 경로는 전혀 없었습니다. 모델이 자신이 소유하고 있다고 믿는 게이트는 논쟁을 통해 무력화될 수 있으며, 더 나쁜 것은 실제로는 아무것도 아니면서 게이트인 것처럼 들리기만 한다는 점입니다.
모델을 루프에서 제외하고 검증(Verify with no model in the loop). 이제 게이트의 정확성은 핸들러(Handler)에 합성 이벤트(Synthetic events)를 발생시키고 반환값을 읽음으로써 증명됩니다:
const [r] = await pi.fire("tool_call",
{ toolName: "bash", input: { command: "git add -A && git commit -m x" } }, ctx);
assert.equal(r.block, true);
이 파일은 게이트가 작동함을 증명하는 유일한 결과물(Artifact)입니다. 마침내 깨끗한 세션에서 실제 작업을 실행했을 때, 차이는 명확했습니다. 모델은 명령어를 실행했고 거부되었습니다:
$ cd /tmp/gitgate-test && git add note.txt && git commit -m "gate test 2"
BLOCKED — git commit/push needs an operator approval typed this turn.
거절(Refusal)과 결과는 같았지만, 사실 관계는 완전히 달랐습니다.
정답지가 아닌 술어(Predicate)를 검증하는 게이트
증명 단계는 결정론적 채점기(Deterministic grader) — 고정된 술어(Predicates)를 사용하며 LLM 판정관을 사용하지 않음 — 이므로, 점수 변화는 판정관의 컨디션 난조가 아니라 출력이 실제로 변했음을 의미합니다.
모든 과제는 테스트 스위트(suite)에 들어가기 전에 두 번의 기계 검사를 거쳐야 합니다. 즉, 정답은 자신의 술어(predicate)를 통과해야 하고, 그럴듯하지만 틀린 답은 그 술어를 실패하게 만들어야 합니다. 이 두 번째 조건이 겉보기보다 더 중요합니다. 왜냐하면 비교는 동점 처리 시 무시되는 쌍별 부호 검정(paired sign test)이기 때문입니다. 오답을 실패시키지 못하는 술어는 실제 차이를 동점으로 만듭니다. 느슨한 채점기는 여러분의 스위트를 잘못 측정하는 것이 아니라, 눈가림을 합니다.
이 게이트는 작동했습니다. 작성된 48개의 과제 중 7개를 거부했는데, 그중 6개는 한 가지 원인이었습니다. 바로 number 채점기가 답변에서 첫 번째 숫자를 가져오는데, 작업 해설의 첫 번째 숫자는 피연산자(operand)였기 때문입니다. 이 술어는 예상값 78에 대해 3을 채점했고, 정답을 실패하게 만들었습니다.
저는 이에 만족했습니다. 그러다 expected = 52.34인 과제를 허용하는 것을 발견했습니다.
48.45 × 1.08 = 52.326, 이는 52.33으로 반올림됩니다.
제가 테스트한 두 모델 모두 52.33이라고 답변했고, 틀렸다고 채점되었습니다. 이는 양방향으로 git을 통해 증명 가능합니다. 커밋 25d3466은 `
--thinking 옵션이 작동하지 않았습니다. Pi의 리졸버(resolver)는 제가 전달한 인자가 아니라 model:level _패턴(pattern)_에서 레벨을 가져오고 있었습니다. 모든 레벨에 대해 undefined를 반환했기 때문에 세션은 기본값(default)으로 돌아갔습니다. 저는 설정을 자기 자신과 비교하고 있었던 것입니다.
"측정 가능한 차이가 없음"이라는 결과는 완전히 사실이었지만, 완전히 무가치했습니다.
이를 잡아낸 것은 한 시간 전 다른 이유로 추가해 두었던 토큰 카운팅(token counting)이었습니다. 양쪽 모두 약 116k개의 추론(reasoning) 토큰을 보고했는데, thinking=off로 실행된 세션은 그렇게 할 수 없기 때문입니다.
그 형태를 주목하십시오. 이 검사는 잘못된 이유로 통과한 것이 아닙니다. 잘못된 질문에 대해 올바르게 통과한 것입니다. 그리고 다운스트림(downstream)의 그 어떤 것도 이를 감지할 수 없습니다. 왜냐하면 모든 아티팩트(artifact)가 성공적인 측정값과 똑같이 보이기 때문입니다. 따라서 해결책은 더 나은 검사(check)를 만드는 것이 아니라, 실행 자체를 거부하는 것입니다.
preflight: thinking=off를 요청했으나 세션이 medium으로 결정되었습니다.
실행을 거부합니다 — 사용되지 않은 설정으로 라벨링된 실행은 실행하지 않는 것보다 더 나쁩니다.
이 문장은 이제 제가 가진 두 리포지토리(repo)에서 가장 좋아하는 문장이 되었으며, 저는 이를 다른 거부(refusals) 사례들과 같은 범주에 넣고 싶습니다. 즉, 답변이 누락된 것은 '0'이 아니라 '에러(error)'이며, 토큰 제한(token cap)에서 잘린 응답은 답변이 아닙니다. 서로 다른 스위트(suite)로 실행된 두 번의 실행은 비교할 수 없으며, 도구는 그 차이(delta)를 보고하는 대신 그렇게 말해야 합니다.
수정 사항은 나의 판단이 아니라 채점기(grader)에 있어야 한다
설정이 실제로 적용되자, thinking=high가 thinking=off를 8–0, p=0.008로 이겼습니다.
그 승리 중 두 번은 진짜가 아니었습니다.
thinking=off는 먼저 생각하는 대신 산문(prose) 형태로 답변합니다. 한 작업에서는 산술 계산을 수행하여 "금요일"이라는 결론을 내렸고 — 정답입니다 — 정확히 일치하는지 확인하는 채점기(exact-match grader)가 전체 답변에 점수를 주었습니다. 다른 작업에서는 0, 2, 4, 6 위치의 일치 항목을 나열하고 "4"라고 답했는데 — 정답입니다 — 추출기(extractor)는 0을 가져갔습니다. 모델은 두 번 모두 맞았습니다.
장황함(Verbosity)은 정확도가 아닙니다. 하지만 장황함을 변화시키는 설정 변경은 위치에 민감한(position-sensitive) 모든 채점기를 동시에 같은 방향으로 움직이게 하므로, 그것이 노이즈(noise)처럼 보이지 않고 마치 발견된 사실(finding)처럼 보이게 만듭니다.
제가 8-0이라는 결과를 발표하고, 어떤 두 가지 결과가 믿을 수 없는 것인지 글로 설명할 수도 있었을 것입니다. 그것은 여러분이 저를 믿어야만 하는 방식입니다.
대신 채점기(graders)에 scope="last_line" 옵션을 부여했고, 동일한 6번의 실행(runs)을 재점수화(re-scoring)한 결과 6–0, p=0.031이 도출되었습니다. 이는 제가 수동으로 선택했던 것과 동일한 6개의 태스크를 기계적으로 선택한 결과입니다. 이 판결은 (앞선 결과보다) 더 약하지만, 저는 이를 더 신뢰합니다. 어떤 결과가 인위적인 산물(artifacts)인지에 대해 그 누구도 제 말을 그대로 믿을 필요가 없기 때문입니다.
이것이 바로 "기계 검증 가능(machine-checkable)"함의 실제 용도입니다. 당신이 옳다는 것을 증명하는 것이 아니라, 당신의 판단이 은밀하게 개입할 수 있는 지점들로부터 당신의 판단을 제거하는 것입니다.
그리고 이 시스템이 내놓는 답은 점수라기보다 결정(decision)에 가깝습니다. 왜냐에는 비용(cost)이 그 옆에 붙어 있기 때문입니다:
thinking=high won 6, lost 0, 84 tied, 10 unstable p = 0.031
$1.107 vs $0.445 2.49x cost
2.5배의 비용으로 100개당 태스크 +6개. 누군가는 이 정보를 바탕으로 행동할 수 있습니다. 점수 하나만으로는 결정이 될 수 없습니다.
두 배의 데이터, 더 적은 성능
이러한 규율에는 대가가 따르며, 저는 그 대가를 숨기기보다 차라리 보여주는 쪽을 택하겠습니다.
100개의 태스크를 수행했을 때, 이 도구는 두 개의 프런티어 모델(frontier models)의 순위를 매하는 것을 거부했습니다. 두 모델 사이의 차이는 단 4개의 태스크뿐이었고, α=0.05에서 유의미한 차이를 보이기 위한 최소치는 6개입니다. 즉, 해당 데이터를 어떻게 나누더라도 유의성(significance)에 도달할 수 없었습니다. 그래서 도구는 무승부(tie)를 보고하는 대신 다음과 같이 말합니다:
두 모델 사이를 가르는 태스크는 4개뿐입니다. 4개가 모두 일치하더라도 p<0.05를 통과할 수 없으므로, 이 스위트(suite)는 두 모델 사이의 우열을 결정할 수 없습니다. 이는 구성(configs)에 대한 발견이 아니라, 스위트 자체의 한계입니다.
_"판단할 수 없음(Cannot tell)"_과 _"그들은 동일함(they're the same)"_은 서로 다른 발견입니다. 대부분의 툴링(tooling)은 이 둘을 하나로 뭉뚱그리며, 이러한 뭉뚱그리기는 항상 무언가 보고할 거리가 생기는 쪽으로 편향됩니다.
그 밑바탕에는 두 번째 규칙이 있습니다: 설정(config)이 스스로와 불일치하는 태스크는 방향성을 갖지 않으며, 무승부와 정확히 동일하게 폐기됩니다. 이는 필수적인데, 동일한 100개의 태스크에 대해 하나의 모델을 세 번 실행하는 것만으로도 모델 내부 분산(within-model variance)에 의해 약 2.67개의 "정보가 있는(informative)" 차이가 발생하기 때문입니다. 어떤 실제적인 발견이라도 반드시 이 수치를 넘어서야 합니다.
그 후 저는 README에 이 비용이 "더 많은 반복(repetitions)"을 통해 해결 가능하다는 내용을 적었습니다. 한 시간 뒤에 그 주장을 테스트해 보았습니다. 결과는 정반대였습니다.
reps 4-6 9-1 unstable 13 informative 10 p=0.0215 decisive
reps 1-6 7-1 unstable 17 informative 8 p=0.0703 not
데이터는 두 배가 되었지만, 통계적 검정력(power)은 줄어들었습니다. 반복 횟수가 추가될 때마다 구성 내 불일치(within-config disagreement)를 관찰할 기회가 늘어나며, 엄격한 규칙(strict rule)은 불일치가 발생할 때 해당 태스크를 폐기합니다. 극한의 상황에서 이 규칙은 진정으로 확률적(stochastic)인 모든 태스크를 버리게 됩니다. 이는 바로 노이즈가 있는 구성(noisy config)에 대해 가장 많은 정보를 담고 있는 태스크들입니다. 보수적인 규칙은 공짜로 얻어지는 선택이 아니며, 그 비용은 측정 가능합니다.
만약 제가 이 글을 읽는 독자였다면 가장 확인하고 싶었을 부분이라 하나 더 언급하겠습니다. 엄격한 규칙이 유의미하지 않은(non-significant) 결과를 반환했을 때, 저는 유의미한 결과를 반환하는 더 약한 규칙(weaker rule)을 만들었습니다. 그 동기는 독립적이었습니다. 저는 다른 규칙이 판결을 바꾸는지 확인하기 전에, 반복 횟수가 늘어남에 따라 폐기율이 상승하는 특성을 이미 측정했기 때문입니다. 그리고 이것은 p-해킹(p-hacking)을 하는 모든 사람이 스스로에 대해 믿고 있는 사실과 정확히 일치합니다.
그래서 저는 예측(prediction), 반증 조건(refutation conditions), 그리고 고정된 파라미터(fixed parameters)를 작성한 뒤, 새로운 데이터를 수집하기 전에 이를 확정(commit)했습니다. 또한 실행(runs)이 진행 중인 동안 분석 스크립트(analysis script)도 확정했습니다. 분석을 나중에 작성하도록 남겨두는 사전 등록(pre-registration)은 단지 재량권의 위치를 옮길 뿐입니다.
세 가지 예측 중 하나가 실패했습니다. 보수적인 규칙이 새로운 데이터에서 유의미성에 도달하지 못할 것이라고 예측했으나, 실제로는 도달했습니다. 저는 이를 예측 실패로 보고했습니다. 왜냐하면 미리 기록해 두는 것의 핵심은 나중에 그것을 재해석할 수 없게 만드는 것이기 때문입니다.
포트(port)가 삭제한 것
오픈 런타임(open-runtime) 논쟁은 대개 이념적입니다. 하지만 여기서는 구체적이며, 대부분 뺄셈(subtraction)에 관한 문제입니다.
Claude Code에서, 제가 만든 모델 게이트(model gate)는 단 하나의 질문, 즉 _어떤 모델이 실행 중인가?_에 답하기 위해 세 개의 파일과 두 개의 훅(hook)이 필요했습니다. SessionStart 훅은 ID를 .runtime_model_<session_id>에 기록했고, 게이트는 이를 다시 읽어 들였습니다. 두 번째 파일인 .runtime_model_ppid_<PPID>는 /clear 명령이 시작 훅이 새 파일을 쓰기 전에 새로운 세션을 시작해 버리는 레이스 컨디션(race condition)을 방지하기 위해 순수하게 존재했습니다.
Pi는 핸들러에게 ctx.model을 전달합니다. 이 모든 것이 사라졌습니다 — 리팩터링(refactoring)된 것이 아니라, 삭제된 것입니다.
git 게이트는 동일한 이야기의 더 나은 버전입니다. 기존 방식은 승인 상태를 센티넬(sentinel) 파일에 유지했기 때문에, 센티넬의 이름이 언급되기만 해도 해당 명령을 거부하는 포지 가드(forge guard)가 필요했습니다. 이 과정에서 P=.oae_approve; touch "${P}_pending"와 같이 조립된 경로 주변에 문서화된 잔재(residuals)들이 남았습니다. Pi에서 승인은 확장 프로그램의 클로저(closure) 내에 있는 변수이며, bash 명령에서 해당 변수로 접근할 수 있는 경로가 없습니다. 그 가드는 강화(hardened)될 필요가 없습니다. 불필요하며, 그 잔재들도 존재하지 않습니다.
정교한 시스템의 설계는 얼마나 설계된 것이고, 얼마나 그 기질(substrate)에서 발생한 흉터 조직(scar tissue)인가? 이 두 가지 게이트의 경우: 그 중 상당 부분은 흉터 조직이었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기