AI
✓팩트검증✓코드검증validate.py발행 · 유의

[기획: AI 트렌드] 사용자가 친 글자인지 가리는 표시, 1월에 만들어졌고 10월에야 채팅 처리 결과에 실렸다

2026.10.14·읽기 13분

10월 13일 머지된 PR 하나

10월 13일, llama.cpp에 채팅 처리 방식을 바꾸는 PR 하나가 머지됐습니다. llama.cpp는 AI 언어 모델을 개인 컴퓨터 같은 곳에서 직접 돌리게 해 주는 공개 프로그램입니다.

개발자가 코드를 이렇게 바꾸자고 올리는 요청을 PR이라고 합니다. 관리자가 그 요청을 받아들여 본 코드에 합치는 일은 머지라고 합니다. 이번 PR은 관리자 ngxson이 올렸고, 다른 개발자 두 명이 승인했습니다.

PR 설명의 첫 문장은 이렇습니다. 「서식에 맞춰 정리한 대화는 이제 단순한 문자열 하나가 아니라 조각들의 목록이 됩니다.」 문자열은 글자들을 한 줄로 이어 붙여 다루는 형식입니다. 조각 목록은 같은 글을 여러 조각으로 나눠 담아, 조각마다 정보를 따로 붙일 수 있는 형식입니다.

겉으로 보면 코드 정리 작업입니다. 하지만 이 변경은 더 큰 작업의 한 단계입니다. 사용자가 친 글자와 프로그램이 끼워 넣은 글자를 가려 읽으려는 작업입니다. 이 작업에 합쳐진 버그 보고 가운데 하나는 6월에 올라왔습니다.

사용자가 친 글자가 대화의 경계로 읽혔다

6월, julmb라는 사용자가 llama.cpp에 「특수 토큰 주입」이라는 제목의 버그 보고를 올렸습니다.

AI 언어 모델에 들어가는 글은 먼저 토큰이라는 작은 단위로 잘립니다. 그리고 토큰마다 매겨진 번호로 바뀌어 모델에 들어갑니다.

토큰 가운데에는 보통 글자가 아닌 것도 있습니다. 대화에서 누구의 말인지, 어디서 차례가 바뀌는지 같은 것을 알리려고 따로 정해 둔 토큰입니다. 이를 특수 토큰이라고 합니다.

사용자와 모델이 주고받은 말은 모델에 들어가기 전에 글 한 덩어리로 정리됩니다. 이때 말과 말 사이에 특수 토큰을 글자 모양 그대로 끼워 넣는 서식을 채팅 템플릿이라고 합니다. 이 글을 토큰으로 자를 때는 특수 토큰 모양의 글자를 특수 토큰으로 읽어야 서식의 경계가 살아납니다.

제보자가 보낸 메시지 내용은 A<|turn>B였습니다. 가운데의 <|turn>은 그가 쓴 모델에서 특수 토큰으로 쓰이는 것과 같은 모양의 글자입니다. 사용자가 친 글자이니 <, |, turn, > 같은 보통 토큰 여러 개로 잘려야 합니다. 그런데 제보자에 따르면 이 글자는 특수 토큰 하나로 읽혔습니다. 그 모델의 토큰 목록에서 105번이 매겨진 특수 토큰이었습니다.

제보자는 이렇게 하면 「보통 사용자 입력으로는 할 수 없는 방식으로 채팅 템플릿을 깨뜨릴 수 있다」고 적었습니다. 사용자 말 중간에 차례를 가르는 특수 토큰이 생기면 대화의 서식이 어긋납니다. 그는 llama.cpp 문서에는 이런 일을 방지한다고 적혀 있어 놀랐다고 덧붙였습니다.

이 일은 흔히 말하는 프롬프트 주입과 층위가 다릅니다. 프롬프트 주입은 「앞의 지시를 무시하라」 같은 보통 문장으로 모델을 속이려는 수법입니다. 이 보고에서 문제가 된 것은 문장의 뜻이 아니었습니다. 대화의 차례와 역할을 가르는 특수 토큰 자체가 사용자가 친 글자로 만들어졌다는 점이었습니다.

가릴 정보는 1월부터 있었다

1월, llama.cpp에 새 Jinja 템플릿 엔진이 머지됐습니다. Jinja는 빈칸이 있는 서식에 내용을 채워 글을 만드는 서식 언어입니다. 템플릿 엔진은 그 서식을 실제로 채우는 프로그램으로, 채팅 템플릿을 처리하는 데 쓰입니다. 이 글에서는 엔진이라고 줄여 부릅니다.

