Skip to content

calintzy/ratchetlock

Repository files navigation

ratchetlock

반복해서 쓰는 LLM 프롬프트를 코드처럼 회귀 관리하는 CLI다. 평가는 promptfoo에 맡기고, 그 위에 promptfoo에는 없는 "래칫" 층 하나를 얹는다.

AI 에이전트에게 이 도구를 맡기려면 AGENTS.md를 컨텍스트로 주면 된다 — 커맨드 계약, 상황별 행동 결정표, 금지 행동이 실행 계약 형태로 정리돼 있다.

왜 필요한가

이런 일을 겪어 봤을 것이다. 프롬프트가 자꾸 출력을 마크다운 코드펜스로 감싸서 파서가 깨지길래, "코드펜스를 쓰지 마라"는 제약을 넣어 고쳤다. 두 달 뒤 다른 문제를 손보면서 프롬프트를 다시 다듬었는데, 그때 코드펜스 문제가 조용히 되살아났다. 알게 되는 건 파서가 또 깨진 다음이다.

코드라면 이런 일은 잘 일어나지 않는다. 버그를 고칠 때 테스트를 같이 남기고, 나중에 누가 그 버그를 되살리면 CI가 빨간불을 켜기 때문이다. 프롬프트에는 이 안전망이 없다. promptfoo 같은 평가 도구는 "지금 이 프롬프트가 테스트를 통과하는가"는 봐 주지만, 상태를 저장하지 않아서 "어제까지 통과하던 게 오늘 깨졌는가"는 모른다.

ratchetlock이 하는 일은 그 기억을 붙이는 것 하나다.

  • 지금 통과하는 결과를 출력 스냅샷째로 저장해 기준선으로 삼는다 — freeze
  • 한번 잡은 실패는 영구 회귀 테스트로 등록한다. 프롬프트를 아무리 고쳐도 이 목록은 줄지 않고 쌓이기만 한다 — add-fail
  • 기준선 아래로 떨어지면 exit 1로 반려한다. CI에 그대로 물린다 — check

이름의 래칫(ratchet)은 한쪽 방향으로만 도는 톱니 장치다. 프롬프트 품질이 올라간 만큼 걸쇠를 걸어 두고, 다시 뒤로 밀리지 못하게 한다.

여기에 하나가 더 있다. 프롬프트가 되살아나는 것만 잡는 게 아니라, 채점하는 자(프로브)가 조용히 헐거워지는 것도 잡는다. 프롬프트를 지키는 게이트를 오래 굴리다 보면, 정작 그 게이트의 채점 기준을 누군가 무심코 약하게 고쳐 놓는 일이 생긴다. 측정기 눈금이 슬며시 밀린 셈이라 아무도 눈치채지 못하고, 그때부턴 초록불이 아무것도 보장하지 않는다. ratchetlock은 동결한 출력을 지금 프로브로 다시 채점하기 때문에, 프로브가 과거 통과작의 판정을 뒤집는 순간 바로 빨간불을 켠다. promptfoo에는 없는 기능이고, 실전에서 가장 값어치를 인정받은 것도 이 지점이다(원리는 ARCHITECTURE.md).

어떤 프롬프트에 쓰나

파일로 저장해 두고 입력만 갈아끼우며 반복 실행하는 프롬프트가 대상이다 — 매일 도는 요약 파이프라인, 서비스에 박혀 있는 재작성 프롬프트 같은 것. 래칫이 성립하려면 세 가지가 갖춰져야 한다.

  • 같은 프롬프트를 반복해서 쓴다. 한 번 쓰고 버리는 프롬프트에는 "예전 실패가 되살아난다"는 개념 자체가 없다.
  • 넣어볼 대표 입력이 쌓여 있다.
  • 출력을 pass/fail로 채점할 수 있다. JSON이 파싱되는가, 금지한 표현이 없는가 같은 것.

채팅창에서 즉석으로 치는 일회성 프롬프트는 대상이 아니다. 사람이 결과를 매번 눈으로 확인하는 프롬프트도 굳이 필요 없다 — 그 눈이 이미 게이트다. 래칫이 값어치를 하는 곳은 사람이 안 보고 결과가 나가는 자동화 경로다.

설치

로컬 개발용은 클론 + link를 권장한다.

