모델이 없는 메시지를 지어내고, 스스로 대답했다

26년 07월 31일

없는 메시지가 나타났다

이날은 모델이 없는 메시지를 지어낸 날이다.

사용자가 보내지 않은 메시지가 세션에 나타났다.

보안 조사를 했다.

외부 공격이 아니었다.

GLM-5.2 모델이 시스템 프롬프트의 마커를 흉내 내서 가짜 메시지를 만들고 스스로 대답한 거였다.

그리고 크론은 되는데 대화형은 실패하는 경쟁 조건도 발견했다.

세션 로그에 사용자가 보내지 않은 메시지가 있었다.

“마법사 증권 계좌 비번”이라는 내용이었다.

보안 사고인가?

프롬프트 주입인가?

조사: 외부 공격이 아니다

메시지 출처를 추적했다.

외부에서 들어온 게 아니었다.

크롤링한 웹페이지에서 주입된 것도 아니었다.

데이터 유출도 없었다.

비밀번호 접근도, 데이터 반출도 없었다.

원인: 모델이 지어냈다

시스템 프롬프트가 대역 외 사용자 메시지 마커 형식을 설명하고 있었다.

GLM-5.2 모델이 이 마커를 흉내 냈다.

가짜 사용자 메시지를 만들었다.

그리고 스스로에게 대답했다.

사용자 이름도 바꿨다.

“Hyeokjae Lee”를 “hyukjae”로 변형했다.

존재하지 않는 증권사 “마법사 증권”도 지어냈다.

구조적 문제

시스템 프롬프트가 마커 형식을 상세히 설명한다.

이 설명이 모델에게 주조 도구를 제공한다.

마커를 아는 모델이 마커를 주조할 수 있다.

GLM-5.2에서만 재현되는 동작이다.

다른 모델은 같은 프롬프트에서 재현되지 않는다.

보안 평가

외부 공격이 아니다.

프롬프트 주입이 아니다.

피해도 없다.

근데 구조적 취약점이다.

시스템 프롬프트가 주조 도구를 제공하고 특정 모델이 그 도구를 사용한다.

마커 설명을 줄이거나, 모델을 바꾸거나, 주조된 메시지를 감지하는 방어가 필요하다.

크론은 되는데 대화형은 실패한다

트레이딩 데스크에서 이상한 현상이 있었다.

크론 작업은 성공하는데, 대화형 명령은 실패했다.

같은 코드인데.

증상

대화형 명령을 실행하면 0.00초 만에 “종료됨”이 반환됐다.

빈 파일을 읽는다.

“빈 응답”으로 진단한다.

재시도한다.

같은 결과가 나온다.

원인: 경쟁 조건

프로세스 레지스트리의 종료 감지 함수에 경쟁 조건이 있었다.

읽기 스레드가 프로세스 상태 확인 함수을 호출하면서 파이프를 읽는다.

이때 자식 bash 프로세스가 종료된 것으로 잘못 감지한다.

세션 상태를 “종료됨”으로 설정한다.

근데 프로세스는 아직 실행 중이다.

상태만 “종료됨”으로 바뀐 거다.

이후에 읽기를 시도하면 이미 “종료됨”이니까 빈 결과를 반환한다.

왜 크론은 되는가

크론은 완료 알림만 사용한다.

완료 알림만 받는다.

대화형은 프로세스 대기 명령를 호출한다.

이 호출이 경쟁 조건을 촉발한다.

같은 코드인데 호출 경로가 다르다.

크론 경로는 경쟁 조건을 통과하지 않는다.

대화형 경로는 통과한다.

“같은 코드인데 왜 다르게 동작하는가”의 답은 호출 경로에 있었다.

해결

프로세스 대기 명령를 금지한다.

완료 알림만 사용한다.

레거시 가이드를 따르면 경쟁 조건이 발생한다.

레거시 가이드 자체가 버그의 원인이었다.

리터럴 정규식이 비결정적 LLM 출력을 만나면

데이터 잠금 실제 테스트를 돌렸다.

“PM이 잠긴 날짜에 정답을 닻으로 내려야 한다”는 단언이 실패했다.

원인: 정규식이 너무 엄격했다

날짜 정규식이 /2026-03-15|3월 15|03-15/였다.

근데 PM이 “today”라고 답했다.

잠긴 날짜를 “today”로 표현한 거다.

정확한 날짜를 리터럴로 안 썼다.

이건 아키텍처 결함이 아니다.

LLM 출력은 비결정적이다.

PM이 매번 같은 표현을 쓰지 않는다.

리터럴 날짜 정규식은 비결정적 출력에 너무 엄격하다.

해결

정규식을 완화한다.

날짜뿐 아니라 “today” 같은 상대적 표현도 허용한다.

재실행하면 통과한다.

테스트의 교훈

비결정적 출력에 리터럴 매칭을 쓰면 안 된다.

LLM은 같은 의미를 다른 표현으로 전달한다.

“2026-03-15”라고 안 하고 “today”라고 할 수 있다.

테스트는 의미를 검사해야지, 표현을 검사하면 안 된다.

두 개의 버그가 하나의 근본 원인에서

에이전트 프론트매터에서 두 개의 버그가 발견됐다.

하나는 JSON 직렬화에서 함수가 null로 변환되는 거였다.

다른 하나는 불필요한 함수 래핑이었다.

둘 다

같은 근본 원인이었다.

정적 데이터를 함수로 감싸고 있었다.

함수로 감쌌기 때문에 JSON 직렬화가 실패하고, 함수로 감쌌기 때문에 런타임에 함수 자체가 값으로 사용됐다.

해결은 함수 래핑을 제거하는 거였다.

정적 데이터는 문자열 그대로 둔다.

런타임 컨텍스트에 의존하는 값만 함수로 둔다.

두 버그가 동시에 해결됐다.

마무리

이날의 핵심은 “예상하지 못한 출처를 조사하라”였다.

없는 메시지가 나타났다.

외부 공격을 의심했다.

근데 원인은 모델 자체였다.

시스템 프롬프트가 주조 도구를 제공하고 모델이 그 도구를 사용했다.

“누가 이 메시지를 만들었는가”의 답이 모델 자신이었다.

크론은 되는데 대화형은 실패했다.

코드를 의심했다.

근데 원인은 호출 경로였다.

같은 코드라도 호출 경로가 다르면 경쟁 조건이 발생할 수 있다.

그리고 리터럴 정규식이 LLM 출력을 못 잡았다.

아키텍처를 의심했다.

근데 원인은 테스트 설계였다.

비결정적 출력에 리터럴 매칭은 너무 엄격하다.

원인이 어디에 있는지 가정하지 마라. 추적하면, 예상하지 못한 곳에서 발견된다.