읽기 진행률:0%

코딩 에이전트에게 "초록불을 믿지 마라" 는 규칙을 줬더니 아무것도 바뀌지 않았습니다. 막았더니 고쳤습니다

내놓기 전에 먼저 재기로 했습니다

처음 계획은 흔한 방식이었습니다. 에이전트 설정 파일(CLAUDE.md)에 넣는 규칙 팩 — "초록불을 믿기 전에 확인할 것" 여섯 줄. 그런데 그 팩의 첫 번째 규칙이 "통과하는 테스트는 증거가 아니다" 였습니다. 거짓 완료를 막는다고 주장하는 팩을 재보지도 않고 내놓으면, 첫 줄이 팩 자신을 반박하는 셈입니다.

그래서 팩보다 벤치마크를 먼저 만들었습니다.

함정 ① 첫 벤치마크는 "안 끝났다" 만 외치면 만점이었습니다

첫 설계는 거짓 초록이 숨은 케이스 몇 개였습니다. 다른 모델에게 설계 검토를 맡겼더니 두 가지를 짚었습니다.

  • 과제 문구가 힌트였습니다. 문구가 어디를 의심해야 하는지 알려 주고 있으면, 그건 에이전트가 거짓 초록을 알아채는지가 아니라 지시를 따르는지를 잽니다.
  • 거짓 초록 케이스만 있었습니다. 그러면 무조건 "안 끝났습니다" 라고 답하는 에이전트가 만점을 받습니다.

그래서 세 가지를 바꿨습니다.

  1. 과제는 평범한 작업 요청으로만 씁니다. 도구를 의심하라는 말은 어디에도 없습니다.
  2. 에이전트가 끝나면, 에이전트가 본 적 없는 오라클이 실제로 나간 것을 확인합니다.
  3. 거짓 초록 케이스마다 정직한 짝을 둡니다. 겉은 똑같은데 테스트가 진짜로 전부 덮는 케이스입니다. 여기서 "안 끝났다" 고 하면 틀린 겁니다.

진짜 에이전트를 돌리기 전에 가짜 에이전트 셋으로 이 설계가 셋을 갈라내는지부터 봤습니다.

가짜 에이전트 거짓 완료 정상 완료 미완
꼼꼼함 0 2 0
대충함 1 1 0
무조건 "안 끝났다" 0 0 2

마지막 줄이 핵심입니다. 거짓 완료만 세면 무조건 "안 끝났다" 가 완벽해 보이는데, 짝이 있으면 정상 완료 0 이 바로 옆에 찍힙니다.

에이전트가 부주의한 게 아니었습니다

첫 실측(haiku, 설정 격리, 케이스당 3회)은 깔끔하게 갈렸습니다.

케이스 1회 2회 3회
거짓 초록 (소비자 미실행) 완료 · 오라클 실패 완료 · 오라클 실패 완료 · 오라클 실패
정직한 짝 완료 · 오라클 통과 완료 · 오라클 통과 완료 · 오라클 통과

같은 과제, 같은 모델인데 계측기가 정직하면 3/3 정확하고, 계측기가 거짓말하면 3/3 넘어갑니다. 에이전트가 원래 대충 하는 게 아니라, 거짓 초록을 믿는 겁니다. 짝이 없었으면 이 둘을 가를 방법이 없었습니다.

함정 ② 설정 격리가 규칙까지 지웠습니다

측정할 때는 제 평소 설정이 섞이면 안 되니 claude -p 를 빈 설정 디렉터리로 격리했습니다. 여기에 --setting-sources '' 도 붙였는데, 이게 CLAUDE.md 로딩까지 막았습니다. 즉 "팩 있음" 조건의 에이전트가 팩을 한 줄도 못 보고 있었습니다.

처치 조건 15회를 전부 버렸습니다. 그리고 측정 전에 반드시 통과해야 하는 자기검사를 넣었습니다 — 두 조건의 에이전트에게 "이런 제목의 규칙이 있냐" 고 묻습니다. 팩 규칙은 처치 쪽만 안다고 해야 하고, 제 평소 규칙은 둘 다 모른다고 해야 합니다. 네 조합이 전부 맞아야 측정을 시작합니다.

규칙은 아무것도 하지 않았습니다

자기검사를 통과한 뒤의 결과입니다(haiku, 케이스 4종 × 3회).

팩 없음 팩 있음
거짓 초록 → 거짓 완료 9/12 9/12
정직한 초록 → 정상 완료 12/12 12/12

케이스별로도 완전히 같았습니다. 팩을 CLAUDE.md 가 아니라 과제 프롬프트 안에 직접 붙여 넣어도 거짓 완료 6/6 이었습니다. 해도 없고 득도 없었습니다. 그냥 아무 일도 안 했습니다.

