지난 글의 13건 실행에서 판정 호출 하나가 300초에 끊겼는데, 그 호출이 헤더를 기다리던 중이었는지 본문을 받던 중이었는지는 기록에 없다고 썼다. 무엇이 어디서 얼마나 걸렸는지를 답하는 것이 M5의 관측 슬라이스 M5-E다. 이번에도 헤더 대기와 본문 수신을 나누지는 않는다. 대신 추출·검색·판정·검증·저장 중 어느 단계에서 시간이 걸렸는지 남긴다. PR #27은 판정 도구 호출마다 run id 하나를 만들고, 그 아래에 단계 기록과 카운터를 남기고, pnpm diagnose <runId>로 단계 표를 읽게 한다. 실제 모델 호출은 없었다.

공유 analyzer에는 run별 hook을 넘길 수 없었다

계획은 run id를 분석기 텔레메트리에 관통시키는 것이었다. 그런데 서버는 analyzer를 하나 만들어 모든 요청이 공유한다. 텔레메트리 hook은 analyzer를 만들 때 한 번 붙는데, 그 hook은 어느 도구 호출이 지금 돌고 있는지 알 길이 없다. 도구 호출마다 analyzer를 새로 만들면 되지만, 호출마다 SDK 클라이언트를 만드는 구조는 원치 않았다.

답은 Node의 AsyncLocalStorage였다. 도구 핸들러가 run tracer를 만들고 그 안에서 작업을 돌리면, 같은 비동기 흐름에서 실행되는 hook이 현재 tracer를 꺼내 자기 단계에 텔레메트리를 붙인다. 어댑터 서명은 그대로이고 텔레메트리 이벤트에 선택 필드 runId 하나가 늘었을 뿐이다. 단계 종류는 추출·검색·판정 모델 호출·검증·저장 다섯으로 정의했다. 개별 판정은 저장된 입력을 사용하고, 추천 배치는 필요한 공고 추출까지 기록한다. 각 단계에는 시작 시각과 소요 시간, 결과가 붙고, 실패하면 고정 분류가, 배치 추천이면 후보 번호까지 붙는다. 실패 분류 함수는 M5-D의 모델 모드 러너에 있던 것을 공유 모듈로 옮겨 썼다.

흔적에는 이력서·공고·근거 원문을 넣지 않는다. 검색 단계는 질의 문장 대신 그 해시와 길이, 적중한 조각 id를 남기고, 검증 단계는 초안과 최종의 판정·확신도, 인용 개수, 그리고 지난 글에서 만든 단계 진단 개수를 남긴다. 모델 단계는 응답 모델과 상위 엔드포인트, 토큰 수다. 흔적 파일은 data/traces 아래 권한 0600으로 쓰이고, pnpm db:reset이 DB와 함께 지운다. 성공 경로와 실패 경로 둘 다 이력서·공고·근거 문장이 0바이트라는 테스트를 두었다.

관측이 결과를 바꿨다

리뷰는 세 경로를 재현했다. 첫째가 가장 아팠다. 흔적 저장은 판정을 DB에 저장한 다음에 일어나는데, 흔적 디렉터리 자리에 일반 파일을 두고 실제 MCP 요청을 보내니 판정 저장은 한 번 성공했는데 응답은 오류였다. 파일 시스템 오류가 도구의 try 블록으로 전파됐고, catch에서 실패 흔적을 또 저장하려다 다시 던졌다. 사용자는 저장된 판정의 id를 받지 못하고 같은 판정을 다시 돌릴 수 있다. 디스크 용량이나 권한 문제도 같은 경로다.

관측은 결과를 바꾸면 안 된다. 흔적 저장은 실패해도 고정 경고 한 줄로 끝나고 메모리 사본만 남기게 했다. 경로도 오류 본문도 출력하지 않는다. 회귀 테스트는 리뷰어의 재현 그대로다. 디렉터리 자리에 파일을 두고 정상 분석기로 호출하면 판정 id가 돌아오고, 모델이 500을 던지면 원래 오류 문구가 보존되며 파일 시스템 오류나 경로가 응답에 섞이지 않는다.

