콘텐츠로 이동

LLM 도구 호출 파서와 타입 복원

10/27(화) → 11/12(목)·3번 물음

한 줄 요약: 오픈 모델은 도구 호출을 자기만의 표기로 쓰고, 서빙 서버(vLLM 등) 안의 모델 전용 파서가 그걸 JSON으로 옮긴다. 오류가 나면 생성(모델) → 변환(파서) → 검증(받는 쪽) 중 어디서 정보가 틀어졌는지 단계별로 나눠 봐야 한다. 책임은 한 곳에만 있지 않다.

📍 만난 상황

  • 2026-10-27 — Gemma 어시스턴트 실패 원인을 분석하던 중이었다. 직전에 "문제의 줄은 매니저 models.py의 payload: dict[str, Any]"라는 설명을 들었다. 10번 물음 → 그날 일지
  • 14:45~15:27 "왜 젬마가 str로 바꾸냐", "vLLM 설정이냐" → 바꾸는 건 vLLM의 Gemma 파서이고, 코드에 박힌 동작이다.
  • 16:36~16:40 "다른 모델도 그러냐", "파서를 꼭 거쳐야 하냐" → JSON이 아닌 표기를 쓰는 모델의 파서만 그렇다.
  • 2026-11-11 — 이슈 #3561을 정식으로 고치려던 중이었다. 2번 물음 → 그날 일지
  • 16:32 "젬마가 dhub 타입을 못 받는 것 아니냐"고 물었다. → 젬마는 맞게 쓴다. vLLM이 타입 없는 payload 안쪽 값을 문자열로 남긴다.
  • 16:44 → 검증 직전에 "되돌리는 단계" 하나를 끼운다는 해법을 들었다.
  • 2026-11-12 — 젬마로 앱·대시보드가 안 되는 원인을 다시 분석하던 중이었다. 2번 물음 → 그날 일지
  • 17:20 직전에 "젬마가 줄바꿈을 적는 방식과 vLLM이 읽는 방식이 맞지 않아 생긴 오류"라는 설명을 들었다. "결국 젬마 파서 문제냐"고 물었다. → 젬마 공식 템플릿을 보니 줄바꿈 \n은 모델이 형식을 어긴 것, 지난번 숫자 오류는 파서가 타입을 버린 것이었다.
  • 17:28 "근본 원인은 vLLM 버그"라는 설명을 듣고 "그럼 우리 문제가 아니냐, payload를 any로 둔 우리 잘못 아니냐"고 물었다. → 원인은 vLLM 버그지만, 타입 없는 payload가 그 버그에 그대로 노출되게 만든 건 우리 몫이다.
  • 지난번과 달라진 점: 11-11에 "모델 탓"을 버렸는데, 오늘은 "파서 탓이냐 우리 탓이냐"의 이분법으로 물었다. 답을 듣고 오류마다 책임을 나누는 법까지 한 단계 나아갔다.

🧩 쉽게 말하면

외국인 직원이 쓴 주문서를 통역사가 사내 양식으로 옮기는 장면을 떠올리자.

직원(모델)은 자기 나라 약속대로 쓴다. 「」로 감싼 건 글자, 안 감싼 건 숫자다. 통역사(파서)는 따옴표 없는 3도 일단 글자 "3"으로 옮기고, 양식(스키마)에 "숫자 칸"이라 적힌 곳만 숫자로 고친다.

이때 사고는 세 종류다. 직원이 약속에 없는 표기를 쓰면 직원 잘못이다. 통역사가 멀쩡한 숫자를 글자로 바꾸면 통역사 잘못이다. 양식을 "기타"로만 적어 통역사가 고칠 근거가 없었다면 양식 만든 쪽 몫이다.

📖 제대로 알기

등장인물

  • 모델(Gemma 등): 도구 호출도 "글"로 쓴다.
  • 도구 호출 파서(tool call parser): 모델이 쓴 글을 OpenAI 형식 tool_calls JSON으로 옮기는 서빙 엔진(vLLM 등) 안의 부품.
  • 채팅 템플릿(chat template): 반대 방향 번역기. 대화와 도구 정의를 모델이 학습한 프롬프트 형식으로 바꾼다. 그 모델의 표기 규칙이 적힌 공식 문서 역할도 한다.
  • 타입 복원(type coercion: 값을 원하는 타입으로 바꾸기): 글자로 온 값을 스키마에 맞춰 숫자·참거짓으로 고치는 단계.