git clone https://github.com/calintzy/ratchetlock.git
cd ratchetlock
npm install          # prepare 훅이 빌드까지 돌린다
npm link             # ratchetlock 커맨드를 PATH에 등록

이후 어디서든 ratchetlock <커맨드>로 쓴다. 걷어내려면 npm unlink -g ratchetlock.

CI(GitHub Actions ubuntu)에서는 git 의존성 직접 설치가 실측으로 잘 돈다. prepare 훅이 빌드까지 마친다.

npm install --no-save github:calintzy/ratchetlock   # ubuntu-latest에서 빌드까지 정상(실측)

로컬 macOS 일부 npm 버전에서는 이 git 전역 설치가 devDependencies(typescript)를 안 깔아 prepare 빌드가 깨질 수 있다. 그 환경은 위의 클론 + npm link를 쓴다. npm 레지스트리 배포(v0.2.0)가 되면 npm install -g ratchetlock 한 줄로 통일되고 이 분기는 사라진다.

런타임 의존성은 promptfoo 하나다. 나머지는 전부 Node 표준 라이브러리로 돌아간다.

새 프롬프트를 계약으로 등록하는 전체 절차는 docs/REGISTER.md에, 프로브 작성의 함정과 참조 구현은 docs/PROBES.md에 있다.

사용 흐름

모든 커맨드는 promptfooconfig.yaml이 있는 디렉토리에서 실행한다. 상태는 그 옆의 ratchet.json 한 파일에 쌓이므로 프롬프트와 함께 git으로 관리하면 된다.

cd my-prompts/           # promptfooconfig.yaml이 있는 곳
ratchetlock init         # 최초 1회 — 상태 파일 생성
ratchetlock freeze       # 지금 통과하는 결과를 기준선으로 동결

이후 프롬프트를 고칠 때마다 이 루프를 돈다.

vi prompt.txt                          # 프롬프트 수정
ratchetlock check --live               # 실제 모델을 불러 효과 확인
ratchetlock add-fail --from-last <케이스>  # 새로 잡은 실패를 영구 가드로 등록
ratchetlock freeze                     # 좋아진 상태를 새 기준선으로 동결

오늘 막 나온 새 출력 한 건에 등록된 프로브를 그대로 적용해 신규 왜곡을 검사하고 싶으면 lint를 쓴다. 과거 케이스를 다시 도는 게 아니라 임의의 출력 1건을 프로브에 통과시켜 보는 것이다.

ratchetlock lint --output today.json   # 등록된 프로브로 새 출력 1건 검사, 위반 시 exit 1

여기서 새 왜곡이 잡히면 그 입력을 tests.yaml에 넣고 add-fail로 영구 가드에 승격한다 — 실전에서 만난 어려운 케이스를 계약에 편입하는 통로다.

lint가 유효한 범위는 프로브가 보는 입력 단위와 같을 때뿐이다. lint는 계약에 등록된 프로브를 그대로 재사용하므로, 프로브가 원본 대조형(생성일·스타 수 같은 판정 메타를 vars로 받아 왜곡을 잡는 방식)이면 그 vars--vars로 반드시 같이 넘겨야 한다. vars 없이 부르면 그 프로브들은 전부 스킵되고, 위반이 있어도 exit 0으로 통과한 것처럼 보인다 — 위반을 놓친 것이지 없는 게 아니다. 반대로 이미 렌더된 문서 전체를 표면 패턴으로 훑어 위반을 추정하는 용도는 lint의 범위가 아니다. 그런 일은 앱 쪽에서 문서를 항목 단위로 쪼개 (출력, vars) 페어를 만들어 lint에 넣는 어댑터가 해야 한다(패턴은 docs/PROBES.md).

CI에서는 ratchetlock check 한 줄이면 된다. 기준선이 깨지면 exit 1로 빌드가 실패한다.

커맨드

바이너리는 ratchetlock, 커맨드는 여섯 개다.