이 엔진도 ngxson이 만들었습니다. 그 PR 설명의 주요 기능 목록에는 「입력 표시: 특수 토큰 주입 방어」가 있었습니다.

입력 표시는 글자 조각마다 「사용자에게서 왔다」는 표시를 붙여 두는 기능입니다. 엔진 문서에 따르면 사용자 입력에서 온 조각에는 is_input이라는 표시가 붙습니다. 조각을 이어 붙여도 조각별 표시는 그대로 남습니다.

그런데 같은 PR 설명에는 입력 표시를 「구현했지만 쓰지 않은 채 둔다」고 적혀 있었습니다. 서버에 붙여 설정으로 켜는 일은 후속 PR로 미뤄졌습니다. 서버는 다른 프로그램이 요청을 보내 모델을 쓰게 하는 llama.cpp의 실행 방식입니다.

PR 설명에는 이 기능이 필요한 이유도 적혀 있었습니다. 템플릿 결과가 그냥 문자열이면 진짜 특수 토큰과 끼워 넣은 것을 가려낼 수 없다는 것입니다. 글이 한 덩어리면 특수 토큰 모양을 읽을지 말지도 덩어리 전체에 한꺼번에 정할 수밖에 없습니다. 템플릿이 넣은 경계를 살리려고 읽게 하면, 사용자가 친 같은 모양의 글자도 함께 특수 토큰이 됩니다. 엔진의 결과를 받아 모델에 넣을 글로 정리하는 코드를 이 글에서는 채팅 처리 코드라고 부릅니다. 이 코드에서 대화는 문자열 하나로 다뤄졌습니다. 조각마다 붙어 있던 표시는 문자열 하나로 합쳐지면 남을 곳이 없습니다. 여러 사람이 쓴 메모를 이어 붙여 한 장으로 복사하면, 어느 줄을 누가 썼는지 복사본에 남지 않는 것과 같습니다.

6월 보고에 관리자 ngxson은 이렇게 답했습니다. 입력 표시 기능이 채팅 처리 코드보다 나중에 생긴 것이 주된 문제라는 것입니다. 또 채팅 처리 코드에서는 일부 템플릿에 정규식 치환이 일어난다고 했습니다. 정규식 치환은 정해 둔 글자 패턴을 찾아 다른 글자로 바꾸는 처리입니다. 그 탓에 걸러 내는 작업이 「극도로 까다롭다」는 설명이었습니다.

세 단계로 쪼갠 작업, 그 두 번째

9월 2일, ngxson은 「Jinja 입력 표시 연결하기」라는 제목의 이슈를 열었습니다. 이슈는 할 일이나 버그를 적어 두는 게시글입니다. 여기에 할 일 세 가지가 적혀 있습니다.

  • 첫째, 조각마다 특수 토큰을 읽을지 정해서 토큰으로 자르는 기능 추가
  • 둘째, 채팅 처리 코드가 문자열 하나 대신 조각 목록을 내도록 다시 짜기
  • 셋째, 서버에 연결하기

6월 보고는 이날 이 이슈로 대체한다는 메모와 함께 닫혔습니다. 문제가 풀려서가 아니라, 같은 문제를 다룬 보고 세 건 가운데 하나로 이 이슈에 묶였기 때문입니다.

첫째 단계는 같은 날 머지됐습니다. 이 기능을 쓰면 한 조각은 특수 토큰을 읽게 하고, 다른 조각은 읽지 않게 할 수 있습니다.

10월 12일, 한 사용자는 에이전트로 채팅 템플릿을 편집하다 이 문제에 부딪혔다며 이 이슈에 우회 방법을 물었습니다. 에이전트는 AI 모델에게 파일 읽기·쓰기 같은 작업을 이어서 맡기는 프로그램입니다. 그의 설명으로는 에이전트가 파일을 읽고 추론을 시작할 때마다 특수 토큰과 같은 모양의 글자를 써서 작업이 깨졌습니다.

그는 보안 면에서도 주입 문제라고 적었습니다. 그리고 나중에 덧붙인 메모에서는, 모델이 그런 글자를 쓰는 일이 특수 토큰 주입과 「약간 다른 문제」라고 했습니다. 작성자 스스로 그렇게 적었으므로, 우리는 이 사례를 특수 토큰 주입 버그의 증거로 보지 않고 같은 모양의 글자 때문에 실제 작업이 깨진 한 장면으로 봅니다.