Gemma 4 표기 규칙

Google이 올린 chat_template.jinja의 format_argument가 규칙이다.

{%- elif argument is string -%}
  {{- '<|"|>' + argument + '<|"|>' -}}   {# 글자는 감싸기만 #}
{%- elif argument is boolean -%}
  {{- 'true' if argument else 'false' -}}  {# 따옴표 없음 #}
{%- else -%}
  {{- argument -}}                        {# 숫자도 따옴표 없음 #}
- <|"|>는 글자 다섯 개가 아니라 특수 토큰(token: 모델이 글을 다루는 최소 단위) 하나다. - 감싼 값은 글자, 안 감싼 값은 숫자·참거짓이다. 즉 타입 정보가 이미 글 안에 있다. - 글자는 감싸기만 하고 아무것도 바꾸지 않는다. 그래서 줄바꿈은 진짜 줄바꿈 문자로 써야 한다. JSON의 \n 같은 이스케이프(escape: 특수 문자를 다른 글자 조합으로 적는 규칙)가 이 형식엔 없다.

⚙️ 어떻게 동작하나

  1. 어시스턴트가 대화와 도구 정의(스키마 포함)를 vLLM에 보낸다.
  2. 모델이 자체 표기로 도구 호출을 쓴다. ← 줄바꿈 오류는 여기서 생긴다. 젬마가 JSON 버릇대로 \n 두 글자를 썼다.
  3. 파서가 <|"|>…<|"|>는 글자로 꺼낸다. 형식 규칙대로라 \n 두 글자도 그대로 꺼낸다(파서는 정확히 읽었다).
  4. 파서는 기호 없는 값도 일단 글자로 둔다. ← 숫자 오류는 여기서 생긴다. 모델이 남긴 타입 정보를 버린 것이다.
  5. vLLM 코드(_coerce_value)가 스키마의 properties·items를 따라 내려가며 타입이 적힌 칸만 되돌린다. anyOf 안으로는 들어가지 않는다.
  6. 우리 payload는 "아무 객체"라 5번이 안에 못 들어간다. ← 노출은 여기서 생긴다.

flowchart LR
    M["① 젬마<br/>자체 표기로 씀"] -->|"줄바꿈 오류<br/>모델 책임"| P["② vLLM 파서<br/>JSON으로 옮김"]
    P -->|"숫자 오류<br/>파서 책임"| D["③ dhub 검증"]
    S["도구 스키마<br/>payload 타입 없음"] -.->|"되돌릴 근거 없음<br/>우리 몫"| P
오류마다 틀어진 단계가 다르다. 그래서 "누구 탓"을 하나로 정할 수 없다.

왜 예전엔 괜찮았나: Claude는 처음부터 JSON으로 쓰고, vLLM v0.23까지는 타입이 유지됐다. payload를 any로 둔 전제는 v0.24의 파서 변경에서 깨졌다(vLLM 이슈 #47906).

🧪 예시

  • 예시 1 · 일상에 빗대어: 주문서 3칸. 직원이 수량 3(따옴표 없음), 포장 true, 메모 「1층\n2층」을 썼다. 통역 결과는 "3", "true", 「1층\n2층」이다. 앞의 둘은 통역사가 숫자·참거짓을 글자로 바꾼 잘못이다. 메모는 직원이 줄을 바꾸지 않고 \n이라고 적은 잘못이다. 통역사는 적힌 대로 옮겼다.

  • 예시 2 · 실제로는: 어시스턴트 파드에서 젬마에게 보내는 스키마를 그대로 만들어 본 결과다.

    plan            → 타입 있음 (LangChain이 $ref를 풀어 보냄)
     └ items[]      → 타입 있음 (id, resource_type, action ...)
        └ payload   → {"type": "object", "additionalProperties": true}
                       ↑ 여기서 타입 정보가 끊김
    
    ① vLLM은 items까지는 타입을 보고 숫자를 되돌린다. ② payload부터는 근거가 없어 nullable, precision, layout.w 같은 값이 "true", "2", "6"으로 남는다. ③ 실제 오류 난 칸이 전부 이 아래였다. 그래서 "vLLM만의 문제"라고 할 수 없다.

⚖️ 비교 · 흔한 오해

오류 누가 형식을 어겼나 고치는 방향
숫자·참거짓이 글자 파서(타입 버림) 받는 쪽에서 되돌려도 무리 없음
줄바꿈이 \n 두 글자 모델(형식 위반) 조건부로만 보정

payload에 타입을 적으면 끝나나? 생각보다 어렵다. 리소스마다 모양이 달라(schema, widgets, ops) 보통 anyOf로 적는데, vLLM은 그 안으로 안 들어간다.

방법 장점 걸리는 점
(가) 리소스별로 도구 나누기 타입을 끝까지 적을 수 있음 한 계획에 여러 리소스 묶는 구조가 바뀜
(나) 모든 칸을 payload 하나에 합치기 anyOf 없이 vLLM이 따라감 스키마가 길고 계약이 흐려짐
(다) 받는 쪽 되돌리기 (11-11 해법) 서빙 방식과 무관 받는 쪽 코드가 늘어남
내가 생각한 것 실제
결국 젬마 파서 문제다 숫자는 파서, 줄바꿈은 모델
우리 문제가 아니니 고쳐지면 된다 vLLM 수정은 숫자 문제만 해결. PR도 닫혀 일정 없음
any로 둔 게 우리 잘못이다 원인은 아니지만 노출은 우리 몫

왜 이분법으로 보기 쉬운가: "모델 → 결과"만 보면 사이 단계가 안 보인다. 그래서 원인이 늘 한 군데라고 느낀다. 실제로는 생성·변환·검증 세 단계가 있고, 각 단계가 서로 다른 오류를 만든다.

🛠️ 실무에서는

  • 책임을 나눌 때 볼 순서
  • 모델 원문 출력(파서 전 글). 오늘은 실패 턴 원문이 안 남아 줄바꿈 원인을 "추정"으로만 말했다.
  • 공식 채팅 템플릿으로 그 모델의 형식 규칙 확인.
  • 서빙 엔진 버전(v0.23과 v0.24처럼 동작이 다를 수 있다).
  • 스키마에서 타입이 끊기는 곳.
  • 보정할 때 원칙
  • 정상 값을 되돌리는 일(숫자)은 넓게 해도 된다.
  • 모델 실수를 받아 주는 일(줄바꿈)은 좁게. 코드 항목에서, 문법 검사가 실패하고 \n을 바꾸면 통과할 때만.
  • 외부 수정에 기대지 않는다. 고쳐져도 외부 엔드포인트와 고객 내부망이 업그레이드해야 반영된다.

❓ 자주 묻는 질문

  • Q. vLLM이 고쳐지면 다 해결되나? A. 아니다. 숫자 문제(#3561)만 해결된다. 줄바꿈, step의 id 키, 승인 때 계획 고쳐 쓰기는 모델 쪽이라 남는다.
  • Q. 줄바꿈도 받는 쪽에서 \n을 전부 바꾸면 안 되나? A. 코드 안에 진짜 "\n" 두 글자가 필요한 경우(문자열 리터럴)까지 망가진다. 그래서 조건부로만 한다.
  • Q. anyOf로 타입을 적으면 vLLM이 알아보나? A. 지금 코드는 properties와 items만 따라간다. anyOf로 적으면 지금과 똑같이 글자로 남는다.

🔭 더 깊이 보기

📚 참고 자료

  • 함수 호출(Function calling) (Gemini API 문서 한국어) — 회사 API가 인자를 JSON으로 돌려주는 모양 · 링크
  • Tool Calling (vLLM 공식 문서, 영문) — 파서 지정 옵션과 모델별 파서 목록 · 링크
  • Chat Templates (Hugging Face Transformers 문서, 영문) — 채팅 템플릿이 모델 형식을 정하는 방식 · 링크

🗓️ 이력

  • 2026-10-27: 문자열로 바꾸는 주체, 설정인지 코드인지, 다른 모델도 그런지, 파서를 꼭 거쳐야 하는지를 10번 물었다 → 일지
  • 2026-11-11: #3561 해법을 듣고 원인을 다시 확인했고, 받는 쪽에서 되돌리는 방식을 2번 물었다 → 일지
  • 2026-11-12: "결국 파서 문제냐", "any로 둔 우리 잘못 아니냐"를 물어 오류별로 책임(파서·모델·스키마)을 나눴다 → 일지

🕸️ 연결된 주제