만들고 있는 Obsidian 플러그인은 기사 여러 편을 넣으면 LLM이 기사마다 개념·인물·회사별 노트를 만든다. 같은 기사에서 나온 노트끼리는 같은 사실을 반복하기 쉬워서, 노트를 쓰기 전에 LLM이 겹치는 문장을 정리하고 다른 LLM이 고친 노트를 다시 읽어 판정하는 단계가 붙어 있다. 앞 글까지가 이 정리와 판정을 믿을 만하게 만드는 이야기였다. 이번에는 같은 단계를 빠르게 만든 이야기다.
시간은 어디로 갔나 — 순차 실행 병목
기사 6개를 넣으면 노트를 만드는 데 매번 4분 반에서 6분 반이 걸렸다. 판정을 붙인 직후라 판정부터 확인했지만, 판정은 노트 하나에 2~12초였다. 짐작 대신 실행 로그의 시각으로 단계를 나눠 보니 큰 덩어리는 따로 있었다.
테스트 설정에서 플러그인은 기사 4개를 동시에 LLM에 보내고, 답이 다 오면 노트를 기사 순서대로 하나씩 쓴다. 중복 정리는 그 쓰기 단계 안,
파일을 쓰기 직전에 있었다. 그래서 기사마다 362초짜리 정리 호출이 줄줄이 이어졌다. 실제 실행 다섯 번에서 앞 4개 기사의 쓰기
구간이 77168초였고, 그중 90% 넘게가 정리 호출이었다. 뒤의 기사 2개는 그 쓰기가 다 끝나야 요청을 보냈다.
그냥 동시에 돌리면 안 되는 이유가 있었다 — 병렬 처리
정리 호출만 기사끼리 동시에 돌리면 될 것 같았지만, 쓰는 순서에는 이유가 있었다. 다음 기사의 노트는 앞 기사가 실제로 쓴 파일을
보고 이름이 겹치는지(겹치면 _dup2를 붙인다)와 링크가 살아 있는지를 판단한다. 정리를 쓰기 전에 동시에 돌리면 정리가 보는 제목과
링크가 지금과 달라진다.
그래서 순서는 그대로 두고 정리만 쓰기 뒤로 옮겼다. 노트를 먼저 쓰고 바로 다음 기사로 넘어가고, 정리와 판정은 기사마다 뒤에서 동시에 돌아 끝나는 대로 쓴 파일을 고친다. 고치기 직전에 파일이 그 사이 바뀌었으면 건너뛴다. 이름이 겹친 노트를 합치는 마지막 단계와 이어지는 자동 정리 제안은 모든 정리가 끝난 뒤에 돈다.
빨라진 건 바로 보였지만, 같다는 건 따로 증명해야 했다 — LLM 응답 재생 테스트
새 빌드로 같은 기사를 다시 돌리자 쓰기 구간이 164초에서 4초로 줄었다. 빨라진 건 숫자 하나로 보였다. 어려운 쪽은 “결과가 이전과 같은가”였다. LLM은 같은 기사로도 매번 다른 노트를 만들기 때문에, 이전 실행과 새 실행의 노트를 나란히 놓고 비교하면 차이가 코드 때문인지 LLM 때문인지 알 수 없다.
그래서 LLM을 빼고 비교했다. 실제 실행에서 저장해 둔 LLM 응답(노트 생성, 중복 정리, 판정)을 그대로 다시 넣어, 이전 코드와 새 코드로 각각 노트를 만들었다. 결과는 56개, 49개 파일 모두 한 글자도 같았다. 이 재생 결과는 실제로 돌렸던 실행의 노트와도 같아서(저장해 두지 않은 병합 응답을 대신 넣은 파일 하나만 달랐다), 재생이 실제를 재현한다는 것도 확인됐다.
다만 노트만 비교하면 놓치는 게 있었다. 기록된 응답을 노트 제목으로 찾아 돌려주니, LLM에 보낸 입력이 달라도 같은 답이 돌아간다.
그래서 호출마다 입력도 비교했고, 딱 한 곳이 달랐다. 이름이 겹쳐 _dup2가 붙은 노트를 가리키는 링크였다. 이전 코드는 이 링크를
바로잡는 처리를 정리 뒤에 했고, 새 코드는 이미 바로잡힌 본문을 정리에 넘긴다. 최종 파일은 같았다.
125초짜리 호출은 동시에 돌려서일까 — 응답 시간과 출력 토큰
새 빌드 첫 실행에서 정리 호출 하나가 125초 걸렸다. 같은 기사 묶음이 이전 실행에서는 22초였다. 동시에 여러 호출을 보내서 하나하나가 느려졌다면 동시 실행 수를 제한해야 한다.
짐작으로 정하지 않고 쟀다. 같은 정리 입력 6개를 하나씩 보낼 때와 6개를 동시에 보낼 때를 세 번씩 번갈아 돌렸다. 호출 시간 중앙값은 14.1초와 14.3초, 출력 속도는 초당 108토큰과 111토큰으로 같았다. 호출 시간은 답의 길이에 비례했는데, 같은 입력이라도 답 길이가 회차마다 몇 배씩 달랐다. 한 묶음은 3,700토큰에서 7,300토큰까지 오갔다. 125초는 그 회차 답이 유난히 길었던 것으로 보이고, 제한은 두지 않았다.
같은 시간대에 번갈아 쟀다 — A/B 테스트
처음 비교는 이전 실행과 몇 시간 차이가 나서 API 응답 속도 차이가 섞여 있었다. 그래서 이전 빌드와 새 빌드를 바로 이어서 번갈아 돌렸다. 기사를 동시에 4개씩 보내는 설정에서는 338초가 272초로, 303초가 171초로 줄었다. 플러그인 기본값인 2개씩에서는 기사가 세 번에 나뉘어 기다리던 구간이 더 많았던 만큼 554초가 313초가 됐다. 44%다. 실제 실행의 정리 결과도 하나씩 읽었다. 지운 사실이 다른 노트에 남아 있지 않은 경우는 새 빌드에서 0건이었다.
취소 테스트에서 로그가 줄을 잃었다 — 파일 쓰기 레이스 컨디션
마지막으로 수집 도중 “중단”을 눌러 봤다. 앞 기사 4개를 쓰고 정리가 뒤에서 돌기 시작한 순간이었다. 수집은 1초 뒤 깨끗하게 끝났고 노트도 멀쩡했다. 그런데 디버그 로그에는 정리 시작이 4줄, 정리 완료가 1줄뿐이었다.
정리 3개가 끝나지 않고 어딘가 매달렸다면 큰 문제였다. 따라가 보니 정리는 취소와 함께 다 멈춰 끝났고, 로그 줄만 사라진 것이었다. 로그 기록은 파일 전체를 읽고, 한 줄을 붙여, 다시 쓰는 방식인데 잠금이 없었다. 동시에 부르면 서로 같은 내용을 읽고 각자 써서 앞의 줄을 덮는다. 실제 코드로 동시에 4번 기록하니 파일에 1줄이 남았고, 차례로 하면 4줄이 남았다.
이 버그는 이번 변경 전부터 있었다. 지금까지 모든 실행 로그에서 기사 4개를 동시에 보낸 시작 기록이 하나만 남아 있었는데, 그게 이 버그였다. 실은 이번 측정 열흘쯤 전에도 눈에 띄었다. 그때 실행 기록에 동시에 보낸 기사들의 시작 기록이 처음부터 없다고 적고, 동시 기록이 부딪친 것 같다고까지 써 두었다. 하지만 따라가 보지 않았고, 그 뒤로는 시작 시각을 완료 시각에서 걸린 시간을 빼서 계산했다. 이번 변경으로 동시 기록이 늘자 더는 넘길 수 없게 드러났다. 기록을 한 줄씩 차례로 하게 고쳐 같은 릴리스에 넣었다.
남는 생각
빨라진 건 숫자 하나로 바로 보였다. 결과가 같다는 건 저절로 보이지 않았다. 매번 다른 답을 내는 LLM이 끼어 있으면 두 결과를 나란히 놓는 비교는 아무것도 증명하지 못한다. LLM의 답을 고정해서 넣고 나서야 코드만 비교할 수 있었다. 그리고 그 검증에 쓰던 로그도 틀릴 수 있었다. 감사가 로그로 재작성과 판정을 세고 있었으니, 로그가 줄을 잃으면 감사도 조용히 덜 셀 수 있었던 셈이다.