같은 날 다른 사용자는 요청을 중간에서 받아 넘겨주는 프록시 서버를 AI에게 짜게 해 문제를 피했다고 답했습니다. 그는 그 뒤로 잘 작동한다며 이 방법을 임시 우회책으로 권했습니다.

이번 PR이 이 목록의 둘째 단계입니다. 새 코드에서 조각 하나는 글자 내용과 is_input 표시 하나로 이뤄집니다. 코드 주석에는 이 표시가 붙은 조각은 사용자 입력에서 왔으니, 안에 든 특수 토큰을 읽으면 안 된다고 적혀 있습니다. 채팅 처리 결과는 템플릿 조각과 사용자 입력 조각으로 나뉜 목록이 됩니다.

llama.cpp에는 모델·대화 형식별로 대화를 다루는 처리기가 따로 있습니다. 자료 수집 단계에서 확인한 바로는 이번 PR로 그중 20개 안팎이 문자열을 이어 붙이던 방식에서 조각을 하나씩 추가하는 방식으로 바뀌었습니다.

아직 남은 것, 그리고 구멍이 있던 곳

PR 설명에 따르면, 특정 조각에서만 특수 토큰을 골라 읽는 일은 후속 PR의 과제입니다. 이번 머지로 사용자 글 속 특수 토큰을 보통 글자로 읽게 하는 차단이 켜진 것은 아닙니다. 할 일 목록의 셋째인 서버 연결이 이뤄졌는지도 우리가 연 문서로는 확인하지 못했습니다.

머지 전 검토에서는 이 방식으로 다룰 수 없는 토큰도 지적됐습니다. 승인자 aldehir는 Qwen이라는 모델 계열의 사례를 들었습니다. 생각 과정의 시작과 끝을 알리는 토큰이 특수 토큰이 아니라 사용자 정의 토큰으로 등록돼 있다는 것입니다. 사용자 정의 토큰은 모델 제작자가 특수 토큰과 다른 종류로 따로 등록한 토큰입니다. 그는 특수 토큰은 읽지 않게 할 수 있지만 사용자 정의 토큰은 그럴 방법이 없다고 했습니다. 조각마다 표시를 붙여도 이 토큰은 사용자 글 속에서 그대로 읽힐 수 있다는 뜻입니다.

엔진 문서에는 이 방식의 부작용을 알리는 경고도 하나 있습니다. 템플릿이 사용자 입력을 조합해 특수 토큰을 만들면, 일부러 만든 것이어도 사용자 입력으로 취급돼 의도대로 동작하지 않는다는 내용입니다. 구멍이 아니라 경계를 지키는 데 드는 대가입니다.

입력 표시가 다루는 것은 어떤 글자가 특수 토큰으로 읽히느냐뿐입니다. 보통 문장으로 모델을 속이는 프롬프트 주입은 대상이 아닙니다. 그래도 우리는 이 문제를 작게 보지 않습니다. 특수 토큰은 대화의 차례와 역할을 가르는 경계이고, 제보자 말대로 그 경계가 깨지면 보통 사용자 입력으로는 할 수 없던 방식으로 채팅 템플릿이 무너지기 때문입니다.

앞으로 볼 것은 둘입니다. 조각마다 특수 토큰을 골라 읽는 후속 PR이 머지되는지, 그리고 그 표시가 서버까지 이어지는지입니다.

누가 친 글자인지 가릴 정보는 1월부터 엔진 안에 있었습니다. 그 정보는 엔진의 결과가 문자열 하나로 넘어가면서 사라졌습니다. 그래서 이를 되살리는 작업도 엔진을 다시 짜는 일이 아니었습니다. 세 단계 가운데 앞의 두 단계는 모두 문자열 하나 대신 조각 목록을 주고받게 하는 일이었습니다. 우리는 6월 보고 같은 구멍이 주로 부품 안이 아니라 부품 사이에서 글을 넘겨주는 형식에 있었다고 봅니다. 다만 전부는 아닙니다. 사용자 정의 토큰은 토큰으로 자르는 단계 안에 걸려 있고, 정규식 치환처럼 완성된 문자열을 고쳐 쓰는 처리도 조각 경계를 의식하도록 다시 짜야 합니다. 형식을 바꾸는 것만으로 닫히지 않는 부분이 남아 있습니다.