커맨드 하는 일 LLM 호출
init promptfooconfig을 읽어 프롬프트·프로브·테스트를 뽑고 ratchet.json을 만든다. 안 함
check 기준선(동결분 + 등록된 실패 케이스)이 여전히 통과하는지 본다. 회귀가 있으면 exit 1. 안 함 (--live일 때만 함)
freeze 지금 통과하는 케이스를 모델 출력 스냅샷째로 얼려 기준선을 올린다.
add-fail 직전 평가에서 잡힌 실패를 영구 회귀 가드로 등록한다. 프롬프트를 바꿔도 계속 지켜진다. 안 함
lint --output <파일> [--vars <JSON>] [--prompt <label>] 등록된 프로브를 새 출력 한 건에 적용해 신규 왜곡을 검사한다. 위반이면 exit 1. 안 함
status 활성 프롬프트, 동결 수, 기준선 크기, 프롬프트·프로브 파일의 변경 여부를 보여준다. 안 함

check는 왜 LLM을 부르지 않나

프롬프트 평가는 사실 두 단계다. 모델을 불러 출력을 받는 단계는 비싸고, 같은 입력에도 매번 결과가 조금씩 다르다. 그 출력을 프로브로 채점하는 단계는 그냥 코드 실행이라 빠르고, 백 번 돌려도 같은 결과가 나온다.

freeze가 첫 단계의 결과물(모델 출력 원문)을 저장해 두기 때문에, 기본 check는 첫 단계를 건너뛰고 저장된 출력에 채점만 다시 한다. 학생에게 시험을 다시 치게 하는 게 아니라, 보관해 둔 답안지를 현재 채점 기준으로 다시 채점하는 것이다. 그래서 API 키 없이 돌고, CI에서 매번 같은 판정이 나온다.

이 방식이 잡는 것은 프로브가 바뀌어 과거 통과작의 판정이 뒤집히는 경우, 저장된 스냅샷이 변조되거나 손상된 경우, 그리고 등록된 실패 케이스 목록의 위반이다. 대신 프롬프트를 새로 고친 효과는 보지 못한다 — 저장된 출력은 옛 프롬프트가 만든 것이니까. 그건 check --livefreeze가 모델을 새로 불러 확인한다. 이렇게 나눈 이유는 ARCHITECTURE.md에 적어 뒀다.

실제 사례: 카드뉴스 프롬프트 v1 → v2

examples/cardnews는 지어낸 데모가 아니라 실제로 운영하던 프롬프트를 고치면서 겪은 일이다. 인스타그램 카드뉴스용으로 기술 뉴스를 대중이 읽기 쉽게 재작성하는 프롬프트인데, v1과 v2를 각각 claude(sonnet)에 실제로 태워 받은 출력이 fixtures/에 그대로 들어 있다.

v1(prompt.txt)은 5개 테스트 케이스 중 0/5만 통과했다. 코드펜스를 쓰지 말라고 명시했는데도 4건이 코드펜스로 감싸 나왔고, 나머지 1건은 아예 JSON 파싱이 깨졌다. 프롬프트를 v2(prompt_v2.txt)로 다시 쓰자 4/5로 올라왔다.

나머지 1건인 OmniRoute 케이스가 이 도구의 값어치를 보여준다. v2에서 코드펜스는 고쳐졌지만, 원문에 있던 "저자 주장" 한정어(누가 그렇게 주장했다는 단서)가 재작성 과정에서 빠진 채로 나왔다. 그리고 그걸 asserts.js에 이미 들어 있던 프레이밍 체크가 잡아냈다. 로드맵으로 미뤄 둔 미해결 문제가 아니라, 기존 프로브가 v2 재작성이 실제로 만든 결함을 검출한 사례다. 그래서 이 한 건은 일부러 동결하지 않았고, 헤드라인은 "0/5 → 4/5, 나머지 1건은 알고 있음"이다.

이 흐름 전체를 demo.sh가 재현한다.

bash examples/cardnews/demo.sh

claude 구독이나 API 키가 없어도 끝까지 돈다. 기본이 fixture 재생 모드이기 때문이다 — fixtures/에 저장해 둔 실제 claude 출력을 되돌려 현재 프로브로 다시 채점하므로 LLM을 새로 부르지 않는다. init으로 시작해서, v1이 진짜 0/5임을 확인하고, 그 실패들을 add-fail로 등록하고, v2로 넘어가 4건을 freeze하고, 결정적 check가 전부 통과하는 걸 exit 0으로 확인한다. 중간에 동결 출력 하나를 일부러 망가뜨려 check가 그걸 잡아내는 것까지 보여준다. 실행 로그 전문은 TRANSCRIPT.md에 그대로 있다.

프레이밍 체크는 "의미 왜곡 검출"이 아니다

