blogyu검색

루프 엔지니어링을 적용해보자

코딩 에이전트 리뷰 루프가 통과시킨 결과물이 밋밋했다. 발견자가 통과 권한을 쥐고, 빈 배열이 괜찮음으로 읽히고, 좋음을 판정할 기준이 없었다. 루프를 뜯어고친 기록.

코딩 에이전트에 리뷰 루프를 붙여 쓰고 있었다. 계획을 승인하면 구현하고, 린트와 테스트를 돌리고, 리뷰 에이전트 둘이 코드를 보고, 지적이 없으면 PR을 만든다. 지적이 있으면 구현으로 돌아간다. 최대 세 바퀴.

몇 번 돌려보니 통과는 잘 됐다. high나 medium 등급 지적이 0건이면 통과인데, 두세 바퀴면 대개 0건이 됐다.

그런데 결과물이 밋밋했다. 틀린 데는 없었다. 그냥 더 나을 수 있는 코드였다. 같은 결과를 절반의 코드로 낼 수 있는데 그 얘기를 아무도 안 했고, 있는 유틸을 두고 새로 만든 것도 지적이 안 됐다.

왜 그런지 루프를 처음부터 다시 읽었다.

루프 글은 이미 많다

먼저 남들은 어떻게 하는지 봤다. 루프 엔지니어링이라는 말은 이미 있었다. 이랜서 블로그 글이 부품 다섯 개와 메모리로 루프를 설명하고, 검증 에이전트를 작업자와 분리하라고 한다. 무엇을 시도했고 무엇이 남았는지 디스크에 적으라는 얘기도 있다. 둘 다 맞는 말이고 내 루프에도 있었다.

논문도 있었다. Refute-or-Promote는 LLM이 찾은 결함 후보를 다른 에이전트가 반박하게 하고, 교차 모델 비평을 거친 뒤 실증 검증을 마지막 관문으로 둔다. 171건의 후보 중 79%를 기각했다고 한다. 정밀도 문제를 구조로 푼 것이다.

그런데 둘을 읽고 나서도 내 루프가 왜 밋밋한 결과를 통과시키는지는 답이 안 나왔다. 둘 다 결함을 어떻게 잘 잡느냐에 관한 글이었다. 결함이 다 잡힌 뒤에 남는 것, 그러니까 틀리진 않았는데 좋지도 않은 것을 어떻게 걸러내느냐는 다루지 않았다. 그게 내가 걸린 지점이었다.

발견자가 통과 권한을 쥐면 생기는 일

원래 루프에서 리뷰 에이전트는 지적마다 등급을 직접 매겼다. high, medium, low, info 넷 중 하나. 그리고 통과 조건은 high와 medium이 0건인 것이었다.

이걸 나란히 놓고 보니 문제가 보였다. 등급을 매기는 쪽이 종료를 결정하는 쪽이다. 리뷰 에이전트가 확신이 없어서 medium을 low로 한 칸 내리면 루프는 끝난다. 아무도 그 판단을 다시 보지 않는다.

더 나쁜 건 프롬프트였다. 리뷰 에이전트에게 "억지로 채우지 마라, 거짓 양성은 신뢰를 깎는다"고 써놨었다. 그 말 자체는 맞다. 하지만 반대편 말이 없었다. 놓친 것도 비용이라는 말이 없으니 확신이 없을 때는 안 적는 쪽으로 기운다. 안 적으면 등급을 올릴 기회도 없다.

그래서 등급을 두 단계로 갈랐다. 리뷰 에이전트는 이제 발견자라고 부르고 severityProposed로 제안만 한다. 별도의 판정자가 전체 지적을 한꺼번에 보고 severity를 확정한다.

판정자가 확정하기 전에 하는 일이 있다. 근거를 직접 밟는다. 지적에 재현 경로가 붙어 있으면 그 입력으로 실제로 돌려보고, 인용이 붙어 있으면 인용된 파일을 열어 그 줄이 정말 그렇게 쓰여 있는지와 주장된 충돌이 성립하는지 본다. 이번에 이 글을 검토한 판정자는 "이랜서 글이 그런 말을 안 했다"는 지적을 받고 이랜서 원문을 열었고, 있었다. 그래서 그 지적은 기각됐다. 밟아서 확인한 것만 high나 medium으로 유지하고, 밟지 못하면 plausible로 표시한다. 그리고 내리는 쪽으로만 움직이는 게 아니라 올리기도 한다. 같은 원인에서 나온 low가 여럿이면 원인 자체가 medium일 때가 있다.