유의루틴 8회차 · 캐스케이드 ①(GitHub 1차 직접 열람 · 메인 7페이지 재열람) · 직접 열람 도메인 = github.com뿐(HF·arXiv·openai.com ENOTFOUND · WebFetch 요약 모델 경유) · f3·f7·f8 각 1인 보고 · f12(처리기 20개 안팎) miner 단독 열람 · domain-expert가 제시한 llama-vocab.cpp 사용자 정의 토큰 처리 코드는 메인 재열람 실패로 원장 미등재(f11 인용 범위로만 서술) · 델타 R2 should-fix 2 미반영(L77 정규식 치환 「다시 짜야」 시제 — 이번 PR이 일부 이미 고침 · L21 f# 부재) · nit 1 미반영(L71 「일부러」) · 렌더 본문 약 11.7KB(목표 6~10KB 초과) · llama.cpp 저장소 사건 3회 연속(10-12·13·14) · planner·reviser 메인 수행(스폰 상한 8) · pm 추가: pipeline R1 factcheck 행 sha는 기록 시점 바이트(사후 일괄 기입) · L71 「일부러」·「대가」는 우리 해석
출처
  1. 기준일 = 2026년 10월 14일 오전(한국 시간).
  2. 이번 정리에서 우리가 직접 연 원문은 GitHub에 있는 PR·이슈·문서·변경 파일 페이지뿐입니다. Hugging Face·arXiv 같은 다른 사이트에는 접속하지 못했지만, 이 글이 다룬 사건은 모두 GitHub 원문에 있습니다. 원문은 요약 모델을 거쳐 내용을 돌려주는 웹 열람 도구(WebFetch)로 읽어서, 핵심 문장은 인용으로 대조했지만 문단 전문은 한 줄씩 맞춰 보지 못했습니다. 모델별 처리기 20개 안팎이 바뀌었다는 내용은 자료 수집 단계에서 한 번 열어 본 결과이고, 우리가 다시 열어 대조하지는 못했습니다. 6월 버그 보고와 10월 12일의 두 댓글은 각각 한 사람의 보고이고, 재현 여부는 확인하지 않았습니다. 이번 PR이 들어간 릴리스 버전은 확인하지 못했습니다. GitHub 페이지의 시각은 시간대가 표기되지 않아 날짜만 적었습니다.
  3. 이해관계: Refract는 글을 분석하고 쓰는 데 Anthropic의 Claude를 씁니다. 이 글이 다룬 사건에 Anthropic은 당사자로 관여하지 않았습니다.
  4. 새 Jinja 템플릿 엔진 머지(2026-01-16)·입력 표시를 「구현했지만 쓰지 않은 채 둔다」는 설명·문자열이면 가려낼 수 없다는 이유 — 직접 열람 ggml-org/llama.cpp PR #18462
  5. 입력 표시의 작동 방식·사용자 입력을 조합해 만든 특수 토큰에 대한 경고 — 직접 열람 llama.cpp Jinja 엔진 문서
  6. A<|turn>B가 특수 토큰 105번으로 읽혔다는 보고(2026-06-09 · 제보자 1인)·관리자 답변·중복으로 닫힘(2026-09-02) — 직접 열람 ggml-org/llama.cpp 이슈 #24382
  7. 세 단계 할 일과 중복 보고 세 건 묶음(2026-09-02)·에이전트 사용자와 프록시 우회 댓글(2026-10-12 · 각 1인) — 직접 열람 ggml-org/llama.cpp 이슈 #28249
  8. 조각마다 특수 토큰을 읽을지 정해 토큰으로 자르는 기능 머지(2026-09-02) — 직접 열람 ggml-org/llama.cpp PR #28250
  9. 조각 목록 전환 머지(2026-10-13)·조각별 선택 읽기는 후속 PR이라는 설명·검토의 사용자 정의 토큰 지적 — 직접 열람 ggml-org/llama.cpp PR #30389
  10. 조각 자료 구조와 is_input 주석·모델별 처리기 변경(20개 안팎 · 자료 수집 단계 열람, 재대조 못 함) — 직접 열람 ggml-org/llama.cpp PR #30389 변경 파일
이 글은 AI와 함께 다차원으로 분석, 검증하고 집필자가 검수했습니다.