지난 글에서 남긴 질문은 실제 모델이 해석 가능한 조각 id를 인용하는가였고, 그것은 M5-D, 모델 모드 평가의 몫이라고 적었다. 이번 PR #25가 그 M5-D다. 골든셋을 만들고, 사례마다 추출과 판정을 실제 모델 경로로 돌린 뒤 결정적 파이프라인을 통과시켜 채점하는 러너 pnpm eval --mode model을 더했다. 구현은 Claude Code가, 리뷰는 Codex가 맡는 구성은 그대로다. 이번에도 실제 모델 호출은 없었다. 러너는 가짜 분석기로만 확인했고, 그 질문은 아직 답이 없다.

무료로 끝내기로 했다

이 슬라이스를 시작하며 방향을 하나 바꿨다. 사용 확인 1차의 세 번째 질문인 ‘같은 자료를 일반 채팅에 붙였을 때보다 편한가?’에 대해 그때 “아직 그렇다고 말할 수 없다”고 썼는데, 그 뒤로도 일반 채팅보다 나은 점을 확신하지 못했다. 추가 비용을 들이기보다 무료 티어 안에서 검증을 마무리하기로 했고, 이 프로젝트에 유료 제공자는 넣지 않는다.

이 결정은 M5 계획에서 M5-A 전에 내가 정해야 할 것으로 남겨 둔 셋도 같이 정리했다. 임베딩 없이 어휘 검색만, 코퍼스는 합성만, 도구 사용 슬라이스 M5-C는 제외. M5는 관측 슬라이스 E에서 끝나고, 정리 글 F의 중심은 일반 채팅과의 비교가 된다. 결정 기록 ADR-0014로 남겼고, OpenAI 어댑터는 코드에 남되 어느 슬라이스도 의존하지 않는다. 무료 호출도 합성 텍스트의 외부 전송이라 승인 절차는 그대로다.

모델 골드는 정책 골드가 아니다

러너를 만들기 전에 골든셋부터 정해야 했는데, 기존 정책 평가의 28건을 그대로 쓸 수는 없었다. 정책 사례는 구조화된 프로필과 공고에 미리 써 둔 초안을 넣고, 정책을 통과한 결과를 기대값으로 둔다. id를 무시하면 네 묶음 열두 건이 같은 입력을 공유하고, 그중 두 묶음은 초안만 다른데 기대 판정이 서로 다르다. 그 기대값은 입력에 대한 답이 아니라 정책에 대한 계약이다. 게다가 정책 사례에는 원문 텍스트가 없다. 프로필은 구조로 만들고 원문 해시 자리는 자리표시자다.

그래서 골든셋은 새로 썼다. 합성 이력서 텍스트와 공고 텍스트 33건, 버전 model-golden-v1. 요건 id는 추출 결과의 구분과 순서에 따라 달라질 수 있어, 기대하는 결격 사유는 id 대신 요건 문장으로 적는다. 기대 판정은 하나가 아니라 허용 집합이고, 사람이 검토해 서명하기 전까지 모든 사례는 pending이다. 러너는 채점 전에 추출된 요건을 정규화한 문장의 동등 비교로만 골드에 대응시키고, 대응하지 못한 요건은 가장 가까운 추출 문장과 함께 나열하되 원인은 분류하지 않는다. 문장 비교로는 의역과 의미 누락을 구분할 수 없어서다. 그 판단은 사람 검토 항목으로 남긴다.

실패는 PASS가 아니라 결과다

사례마다 호출은 최대 셋이다. 프로필 추출, 공고 추출, 판정. 어느 하나가 실패하면 그 사례의 결과는 추출 실패나 판정 실패다. 실패의 종류는 스키마 실패, 거절, 잘림, 타임아웃, 제공자 오류로 분류하고, 어느 것에도 해당하지 않으면 기타로 남긴다. 분류는 어댑터의 고정 오류 문구와 오류 이름, 호출 텔레메트리로만 하고 오류 메시지의 문장을 해석하지 않는다. 실패가 PASS로 둔갑하지 않는 것이 이 러너의 첫 번째 계약이다.