발견자에게 주는 지시도 바꿨다. "억지로 채우지 마라"와 "놓친 것도 비용이다"를 반드시 같이 준다. 확신이 없으면 안 적는 게 아니라 등급을 내려서 적는다.

빈 배열의 두 가지 뜻

두 번째 구멍은 커버리지였다.

UI를 검사하는 에이전트가 있었다. 브라우저를 열어서 바뀐 화면을 보고 깨진 곳을 적는다. 그런데 dev 서버가 안 떠 있으면 어떻게 되냐면 에이전트가 "서버에 못 붙었다"고 보고하고 빈 배열을 낸다. 루프는 그 보고를 읽고 넘어간다. 지적 0건이니까 통과 조건에는 문제가 없다.

빈 배열은 두 가지 뜻이다. 봤는데 없거나, 안 봤거나. 구분하지 않으면 안 본 것이 괜찮은 것이 된다. 실제로 그 에이전트는 검사한 라우트를 산문으로 보고하고 있었다. 산문은 사람이 읽을 수는 있지만 통과 여부를 정하는 쪽이 읽지는 않는다.

고친 것은 이렇다. 모든 발견자는 지적 목록과 함께 커버리지 파일을 낸다. 무엇을 봤고, 무엇을 못 봤고, 왜 못 봤는지.

{
  "dimension": "ui",
  "status": "skipped",
  "examined": [],
  "notExamined": [{ "unit": "/posts", "reason": "dev 서버 미기동" }],
  "blockedBy": "port 3000 응답 없음"
}

이 파일을 읽는 건 판정자가 아니다. 판정자는 등급을 매기는 LLM이고 통과냐 복귀냐는 그 뒤에 오는 판정 규칙이 정한다. 판정 규칙은 표 하나다. skipped가 하나라도 있으면 지적이 0건이어도 통과가 아니고 사람에게 넘어간다. 표 하나로 정해지는 계산이라 LLM에 맡길 이유가 없었다. 보고만 하고 통과시키는 것은 금지했다.

결함 축만으로는 좋음을 판정할 수 없다

여기까지 고치고 나서도 처음 문제는 그대로였다. 등급을 판정자가 확정하고 커버리지를 읽어도, 통과 조건이 "high와 medium 0건"인 이상 low는 아무것도 막지 않는다. 그런데 "절반의 코드로 낼 수 있다"나 "있는 유틸을 안 썼다"는 지적은 결함이 아니라서 언제나 low다.

결함 축 하나로는 좋음을 판정할 수 없다. 결함 축은 틀린 것을 잡는다. 틀리지 않은 것 중에서 좋은 것을 고르는 축이 따로 있어야 한다.

그래서 통과 조건을 두 축으로 나눴다. 하나는 결함 축이고 위에서 말한 그대로다. 다른 하나는 기준 축이다. 계획을 승인받을 때 사람이 검수 기준을 함께 쓴다. "이 API는 N+1 없이 한 번의 쿼리로 목록을 낸다" 같은 관찰 가능한 문장으로, 서너 개에서 일곱 개. 판정자는 이 기준 각각에 met, not-met, unverifiable 중 하나를 붙이고, 하나라도 not-met이면 복귀한다.

기준 축은 커버리지와도 묶인다. 기준이 참조하는 화면이 어느 발견자의 notExamined에 있으면 그 기준은 met이 되지 않는다. 못 본 것을 근거로 통과를 내지는 않으니까.

세 바퀴 상한과 이 규칙이 부딪치면 어떻게 되냐면 세 바퀴를 다 썼는데 not-met이 남으면 루프는 멈추고 사람에게 넘긴다. 상한은 통과시키는 조건이 아니라 사람을 부르는 조건이다. 기준을 못 채운 채로 통과되는 경로는 없다.

