메뉴
HN
Hacker News 51일 전

도구 호출과 오픈소스 모델의 M×N 문제

IMP
8/10
핵심 요약

클로즈드 소스 AI 모델에서는 매끄러웠던 도구 호출(Tool calling) 기능이 오픈소스 모델에서는 파편화된 문제를 다룹니다. 각 모델(GPT, DeepSeek, GLM 등)마다 도구 호출을 인코딩하는 형식이 다르기 때문입니다. 다양한 애플리케이션(M)과 모델(N)이 증가함에 따라 포맷 파싱과 문법 적용을 위한 M×N의 개발 부담이 기하급수적으로 커지는 것이 핵심 문제입니다.

번역된 본문

클로즈드소스 모델의 도구 호출(Tool calling)은 매끄럽습니다. 함수 목록을 API에 전달하면, 모델이 이를 호출하고 구조화된 JSON을 반환받습니다. 이때 전송 형식(wire format)은 사용자에게 노출되지 않습니다. 하지만 오픈소스 모델로 넘어오면, 도구 호출이 엔진이 이해해야 하는 전송 형식에 의존한다는 사실을 깨닫게 됩니다. 엔진이 특정 모델의 형식을 아직 지원하지 않는다면, 출력이 깨져서 나옵니다. 인수(arguments) 안에 추론 토큰이 섞여 있거나, 잘못된 JSON 형식이거나, 도구 호출 자체가 누락될 수 있습니다. 그러면 누군가 지원할 때까지 기다리거나, 직접 파서(parser)를 작성해야 합니다.

'모델 지원'이 실제로 의미하는 것 모든 모델 패밀리는 도구 호출을 서로 다르게 인코딩합니다. 다음은 함수 search(query="GPU")를 호출하는 동일한 시맨틱 작업이 세 가지 다른 전송 형식으로 표현된 모습입니다:

gpt-oss (Harmony): <|channel|>commentary to=functions.search <|constrain|>json<|message|> {"query": "GPU"} <|call|>

DeepSeek: <|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>search '''json {"query": "GPU"} ''' <|tool▁call▁end|><|tool▁calls▁end|>

GLM5: ünserschied search unserschied query unserschied GPU unserschied /search unserschied (역주: 원문에서 GLM5 포맷은 특수 태그 쌍으로 처리됨)

동일한 작업이지만 호환되지 않는 전송 형식입니다. 토큰 어휘(vocabulary), 경계 마커(boundary markers), 인수 직렬화(serialization) 방식이 모두 다릅니다. 생성된 도구 호출을 깔끔한 JSON 객체 배열로 반환하려면, 모델의 원본 출력을 다시 파싱하여 깔끔한 API 응답으로 만들어야 합니다.

실제로 M개의 애플리케이션(vLLM, SGLang, TensorRT-LLM, transformers 등)은 지원하고자 하는 각 N개의 모델에 대해 커스텀 파서를 작성해야 합니다. 그리고 이것은 구현 부담의 절반에 불과합니다.