결격 재현율은 둘로 나눠 보고한다. 하나는 추출 결과에 대응된 골드 결격 요건만 분모로 두고, 다른 하나는 골드의 결격 전부를 분모로 두어 대응 못 한 요건을 놓친 것으로 센다. 골드 결격 요건 둘 중 하나만 추출되고, 판정이 그 하나를 결격으로 잡았다면 첫 번째는 1.0, 두 번째는 0.5가 된다. 대응된 요건이 하나도 없으면 첫 번째는 1.0이 아니라 N/A다. 계획 리뷰에서 요구한 조건이었고 테스트로 고정했다.

가짜 분석기로 돌린 결과는 33건 전부 채점, 호출 99회, 요건 대응 45/45, 결격 재현율 10/11, 허용 집합 안의 판정 27/33이다. 이 숫자는 모델을 말하지 않는다. 가짜 분석기는 골든 텍스트 형식을 파싱하고 토큰 겹침으로 판정하는 고정 규칙이라, 집합 밖 판정 여섯 건과 놓친 결격 한 건은 그 규칙의 결과다. dry run이 확인하는 것은 러너가 끝까지 돌고 숫자를 정해진 방식으로 내는가뿐이다.

제공자를 골랐다고 무료가 되지는 않았다

리뷰는 세 건을 잡았다. 첫째가 이 글의 제목이다. 러너의 승인 규칙은 전송 승인 플래그 하나로 라이브가 되고, 기본 제공자는 OpenRouter다. 비용 승인 플래그는 OpenAI 경로에만 요구했다. 리뷰어는 실제 SDK의 fetch를 스텁으로 바꿔, 전송 승인만으로 gate가 라이브를 허용하고 synthetic/paid-model이라는 모델명이 요청에 그대로 들어가는 것을 확인했다. 외부 호출은 없었다. 환경 설정의 모델이 유료 모델이면 무료 티어 검증이라는 승인 범위와 달리 최대 99회가 과금 경로로 나갈 수 있었다. 리뷰 문장을 그대로 옮기면 “계획을 출력하는 것만으로는 자동으로 이어지는 실행을 막지 못합니다.”

결정은 ADR과 계획 출력에 있었고, 거절 경로에는 없었다. OpenRouter는 무료 엔드포인트를 모델 id 뒤에 :free 접미사를 붙인 변형으로 구분한다. 고친 러너는 라이브 실행 전에 .env.local을 먼저 읽고, OpenRouter 라이브는 모델 id가 :free로 끝날 때만 허용한다. 모델이 비어 있어도, 접미사가 없어도, 무료 모델 사이를 무작위로 고르는 openrouter/free여도 고정 문구로 거절한다. 실행은 자기가 쓰는 모델의 이름을 대야 한다. 예외는 비용 승인 플래그를 명시할 때뿐이다. 검사는 id 접미사 기준이고 라우팅 자체는 호출 전에 검증할 수 없어서, 호출별로 기록되는 상위 엔드포인트와 OpenRouter 대시보드가 비용의 근거라고 문서에 적었다. 테스트는 비무료 모델 거절과 fetch 미호출, 그리고 :free 모델이 gate를 지나 스텁으로 요청이 나가는 대조군이다.

SDK 타임아웃은 헤더까지였다, 두 번째

둘째는 이미 배웠던 것이다. 사용 확인 러너를 만들 때 리뷰에서 OpenAI SDK의 timeout이 우리 측정에서는 응답 헤더까지만 막고 느린 본문은 막지 못한다는 것을 알았고, 호출마다 본문까지 유효한 AbortSignal을 넘기도록 고쳤다. 새 러너는 SDK timeout 300초만 핀으로 박고 세 호출에 signal을 넘기지 않았다. 리뷰어는 루프백 서버로 재현했다. 헤더는 바로 보내고 본문은 늦게 닫는 서버에 timeout 100밀리초로 호출하니 362밀리초 뒤에 완료됐고, AbortSignal을 함께 준 대조군은 103밀리초에 중단됐다. 같은 방식으로 내가 재현한 값은 417밀리초와 106밀리초였다.