위에서 OmniRoute를 잡은 프레이밍 체크는 저자 주장 한정어가 있는지 정규식으로 보는 결정적 키워드 체크다. LLM이 "이 문장이 원문 의미를 왜곡했나"를 판단하는 루브릭이 아니다. 로드맵에 있는 의미 왜곡 검출은 그보다 한 층 위의 문제이고, 이번 범위 밖이다.

CI 연동

check가 exit code로 회귀 여부를 알려 주므로 CI에 그대로 붙일 수 있다.

ratchetlock check   # 회귀 있으면 exit 1 → 빌드 실패

정직한 한계

포트폴리오용 도구이기도 하고 실전에서 착시를 겪기도 해서, 이 게이트가 보장하지 않는 것을 분명히 적어 둔다.

  • 라이브 통과는 근사치다. 계약 평가는 프롬프트 한 건을 단독으로 태우지만, 프로덕션에서는 같은 프롬프트가 전체 스펙 맥락 안에서, 다른 호출 방식으로, 때로는 다른 모델로 돈다. 그래서 check --live가 통과했다고 프로덕션에서 똑같이 통과한다는 보장은 없다 — 이 도구는 프로덕션 게이트를 대체하지 않는다. "계약 통과 = 안전"으로 읽으면 안 된다.
  • 결정적 check의 CI 초록불은 "아무도 계약을 안 건드렸다"는 뜻이지, "프롬프트가 지금도 잘 작동한다"가 아니다. check는 동결 시점의 출력을 다시 채점할 뿐이라, 모델이 조용히 드리프트했거나 실제 입력 분포가 바뀐 것은 보지 못한다. 그건 check --live를 새로 돌려야 보인다.
  • freezecheck --live는 로컬 LLM CLI가 있어야 돈다. provider가 실제로 실행 가능해야 하므로 (예: claude CLI가 PATH에 있어야) 원격·모바일 환경에서는 프롬프트를 새로 동결하지 못한다.

표본 운영 수칙 — 래칫은 표본만큼만 지킨다

동결한 케이스가 곧 계약의 전부다. 하루치 뉴스 6건만 동결했다면 정치·사망사고·영문 고유명사가 많은 어려운 유형은 계약에 아예 없는 것이고, 래칫은 그런 입력을 지켜 주지 않는다. 도구가 표본 밖까지 보장한다고 착각하지 않는 게 첫 수칙이다.

그래서 운영은 이렇게 돈다. 실전에서 어려운 입력을 만날 때마다 tests.yaml에 추가하고, 라이브로 통과하면 freeze로 편입한다. 새 스냅샷은 기존 동결을 덮어쓰지 않고 합집합으로 쌓인다 — floor는 동결분과 등록된 실패 케이스의 합이라, 한번 편입한 케이스는 계속 통과를 요구받는다. 표본이 늘수록 계약이 촘촘해지는 구조다.

로드맵

  • LLM 판정의 결정화 — 이진 assert가 못 잡는 사각지대(수치·단위·프레이밍의 의미 왜곡)를 LLM 그레이더로 검증하되, 비결정인 LLM 판정을 결정적 check와 화해시킨다. 방법은 판정도 fixture처럼 동결하는 것freeze 때 LLM 판정을 저장하고, check는 그 판정을 replay하며, 루브릭 해시가 바뀔 때만 재판정한다. 이러면 LLM 그레이더를 붙이고도 게이트의 재현성을 지킨다.
  • promptfoo optimize 편입 — 개선안을 자동 생성하고 check 게이트를 통과한 것만 채택하는 루프.
  • 프로브 락 강제 모드 — 프로브 해시 불일치를 기본 하드 페일로 승격해 측정기 드리프트를 원천 차단한다.
  • 다중 타깃 — 한 저장소에서 여러 프롬프트 계약을 병렬로 관리한다.

어떻게 여기까지 왔나

만들고, 실전 자동화 두 곳에 넣고, 거기서 깨진 것을 세 바퀴 고쳤다. 각 수정의 이유와 그 과정에서 배운 것은 docs/RETROSPECTIVE.md에 정리했다 — 가장 큰 결함이 왜 예제가 아니라 실사용에서만 나왔는지, 왜 이 도구의 진짜 적이 회귀가 아니라 측정기 자신이었는지에 대한 기록이다.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages