지난 글에서 merge/tags 두 작업이 같은 데이터를 두 번 보내고 있던 걸 고쳤다. 순수한 낭비였지 결과가 틀리진 않았다고 정리하고 넘어가려는데, “프롬프트는 AI한테 가는 부분이라 지금까지 크게 신경을 안 썼던 곳”이라는 생각이 들었다. 맞는 생각이었다. 프롬프트 조립 파일을 처음부터 한 줄씩 다시 읽어보기로 했다.
이상한 걸 하나 발견했다
/ingest(신규 파일을 요약해서 위키에 병합하는, 이 플러그인에서 제일 많이 쓰는 기능)의
시스템 프롬프트를 만드는 함수를 보다가 이런 줄을 발견했다.
5. [속성(Properties) 생성 규칙 - 필수]: ...
\${getFrontmatterGuideline('ingest')}
6. [인덱스(index.md) 관리 규칙 - 필수]: ...
$ 앞에 백슬래시가 붙어 있었다. 자바스크립트 템플릿 리터럴에서 \$는 절대 치환되지
않고 그대로 리터럴 텍스트로 남는다 — 즉 AI한테 실제 규칙 내용이 아니라 ${getFrontmatterGuideline('ingest')}라는
글자 그대로가 전송되고 있었다는 뜻이다. 바로 옆의 split/merge 작업은 똑같은 함수를
${getFrontmatterGuideline('split')}처럼 백슬래시 없이 정상적으로 부르고 있었다.
ingest에만 있는 오타였다.
”정말로 그런거야?” — 세 갈래로 재검증
바로 고칠려다가 한 가지 생각이 들었다. 정말 그렇다면 왜 지금까지 그럴싸하게 동작한 거냐고. 합리적인 의심이라 세 가지로 나눠 다시 확인했다.
1) 이게 정말 이 프로젝트의 어떤 도구와도 무관한 순수 JS 문법인가. 이 저장소의 빌드 도구(esbuild)나 테스트 도구(jiti)를 전혀 안 거치고, 아무 관계 없는 Node.js 한 줄로 최소 재현해봤다.
const x = 'REAL_VALUE';
console.log(`before \${x} after`); // "before ${x} after" — 치환 안 됨
console.log(`before ${x} after`); // "before REAL_VALUE after" — 정상
\$는 어떤 JS 엔진에서도 절대 치환되지 않는 표준 문법이다. 어떤 번들러도 이걸
“고쳐서” 치환시킬 방법이 없다. 즉 esbuild가 프로덕션 빌드 과정에서 우연히 이걸
정상 동작시켰을 가능성은 없다.
2) 정말 이 한 곳뿐인가. 프롬프트를 조립하는 코드 전체에서 같은 오타 패턴을 다시 검색했다. 딱 이 한 줄이었다.
3) 왜 테스트가 못 잡았나. 기존 테스트 전체를 뒤져보니, /ingest 시스템
프롬프트의 실제 텍스트 내용(예: “10자리 ID” 같은 구체적인 문구)을 검사하는 테스트가
하나도 없었다. 최근에 만든 디버그 모드 관련 테스트들은 전부 “AI가 응답한 뒤에 그걸
어떻게 처리하는지”를 검증하는 것들이었지, 프롬프트 텍스트 자체를 글자 단위로 검증한
적은 이번이 처음이었다.
git blame으로 언제부터였는지도 확인했다 — 이 함수가 처음 만들어진 순간부터, 그러니까
한 달 가까이 있던 버그였다.
그런데 왜 완전히 망가지진 않았나
세 가지 검증으로 버그가 진짜라는 건 확인됐는데, 그럼 왜 지금까지 결과물이 아주 이상하진 않았는지가 남았다. 같은 프롬프트 안에, 규칙 문장과는 별도로 AI가 실제로 채워야 할 형식을 보여주는 예시 블록이 있었다.
---
title: 요약 문서의 핵심 제목 (특수문자 및 콜론 절대 사용 금지)
source: "[[원본_파일_ID]]"
created: YYYY-MM-DD
classification: entity
tags:
- 카테고리명 (예: technology)
- Wi-Fi
---
이 예시 하나가 title/source/created/classification/tags 구조를 이미 다 보여주고 있었다. LLM은 추상적인 규칙 문장보다 구체적인 예시를 모방하는 데 강하다 — 그래서 빠진 규칙 문장 없이도 예시만으로 어느 정도는 그럴싸하게 동작했을 걸로 보인다.
다만 예시에는 없고 규칙 문장에만 있던 세부 내용은 실제로 한 달 내내 전달되지
않았다 — classification을 entity로 할지 concept로 할지 가르는 구체적인 판단
기준(“고유 개체나 사람, 단체, 특정 도구 등은 entity로, 일반 지식·방법론·아키텍처
패턴 등은 concept로”) 같은 것들. 그리고 다듬는 코드도 이런 의미적 정확성까지는
안 본다 — YAML 문법(들여쓰기, 따옴표)만 정리할 뿐 classification 값이 실제로
맞는지는 검증하지 않는다.
곁가지 의심 하나 — 조사해보니 아니었다
여기서 또 하나 — “AI가 source에 파일 ID를 대괄호 없이 그냥 적는 것도 이것 때문 아니냐”는 거였다. 실제로 코드베이스엔 AI가 ID를 따옴표로만, 불릿으로만, 혹은 그냥 맨 텍스트로 적어버린 경우까지 네 가지 형태를 전부 정상 링크로 되돌리는 방어 로직이 있다. 이것도 규칙이 안 갔던 결과가 아닐까 싶을 만했다.
git log -S로 이 방어 로직이 언제 처음 생겼는지 추적해봤다. 결과는 예상과 달랐다
— 이 코드는 오타가 생기기 이틀 전에 이미 있었다. 즉 이번 버그의 결과물이
아니라, 애초에 “LLM이 형식을 항상 정확히 안 지킬 수 있다”는 일반적인 전제로 처음부터
넣어둔 방어 코드였다. 근거 있는 의심이었지만, 확인해보니 무관했다.
고치고 검증했다
고치기 전에 회귀 테스트부터 추가했다 — 시스템 프롬프트 결과물에 리터럴
${getFrontmatterGuideline('ingest')} 문자열이 없고, 실제 규칙 문구(엔티티/개념
판단 기준, “10자리”)가 들어있는지 확인하는 테스트. 예상대로 FAIL이었다. 그다음
백슬래시 한 글자를 지웠다.
- \${getFrontmatterGuideline('ingest')}
+ ${getFrontmatterGuideline('ingest')}
같은 테스트를 다시 돌리니 PASS로 바뀌었다. 전체 테스트, 타입 체크, 린트 전부 클린한 것도 확인했다.
남는 생각
2편에서 찾은 이중 전송은 결과가 틀리진 않는, 순수한 낭비였다. 이번 건 성격이 완전히 달랐다 — 가장 많이 쓰는 기능이 한 달 내내 규칙의 절반을 못 받고 있었는데, 전혀 몰랐다. 겉보기엔 그럴싸하게 동작해서였다.
“프롬프트는 AI한테 가는 거라 신경을 덜 썼다”는 말이 정확했다. 지금까지의 디버그 작업은 전부 AI가 응답한 다음을 검증하는 것들이었지, AI한테 실제로 뭐가 전달되는지 글자 단위로 확인한 적은 없었다. 응답을 아무리 꼼꼼히 검증해도, 애초에 잘못된 입력이 나가고 있으면 그 검증은 엉뚱한 지점만 지키고 있는 셈이다. 그리고 의심이 두 번 왔다 — “정말 그런거야?”와 “이게 다일 리가 없잖아” — 둘 다 맞는 의심이었다.