사용 확인 러너의 호출별 deadline 함수를 공유 모듈로 옮기고, 모델 모드 러너가 분석기를 그 함수로 감싸도록 했다. 세 호출 모두 본문까지 덮는 signal을 받고, 잘린 호출은 타임아웃 실패로 분류되며, 보고서에는 deadline에 잘린 호출 수가 추가됐다. 회귀 테스트는 둘이다. 가짜 분석기에 지연을 주고 20밀리초 deadline으로 돌려 세 사례가 모두 타임아웃으로 기록되고 다음 사례로 넘어가는지, 그리고 실제 SDK와 루프백 서버로 deadline 없는 대조군은 350밀리초를 넘겨 스키마 실패로, deadline 100밀리초는 타임아웃으로 끝나는지 확인한다.

같은 교훈을 이틀 만에 다른 자리에서 다시 놓쳤다. 분석기를 만드는 진입점이 늘 때마다 같은 점검이 필요하다. 전송 핀, 목적지 변수 거절, 호출별 deadline, 승인 gate. 라이브 측정 하네스에서 고친 전송 핀과 목적지 거절을 사용 확인 러너에서 놓쳤고, 사용 확인 러너에서 고친 본문 deadline을 모델 모드 러너에서 놓쳤다.

검증 뒤에 계속하는 코드는 정말 멈추는가

셋째는 골든셋 무결성이다. 러너는 호출 전에 id 중복, 요건에 없는 결격, 같은 입력에 다른 판정 집합을 검사해 문제를 모은다. 그런데 문제를 모은 뒤 루프를 그대로 돌고, 마지막에 성공 여부만 거짓으로 만들었다. 리뷰어가 요건에 없는 결격 사유를 넣은 사례를 돌리니 문제가 하나 보고되는데도 분석기가 한 번 만들어지고 세 번 호출됐으며 결과는 채점 완료였다. 채점 전제가 깨진 입력에 라이브 호출과 한도를 쓰는 경로다.

이제 문제가 있으면 분석기를 만들지 않는다. 라이브러리는 호출 없이 모든 사례를 ‘시도하지 않음’으로 기록하고, CLI는 계획을 출력하기 전에 고정 문구와 사례 id로 거절한다. 테스트는 분석기 생성 함수가 호출되지 않았다는 것과 호출 수 0을 본다. 검증 코드가 있다는 것과 검증이 실행을 멈춘다는 것은 다른 사실이고, 이번에는 분석기 생성 여부와 호출 횟수로 그 차이를 확인했다.

확인한 것과 남은 것

확인한 것은 여전히 합성 입력뿐이다. 서버 테스트가 서른아홉 개 늘어 420건, 공유 패키지 7건과 함께 통과했고, 정책 평가는 35건 통과, M5-0 기준선과의 비교는 28건 그대로에 회귀 없음이다. 실제 제공자 호출은 0회다. 재리뷰의 결론은 “추가로 병합을 막을 결함은 발견하지 못했습니다”였고, 같은 리뷰가 “모델 정확도·실제 비용을 검증했다는 뜻은 아닙니다”라고 덧붙였다. 그 말이 맞다.

남은 것은 승인된 무료 실행 한 번이다. pnpm eval --mode model --approve-transmission으로 골든 텍스트를 OpenRouter의 :free 모델에 보내면 첫 live-verified 보고서가 나오고, 그것이 모델 모드의 기준선이 된다. 실제 모델이 해석 가능한 조각 id를 인용하는지도 그때 처음 잰다. 그 뒤는 M5-E 관측이고, 정리 글 F의 중심은 일반 채팅과 견주어 이 앱이 무엇을 더하고 무엇을 더하지 못하는가다. 러너의 계약 전문은 저장소의 model-mode.ts에 있다.