문제의 전개 속도 Gemma 4는 이와 관련된 어려움을 잘 보여줍니다. <|channel> 추론 토큰은 파서가 처리하기 전에 디코더에 의해 제거됩니다(vLLM #38855). 추론 내용이 도구 호출 인수로 새어 나갈 수도 있습니다(vLLM PR #39027). 이 모델의 비표준 형식은 충분히 달랐기 때문에, llama.cpp는 기존의 범용 자동 파서를 포기하고 전용 구현을 만들어야 했습니다(llama.cpp PR #21418). 이는 모델 훈련 시점에 결정된 형식 선택이 파서 버그로 표면화되는 것입니다.

범용 파서의 역부족 자연스러운 대응책은 모든 형식을 처리할 수 있을 만큼 범용적인 파서를 구축하는 것입니다. 모든 엔진이 이를 시도했습니다. '특수 토큰을 찾아 그 사이의 JSON을 추출하라'와 같은 합리적인 휴리스틱은 일부 형식에서는 충분히 잘 작동합니다. 하지만 Harmony는 to= 속성과 함께 <|channel|>을 통해 라우팅하고, GLM5는 아예 JSON 대신 태그 쌍으로 인수를 직렬화합니다.

이것이 근본적인 문제입니다. 전송 형식은 모델 훈련 시점에 결정되는 사항이며, 이를 공통된 규약으로 제한할 어떠한 장치도 없습니다. 가능한 형식의 공간이 무한히 열려 있기 때문에, 범용 파서는 아직 만들어지지도 않은 설계 선택을 예측하려고 시도하는 것과 같습니다. 그래서 범용 파서가 일반적인 경우에는 도움이 되지만, 어려운 버그가 존재하는 '모델별 예외 상황(per-model tail)'을 제거하지 못하는 것입니다. 여기에는 인수로 새어 나가는 추론 토큰, 파서가 처리하기 전에 특수 토큰을 제거해 버리는 디코더, 콘텐츠와 충돌하는 생성 종료 신호 등의 문제가 포함됩니다.

이러한 모델별 형식 지식은 결과를 파싱할 때뿐만 아니라 텍스트 생성 중에도 필요합니다. 바로 이 지점에서 문법 엔진(Grammar engines)이 등장합니다.

부재한 관심사의 분리 새로운 모델이 출시되면 두 곳의 독립적인 곳에서 작업이 이루어집니다. Outlines, XGrammar, 그리고 llama.cpp의 문법 지원과 같은 문법 엔진은 생성 중에 제약을 어디에 적용할지 알아야 합니다. 어떤 토큰이 도구 호출의 봉투(envelope)를 나타내는지, 그 안에서 구조화된 생성을 언제 활성화할지, 그 외부에서 언제 모델을 제약 없이 둘지를 결정해야 합니다.

vLLM, SGLang, TensorRT-LLM, transformers 내부의 출력 파서는 그 반대의 작업을 수행해야 합니다. 원본 생성 텍스트를 가져와 도구 호출을 깔끔한 API 응답으로 추출해야 하며, 이를 위해 동일한 형식 지식을 역방향으로 필요로 합니다.

이들은 서로 다른 팀이고, 다른 코드베이스를 사용하며, 다른 릴리스 주기를 가집니다. 하지만 이들에게 필요한 모델별 지식은 동일합니다. 즉, 어떤 토큰이 경계를 나타내는지, 인수가 어떻게 직렬화되는지, 추론 토큰이 어디에 나타날 수 있는지 등입니다. 오늘날 각 팀은 혼란스러운 상황에서 이를 독립적으로 역엔지니어링하고 있습니다.

원문 보기
원문 보기 (영어)
Tool calling with closed-source models is seamless. You pass a list of functions to the API, the model calls them, you get structured JSON back. The wire format is invisible to you. Then you move to open models and discover that tool calling depends on a wire format the engine has to understand. If the engine doesn’t support that model’s format yet, the output comes back garbled: reasoning tokens in arguments, malformed JSON, missing tool calls. Then you either wait, or write the parser yourself. What “supporting a model” actually means Every model family encodes tool calls differently. Here’s the same semantic operation, calling a function search(query="GPU") , in three wire formats: gpt-oss (Harmony): <|channel|>commentary to=functions.search <|constrain|>json<|message|> {"query": "GPU"} <|call|> DeepSeek: <|tool▁calls▁begin|><|tool▁call▁begin|>function<|tool▁sep|>search '''json {"query": "GPU"} ''' <|tool▁call▁end|><|tool▁calls▁end|> GLM5 : <tool_call>search <arg_key>query</arg_key><arg_value>GPU</arg_value> </tool_call> Same operation, incompatible wire formats: different token vocabularies, boundary markers, and argument serialization schemes. To return a nice array of JSON objects with the generated tool calls, you need to parse the model output back into a clean API response. In practice, each of the M applications (vLLM, SGLang, TensorRT-LLM, transformers, etc.) ends up writing custom parsers for each model it wants to support. And that is only half of the implementation burden. The pace of the problem Gemma 4 is a good illustration of the difficulty involved. Its <|channel> reasoning tokens get stripped by the decoder before the parser sees them ( vLLM #38855 ). Reasoning content can leak into tool-call arguments ( vLLM PR #39027 ). The model’s non-standard format was different enough that llama.cpp had to abandon its generic autoparser and build a dedicated implementation ( llama.cpp PR #21418 ). These are training-time format choices surfacing as parser bugs. Generic parsers are swimming against the current The natural response is to build a parser generic enough to handle all formats. Every engine has tried. A reasonable heuristic, say “find special tokens, extract JSON between them,” covers some formats well enough. But then Harmony routes through <|channel|> with a to= attribute, and GLM5 serializes arguments as <arg_key> / <arg_value> pairs instead of JSON at all. This is the fundamental problem: wire formats are training-time decisions, and nothing constrains them to a shared convention. The space of possible formats is open-ended, so a generic parser is trying to anticipate design choices that haven’t been made yet. That is why generic parsers help with the common cases but do not eliminate the per-model tail, where the hard bugs live: reasoning tokens leaking into arguments, decoders stripping special tokens before the parser sees them, end-of-generation signals colliding with content. The same model-specific format knowledge is also needed during generation, not just after the fact when parsing the result. That is where grammar engines enter the picture. The missing separation When a new model ships, work happens in two independent places. Grammar engines, like Outlines, XGrammar, and llama.cpp’s grammar support, need to know where to apply constraints during generation: which tokens mark the tool-call envelope, when to activate structured generation inside it, and when to leave the model unconstrained outside it. Output parsers inside vLLM, SGLang, TensorRT-LLM, transformers need to do the reverse: take the raw generated text and extract tool calls into a clean API response. They need the same format knowledge in reverse. These are different teams, different codebases, different release cycles. But the model-specific knowledge they need is the same: which tokens mark the boundaries, how arguments are serialized, where reasoning tokens can appear. Today each team reverse-engineers this independently from chat templates and (if they’re lucky) documentation. The result is N models × M implementations of the same format knowledge, developed in parallel with no shared contract. A new model ships, and grammar engine maintainers and inference engine maintainers both start the same reverse-engineering work from scratch. We have already seen the ecosystem converge on shared chat templates in Hugging Face, standardizing how prompts and turns are formatted. Tool calling needs the same kind of separation: not one wire format, but a shared declarative way to describe them. Until that exists, each new model will keep triggering the same reverse-engineering work across the stack. The separation that’s missing is extracting that shared format knowledge into configuration rather than code. A model’s wire format, its boundary tokens, its argument serialization, and its reasoning token behavior, should be a declarative spec that both grammar engines and parsers consume. The model changes, you update the spec. The grammar engine and the parser don’t move. I am Rémi Louf, CEO of dottxt . Follow @remilouf / @dottxtai for our work on structured generation and tool calling.