소형 모델이 툴 호출(tool-call) JSON을 망가뜨리는 방법
요약
소형 모델 사용 시 툴 호출 JSON 파싱의 취약점을 다루며, 특히 `arguments` 필드가 유효한 JSON임을 보장하기 어렵다는 점을 지적합니다. 마크다운 펜스나 산문으로 감싸진 형태 등 다양한 포맷팅 오류가 발생할 수 있습니다. 따라서 클라이언트 측에서 수정 가능한 부분은 최소화하고, 모델이 생성한 원본 출력을 보고하는 것이 중요하다고 강조합니다.
핵심 포인트
- 소형 모델의 경우 툴 호출 JSON 파싱에 취약점이 존재함.
- JSON은 `arguments` 문자열 형태로 전달되며 유효성 보장이 어려움.
- 마크다운 펜스나 산문 등 다양한 포맷팅 오류가 발생할 수 있음.
- 파싱 실패 시 기본값(empty object)을 사용하는 것은 문제를 은폐할 위험이 있음.
코딩 에이전트는 툴 호출에 생사가 달려 있습니다. 최신 모델(frontier model)을 사용하면 이 부분을 거의 신경 쓰지 않습니다. 하지만 노트북에서 실행되는 7B 모델의 경우, 문제가 발생하는 지점은 바로 JSON입니다. 이것이 저희가 oxi에서 직면했던 문제이며, 클라이언트 측에서 수정 가능한 부분과 나머지 모든 것이 왜 모델로 돌아가야 하는지 설명합니다.
툴 호출이 코드에 도달하는 방식
OpenAI와 호환되는 API에서는 모델 자체가 아무것도 실행하지 않습니다. 대신 tool_calls 배열을 가진 메시지를 반환하며, 여기에는 툴 이름과 arguments 필드가 포함됩니다. 이 필드는 _문자열(string)_이며, 그 문자열은 툴의 스키마와 일치하는 JSON 객체를 담고 있어야 합니다:
{
"type": "function",
"function": {
...
로컬 모델을 사용할 때는 한 단계가 더 추가됩니다. 모델이 학습된 어떤 형식으로든 일반 텍스트를 작성하면, 서버(llama-server, Ollama, LM Studio)는 모델의 채팅 템플릿을 사용하여 그 텍스트에서 툴 호출을 인식하고 위 구조로 변환합니다. oxi는 모델이 해당 형식에 대해 학습되었고 가장 잘 따르기 때문에 자체적인 텍스트 프로토콜을 고안하기보다는 이 네이티브(native) 툴 호출 기능에 의존합니다.
취약점은 바로 arguments 문자열입니다. 이것이 유효한 JSON임을 보장하는 것은 아무것도 없습니다. 서버는 모델이 작성한 것을 추출하지만, 소형 모델은 항상 자신이 의도했던 바를 작성하지 못할 때가 있습니다.
깨진 arguments의 형태
실패 사례들은 몇 가지 인식 가능한 형태로 나타납니다:
- 마크다운 펜스(Markdown fences). 모델은 JSON을 사람에게 보여주는 데 익숙하기 때문에 다음과 같이 감싸서 출력합니다:
```json {"path": "src/main.rs"} ```. - 객체 주변의 산문(Prose around the object). 예시:
Let me read the file: {"path": "src/main.rs"}. - 이중 인코딩(Double encoding). 객체가 JSON을 포함하는 JSON 문자열 형태로 도착합니다: `
첫 세 가지는 포맷팅 실수입니다. 모델은 무엇을 호출해야 하는지 알고 있으며 의도 자체는 모두 존재합니다. 마지막 두 개는 실제 오류이며, 클라이언트가 아무리 영리해도 파일의 절반이 누락된 것을 복구할 수는 없습니다.
최악의 수정: 비어있었다고 가정하기
유혹적인 코드는 한 줄입니다. 그리고 oxi가 이번 주까지 사용하던 코드도 이 한 줄입니다:
let args: Value = serde_json::from_str(&tc.arguments).unwrap_or(json!({}));
이것은 절대 충돌하지 않기 때문에 괜찮아 보입니다. 하지만 모델이 다음에 무엇을 보는지 생각해 보세요. src/main.rs를 읽으라고 요청했고, 인자가 파싱되지 않았으며, 툴은 {}로 실행되었고, 돌아온 결과는 다음과 같았습니다:
missing path
이것은 무슨 일이 일어났는지에 대한 거짓말입니다. 모델은 경로를 보냈습니다. 강력한 모델은 어깨를 으쓱하며 다시 시도합니다. 작은 모델은 그것을 문자 그대로 읽습니다. 아마 파일이 존재하지 않을 수도 있고, 디렉토리를 먼저 나열해야 할 수도 있고, 사과하고 중단해야 할 수도 있습니다. 해결할 필요가 없는 문제에 라운드를 소모하고, 외부에서는 마치 클라이언트가 증거를 버렸기 때문에 모델이 멍청해 보이는 것처럼 보입니다.
모호하지 않은 것은 수정하고, 나머지는 보고하기
우리가 정한 규칙은 다음과 같습니다. 오직 그것을 해석할 수 있는 합리적인 방법이 정확히 하나만 있을 때만 호출을 수정합니다. 경계(Fences), 주변의 서술(prose), 그리고 이중 인코딩 모두 해당됩니다. 왜냐하면 의도된 객체가 바로 그곳에 놓여 있기 때문입니다. 나머지 모든 것은 모델에게 툴 오류로 전송되며, 무엇이 잘못되었는지 알려주고 _보낸 내용까지 따옴표로 인용_하며, 툴은 전혀 실행되지 않습니다.
이것이 oxi의 전체 함수입니다 (Rust, serde_json):
pub(crate) fn parse_tool_args(name: &str, raw: &str) -> Result<Value, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
...
몇 가지 세부 사항은 보이는 것보다 더 중요합니다. 복구는 도구의 인수가 될 수 있는 유일한 형식인 JSON 객체 결과만 허용합니다. 오류 메시지는 원본 입력을 500자로 자르기(cap)하여 따옴표로 표시하므로, 잘린 20 KB write 호출이 컨텍스트를 넘치게 만들지 않습니다. 또한 모델이 같은 턴에 여러 번 호출했을 수 있기 때문에 도구의 이름을 명시합니다.
잘린 호출에 대해 모델이 이제 받는 내용은 다음과 같습니다:
The arguments for `read` were not valid JSON (EOF while parsing a string at line 1 column 21).
Received: {"path": "src/main.rs"
Call `read` again with a single JSON object that matches its parameters.
이 메시지는 진실하고, 구체적이며, 실행 가능한 정보입니다. 모델은 자신의 잘못된 출력을 보고 파싱이 정확히 어디서 멈췄는지 알 수 있으며, 이는 다음 시도에서 호출을 수정하는 데 필요한 모든 것입니다. 이것이 컴파일러가 문제가 되는 줄을 인용하는 것과 같은 이유입니다.
검사가 실행되는 위치가 중요합니다
파싱은 다른 어떤 것도 호출에 접근하기 전에 발생합니다. 잘못된 인수를 가진 호출은 다음과 같습니다:
- 승인을 요청하지 않습니다. 승인할 의미 있는 것이 없으며,
edit {}에 대한 승인 프롬프트는 키보드 앞에 있는 사람을 혼란스럽게 할 뿐입니다. - 병렬 읽기 전용 호출과 배치되지 않습니다. 개별적으로 처리되므로, 하나의 잘못된 호출이 다른 호출들을 지연시키거나 섞이지 못하게 합니다.
- UI에서 실패한 도구로 표시됩니다. 모델이 받은 것과 동일한 메시지와 함께 표시되므로, 실행을 모니터링할 때 왜 재시도했는지 정확히 알 수 있습니다.
oxi는 세 가지 와이어 형식(OpenAI 스타일의 채팅 완료, Anthropic 메시지, Codex 응답 API)과 통신하며, 이 세 루프 모두 동일한 함수를 거칩니다. 비록 가장 필요로 하는 것은 소형 로컬 모델일지라도 말입니다.
대신 출력을 제한하지는 않는가요?
더 강력한 해결책은 제약 디코딩(constrained decoding)입니다. llama.cpp는 샘플러 단계에서 문법이나 JSON 스키마를 적용할 수 있어, 모델이 유효하지 않은 JSON을 물리적으로 출력하는 것을 막아줍니다. 이는 좋은 도구이며, 서버를 직접 제어할 때 사용할 가치가 있습니다. 하지만 저희가 이 방법으로 시작하지 않은 이유가 두 가지 있습니다. oxi는 또한 Ollama, LM Studio, SSH를 통한 원격 박스, 호스팅된 API 등 다양한 환경에서 작동해야 하므로, 클라이언트는 백엔드와 관계없이 잘못된 출력에 대해 합리적인 답변을 받아야 합니다. 그리고 제약은 _구문(syntax)_만 수정할 뿐, 의도(intent)는 아닙니다. 유효한 JSON으로 강제된 모델이라도 여전히 틀린 필드 이름을 보낼 수 있으며, 이 역시 명확한 오류로 반환되어야 합니다.
두 접근 방식은 잘 결합됩니다: 가능한 범위 내에서 제약하고, 모든 곳에서 명확하게 보고합니다.
소형 모델을 기반으로 구축하는 경우
- 파싱 오류를 절대 무시하지 마세요. 모델 출력에
unwrap_or_default()를 사용하는 것은 아직 나타나지 않았을 뿐인 버그입니다. - 명확한 것만 복구하세요. 경계(fences), 산문, 이중 인코딩은 예외입니다. 잘린 내용이나 잘못된 내용을 추측하는 것은 안 됩니다.
- 입력을 다시 따옴표로 묶어 전달하세요. 모델은 볼 수 있는 것을 수정합니다.
- 도구 표면(tool surface)을 작게 유지하세요. 도구가 적고 스키마가 짧으면, 소형 모델이 각 호출을 올바르게 수행할 여유 공간이 더 많아집니다.
- 실패 경로를 테스트하세요. oxi는 모의 서버에서 잘린 툴 호출을 스트리밍하고 모델이 오류를 받고 도구가 실행되지 않는지 확인하는 엔드투엔드 테스트를 가지고 있습니다.
이 변경 사항은 oxi v1.11.2에서 배포되었습니다. 이는 X에서 소형 모델이 툴 호출 JSON을 벗어나는 것에 대한 질문에서 비롯되었으며, 저희는 항상 이러한 피드백을 받게 되어 기쁩니다.
본 게시물은 Claude의 도움을 받아 작성되었고 oxi 유지 관리자에 의해 검토되었습니다.
oxi는 모든 모델을 위한 네이티브한 오픈소스 코딩 에이전트입니다. 자체 기기에서 GGUF 모델을 실행할 수 있으며 (oxi가 llama-server를 설정해 줍니다), Ollama나 LM Studio를 사용하거나 Claude Code, Codex 또는 Cursor 구독을 가져와 사용할 수 있습니다. 하나의 Rust 바이너리이며 약 110MB의 RAM을 사용하고 MIT 라이선스입니다. 설치하기 또는 GitHub에서 별표 표시하기.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기