전체 글

AI 에이전트 스레드 발행에서 또 샌 규칙, 이력과 에러로 막은 것

xXfacebook페이스북

이 저장소에서 "프롬프트로 시킨 규칙은 언젠가 샌다"는 이야기를 벌써 세 번 적었습니다. 자동 발행 파이프라인이 매일 무엇을 하는지, 프롬프트 규칙이 세 번 새고 나서 코드로 옮긴 것, 사이트 여섯 개를 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 이력 배열을 나란히 두고, 재발행 판정이 이력 쪽을 먼저 본다는 흐름을 보여주는 그림

문구를 고치면 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시 잡이 밀린 글 한 편을 그 폴백 경로로 올렸고, 존댓말 카피가 스레드에 그대로 나갔습니다. 스레드에서 존댓말 줄글은 기관 공지처럼 읽혀 아무도 멈추지 않습니다. 못 올리는 게 차라리 나은 상황을, 대비책이 오히려 만든 셈입니다.

thread 필드가 비었을 때 존댓말 카피로 물러나던 옛 폴백 경로와, 그 경로를 없애고 예외를 던져 발행을 멈추는 새 경로를 비교해 보여주는 그림

폴백은 못 올리는 것보다 낫지 않았다. 잘못 나간 글은 지워도 이력에 남는다.

게이트가 자기가 생각한 경로만 지켰다

더 나쁜 건 검사 쪽이었습니다. 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 는 스레드 문구에서 존댓말 하나만 검사합니다. 존댓말은 애매함이 없어 정규식으로 잡히지만 기사체나 서론체까지 재려다 한 번 걷어냈습니다. 그건 규칙이 아니라 취향이었기 때문입니다.

그래서 "코드로 옮겨라"는 만능 처방이 아닙니다. 경계가 검증 가능할 때만 코드가 프롬프트를 이깁니다. 검증이 안 되는 자리에서 억지로 정규식을 세우면 통과시키려고 기준을 낮추거나 오탐으로 발행을 막는 쪽으로 새기 때문입니다.

정리

  • 필드 하나가 두 역할을 겸하면 한 역할을 건드리는 편집이 다른 역할의 신호가 된다. 발행 이력은 지우지 않고 쌓아 판정을 분리했다.
  • 대비책이 잘못된 값을 흘려보낼 수 있으면 대비책을 없애고 예외로 멈추는 편이 낫다. 게이트는 "필드가 있을 때만"이 아니라 필드 밖에서 돌아야 한다.
  • 검증 가능한 경계는 코드로, 말투 같은 창작적 판단은 프롬프트로. 코드가 이기는 건 경계가 참·거짓으로 떨어질 때뿐이다.

근거와 참고

이 글 공유하기xXfacebook페이스북