요약
vLLM에서 제약 디코딩을 켜면 Kimi와 DeepSeek의 도구 호출 형식 정확도가 100%가 됩니다.
유비클라우드 엔지니어들이 가중치를 공개한 AI 모델을 서비스하며 겪은 문제와 해결 과정을 정리한 글입니다. vLLM 0.28로 Kimi K2.6을 돌렸더니 도구 호출 25번 중 2번꼴로 정해진 형식(스키마)을 벗어났다고 합니다.
왜 중요한가
- 잘못된 도구 호출이 대화 맥락에 쌓이면 여러 턴을 이어 가는 에이전트 작업의 품질이 함께 떨어집니다.
- 추론 엔진의 기본값만 바뀌어도 같은 모델의 결과가 달라지므로, 엔진 설정도 품질 관리 대상이 됩니다.
핵심 내용
- 제약 디코딩(스키마에 맞지 않는 토큰을 생성 단계에서 막는 기법)을 켜자 모든 모델 버전이 100%를 기록했습니다.
- 처음에는 자체 포크에 이 기능을 넣었고, vLLM 0.25에 같은 기능이 들어오자 공식 버전으로 옮겼습니다.
- 그 뒤 vLLM이 요청에서 따로 켜지 않으면 끄도록 기본값을 바꾸면서 정확도가 91%로 다시 떨어졌습니다.
- vLLM 0.31부터는 서버 쪽 설정으로 켤 수 있고, 이후
--tool-strict-level플래그도 생겼습니다. - 일부 모델은 제약을 걸면 오히려 형식이 흐트러지고, 다른 기능과 함께 쓰면 버그가 날 수 있다고 경고했습니다.
HN 반응
- 제약 디코딩은 원래 vLLM에 있던 기능이라 설정 문제였다는 지적에, 글쓴이는 당시 Kimi는 코드 수정이 필요했다고 답했습니다.
- 제약 디코딩이 모델의 출력 분포를 바꿔 품질을 해칠 수 있으니 호출 전체를 다시 시도하는 방식과 비교해 봐야 한다는 반론도 나왔습니다.
제약 디코딩(constrained decoding)은 vLLM에 들어온 지 오래됐고, 제약 도구 호출 디코딩도 한동안 있었습니다.
이 블로그 글은 전체 상황을 파악하기 전에는 꽤 헷갈립니다. 제목처럼 vLLM을 고쳤다는 이야기로 읽히는데, 실제로는 설정을 제대로 해야 했을 뿐입니다.
공정하게 말하면, vLLM의 제약 도구 호출 설정이 시간이 지나면서 동작이 달라지는 방식으로 바뀌어 왔고, 진짜 문제는 그쪽입니다. Ubicloud 팀은 성능을 추적하고 회귀를 잡아내 원인을 조사할 벤치마크를 갖춰 두었다는 점에서 칭찬받을 만합니다. OpenRouter에서 볼 수 있는 일부 제공업체는 다른 곳에 비해 성능이 이유 없이 나쁜데, 이 정도로 신경 쓰지 않는 게 분명합니다.
다만 제약 디코딩과 제약 도구 호출 스키마가 vLLM에 오래전부터 있던 기능이라 이 글이 혼란스러웠다면, 끝으로 건너뛰어 어떤 설정을 바꿨는지 보시면 됩니다.
전반적으로 공정한 평가입니다. 다만 각주를 하나 달고 싶은데, Kimi가 올바르게 동작하려면 코드 수정이 필요했다는 의미에서는 저희가 vLLM을 고친 게 맞습니다. 설정만으로는 고칠 수 없었습니다. 나중에 vLLM 팀이 이 문제를 업스트림에서 고쳤습니다. 이제 최신 vLLM에서는
--tool-strict-level플래그를parameter로 설정하면 글에서 설명한 문제를 해결할 수 있습니다.ubicloud에서 일하시는 분이라면 질문이 있습니다. Kimi 같은 모델의 API 접근을 판매하시나요? 판매한다면 어디에 나와 있죠??
저희는 현재 대형 고객을 위해 전용 클러스터에서 DeepSeek 모델을 서빙하는 데 집중하고 있습니다. 지금은 공개 서비스로 제공하지 않지만, 앞으로 검토할 수도 있습니다.
이런 면은 vLLM이 헷갈리는 난장판이라서이기도 합니다. 성능을 제대로 끌어내려면 시간을 엄청나게 투자해야 하고(잘 되면 정말 좋긴 합니다), 그래도 깨지기 쉽고 지나치게 복잡합니다.
글쓴이분들께 피드백을 드리자면, 저는 글을 재미있게 읽었고 위에서 말씀하신 혼란은 겪지 않았습니다.
재밌게도, 제약 디코딩은 우리가 AR 모델을 /v1/systemone 엔드포인트로 바꿀 때 쓰는 방식과 같습니다(저는 vLLM의 그 기능을 만들었습니다).
도구 호출은 위험이 가득합니다. 특히 모델이 실행되는 동안 도구가 계속 고정돼 있다고 보장하는 게 아무것도 없기 때문입니다. 어떤 경우에는 거대한 프리픽스 미스를 피하려고 실행 도중에 도구를 추가하기도 하는데, (적어도 제가 마지막으로 확인했을 때는!) 도구 호출 업데이트를 위한 OpenAI식 채팅 메시지 형식으로 업계가 합의하지 못했습니다.
저라면 도구 호출이 완전히 제약된 환경에서 돌리는 쪽을 단연코 택하겠고, 우리가 이 모든 걸 YOLO로 처리하지 않았더라면 좋았겠다 싶습니다! PTC 방식 도구는 도구 호출을 JavaScript로 떠넘겨서 이 문제를 어느 정도 해결하지만, 그건 강제하기가 더 어렵고, 그 방식으로 학습되지 않은 모델은 도구 호출을 몇 번 실패하고 나서야 제대로 할 수도 있습니다.
컨텍스트를 이어 붙이기만 하지 않고 수정하는 게 얼마나 현실적일지 궁금했습니다. 예를 들어 잘못된 형식의 도구 호출과 그 오류 메시지를 지워서 컨텍스트가 오염되지 않게 하는 식으로요.
물론 KV 캐시를 다시 만들어야 해서 성능 손실이 있겠지만, 드물게만 일어난다면 감수할 만할 수도 있습니다.
제가 택한 방식이 그겁니다. 스코프를 벗어나기 전에 결과를 걸러서 도구 호출 오류와 불필요하게 부푼 내용을 빼고, 그 오류는 별도의 시스템에 덧붙입니다. 에이전트에게는 훨씬 작은 도구 호출 오류 결과를 돌려주면서 실패 이유에 대한 추론은 그대로 남겨 둡니다.
잠깐, 그게 실제로 가능한가요? 모델은 도구가 컨텍스트의 어디에 있는지 딱히 신경 쓰지 않아야 하겠지만, 그게 실제로 통하는 방법이라는 건 몰랐네요.
컨텍스트는 그냥 긴 문자열일 뿐입니다. 무엇이든 마음대로 할 수 있어요. 학습은 특정한 구문상의 특이한 규칙에 맞추는 데 도움이 되지만, 요즘 LLM은 충분히 커서 대부분의 경우 거의 어떤 지시든 따를 수 있습니다.
예를 들어 도구 호출을 기본 지원하지 않는 LLM API를 쓰고 싶다면, LLM에게 원하는 도구의 호출을 마크다운 코드 블록으로 내놓게 하고 직접 파싱하면 됩니다.
"완전히 제약된 도구 호출 환경"이란 어떤 건가요?
모델 품질에 실제로 어떤 영향이 있는지에 대한 내용도 공개했으면 좋았겠습니다. 스키마 제약 디코딩은 LLM이 출력하는 분포를 바꿉니다. 도구 호출을 되돌려서 다시 시도하는 방식(컨텍스트를 오염시키지 않고, 도구 호출 정확도도 100%인 방식)과 비교하면 어떨지 궁금합니다. 글의 예시 수치(성공률 23/25)로 보면 도구 호출 시도 자체에 토큰이 9% 더 들겠지만, 전체 토큰의 작은 일부에 대한 9%일 뿐이라서, 재시도 방식이 정확도를 조금이라도 더 높인다면 그쪽이 이길 수도 있다고 생각합니다.
저희도 이 부분이 궁금해서 나중 글에서 다룰지도 모릅니다. 관점이 두 가지로 갈립니다. (1) LLM, 서빙 스택, 하네스를 합친 전체 품질은 어떤가, (2) 저희 구성의 동작이 1차 제공업체와 얼마나 가까운가. 이번 글은 후자에 초점을 맞췄습니다. Kimi를 만든 Moonshot AI는 제약 디코딩을 기본으로 켜 두라고 권장하고, Kimi도 그렇게 서빙합니다. 수치는 내놓지 않았지만, 제약 디코딩이 전반적인 성능에 도움이 된다는 뜻으로 읽힙니다. 저희는 권장대로 Kimi를 서빙하고 싶었고, 그 과정에서 글에 적은 어려움을 만났습니다.
제약 디코딩이 모델 품질에 부정적인 영향을 줄 수 있다는 연구도 있지만, 이는 여러 요인, 특히 해당 모델에 따라 달라집니다 [1, 2, 3]. 전반적으로 제약 디코딩을 쓰는 쪽으로 흐름이 가는 듯합니다. OpenAI의 새 Responses API도 Moonshot AI의 Kimi처럼 제약 디코딩이 기본으로 켜져 있습니다.
1: https://arxiv.org/abs/2408.02442
2: https://blog.dottxt.ai/say-what-you-mean.html
3: https://dylancastillo.co/posts/say-what-you-mean-sometimes.html
스키마 제약은 새 분포가 재시도를 거친 옛 분포와 똑같이 스케일되도록 구현되어 있지 않나요? 저는 유효하지 않은 다음 상태를 막는 방식으로 동작한다고 생각했는데... 아, 아니면 (개념적으로) 토큰마다 재시도하기보다 도구 호출 전체 단위로 재시도하는 편이 훨씬 낫다는 말씀인가요? 그렇다면 일리가 있어 보이긴 합니다. 처음에 아주 나쁜 선택을 하는 바람에 모델이 이를테면 '궁지에 몰려서' 끔찍한 상태를 수습하려 애쓰게 되는 걸 수도 있겠네요.
동작 방식은 유효하지 않은 다음 상태를 막는 것이고, 궁지에 몰린다는 건 가능한 실패 양상 하나를 설명하기에 괜찮은 표현입니다.
구체적인 예를 들어 보겠습니다(큰 모델에서는 요즘 덜하지만 여전히 일어나고, 특히 컨텍스트 윈도 후반으로 갈수록 그렇습니다). 모델은 긴 JSON 문자열을 싫어합니다. 이런 JSON 출력이 나오기 쉽습니다.
{"name": "Bob", "biography": "Bob은 훌륭한 건축가로서 ...
결정적인 건 말줄임표가 JSON을 끝낸다는 점입니다. 스키마 제약 디코딩에서는 그 깨진 문자열을 결국 반드시 닫아야 하고, 보통 말줄임표 바로 뒤에서 닫습니다. 사실상 첫 번째 마침표에 도달한 시점에 이미 출력은 망가진 겁니다. 궁지에 몰린 거죠. 재시도 전략은 이걸 고칩니다.
좀 더 미묘한 다른 실패 양상도 있습니다. 출력 초반에 "올바른" 토큰들이 이어지다가 KV 캐시가 약간 이상한 상태가 되어, 출력 후반의 품질이 나빠질 수 있습니다. 유효하지 않은 다음 상태를 막으면 그 나쁜 상태에서 토큰을 계속 생성할 수밖에 없습니다. 재시도를 쓰면 더 자주 실패하는 대신 그 이상한 상태를 만드는 초반 토큰을 피할 가능성이 있습니다. 반드시 나쁜 상태에 몰린다는 뜻이 아니라, 확률적으로 나쁜 상태에 몰린다는 얘기입니다.
생각해 볼 간단한 모델로, 문법 전체가 [ab, ba]이고 LLM의 학습 데이터 전체가 [ab, ab, ba, bb]라고 해 봅시다. "올바른" 분포(실패하면 재시도)는 {ab: 67%, ba: 33%}입니다. 스키마 제약 분포는 {ab: 50%, ba: 50%}입니다.
다른 분이 말하려는 건, 모델이 분포 바깥에서 디코딩하도록 강제될 수 있고, 그러면 시퀀스의 이후 부분이 엔트로피가 더 높은 분포에서 샘플링된다는 겁니다.
예를 들어 특정 모델은 추가적인 조건 부여가 필요할 수도 있고, JSON 출력의 순서를 조정하면 품질이 낮은 결과가 나올 수도 있습니다.
LLM은 한 턴에 수만 토큰을 생성해야 할 수 있고, 양자화 잡음 같은 것이 뒤로 갈수록 오류를 연쇄적으로 키운다는 점을 기억해야 합니다. 그래서 가능하면 모델의 분포 안에 머무르는 게 정말 중요합니다.
출력 필드의 순서는 실제로 출력 성능을 조정하는 데 쓸 수 있습니다. 뒤쪽 필드가 앞쪽 필드의 출력을 보면 이득을 보는 경우, LLM이 특정 필드를 먼저 추론하도록 강제하면 도움이 될 수 있습니다. 때로는 예상과 다른 방향으로 놀라운 결과가 나오니 꼭 테스트해야 합니다.
XGrammar에는 필드가 어떤 순서로든 나타나게 허용하는 옵션이 어딘가 있는데, vLLM이 그걸 노출하는지는 모르겠습니다. 필드를 임의의 순서로 허용하면 스키마 파싱이 복잡해지는 만큼 절충점과 한계가 있습니다.
더 구체적으로 말하자면, 저는 스키마를 인식하는 디코딩이 토큰 단위 재시도와 똑같이 동작한다고 생각했습니다. "짝수만 허용"이라는 규칙이 {0:0.1, 1:0.2, 2:0.2, 3:0.1, 4:0.2, 5:0.2}를 {0:0.2, 2:0.4, 4:0.4}로 바꾸는 것처럼요. 그래서 지금 문제 삼는 차이가 토큰마다가 아니라 도구 호출 전체 단위로 재시도하는 것(그러면 스키마 후반의 오류가 앞서 확정된 상태를 다시 뽑게 해 주니까)인지 묻는 겁니다. (그런데 그게 말씀하신 뜻인지 저로서는 불분명해서 물어봐야겠다고 느꼈습니다. 예를 들어 지금은 필드 순서를 얘기하고 계신데, 그건 지나치게 제약이 강하다는 완전히 다른 문제입니다. 제약 디코딩용 문법을 필드가 어떤 순서로든 나오도록 구현하는 걸 막을 건 없고, 중복 필드를 허용하면 -- JSON은 보통 어차피 허용하죠 -- 언어가 추가 문맥 요구 조건을 잃게 되니 아마 이미 지원될 겁니다.)
스키마 제약은 토큰 단위 재시도와 동등합니다. 더 긴 시퀀스를 재시도하는 것과는 동등하지 않습니다.
맞아요... 제가 두 댓글 모두에서 정확히 그렇게 말했는데, 그게 문제 삼는 쟁점이 맞는지 확인하고 싶었던 겁니다. 무슨 뜻인지 분명하지 않았고 (혼란의 증거로 들자면) 이제 사람들이 필드 순서처럼 전혀 상관없는 제약 같은 다른 아이디어를 꺼내고 있으니까요. (참고로 제가 처음 질문한 상대가 당신이니, "그렇다, 도구 호출 전체를 재시도하는 게 낫다는 말이다"라는 확답으로 받아들이겠습니다 ;P.)
아, 알겠습니다. 제가 한 다른 답변 [0]이 제 생각에 가장 가까울 겁니다. 흥미롭게도 재시도가 더 낫지 않을 수도 있습니다. 특히 비용까지 포함한 파레토 프런티어로 재면 더 그렇습니다.
그래도 재시도가 더 나아야 한다는 꽤 강한 사전 믿음이 있습니다. 분포의 차이가 눈에 띌 때마다 재시도 방식이 더 나았다는 제 관찰에서 나온 겁니다(명백한 샘플링 편향이지만, 자기 경험을 무시하는 사람이 어디 있겠어요). 더 강한 근거이자 직관은, 이런 시스템의 목표 전체가 어떤 분포를 재현하는 것이라는 점입니다. 그 분포가 grep보다 ripgrep을 10배 자주 쓰라고 말한다면, 그건 버리고 싶지 않은 신호일 겁니다. 제약 디코딩은 그 신호를 버리고 둘을 같은 선상에 놓거나, 반대 방향으로 10배 치우치게 만들 수도 있습니다. 재시도는 원래 분포를 충실히 보존합니다. 저는 그 분포에서 너무 멀어지려는 해법에 회의적입니다(모델 온도를 바꾸는 대부분의 활용도 마찬가지고요).
더 중요한 건 둘이 다르다는 점이니, 그 영향을 측정해 봐야 한다는 것입니다.
[0] https://news.ycombinator.com/item?id=50245714
도구 호출 결정 모델 같은 걸 만든다면, 조건 부여는 그 데이터를 확보하는 것보다 시간이 덜 걸리고, 세션 데이터를 보관해 둔다면 데이터 확보도 별로 오래 걸리지 않습니다. 실패한 건 부정 대조군, 통과한 건 긍정 데이터로 쓰면 됩니다. 에이전트가 특정 환경에서 쓰는 도구 호출의 공간은 사실 그리 크지 않아서, 가장 쉽게 얻을 수 있는 성과 중 하나입니다.
이 블로그 글을 쓴 사람 중 한 명입니다. LLM, 추론·서빙 엔진, 제약 디코딩 구현을 통합해서 의도한 출력과 전반적인 품질을 얻으려면 신중한 엔지니어링과 테스트가 꼭 필요합니다. 편하게 질문하세요(AMA). :)
제가 제대로 이해했다면, 도구 호출은 사실 모델이 하는 게 아니라는 건가요?
모델이 도구 호출을 만들어 내긴 하지만, 모델이 한 일은 없고 추론 엔진이 아무 말이나 걸러서 유효한 수만 남기는 것뿐이라는 뜻에서요.
이런 방식을 다른 맥락에서도 쓸 수 있을까요?
사람들이 가중치를 살짝 건드려서 모델을 동적으로 조종한다는 얘기는 들었는데, 샘플러(?)를 건드려도 가중치는 기술적으로 그대로인 채 출력을 많이 통제할 수 있을 것 같기도 합니다.
네, 그게 바로 제약 디코딩입니다. 모델이 "허용된" 토큰 집합(이른바 문법)에서만 출력하도록 제약하는 거죠.
그래서 제약 디코딩과 함께 도구 사용을 쓰면 모델이 항상 승인된 도구 목록에서만 고른다고 확신할 수 있습니다(예: tools=['read','write']이면 다른 세션에서 execute가 승인 도구에 있어 출력할 수 있었더라도, 이번엔 'execute'를 출력할 수 없습니다).
까다로운 문제는 모델마다 동작하도록 학습된 방식이 다르다는 겁니다. 그런 모델을 제약하면 "눈에 보이지 않는" 문제가 생길 수 있습니다. 제약 디코딩은 완벽한 구문만 보장하고 완벽한 의미는 보장하지 않습니다(즉, 올바른 파일에 엉터리를 쓸 수도 있습니다).
물론입니다. 꽤 멋진 문법을 만들어 모델이 멋진 일을 하도록 제약할 수 있어요. "도구 사용"과 비슷하지만 더 자유로운 형태로요. 예를 들어 항상 10단계 계획을 내놓게 만들 수 있습니다. 어떤 프롬프트를 줘도 형식이 고정된 10단계 계획을 항상 내놓을 겁니다. 계획은 plan_title과 10개의 {plan_item, item_text}로 고정이고요. 하지만 위에서 말했듯 계획의 각 항목이 말이 된다는 보장은 없습니다(예: 파스타 레시피의 7단계가 "접착제를 넣는다"일 수도 있죠).
제가 하려던 말은 "좋아요, 이걸 악의적으로 쓰려면 어떻게 하죠?" 혹은 "어떻게 하면 깨뜨릴 수 있죠?"에 더 가까운 질문이었습니다.
"악의적인 추론 엔진이 할 수 있는 일은 무엇인가"
뭐 그런 거요.
죄송하지만 무슨 말씀인지 잘 모르겠습니다. 제약 생성은 호출하는 쪽이 특정한 모양의 메시지를 돌려받도록 돕는 기능입니다. 이게 악의적으로 어떻게 작동할 수 있는지 상상이 잘 안 가네요. 좀 더 설명해 주시겠어요?
악의적인 추론 엔진은 LLM 쪽에서 제약 디코딩을 쓰지 않아도 이미 원하는 건 뭐든 돌려줄 수 있습니다. 클라이언트가 보호되지 않은 환경에서 명령을 무작정 실행한다면 이미 털린 겁니다. LLM을 돌릴 필요도 없이 그냥 "tool_response:{'execute':'rm -fR *'}"를 무작위로 끼워 넣으면 끝이니까요...
제 질문이 바로 그거였습니다.
제가 궁금해진 건 출력을 조종할 수 있다는 사실인데, 제가 아는 이 기술의 이해로는 그럴 수 없다는 가정이 깨지는 것 같아서요.
그래서 "여기서 가정들이 깨지고 있다. 어떤 가정이고, 그 영향은 뭘까?" 하는 생각이 든 겁니다.
추론 제공업체는 루트킷의 C2 노드로 취급해야 합니다. 그게 내 C2이거나 나와 어느 정도 이해관계가 맞는 곳이라면 괜찮습니다.