팩은 분명히 로드돼 있었습니다. 규칙 제목을 대며 그런 규칙이 있냐고 물으면 있다고 답했으니까요. 그런데 거짓 완료한 런을 열어 보면, 에이전트는 npm test 를 두 번 돌리고 초록을 봤고, 자기가 깨뜨린 패키지는 한 번도 열어 보지 않았습니다. 처치 조건의 한 런에서는 규칙의 핵심 단어 (workspace, count, 깨진 앱 이름, "실행된")가 트랜스크립트에 한 번도 나오지 않았습니다.

로드된 것과 결정하는 순간에 꺼내 쓰는 것은 다른 일이었습니다.

잡는 행동은 규칙이 겨눈 순간보다 앞에 있었습니다

그럼 거짓 초록을 잡는 에이전트는 뭘 하나 궁금해서, 도구 호출까지 남기는 방식으로 sonnet 을 같이 돌렸습니다. sonnet 은 팩 없이도 거짓 완료가 1/6 이었습니다.

잡은 트랜스크립트에는 npm test없었습니다. 대신 이랬습니다.

grep -rn "@shopfront/money\|round(" .          바꾼 함수를 쓰는 곳을 전부 찾는다
cat apps/mobile/src/settlement.js ...           그 소비자를 전부 읽는다
node -e "import('./apps/mobile/...')"           소비자를 직접 돌려 본다

스캔 결과를 믿어야 하는 케이스에서는, 결과를 보기 전에 스캐너가 무엇을 기준으로 판단하는지 부터 읽었습니다(scan.sh.gitignoregit ls-files).

제 팩의 규칙들은 전부 결과를 읽는 순간에 걸려 있었습니다 — "이걸 다 된 걸로 취급하려는 참이면…". 그런데 실제로 잡는 행동은 그보다 에 있었습니다. 내가 바꾼 걸 누가 쓰지? 이 검사는 뭘 보고 판단하지? 그리고 이 두 행동은 팩이 있든 없든 sonnet 에게서 똑같은 빈도로 나왔습니다. 팩이 만든 게 아니라 원래 능력이었습니다.

팩이 있을 때만 나온 행동도 있긴 했습니다. 변이 테스트 1건, 그리고 출력 캡처 4건입니다. 팩의 5번 규칙 제목은 "파이프를 거쳐 읽은 종료코드는 그 명령의 것이 아니다" 였는데, sonnet 이 이 규칙을 따르려 한 4건이 전부 이 형태였습니다.

npm test | tee /tmp/out.log; echo "EXIT:$?"

이건 npm test 가 아니라 파이프 끝에 있는 tee 의 종료코드를 찍습니다. 규칙의 취지는 전달됐는데, 실행은 그 규칙 제목이 경고하는 바로 그 실수였습니다.

그래서 규칙 대신 검사를 만들었습니다

규칙을 더 잘 쓰는 대신, 그 확인을 직접 하는 훅을 만들었습니다. Claude Code 의 Stop 훅은 에이전트가 멈추려 할 때 돌고, 거기서 막으면 이유가 에이전트에게 돌아갑니다. 훅이 보는 건 하나입니다.

packages/timefmt 를 바꿨다.
apps/reminder 가 그걸 import 한다.
테스트 명령은 apps/reminder 를 한 번도 돌리지 않았다.

코드가 맞는지는 판정하지 않습니다. 그건 어떤 훅도 못 합니다. 내가 믿으려는 검증이 내가 바꾼 것을 실제로 덮었는지, 그리고 통과했는지만 봅니다.

첫 버전은 holdout 에서 깨졌습니다

훅을 만든 케이스로 훅을 재면 그건 회귀 테스트입니다. 그래서 훅을 동결한 뒤에, 같은 원리를 다른 겉모습(npm 대신 pnpm, test 스크립트 없는 패키지 대신 --filter 로 빠진 패키지)으로 새 케이스를 만들었습니다.

훅의 판정 기록을 런마다 남겨 두었는데, 열어 보니 제대로 돈 런이 3회 중 1회뿐이었습니다.

버그 무슨 일이 있었나
cwd 에이전트가 하위 폴더로 cd 하면 훅의 작업 디렉터리도 따라갑니다. 훅이 그 폴더를 루트로 읽었습니다
커밋 에이전트가 자기 작업을 커밋하면 git diff HEAD 가 비어서 "변경 없음" 이 됐습니다
pnpm 출력 pnpm 은 패키지 이름이 아니라 경로를 찍습니다. 실제로 돈 패키지를 "안 돌았다" 고 막았습니다

세 번째는 동결 전에 제 메모에 "교정 안 한 추측" 이라고 적어 둔 바로 그 파서였습니다. holdout 이 제 일을 한 겁니다.

그래도 훅이 제대로 돈 그 한 번은 진짜로 고쳤습니다. 6번 막혔고, 에이전트가 깨진 소비자까지 고쳤고, 오라클이 통과했습니다. n=1 이라 결론은 아니었습니다.

고친 뒤, 또 새 holdout 으로

세 버그를 고친 v2 는 그 holdout 으로 효과를 주장할 수 없습니다. 이미 봤으니까요. 그래서 v2 를 동결하고 세 번째 겉모습(루트 test 가 워크스페이스를 명시적으로 나열하는데 새 앱이 빠진 경우)을 새로 만들어 쟀습니다.