기준은 에이전트가 쓰지 않는다. 에이전트가 초안을 낼 수는 있지만 확정은 사람이 한다. 기준을 에이전트가 쓰면 결국 자기가 통과할 만한 기준을 쓰게 된다.

이 축이 없을 때 루프가 하는 일은 결함 제거다. 이 축이 있어야 품질 상향이 된다. 결함 제거 루프와 품질 상향 루프의 차이는 통과 조건에 사람이 쓴 기준이 있느냐다.

회차 사이에 결정이 증발한다

세 번째는 반복 그 자체의 문제였다.

루프가 두 바퀴째 돌 때 발견자는 첫 바퀴를 모른다. 첫 바퀴에서 오탐이라고 기각한 지적이 두 바퀴째에 그대로 다시 올라온다. 그러면 "같은 지적이 두 번 연속 나오면 에이전트가 막힌 것이니 사람을 부른다"는 종료 조건이 그걸 진짜 막힘으로 오인한다. 안 고쳐진 것과 오탐이 반복된 것이 구분되지 않는다.

이건 이랜서 글이 말한 "디스크에 적어라"가 답이긴 했다. 다만 무엇을 적느냐가 중요했다. 지적마다 결정을 한 줄씩 남긴다. 고쳤는지, 기각했는지, 보류했는지, 그리고 이유. 이 파일을 결정 원장이라고 부른다.

{"iter":1,"id":"a1b2c3d4","decision":"fixed","rationale":"유틸로 교체"}
{"iter":1,"id":"e5f6a7b8","decision":"rejected","rationale":"의도된 동작. 주석 추가"}

다음 바퀴의 발견자에게 기각 목록을 넘긴다. 기각된 걸 다시 올리려면 새 근거가 있어야 한다. 같은 근거면 올리지 않는다. 이렇게 하고 나서야 "두 번 연속 재출현"이 신호가 됐다.

id에 줄 번호를 넣지 않았다. 파일을 편집하면 줄 번호가 밀려서 같은 결함이 다른 id가 된다. 파일 경로와 결함 종류만으로 해시를 만든다.

관문도 실측 없이는 못 믿는다

루프를 디자인 검사로도 확장하려고 결정론적 관문을 하나 만들었다. 헤드리스 브라우저를 띄워서 대비율, 가로 오버플로우, 한글 행간, 폰트 로드를 계산하는 스크립트다. 에이전트 판단이 아니라 계산이니까 회차마다 답이 흔들리지 않는다는 게 요점이었다.

이걸 실제 블로그에 두 번 돌렸다. 두 실행은 라우트 집합이 달라서 수치를 합칠 수 없다.

첫 실행은 목록과 태그, 시리즈, 검색 화면 다섯 개를 375, 768, 1440 세 폭으로 쟀다. 위반 40건. 이 중 세 종류가 오탐이었다. 행간 위반 18건 중 15건이 40px 제목이었는데, 본문 한글 행간 하한인 1.375를 제목에 적용한 것이다. 제목은 1.2가 정상이다. sr-only, 그러니까 화면에는 안 보이고 스크린리더에만 읽히는 1픽셀짜리 요소 3개를 잘린 텍스트로 잡았다. 그리고 대비 위반 3건이 전부 존재하지 않는 /posts에서 나왔다. 설정에 그 라우트를 넣어뒀는데 이 블로그에 목록 페이지는 없었고, 관문은 404 페이지를 정상으로 알고 대비율을 쟀다.

주석 안에 적힌 next/font/google 문자열을 금지 import로 잡은 것과, document.fonts.check가 선언되지 않은 폰트에도 참을 돌려줘서 폰트 검사가 항상 통과였던 것은 사이트가 아니라 따로 만든 테스트 페이지에서 잡았다.

/posts를 실존하는 글 상세 주소로 바꿔서 두 번째로 돌렸다. 위반 325건. 글 상세에는 코드블록이 있고, 코드블록 안의 span이 대비 위반 162건과 오버플로우 위반 119건을 냈다. 코드블록은 가로 스크롤이 정상이고 색은 하이라이팅 테마가 정한다. 가로 스크롤 컨테이너 안의 요소는 오버플로우로 세지 않게 하고, 코드블록을 제외 선택자에 넣었다. 325에서 162와 119를 빼면 44다. 다시 돌리니 44건이었다.

