더 좋아진 모델, 더 나빠진 도구
원문은 Armin Ronacher님이 에 게재했습니다. 이 블로그 구독하기
지난 이틀간 아주 이상한 Pi 이슈 하나 때문에 토끼굴에 빠졌다. 요약하자면 최신 Claude 모델들이 Pi의 edit 도구를 호출할 때 중첩된 edits[] 배열에 존재하지도 않는 필드를 끼워 넣는 경우가 있다는 것이다. 그것도 Haiku 같은 경량 모델이 아니라 Opus 4.8이 그렇다. 편집 내용 자체는 대개 정확한데, 모델이 만들어낸 키를 지어내 인자가 스키마와 맞지 않자 Pi가 툴 호출을 거부하고 다시 시도하라고 요청한다.
모델이 가끔 잘못된 형식의 툴 호출을 내보내는 것 자체는 그리 놀랍지 않다. 특히 작은 모델이라면 더 그렇다. 놀라운 점은 이 문제가 최신 Anthropic 모델에서 오히려 더 심해지고 있다는 것이다. Opus 4.8과 Sonnet 5 모두에서 나타나지만 이전 모델들에서는 전혀 보이지 않는다. 다시 말해, 해당 패밀리에서 최상위(SOTA) 모델들이 이 특정 툴 스키마에서는 이전 세대보다 오히려 못하다는 뜻이다.
Fable이 궁금한 분들을 위해 말하자면, 의도적으로 테스트하지 않았다. 혹시 그쪽에서 돌리는 분류기가 나도 모르게 Opus로 조용히 다운그레이드할지 확신할 수 없었기 때문이다.
툴 호출은 텍스트다
LLM의 툴 호출 내부 동작을 깊게 들여다본 적이 없다면 꼭 알아둬야 할 게 있다. 툴 호출은 마법이 아니며 다소 조잡한 인밴드 시그널링(in-band signalling)에 의존한다는 점이다. 모델은 대화 기록과 시스템 프롬프트, 그리고 사용 가능한 툴 목록을 받는다. 서버는 이를 특수 마커 토큰이 포함된 거대한 프롬프트로 뭉쳐 넣는다. 모델은 그 형식의 예시로 학습되고 강화 학습을 거쳤기 때문에, 생성 도중 어느 시점에 API나 클라이언트가 “이 인자로 이 툴을 호출하라”는 신호로 해석하는 무언가를 내보낸다.
파일 편집 툴이라면 의도된 호출 페이로드는 대략 이렇게 생겼다:
{
"path": "some/file.py",
"edits": [
{
"oldText": "text to replace",
"newText": "replacement text"
}
]
}하네스는 이후 인자를 검증하고 편집을 수행한 뒤 결과를 다시 모델에 전달한다. 검증에 실패하면 모델은 오류를 보고 대개 다시 시도한다.
이 포맷팅이 정확히 어떻게 이뤄지는지는 Anthropic 모델의 경우 알려진 바가 없지만, 일부 사람들이 “ANTML” 마커를 뽑아낸 적이 있고 때로는 공개 커뮤니케이션에도 새어 나오기도 한다. 내가 아는 한 위 호출은 모델에서 직렬화되어 대략 이렇게 나온다:
<antml:function_calls>
<antml:invoke name="edit">
<antml:parameter name="path">some/file.py</antml:parameter>
<antml:parameter name="edits">
[
{
"oldText": "text to replace",
"newText": "replacement text"
}
]
</antml:parameter>
</antml:invoke>
</antml:function_calls>여기서 중요한 점은 이게 XML처럼 보이지만 실제로는 XML이 아니라는 것이다. 그저 토큰화하고 학습시키기 편해서 쓰게 된 형식일 뿐이다. 또 하나 주목할 점은 기본적인 최상위 문자열 파라미터는 인라인으로 나타나는 반면, 객체 배열은 JSON 직렬화로 구현된다는 것이다. 이게 정확히 이렇게 동작한다고 확신할 수는 없지만, 크게 벗어나지 않았음을 시사하는 정황들이 있다. 이 부분은 나중에 중요해진다.
모델이 이런 구조를 생성하도록 만드는 방법은 크게 두 가지로 전혀 다르다:
- 모델에 스키마에 맞는 유효한 JSON을 생성해 달라고 요청한 뒤 나중에 검증하는 방법.
- 샘플러 자체를 제약해 애초에 유효하지 않은 JSON, 혹은 유효하지 않은 스키마 형태가 샘플링되지 않도록 하는 방법.
두 번째 접근법은 보통 문법 인식(grammar-aware) 혹은 제약 디코딩(constrained decoding)이라고 부르는 것이다. 샘플러가 문법에 어긋나는 토큰을 마스킹한다. 모델이 현재 JSON 객체 안에 있고 스키마에서 oldText와 newText만 허용된다면, 샘플러는 모델이 "in_file"이나 "type"을 내보내는 것을 막을 수 있다. 문법 인식 디코딩은 구문적으로 유효한 JSON이 되도록 제약하는 데도, 특정 enum 값이나 키를 강제하는 데도 사용할 수 있다.
아무런 제약이 없으면 모델은 그저 학습된 관습을 따를 뿐이다.
실패 양상
Pi의 edit 툴은 한 번의 호출로 여러 개의 정확한 문자열 치환을 지원한다. 그래서 인자에 edits 배열이 들어간다. 실패 사례에서 모델은 이런 식의 항목을 생성한다:
{
"oldText": "...",
"newText": "...",
"requireUnique": true
}혹은 이런 식이다:
{
"oldText": "...",
"newText": "...",
"oldText2": "",
"newText2": ""
}반복 실험을 거치며 온갖 만들어낸 trailing 키들을 목격했다: type, id, kind, unique, requireUnique, matchCase, in_file, forceMatchCount, children, notes, cost, oldText2, newText2, oldText_2, newText_2, 심지어 edit 객체 안에 event.0.additionalProperties라는 키까지 있었다.
가장 짜증 나는 부분은 내가 살펴본 잘못된 호출들에서 실제 oldText와 newText 페이로드는 바이트 단위로 정확했다는 점이다. 모델은 사실 올바른 호출을 만들어 놓고는 객체 끝에 쓸모없는 내용을 덧붙인 셈이다.
이 실패는 또 맥락에 크게 의존한다. “이 파일을 편집해 줘” 같은 새로 시작한 단일 턴 프롬프트로는 내 환경에서 전혀 재현되지 않았다. 모델이 파일을 읽고 문제를 진단한 뒤 여러 줄에 걸친 편집을 작성하는 에이전틱한 히스토리에서는 재현됐다. 더 골치 아픈 건 모든 대화 기록에서 이런 동작이 나타나는 건 아니라는 점이다. 사실 나는 Petr Baudis의 대화 기록이 있어야만 겨우 재현할 수 있었다! 그 사용자의 세션에서 세션을 이어가자 Opus 4.8은 약 20% 확률로 실패했다. 히스토리에서 thinking 블록을 제거하자 실패율이 절반으로 줄었다. strict 툴 호출을 켜면 내 실험에서는 문제가 사라졌다.
왜 점점 더 나빠지는가
내가 가장 유력하게 보는 가설은 이것이 무작위적인 성능 저하가 아니라 학습 과정의 부산물이라는 것이다.
초기 Anthropic 모델이 학습될 때는 일부 툴(그중 일부는 문서화되어 있었다)에 대해 학습됐다. 하지만 그 학습에는 Claude Code 같은 사용자 배포용 하네스가 명확한 목표로 존재하지 않았다. 최신 Anthropic 모델은 아마도 다르다. 포스트 트레이닝에 Claude Code 혹은 그와 매우 유사한 하네스가 포함되어 있기 때문이다. 모델은 그 환경에서 성공적인 툴 호출이 어떻게 생겼는지 배운다. 그리고 그 환경에서 어떤 실수가 용인되는지도 함께 배운다.
Claude Code 자체의 툴은 비교적 평평한(flat) 구조다. 일반적인 edit 툴은 Pi의 중첩된 edits[] 형태가 아니라 file_path, old_string, new_string, 그리고 선택적 플래그(replace_all)에 가깝다. Claude Code 클라이언트를 들여다보면 매우 시사하는 바가 크다. 잘못된 툴 사용에 대한 재시도 경로, 파라미터 별칭, 타입 강제 변환, 유니코드 복구, 알 수 없는 키 필터링 등이 들어 있다. 다시 말해 Anthropic 자체 클라이언트는 상당한 수준의 엉성함(slop)을 예상하고 받아들이며, 대부분 조용히 고쳐 버리는 것으로 보인다.
강화 학습이 그런 하네스 혹은 그 하네스를 모방한 환경에서 이뤄진다면, 약간 잘못된 형식의 툴 호출도 여전히 작업을 완료하고 보상을 받을 수 있다. 하네스가 오류를 완전히 흡수해 버리니 별칭을 지어내거나 불필요한 필드를 추가하거나 비슷한 파라미터 이름을 쓰는 행위에 대해 별다른 그래디언트가 생기지 않는다.
더 나쁜 점은 모델이 Claude Code의 표준적인 edit 툴 형태에 매우 강하게 적응해 버릴 수 있다는 것이다. 다른 하네스는 동일한 의미적 의도를 가지지만 다른 스키마를 가진 툴을 제시할 수 있다. 그런 툴은 점점 분포를 벗어난(off-distribution) 것이 될 수 있다. 더 잘 학습된 모델일수록 사전 믿음(prior)이 더 강하기 때문에 오히려 더 강하게 저항할 수도 있다.
이 자체는 그리 놀랍지 않지만, 몇 달 전과는 달라진 양상이다. Opus 4.5가 출시됐을 때는 다른 edit 툴에도 유난히 잘 적응했다. 사실 나는 지침만 잘 되어 있으면 모델이 어떤 툴 형태든 적응할 가능성이 더 높은, 좋은 방향으로 가고 있다고 꽤 확신했었다.
지금은 우리가 가고 있는 방향이 조금 걱정된다. 대체 툴 스키마는 단순히 낯선 정도가 아닐 수도 있다. 하나의 특정하고 관대한 툴 생태계에 최적화된 포스트 트레이닝에 의해 암묵적으로 불이익을 받을 수도 있다. 그리고 그 생태계는 문서화되어 있지 않다. 문서화된 text editor tool이 있긴 하지만, 실제로 Claude Code가 그 형식을 따르지 않는다는 걸 알게 될 것이다. Claude Code가 내부적으로 하는 일(클로즈드 소스 하네스)은 외부에 가려져 있다.
허술함을 용인하는 하네스
Claude Code는 당연히 클로즈드 소스지만, 난독화된 코드를 살펴보면 대략 어떤 일을 하는지 감을 잡을 수 있다. 솔직히 말해 들어오는 데이터에 대해 매우 관대하다.
우선 Claude Code는 모델이 출력한 가시 텍스트에서 새어 나온 <invoke 마크업을 검사한다. 그런 일이 발생하면 텔레메트리도 보내고, 자체 상태 머신을 통해 모델에 다시 푸시백하며 잘못된 호출을 재시도한다.
문자열 값에서 깨진 \uXXXX 시퀀스와 단독 서로게이트(lone surrogate)를 고치는 명시적인 유니코드 이스케이프 복구 기능도 있다. 툴별로 파라미터 별칭도 있다. 예를 들어 Edit은 old_str(아마도 모델이 공식 문서화된 text editor tool로 학습되던 시절의 잔재), 스키마상의 최신 이름인 old_string, new_str/new_string, file_path의 별칭인 path 등 여러 가지를 받아들인다.
또한 예상치 못한 키는 조용히 걸러내며, strict 모드도 사용하지 않는다. strict 모드의 문제는 Anthropic이 툴 정의에 복잡도 제한을 적용해 API 요청이 실패하게 만든다는 점이며, 아마도 그래서 Claude Code가 이를 사용하려 하지 않는 것으로 보인다.
엄격함
이 문제가 다른 하네스에서도 계속될까? Anthropic의 큰 문제 중 하나는 모델이 완전히 클로즈드라는 점이며, 하네스 역시 그렇다는 것이다. Codex 모델도 클로즈드지만 적어도 하네스는 그렇지 않다. 그리고 적어도 조금은 흥미로운 gpt-oss도 있다. 이 모델들은 OpenAI의 harmony 응답 형식을 사용하도록 명시적으로 학습됐고, 적어도 OpenAI 사람들이 이 문제를 어떻게 생각하는지 알려주는 문서도 꽤 많다.
Harmony는 채널과 툴 호출 콘텐츠 타입을 프롬프트 형식의 일부로 만든다. 함수 호출은 이렇게 생길 수 있다:
<|start|>assistant<|channel|>commentary to=functions.get_weather
<|constrain|>json<|message|>{"location":"San Francisco"}<|call|>여기서 중요한 부분은 <|constrain|>json이다. 모델은 이 메시지 본문이 JSON임을 인밴드로 표현할 수 있으며, 추론 스택은 그 경계를 이용해 툴 호출 본문에 대해 JSON 제약 샘플링으로 전환할 수 있다. 아마 Anthropic 모델에서도 비슷한 일이 어느 정도 일어나고 있을 것이다. 적어도 strict 모드에서는 그렇다고 생각한다.
harmony의 마커는 샘플러가 특정 문법으로 샘플링해야 할 시점을 감지하도록 돕고, 대화 기록의 일부이기 때문에 이를 비교적 쉽게 만들 수 있다. 호스팅되는 GPT 모델의 경우, 이런 식으로 준수해야 하는 커스텀 툴을 위해 LARK 문법을 제공하는 옵션도 있다.
Anthropic은 이와는 달라 보이지만, 완전히 다른 것은 아닐지도 모른다. 객체 배열이 보이는 그대로 JSON으로 표현된다면, 모델은 툴 파라미터 안에 JSON을 써야 한다. 아마도 기본적인 문법 제약 샘플링이 동작하고 있을 것이고, 그것이 추가 키를 부분적으로 설명할 수도 있다. 중첩된 배열 파라미터의 경우, 그 JSON은 하나의 태그 안에서 문자열 리터럴 속에 이스케이프된 여러 줄짜리 파일 내용을 포함한다. 예상치 못한 만들어진 키들은 바로 그 작업에서 엔트로피가 가장 높은 지점, 즉 수백 토큰에 달하는 이스케이프된 newText 문자열을 닫은 뒤 모델이 }와 , "..." 중 하나를 결정해야 하는 지점에서 정확히 나타난다.
Opus 4.8과 Sonnet 5는 edit 툴 호출이 어떤 모습이어야 하는지에 대해 훨씬 더 강한 사전 믿음(prior)을 가진 것으로 보이며, 그 prior는 Claude Code의 edit 스키마, 즉 평평한 old/new 문자열 쌍에 선택적 replace_all 플래그가 더해진 형태로 보인다. 내 추측으로는 Opus가 편집 연산에는 하나의 추가 선택적 필드가 있을 수 있다고 학습했지만, Pi의 중첩된 oldText/newText 형태에서는 그 필드에 대해 학습된 이름이 없다는 것이다. 그래서 매번 그럴듯한 이름을 새로 샘플링하고, 그 때문에 실패 시 하나의 안정적인 별칭이 아니라 수십 개의 무작위 키가 생성되는 것이다.
Anthropic의 strict 모드가 이 문제를 해결하는 것으로 보이는 만큼, 서버 측에서 JSON 스키마 구조상 허용되지 않은 키가 샘플링되지 않도록 차단하고 있다고 추측한다. 그것이 strict 모드가 활성화됐을 때 툴 정의의 복잡도에 제한이 있는 이유도 설명해 준다.
지금까지 내가 테스트한 Codex 모델에서는 이런 유형의 퇴보가 나타나지 않았다. 아직 접근 권한이 없는 5.6을 제외하고 사용 가능한 모델은 모두 테스트했다.
하네스에 주는 시사점
불편한 교훈은 툴 스키마가 중립적이지 않다는 것이다. 적어도 Anthropic 모델에서는 그렇다. 우리는 스키마가 추상적인 계약이고 모델은 이를 따르는 범용 추론자라고 가정하고 싶어 하지만, 일부 툴에 대해서는 더 이상 그렇지 않을 수도 있다.
툴 스키마는 분포 어딘가에 존재하며, 어떤 형태는 모델이 포스트 트레이닝에서 본 것과 가깝고 어떤 형태는 멀다. 어떤 것은 제공자의 숨겨진 인코딩(예: ANTML의 최상위 속성)에서는 쉽게 처리되지만, 어떤 것은 모델이 긴 여러 줄 문자열 뒤에 중첩 배열 안에 거대한 이스케이프된 JSON 객체를 작성하도록 요구한다. 모델은 스키마를 이해할 만큼 충분히 똑똑하면서도, 압박 속에서 정확한 형태를 샘플링하는 데는 서툴 수 있다.
이런 종류의 모델 행동이 계속된다면 하네스에 어떤 영향을 미칠지 궁금해진다. 물론 Anthropic에서 strict 샘플링을 켜면 문제는 사라져야 한다. 반면 모델이 이런 행동을 보인다는 사실 자체가 강화 학습이 모델에 미치는 영향을 보여준다. 최고의 모델 성능을 원한다면 그 prior와 싸우는 것은 아마 헛된 일일 것이다.
현실은 Claude Code가 오픈소스가 아니며 RL 환경에서 그들이 무엇을 하고 있는지도 제대로 알 수 없다는 것이다. 여러분의 툴이 거의 일치하지 않는 한 Claude Code로 학습된 행동이 여러분의 툴에 깔끔하게 전이될 거라고 가정할 수 없다. 하나의 지배적인 하네스 안에서 포스트 트레이닝이 많이 이뤄질수록, 다른 모든 하네스는 그 기벽을 더 많이 물려받아야 한다.
나는 제약 디코딩이 품질상의 트레이드오프를 가질 수 있기 때문에 엄격한 문법 제약 툴 호출에 대해 이전에는 더 회의적이었다. 일반적으로는 여전히 그럴 수 있다고 생각하지만, 이번 버그는 내 prior를 크게 움직였다. 최신 모델이 작업을 해결하는 데는 더 뛰어나면서 대체 툴 스키마를 충실히 내보내는 데는 더 못해진다면, 하네스는 어딘가에서 더 강력한 보장이 필요하다.
더 자세히 알아보거나 이 주제에 대해 논의하고 싶다면 Pi 트래커의 이슈를 읽어 보길 바란다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기