관측 자체에도 마감이 필요했다

둘째는 배치 추천이다. 추천은 마감 시간을 Promise.race로 걸어 두어서, 모델 호출이 안 끝나도 마감이 오면 현재 후보를 실패로 처리하고, 남은 후보는 분석하지 않은 상태로 반환한다. 그런데 단계 기록은 실제 모델 호출 promise가 끝나야 닫힌다. 리뷰어가 마감 20밀리초에 abort 뒤 30밀리초 만에 reject하는 분석기로 돌리니, 실패 1건인 결과를 저장하는 시점의 추출 단계는 성공으로, 소요 시간은 0밀리초로 기록됐고 타임아웃 카운터는 0이었다. 그 뒤 메모리의 기록은 중단, 53밀리초로 바뀌지만 이미 쓴 파일에는 반영되지 않는다. 리뷰 문장을 그대로 옮기면 “실제 SDK도 abort 이벤트와 fetch reject가 같은 시점에 완료된다고 보장할 수 없습니다.”

열린 단계를 집합으로 명시하고, 실행을 마칠 때 아직 열린 단계는 그때까지의 소요 시간과 함께 중단으로 닫고 타임아웃으로 센다. 뒤늦게 끝난 promise는 이미 닫힌 기록을 덮지 않는다. 판정 호출에 마감을 두었듯이 관측에도 마감이 있어야 했다. 회귀 테스트는 같은 지연 분석기로 저장된 JSON까지 확인한다.

셋째는 스위치다. 흔적 저장을 끄는 환경 변수를 두었는데, 디렉터리 경로를 정하는 함수가 그 스위치를 보고 빈 값을 돌려주니 삭제 스크립트와 DB 리셋도 지울 곳이 없다며 끝났다. 켜 둔 채 만든 파일이 끈 뒤에는 지워지지 않는다. 리뷰어의 요구는 “off는 앞으로의 저장만 제어하고, 명시적 삭제는 설정된 TRACE_DIR(또는 기본 경로)을 계속 해석하도록 분리해 주세요.”였다. 저장 여부와 경로를 다른 함수로 나눴고, 켠 상태에서 만든 파일을 끈 상태의 두 스크립트가 각각 지우는 테스트를 넣었다.

마지막에 빠뜨린 것

리뷰는 typecheck 오류 여덟 개도 잡았다. 테스트 파일 중간에 같은 이름의 import 블록이 하나 더 있었다. 테스트 러너는 통과시켰고 typecheck만 잡는 종류의 오류였는데, 마지막 수정 뒤 나는 테스트만 다시 돌리고 typecheck를 빠뜨렸다. 커밋 전 검증은 typecheck, lint, build, test 넷을 매번 다 돌리는 것으로 다시 못 박았다.

확인한 것과 남은 것

테스트는 447건, 정책 기준선 비교는 28건 그대로에 회귀 없음. 흔적이 실제 제공자 호출을 기록한 적은 아직 없다. 모델 모드 평가 러너는 흔적을 만들지 않으므로 골든셋 실행으로는 진단 파일이 생기지 않는다는 점도 리뷰에서 바로잡았다. 첫 라이브 흔적은 승인된 사용 확인 실행이나 ChatGPT 호스트의 판정 호출에서 나온다.

도구 사용 슬라이스가 빠진 현재 계획에서 M5-E는 마지막 코드 슬라이스다. 남은 것은 새 키로 v3 러너를 돌려 만드는 모델 모드 기준선과, 일반 채팅과 견주어 이 앱이 무엇을 더하고 더하지 못하는지 쓰는 정리 글이다. tracer의 계약은 저장소의 run-trace.ts에 있다.