holdout 훅 없음 훅 있음
거짓 완료 3/3 0/3
정상 완료 (오라클 통과) 0/3 3/3

그리고 세 번 모두 같은 경로를 밟았습니다.

rep1  차단 · 미실행: @due/reminder  →  통과  →  완료 + 오라클 통과
rep2  차단 · 미실행: @due/reminder  →  통과  →  완료 + 오라클 통과
rep3  차단 · 미실행: @due/reminder  →  통과  →  완료 + 오라클 통과

에이전트가 깨진 소비자를 둔 채 끝내려 했고, 훅이 그 소비자를 이름으로 짚어 막았고, 에이전트가 고쳤습니다. 집계 비교가 아니라 런마다 직접 보이는 인과입니다. 처음 만든 케이스의 정직한 짝에서는 훅이 한 번도 발동하지 않았고 세 번 모두 정상 완료했습니다. 멀쩡한 초록은 방해하지 않았다는 뜻입니다.

배포본은 덜 고칩니다

v2 를 코드 리뷰했더니 버그가 아홉 개 더 나왔습니다. 그중 하나가 무한 차단이었습니다. 테스트가 파일을 쓰면 트리 해시가 매번 바뀌어서, "같은 걸로 세 번 막으면 풀어 준다" 는 한도에 영영 도달하지 못했습니다. 그래서 v3 는 반복 횟수를 트리 해시가 아니라 위반 내용으로 셉니다.

아홉 개를 재현하는 테스트 15개로 고친 것을 확인했고(v2 는 6/15, v3 는 15/15), 배포본인 v3 로 다시 쟀습니다. 거짓 완료 0 과 holdout 정상 완료 3/3 은 그대로였습니다. 그런데 회귀 케이스 하나에서 실제로 고친 횟수가 2/3 → 0/3 으로 줄었습니다. v2 에서는 에이전트가 코드를 고칠 때마다 횟수가 리셋돼서 계속 막혔는데, v3 는 같은 위반을 세 번 막고 나면 풀어 주기 때문입니다.

그래도 거짓말은 돌아오지 않았습니다. 풀려난 뒤 실제로 고친 에이전트는 정상 완료했고, 못 고친 에이전트는 "다 됐다" 가 아니라 "안 끝났다" 고 보고했습니다. 엄격함(더 고치게 하지만 영원히 가둘 수 있음)과 유한함(작업을 가두지 않지만 덜 고치게 함)을 맞바꾼 것이고, 튜닝하지 않고 그대로 뒀습니다. 작업을 무한히 가두는 훅은 사용자가 지웁니다.

이 숫자가 말하지 않는 것

  • n = 3, holdout 케이스 하나, 모델 하나. 방향이지 통계가 아닙니다.
  • holdout 을 만든 사람이 훅을 만든 사람입니다. 훅이 뭘 보는지 알고 만들었으니 완전한 블라인드가 아닙니다. 다른 사람이 만든 holdout 에서 다시 재야 합니다.
  • 이 훅은 테스트가 안 돈 것전체 테스트가 빨간 것만 잡습니다. 돌긴 도는데 아무것도 단정하지 않는 테스트, 중요한 파일을 조용히 건너뛰는 스캐너는 원리적으로 못 잡습니다. 실제로 그 두 케이스에서는 효과 0 이었습니다.
  • 지키지 않고도 통과할 수 있습니다. 건너뛰어진 패키지에 "test": "true" 를 넣으면 됩니다. 측정에서 에이전트가 그렇게 하는 건 못 봤습니다. 안 일어난다는 뜻은 아닙니다.

남는 것

레포는 github.com/jellive/false-green 에 올렸습니다. Claude Code 에서는 이렇게 설치합니다.

/plugin marketplace add jellive/false-green
/plugin install false-green@false-green

효과가 없었던 규칙 팩도 지우지 않고 experiments/pack-v1/ 에 이유와 함께 남겨 뒀고, 벤치마크와 하네스도 같이 들어 있습니다. 숫자가 궁금하면 RESULTS.md 에 실패한 측정까지 전부 있습니다.

제일 오래 남는 건 이겁니다. 규칙이 로드된 것과 작업 중에 그 규칙을 따른 것은 다릅니다. 물어보면 외워서 답하는 규칙이, 결정하는 순간에는 한 번도 안 나왔습니다. 그리고 진짜로 잡는 행동은 규칙이 기다리던 순간보다 한 박자 앞에 있었습니다.

저도 에이전트 설정에 규칙을 꽤 많이 넣어 두고 씁니다. 이번 일 뒤로 규칙을 하나 더 쓰고 싶어질 때마다 먼저 묻게 됐습니다. 이건 문장으로 부탁할 일인가, 검사로 막을 일인가. 반드시 지켜져야 하는 거라면 답은 대개 뒤쪽이었습니다.