llama-server 스트리밍에서 이모지 토큰 ID가 빠지는 이유

llama-server의 /completion을 stream과 return_tokens로 쓰면, 한 글자가 여러 토큰으로 나뉠 때 본문은 멀쩡해도 원본 토큰 ID 일부가 전달되지 않습니다. 2024년 12월 도입된 코드부터 있던 경로입니다.

생성한 토큰 ID를 받아 다음 요청의 입력으로 그대로 이어 붙이는 벤치마크를 돌리다가, 256토큰을 생성했는데 ID는 251개만 받은 회차가 나왔습니다. 응답 본문은 멀쩡했습니다. 추적해 보니 llama-server의 스트리밍 응답에서 UTF-8 문자 하나가 여러 토큰으로 나뉘면, 앞쪽 토큰들의 ID가 클라이언트에 전달되지 않았습니다.

해당 조건은 native /completion에 stream=true와 return_tokens=true를 함께 쓸 때입니다. 일반 채팅처럼 본문만 쓰는 경우에는 드러나지 않고, 토큰 ID를 그대로 재사용하는 경우에만 문제가 됩니다.

재현

출력을 문법(grammar)으로 고정하고, 스트리밍 여부만 바꿔 비교했습니다. llama.cpp db00347a4(당시 최신)와 90c26fc 두 빌드에서 같았습니다.

강제 출력 스트리밍 아님: 생성 / 받은 ID 스트리밍: 생성 / 받은 ID 본문
ABCD 4 / 4 4 / 4 일치
😊📚😊 10 / 10 10 / 4 일치
안녕하세요. 5 / 5 5 / 5 일치

이모지 응답의 ID는 스트리밍이 아니면 [9008, 246, 232, 9008, 241, 248, 9008, 246, 232, 248046] 10개입니다. 스트리밍에서는 생성 누계 3, 6, 9, 10번째에 [232], [248], [232], [248046]만 왔고 마지막 요약의 tokens는 빈 배열이었습니다. 이모지 하나를 이루는 토큰 세 개 중 마지막 하나만 받은 셈입니다.

처음 문제가 된 벤치마크 회차도 이것으로 설명됩니다. 생성 256토큰 중 출력에 이모지가 네 번 나왔고, 각각에서 빠진 ID가 1+2+1+1=5개였습니다. 256-5=251로 정확히 맞았습니다.

원인

서버 코드에서 네 단계가 맞물려 있습니다.

  1. 토큰을 생성할 때마다 텍스트와 서버 쪽 generated_tokens에는 모두 누적됩니다.
  2. UTF-8 문자가 아직 완성되지 않았으면 그 조각의 스트리밍 전송을 보류합니다.
  3. 문자가 완성되면 보류했던 텍스트는 한꺼번에 보내지만, 토큰 ID는 현재 토큰 하나만 넣습니다.
  4. 스트리밍의 마지막 응답은 전체 generated_tokens 대신 빈 배열을 보내서, 보류 중에 빠진 ID를 복구할 기회가 없습니다.

본문 텍스트, UTF-8 바이트, 모델 토큰, SSE 이벤트는 각각 단위가 다릅니다. 텍스트 쪽은 보류 후 한꺼번에 보내도록 맞춰져 있는데, ID 쪽은 이벤트마다 하나씩이라는 가정이 남아 있었습니다.

언제부터였나

원본 토큰 ID를 응답에 넣는 기능은 PR #10853으로 2024년 12월 18일 병합됐습니다. 병합 시점의 서버 코드에 이미 세 요소(미완성 UTF-8 보류, 이벤트당 현재 ID 하나, 스트리밍 종료 시 빈 배열)가 함께 들어 있었습니다. 당시 테스트는 ID가 정수인지는 확인했지만, 나뉜 UTF-8 문자의 ID 개수와 순서는 대조하지 않았습니다. 2025년 2월 커밋과 이번에 쓴 빌드에서도 같은 경로를 확인했습니다. 다만 중간 릴리스를 모두 실행해 본 것은 아닙니다.

영향 범위

  • 영향을 받는 것: 스트리밍으로 받은 토큰 ID를 그대로 다음 요청의 prefix로 쓰는 경우. 생성 수와 받은 ID 수가 어긋나 정확한 누적이 깨집니다.
  • 영향을 받지 않는 것: 본문 텍스트. 이번 재현에서 텍스트는 모두 정상이었습니다. 서버가 멈추거나 응답이 잘리는 문제가 아닙니다.
  • GPU나 backend 문제가 아닙니다. 계산이 끝난 뒤 공통 서버 응답 코드에서 생깁니다.
  • 4바이트 문자만의 문제도 아닙니다. 코드는 2·3·4바이트를 모두 다룹니다. 다만 이번 한국어 대조군은 정상이었고, 2·3바이트 문자에서 ID가 빠지는 것은 실측하지 못했습니다. 토큰 경계가 문자 중간에 걸리면 같은 경로를 탈 수 있다는 것은 코드를 읽고 한 추론입니다.

우회와 수정 방향

당장은 벤치마크를 멈추지 않고, 생성 요청에만 주요 이모지 대역(U+2600–U+27BF, U+1F000–U+1FAFF)을 제외하는 grammar를 걸었습니다. 수정이 아니라 위험을 줄이는 우회입니다. 이모지 전체를 막는 필터도 아니고, 출력과 이후 prefix를 바꿀 수 있어서 이 조건으로 잰 결과는 제한 없는 결과와 섞지 않습니다. 매 응답에서 생성 수, 받은 ID 수, 서버 timings가 모두 256인지 확인하고, 어긋난 회차는 평균에서 빼되 기록은 남깁니다. 생성 후 텍스트를 다시 토큰화해 ID 수를 맞추는 방법은 원래 생성 경로를 보장하지 않아 쓰지 않습니다.

근본적으로는 보류했던 ID를 다음 이벤트에 함께 보내거나, 스트리밍 종료 응답에 서버가 보관한 전체 ID를 돌려주면 됩니다. 후자는 이미 받은 ID와 중복해서 세지 않는 규칙이 같이 필요합니다. 고친 뒤에는 ASCII, 한국어, 나뉜 UTF-8 문자, 종료·stop 조건에서 전체 ID의 개수와 순서를 확인해야 합니다. 이 글을 쓰는 시점에 upstream에 패치나 이슈를 올리지는 않았습니다.