AI 에이전트 스레드 발행에서 또 샌 규칙, 이력과 에러로 막은 것
이 저장소에서 "프롬프트로 시킨 규칙은 언젠가 샌다"는 이야기를 벌써 세 번 적었습니다. 자동 발행 파이프라인이 매일 무엇을 하는지, 프롬프트 규칙이 세 번 새고 나서 코드로 옮긴 것, 사이트 여섯 개를 git worktree 로 격리한 이유 — 각각 다른 사고에서 출발했지만 결론은 같았습니다. LLM 에이전트는 매 실행마다 컨텍스트를 처음부터 새로 읽기 때문에 프롬프트에 적어 둔 지시는 명령이 아니라 확률입니다. 확률은 대부분 지켜지지만 언젠가 한 번은 깨집니다.
이번 글은 그 세 글이 다루지 않은 두 사건입니다. 공교롭게 둘 다 스레드 발행 코드에서 났습니다. 하나는 필드 하나가 두 가지 역할을 겸하다 같은 글을 두 번 발행한 일이고, 다른 하나는 스레드 문구가 없을 때 존댓말 카피가 그대로 새어나간 일입니다. 둘 다 프롬프트에 "이럴 땐 이렇게 하지 마"라고 적을 수 있었고, 그렇게 적었다면 똑같이 샜을 종류입니다. 그래서 코드로 옮겼습니다. (격리를 worktree 로 강제한 이야기와 담당자 배정이 14,221번 자기호출을 부른 이야기는 위 링크한 두 글에 이미 있으니 여기서는 다시 풀지 않습니다.)
프롬프트 지시는 확률이고, 코드 게이트는 매 실행마다 강제된다.
결론부터
두 사건 모두 "프롬프트로 막을 수 있었지만 언젠가 샜을" 규칙이었고, 결국 코드의 어느 층으로 옮겼는지가 다릅니다. 하나는 발행 이력을 append-only 로 쌓아 판정을 옮겼고, 다른 하나는 문구가 없으면 예외를 던져 발행 자체를 멈춥니다.
| 사건 | 무엇이 샜나 | 프롬프트로 적었다면 | 코드로 무엇을 강제했나 |
|---|---|---|---|
| 필드 겸직 | 문구를 고치며 threadsUrl 을 비웠더니 이미 올린 글이 되살아나 두 번 발행 |
"문구 고칠 때 threadsUrl 건드리지 마" |
threadsPosts 이력을 지우지 않고 쌓아 재발행 판정 |
| 존댓말 폴백 | thread 가 없어 social 로 물러났고 그건 존댓말이라 스레드에 그대로 나감 |
"스레드는 반말로 써라" | MissingThreadError 로 발행 중단 + 검사를 필드 밖으로 |
이 표의 오른쪽 두 칸이 이 글의 전부입니다. 왼쪽은 "프롬프트에 이렇게 적으면 되지 않나"라는, 실제로 한때 그렇게 하려던 대응이고, 오른쪽은 그게 확률이라 언젠가 깨진다는 걸 겪고 옮긴 자리입니다.
필드 하나가 "지금 링크"와 "올린 적 있나"를 겸했다
첫 사건은 커밋 ec3f8ed(2026-08-19)에서 정리했습니다. 스레드에 한 번 올린 글은 threadsUrl 이라는 frontmatter 필드에 게시물 주소를 남깁니다. 그런데 이 필드 하나가 두 가지를 동시에 뜻했습니다. **"지금 이 글의 링크"**이자 **"이 글을 올린 적이 있나"**였습니다. 큐는 후자를 물어보려고 이 필드가 비었는지만 봤습니다.
문제는 문구를 고치는 일과 재발행을 막는 일이 같은 스위치를 공유했다는 것입니다. 스레드 문구를 다듬으려고 12시 45분과 15시 12분 커밋이 threadsUrl 을 비웠더니, 큐는 그 글을 "아직 안 올린 글"로 읽었습니다. 그렇게 종부세 특례 글이 두 번 발행됐습니다. 지운 글은 계정 이력에 남고, 두 번 본 사람은 두 번 본 것입니다. 한 필드가 두 역할을 겸하면 한 역할을 건드리는 편집이 다른 역할의 신호가 됩니다.
문구를 고치면 threadsUrl 은 비지만, threadsPosts 이력은 지워지지 않는다.
판정을 이력으로 옮겼다
지금 pipeline/threads.ts 의 publish() 는 발행할 때마다 새 permalink 를 threadsPosts 배열 뒤에 붙이기만 합니다. 기존 배열을 펼치고 새 주소를 append 하는 append-only 이력이라, 문구를 고치며 threadsUrl 을 비워도 "올린 적 있다"는 사실은 그대로 남습니다. 코드 주석에 그대로 적어 뒀습니다.
// 지우지 않는 이력. threadsUrl 이 비어도 "올린 적 있다"는 사실은 남는다.재발행 판정은 assertNotPublished(file, force) 가 합니다. 핵심은 이력을 먼저 보는 한 줄입니다.
const already = history.length > 0 || Boolean(data.threadsUrl);--force 없이 이미 올린 글이면 예외를 던집니다. 일부러 다시 올리는 건 사람이 눈으로 정하는 일이라 그때만 --force 를 줍니다. 같은 이력을 보는 곳이 threads-pace.ts 와 threads-propose.ts 에도 있어서 밀린 글을 고를 때와 다음 후보를 제안할 때 모두 이미 올린 글을 뺍니다. 그리고 이미 올려 둔 글들에는 이력을 소급으로 심었습니다. 안 그러면 그 글들은 threadsUrl 만 비는 순간 되살아나기 때문입니다.
문구가 없으면 존댓말로 새어나갔다
두 번째 사건은 커밋 a035bac(2026-08-19 09시)입니다. 스레드에 올릴 문구는 frontmatter 의 thread 배열에 에이전트가 스레드 말투로 직접 씁니다. 스레드는 반말이 기본입니다. 그런데 thread 가 없을 때를 대비해 social 과 description 으로 물러나는 대비책이 있었습니다. "못 올리는 것보다 낫다"는 생각이었습니다.
그게 틀렸습니다. social 과 description 은 블로그·인스타용이라 **존댓말("~합니다")**입니다. 2026-08-19 09시 잡이 밀린 글 한 편을 그 폴백 경로로 올렸고, 존댓말 카피가 스레드에 그대로 나갔습니다. 스레드에서 존댓말 줄글은 기관 공지처럼 읽혀 아무도 멈추지 않습니다. 못 올리는 게 차라리 나은 상황을, 대비책이 오히려 만든 셈입니다.
폴백은 못 올리는 것보다 낫지 않았다. 잘못 나간 글은 지워도 이력에 남는다.
게이트가 자기가 생각한 경로만 지켰다
더 나쁜 건 검사 쪽이었습니다. check-content 에는 스레드 문구가 존댓말인지 보는 게이트가 있는데, 그게 이런 조건 안에 갇혀 있었습니다.
if (Array.isArray(thread) && thread.length) { ... }thread 필드가 있을 때만 검사가 돌았습니다. 그래서 필드가 아예 없는 글은 존댓말 검사를 통째로 건너뛰었고, 발행 쪽은 그 사이에 social 로 물러나 존댓말을 올렸습니다. 지키려던 게이트가 "필드가 없다"는 바로 그 이유로 사라진 것입니다. 게이트가 자기가 생각해 본 경로만 지킨 셈입니다.
수정은 세 곳입니다. pipeline/thread-chain.ts 에 MissingThreadError 클래스를 두고, buildChain 이 조각이 하나도 없으면 던지게 했습니다.
if (texts.length === 0) throw new MissingThreadError();social 로 물러나던 대비책을 없앴습니다. 잘못된 말투로 나간 글은 지워도 계정 이력에 남지만, 안 올린 글은 내일 올리면 됩니다. pipeline/check-content.ts 의 threadChecks(thread, enabled) 는 이제 thread 가 없을 때도 검사합니다. if (!Array.isArray(thread) || !thread.length) 이면서 스레드 대상 사이트면 경고를 push 하도록 게이트를 필드 밖으로 꺼냈습니다. pipeline/threads-pace.ts 는 문구가 없어 못 올리는 글을 대상에서 빼되 조용히 빠지지 않도록 따로 셉니다.
무엇을 코드로 옮기고 무엇을 프롬프트로 남기나
여기까지의 두 사건과 앞선 글의 두 사건을 합쳐 보면 기준이 하나로 모입니다. 경계가 검증 가능할 때만 코드로 옮깁니다. 필드에 값이 있나 없나, 파일을 누가 소유하나, 담당자가 배정됐나, 발행 이력이 있나 — 이런 건 참·거짓이 딱 떨어지므로 게이트·이력·에러로 강제할 수 있습니다. 그리고 그래야 합니다. 프롬프트에 적어 두면 확률이 되고, 확률은 언젠가 깨집니다.
반대로 창작적 판단은 코드로 재지 않습니다. 스레드 말투가 그렇습니다. 반말을 기본으로 하되 첫 글은 말 거는 반말, 중간 서술은 음슴체, 마지막은 명령형으로 자리마다 섞어야 자연스러운데, 이걸 정규식으로 판정할 수는 없습니다. 그래서 말투는 프롬프트가 쓰고 판정 단계(post-editor)가 읽어 보는 쪽으로 남깁니다. 실제로 check-content 는 스레드 문구에서 존댓말 하나만 검사합니다. 존댓말은 애매함이 없어 정규식으로 잡히지만 기사체나 서론체까지 재려다 한 번 걷어냈습니다. 그건 규칙이 아니라 취향이었기 때문입니다.
그래서 "코드로 옮겨라"는 만능 처방이 아닙니다. 경계가 검증 가능할 때만 코드가 프롬프트를 이깁니다. 검증이 안 되는 자리에서 억지로 정규식을 세우면 통과시키려고 기준을 낮추거나 오탐으로 발행을 막는 쪽으로 새기 때문입니다.
정리
- 필드 하나가 두 역할을 겸하면 한 역할을 건드리는 편집이 다른 역할의 신호가 된다. 발행 이력은 지우지 않고 쌓아 판정을 분리했다.
- 대비책이 잘못된 값을 흘려보낼 수 있으면 대비책을 없애고 예외로 멈추는 편이 낫다. 게이트는 "필드가 있을 때만"이 아니라 필드 밖에서 돌아야 한다.
- 검증 가능한 경계는 코드로, 말투 같은 창작적 판단은 프롬프트로. 코드가 이기는 건 경계가 참·거짓으로 떨어질 때뿐이다.
근거와 참고
pipeline/threads.ts—publish()의threadsPostsappend-only 이력과assertNotPublished()판정 (커밋ec3f8ed)pipeline/thread-chain.ts—MissingThreadError와buildChain의 빈 조각 예외 (커밋a035bac)pipeline/check-content.ts—threadChecks()를 필드 밖으로 꺼낸 존댓말 검사pipeline/threads-pace.ts·pipeline/threads-propose.ts— 같은 발행 이력을 보는 곳- 자동 발행 파이프라인이 매일 무엇을 하는지
- 프롬프트 규칙이 세 번 새고 나서 코드로 옮긴 것
- 사이트 여섯 개를 git worktree 로 격리한 이유