이틀간 이상한 Pi 버그를 파고들다 흥미로운 사실을 발견했다. 최신 Claude 모델들이 Pi의 edit 도구를 호출할 때, 중첩된 edits[] 배열에 실제로 존재하지 않는 필드를 임의로 만들어 넣는 현상이 확인됐다. 소형 모델인 Haiku도 아니고, Opus 4.8에서 발생한 문제다. 편집 내용 자체는 대체로 정확하지만, 인자가 스키마와 맞지 않는다. 모델이 없는 키를 지어내면 Pi가 해당 도구 호출을 거부하고 재시도를 요청하는 식이다.
모델이 가끔 잘못된 형식의 도구 호출을 내보내는 것 자체는 그리 놀라운 일이 아니다. 특히 소형 모델에서는 흔한 일이기도 하다. 정작 놀라운 건 이 문제가 최신 Anthropic 모델로 갈수록 오히려 심해진다는 점이다. Opus 4.8과 Sonnet 5에서는 재현되지만, 이전 모델에서는 전혀 나타나지 않는다. 다시 말해, 패밀리 내 최신 모델들이 이 특정 도구 스키마에서는 이전 세대보다 오히려 더 나쁜 성능을 보인다는 뜻이다.
Fable을 테스트하지 않은 이유가 궁금한 분들을 위해 밝히자면, 의도적으로 제외했다. Anthropic이 실행 중인 분류기가 나를 Opus로 조용히 다운그레이드할 가능성을 배제할 수 없었기 때문이다.
LLM의 도구 호출 내부 구조를 깊이 들여다본 적이 없다면, 한 가지 핵심을 짚고 넘어가야 한다. 도구 호출은 마법이 아니다. 꽤 조잡한 인밴드(in-band) 시그널링 방식을 쓴다. 모델은 대화 기록, 시스템 프롬프트, 사용 가능한 도구 목록을 입력으로 받는다. 서버는 이를 특수 마커 토큰이 포함된 하나의 큰 프롬프트로 조합한다. 모델이 해당 형식의 예시로 학습과 강화를 거쳤기 때문에, 생성 과정 어느 시점에서 API나 클라이언트가 "이 인자로 이 도구를 호출하라"고 해석할 수 있는 출력을 내보내는 것이다.
파일 편집 도구의 경우, 의도한 호출 페이로드는 대략 이런 모습이다:
{
"path": "some/file.py",
"edits": [
{
"oldText": "text to replace",
"newText": "replacement text"
}
]
}
그러면 하네스(harness)가 인자를 검증하고 편집을 수행한 뒤, 그 결과를 다시 모델에 돌려준다. 검증에 실패하면 모델은 오류를 확인하고 보통 재시도한다.
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 직렬화 방식으로 구현된다는 점이다. 이 방식이 정확히 맞는다고 완전히 확신하는 건 아니지만, 크게 벗어나지 않는다는 근거는 있다. 이 부분은 나중에 다시 중요하게 다뤄진다.
모델이 이런 구조를 출력하도록 만드는 방법은 크게 두 가지다:
두 번째 방식이 바로 문법 인식(grammar-aware) 또는 제약 디코딩(constrained decoding)이라고 부르는 것이다. 샘플러가 문법을 위반하는 토큰을 마스킹해 차단한다. 예를 들어 모델이 현재 JSON 객체 내부에 있고 스키마상 oldText와 newText만 허용된다면, 샘플러가 "in_file"나 "type"의 출력을 원천 차단할 수 있다. 문법 인식 디코딩은 구문적으로 유효한 JSON을 강제하는 것은 물론, 특정 enum 값이나 키를 제한하는 데도 활용된다.
아무런 제약이 없으면 모델은 그저 학습으로 익힌 관례를 따를 뿐이다.
Pi의 edit 도구는 한 번의 호출로 여러 개의 정확한 문자열 교체를 지원한다. 인자에 edits 배열이 포함된 이유다. 실패 사례에서 모델은 다음과 같은 항목을 생성했다:
{
"oldText": "...",
"newText": "...",
"requireUnique": true
}
또는 이런 형태:
{
"oldText": "...",
"newText": "...",
"oldText2": "",
"newText2": ""
}
반복 테스트를 거치며 온갖 종류의 임의 후행 키들을 목격했다: type, id, kind, unique, requireUnique, matchCase, in_file, forceMatchCount, children, notes, cost, oldText2, newText2, oldText_2, newText_2, 심지어 편집 객체 내부에 event.0.additionalProperties 키까지 등장했다.
가장 황당한 부분은, 내가 직접 확인한 잘못된 호출에서 실제 oldText과 newText 페이로드는 바이트 단위까지 정확했다는 점이다. 모델은 올바른 호출을 만들어냈지만, 객체 끝에 의미 없는 내용을 덧붙인 것이다.
이 실패는 컨텍스트에도 크게 좌우된다. "이 파일을 편집해"처럼 단순한 단일 턴 프롬프트에서는 전혀 재현되지 않았다. 반면 모델이 파일을 읽고 문제를 진단한 뒤 여러 줄짜리 편집을 구성하는 에이전트 방식의 히스토리에서는 재현됐다. 더 까다로운 건, 모든 대화 기록에서 이 동작이 나타나는 건 아니라는 점이다. 실제로 나는 Petr Baudis의 대화 기록이 있어야 재현할 수 있었다. 그 사용자의 세션에서 이어서 진행하면 Opus 4.8이 약 20% 확률로 실패했다. 히스토리에서 thinking 블록을 제거하면 실패율이 절반으로 줄었고, 엄격한 도구 호출 모드를 켜면 내 테스트에서는 완전히 사라졌다.
가장 유력한 가설은, 이것이 무작위적인 성능 저하가 아니라 학습 과정에서 비롯된 부작용이라는 것이다.
이전 Anthropic 모델들은 일부 문서화된 도구들을 포함해 여러 도구로 학습됐다. 하지만 당시에는 Claude Code 같은 실제 사용자에게 배포된 하네스가 명확한 학습 대상으로 존재하지 않았다. 최신 Anthropic 모델들이 다른 양상을 보이는 건 아마 사후 학습(post-training)에 Claude Code 또는 그와 매우 유사한 하네스가 포함됐기 때문일 것이다. 모델은 해당 환경에서 성공적인 도구 호출이 어떤 모습인지 학습한다. 그리고 그 환경이 어떤 실수를 용인하는지도 함께 학습한다.
Claude Code 자체 도구는 구조가 비교적 단순하다. 기본 편집 도구는 Pi의 중첩된 edits[] 형태가 아니라, file_path, old_string, new_string에 선택적 플래그(replace_all) 정도를 갖춘 수준에 가깝다. Claude Code의 클라이언트 코드를 살펴보면 많은 것을 알 수 있다. 잘못된 도구 사용에 대한 재시도 경로, 파라미터 별칭, 타입 강제 변환, 유니코드 복구, 알 수 없는 키 필터링이 모두 포함돼 있다. 한마디로 Anthropic 자체 클라이언트는 상당한 수준의 불완전한 입력을 예상하고, 대부분 조용히 수정한다.
강화 학습이 이런 하네스나 그 시뮬레이션 환경에서 이뤄진다면, 약간 잘못된 형식의 도구 호출도 태스크를 완료하고 보상을 받을 수 있다. 하네스가 오류를 완전히 흡수하기 때문에, 별칭을 지어내거나 불필요한 필드를 추가하거나 비슷한 파라미터명을 쓰는 행동에 대한 그래디언트가 거의 생기지 않는다.
더 나아가 모델은 Claude Code의 편집 도구 형태에 강하게 적응해버릴 수 있다. 다른 하네스가 동일한 의미를 가진 도구를 다른 스키마로 제공하면, 해당 도구가 모델의 학습 분포에서 점점 벗어나게 된다. 더 잘 학습된 모델일수록 사전 분포(prior)가 강해져, 오히려 더 완강하게 기존 방식을 고집할 수 있다.
놀라운 일은 아니지만, 불과 몇 달 전과는 분명히 달라진 모습이다. Opus 4.5가 출시됐을 때는 다른 편집 도구에 대한 적응력이 탁월했다. 당시에는 지시사항만 충분하면 어떤 형태의 도구 스키마에도 잘 적응하는 방향으로 가고 있다고 확신했다.
지금은 솔직히 걱정이 된다. 대안적인 도구 스키마가 단순히 낯선 것에 그치지 않을 수 있다. 관대하고 특정 도구 생태계에 최적화된 사후 학습에 의해 암묵적으로 불이익을 받을 수도 있다. 그 생태계는 문서화돼 있지 않다. 텍스트 편집기 도구 문서가 존재하기는 하지만, 실제로 Claude Code는 해당 형식을 따르지 않는다. Claude Code가 내부적으로 무엇을 하는지(클로즈드 소스 하네스)는 외부에서 알 수 없다.
Claude Code는 클로즈드 소스지만, 난독화된 코드를 통해 어느 정도 동작을 파악할 수 있다. 그리고 솔직히 말하면, 입력 데이터에 상당히 관대하다.
먼저 Claude Code는 모델의 가시적 텍스트에서 <invoke 마크업 유출 여부를 확인한다. 발생 시 텔레메트리를 전송하고, 자체 상태 머신을 통해 모델에 되돌려 보내 재시도를 유도한다.
문자열 값의 손상된 \uXXXX 시퀀스와 단독 서로게이트(lone surrogate)를 수정하는 명시적 유니코드 이스케이프 복구 기능도 있다. 도구별 파라미터 별칭도 지원한다. 예를 들어 Edit은 old_str(아마 공식 문서화된 텍스트 편집기 도구로 학습됐던 시절의 흔적), 스키마의 최신 형태인 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는 편집 도구 호출이 어떤 모습이어야 하는지에 대한 강한 사전 분포를 갖고 있는 것으로 보이며, 그 기준은 Claude Code의 편집 스키마, 즉 old/new 문자열 쌍에 선택적 replace_all 플래그를 더한 단순한 구조다. 내 추측으로는 Opus가 편집 작업에 선택적 필드 하나가 추가될 수 있다고 학습했지만, Pi의 중첩된 oldText/newText 구조에서는 그 필드에 붙일 학습된 이름이 없는 것이다. 그래서 매번 그럴듯한 이름을 즉흥으로 샘플링하고, 덕분에 실패 사례마다 수십 가지 서로 다른 랜덤 키가 등장하게 된다.
Anthropic의 strict 모드가 이 문제를 해결하는 것으로 보아, 서버 측에서 JSON 스키마 구조상 허용되지 않는 키의 샘플링을 차단하는 것으로 추정된다. 이는 엄격 모드 활성화 시 도구 정의 복잡도에 제한을 두는 이유도 설명해준다.
지금까지 테스트한 Codex 모델에서는 이런 종류의 회귀가 나타나지 않았다. 아직 접근 권한이 없는 5.6을 제외한 전 모델을 테스트했다.
불편하지만 받아들여야 할 교훈이 있다. 적어도 Anthropic 모델에서는 도구 스키마가 중립적이지 않다. 우리는 스키마를 추상적인 계약이고 모델은 그것을 따르는 범용 추론기라고 생각하고 싶어 하지만, 일부 도구에서는 그 가정이 더 이상 통하지 않을 수 있다.
도구 스키마는 분포 어딘가에 위치한다. 모델이 사후 학습 과정에서 접했던 형태에 가까운 스키마가 있고, 훨씬 먼 스키마가 있다. 또 제공자의 숨겨진 인코딩(예: ANTML의 최상위 속성)에서 표현하기 쉬운 형태가 있는 반면, 긴 멀티라인 문자열 뒤 중첩 배열 안에 대규모 이스케이프된 JSON 객체를 작성해야 하는 형태도 있다. 모델이 스키마를 충분히 이해하더라도, 압박 상황에서 정확한 형태로 샘플링하는 건 별개의 문제다.
이런 모델 동작이 계속된다면 하네스에 어떤 함의를 가질지 생각하지 않을 수 없다. Anthropic에서 strict 샘플링을 활성화하면 문제는 해결된다. 하지만 이 동작이 존재한다는 사실 자체가 강화 학습의 파급력을 보여준다. 최고 성능을 원한다면, 그 사전 분포와 싸우는 건 아마 헛수고일 것이다.
현실을 직시하자면, Claude Code는 오픈 소스가 아니고 Anthropic의 강화 학습 환경에서 무슨 일이 일어나는지도 알 수 없다. Claude Code 학습으로 형성된 동작이 여러분의 도구에도 깔끔하게 전이될 거라고 가정해서는 안 된다. 유사한 형태가 아닌 이상. 사후 학습이 하나의 지배적인 하네스 안에서 집중될수록, 다른 모든 하네스는 그 하네스의 특이한 습성을 점점 더 많이 물려받아야 할 것이다.
예전에는 엄격한 문법 제약 도구 호출에 회의적이었다. 제약 디코딩이 품질 측면에서 트레이드오프를 유발할 수 있기 때문이다. 일반론으로는 여전히 그렇게 생각하지만, 이번 버그가 내 시각을 크게 바꿔놓았다. 최신 모델들이 태스크 해결 능력은 높아지면서 대안적인 도구 스키마를 충실하게 따르는 능력은 떨어진다면, 하네스 어딘가에 더 강한 보장이 필요하다.
더 자세한 내용이 궁금하거나 논의하고 싶다면 Pi 트래커의 이슈를 참고하기 바란다.