남은 44건은 이렇다. 20px 소제목의 행간이 1.2인 것 30건. 본문 하한 1.375에는 못 미치는데 20px가 본문인지 제목인지는 사람이 정할 문제라 그대로 뒀다. 토큰 파일 밖의 색상 리터럴 13건. 코드 하이라이팅 테마 파일, OG 이미지 생성 파일, 로고 SVG에 있었고 셋 다 토큰을 쓸 수 없는 자리다. 콘솔 에러 1건. 44건 모두 계산이 틀린 게 아니라 판단이 필요한 것들이었고, 그건 관문이 아니라 사람이 할 일이다.

여기서 배운 건 관문이 에이전트보다 믿을 만하다는 게 아니었다. 관문도 실측 없이는 못 믿는다는 것이었다. 계산이라서 회차마다 답이 같다는 건 맞다. 하지만 같은 답이 틀린 답일 수 있다.

LLM이 LLM을 심사하는 건 그대로다

이 설계를 두고 나올 반론을 안다.

판정자를 분리해봐야 결국 LLM이 LLM을 심사하는 것 아니냐는 말이 먼저 나올 것이다. 맞다. 그래서 LLM이 정할 수 없는 것을 밖으로 뺐다. 계산 가능한 것은 관문이 계산하고, 좋음의 기준은 사람이 쓴다. 판정자는 그 사이에서 등급을 매기되 근거를 밟은 것만 확정한다. LLM 심사를 없앤 게 아니라 LLM이 혼자 결정하는 범위를 줄인 것이다.

커버리지도 마찬가지다. 에이전트가 자기가 본 것을 자기가 신고하는 건데 믿을 수 있냐고 물으면 못 믿는다. 그래서 examined에는 실제로 연 것만 넣게 하고 판정자가 기준 항목과 대조한다. 완벽한 장치는 아니다. 에이전트가 안 본 것을 봤다고 적으면 잡을 방법이 없다. 다만 안 적었을 때 통과되던 것보다는 낫다.

오탐 일곱 종은 네 스크립트의 버그지 루프 설계의 교훈이 아니지 않냐는 반론도 있을 것이다. 절반은 인정한다. 그 일곱 개는 내 관문의 버그 목록이고 다른 사람의 관문에는 다른 버그가 있을 것이다. 하지만 관문을 만들고 나서 실제 대상에 돌려보기 전까지는 그 버그가 있는 줄 몰랐다는 것, 그리고 관문이 계산이라는 이유로 결과를 검증 없이 믿을 뻔했다는 것은 스크립트가 아니라 설계의 문제다. 결정론적 관문은 루프에서 유일하게 흔들리지 않는 부품이라 오히려 실측이 더 필요하다.

아직 완주하지 않았다

솔직하게 적어두면, 이 루프가 실제로 품질을 올렸다는 근거는 아직 없다. 커널을 쓰고 세 도메인의 스킬을 만들고 관문을 실측했지만, 처음부터 끝까지 한 바퀴를 돈 적이 없다. 이 글은 설계 근거까지다.

고친 것과 아직 확인 못 한 것을 나란히 두면 이렇다.

고친 것 아직 확인 못 한 것
판정자가 등급을 확정하고 근거를 밟는다 판정자가 밟았다고 적고 안 밟았을 때 잡을 방법
커버리지가 통과 조건에 들어간다 자기 신고가 거짓일 때
사람이 쓴 기준 축이 통과를 막는다 사람이 기준을 느슨하게 쓰면 결함 축과 같아진다
결정 원장이 재출현을 걸러낸다 기각이 틀렸을 때 되돌리는 경로
관문 오탐 일곱 종을 실측으로 잡았다 아직 못 잡은 여덟 번째

다음은 실제로 돌려보는 것이다. 돌려보면 이 설계에서 또 무엇이 틀렸는지 나올 것이다. 그때 다시 쓴다.

댓글