LLM 제공업체의 완성형/응답형 API가 왜 근본적으로 분산 상태 동기화 문제인지, 숨겨진 컨텍스트와 KV 캐시, 로컬 퍼스트 개념을 통해 살펴본다.
2025년 11월 22일에 씀
제공업체가 노출하는 API를 통해 대형 언어 모델을 다룰수록, 우리는 꽤 불행한 형태의 API 표면을 스스로 만들어 버린 것 같다는 생각이 든다. 실제로 내부에서 일어나는 일을 고려했을 때, 지금의 API 추상화가 꼭 올바른 형태인 것 같지는 않다. 이제 나는 이 문제를 “분산 상태 동기화(distributed state synchronization)” 문제로 보는 편이 더 맞다고 느낀다.
가장 핵심에 있는 것은, 대형 언어 모델이 텍스트를 받아 토큰으로 분해한 뒤, 그 숫자 토큰들을 GPU 위의 행렬 곱셈과 어텐션 레이어 스택에 흘려 보낸다는 점이다. 고정된 거대한 가중치 집합을 사용해서 활성값(activations)을 만들고, 다음 토큰을 예측한다. 온도(temperature, 무작위성)가 없다면, 원론적으로는 훨씬 더 결정론적인 시스템이 될 잠재력이 있다고 볼 수도 있다.
핵심 모델 입장에서 보면, “사용자 텍스트”와 “어시스턴트 텍스트” 사이에 마법 같은 구분은 전혀 없다. 전부 그냥 토큰일 뿐이다. 차이는 단지 역할(시스템, 사용자, 어시스턴트, 도구/tool)을 인코딩하는 특수 토큰과 포맷팅에서 온다. 이 역할 정보는 프롬프트 템플릿을 통해 토큰 스트림에 주입된다. 모델별로 어떤 시스템 프롬프트 템플릿을 쓰는지 보고 싶다면, Ollama에 올라온 모델들의 시스템 프롬프트 템플릿을 보면 감을 잡을 수 있다.
기존에 어떤 API가 있는지는 잠시 잊고, 보통의 에이전트 시스템에서 실제로 무슨 일이 일어나는지만 생각해 보자. 만약 내 LLM이 같은 머신에서 로컬로 돈다고 하면, 여전히 유지해야 할 상태는 존재하지만, 그 상태는 온전히 내 로컬 영역에 머물러 있다. RAM 안에 대화 히스토리를 토큰 형태로 유지하고, 모델은 그 토큰들로부터 파생된 “작업 상태(working state)”를 GPU에 유지한다. 주로 어텐션의 key/value 캐시가 그렇다. 가중치 자체는 고정 상태로 남고, 스텝마다 달라지는 것은 활성값과 KV 캐시뿐이다.
마음속 모델로 보자면, 캐싱이란 “이미 계산해 둔 프리픽스(prefix)에 대한 연산을 다시 하지 않도록 기억해 두는 것”을 뜻한다. 내부적으로는 보통 서버 측에 그 프리픽스 토큰들에 대한 어텐션 KV 캐시를 저장해 두고 재사용하도록 해 주는 방식이지, 진짜로 GPU의 로우 레벨 상태를 통째로 손에 쥐여 주는 건 아니다.
내가 놓치고 있는 미묘한 부분들도 분명 있겠지만, 이 정도 모델만으로도 생각을 전개하기에는 꽤 괜찮다고 본다.
OpenAI나 Anthropic 같은 곳의 completion 스타일 API를 사용하는 순간, 이 단순한 모델과는 조금 다른 추상화들이 덧씌워진다. 첫 번째 차이는, 실제로는 “날것의 토큰”을 주고받는 게 아니라는 점이다. GPU가 바라보는 대화 히스토리와 우리가 바라보는 대화 히스토리는 근본적으로 다른 추상화 레벨에 있다. 물론 우리가 클라이언트 쪽에서 토큰을 세거나 조작할 수는 있지만, 실제로는 우리가 보지 못하는 토큰들이 스트림 안에 추가로 주입된다.
이 토큰들 중 일부는 JSON 메시지 표현을 모델 입력 토큰으로 변환하는 과정에서 생긴다. 하지만 그것만 있는 게 아니다. 예를 들어 도구 정의(tool definitions) 같은 것들은 각 제공업체 고유의 방식으로 대화에 주입된다. 여기에 캐시 포인트 같은, 밴드 밖(out-of-band)의 정보도 더해진다.
그리고 그 외에도 우리는 절대 볼 수 없는 토큰들이 있다. 예를 들어 "reasoning 모델"의 경우, 실제 추론에 해당하는 토큰을 거의 보여주지 않는 경우가 많다. 일부 LLM 제공업체는 자사 모델의 추론 상태로 다른 사람이 모델을 재학습하지 못하도록, 가능한 한 많이 숨기려고 한다. 대신, 우리는 사용자에게 보여 줄 수 있도록 가공된 어떤 다른 설명 텍스트를 받기도 한다. 또, 검색 결과와 그 결과가 토큰 스트림에 어떻게 주입되었는지도 제공업체는 매우 좋아서 숨긴다. 그 대신, 대화 유지를 위해 다시 보내야만 하는 암호화된 블롭(blob)을 돌려줄 뿐이다. 이렇게 되면, 클라이언트 측의 어떤 정보를 다시 서버 쪽으로 흘려보내 양쪽 상태가 서로 맞춰지도록 해야 하는 상황이 된다.
Completion 스타일 API에서는, 매 턴마다 전체 프롬프트 히스토리를 다시 보내야 한다. 각 요청의 크기는 턴 수에 비례해서 선형으로 증가하지만, 긴 대화 전체 동안 전송되는 데이터의 누적량은 제곱 꼴로 증가한다. 매번 선형으로 큰 히스토리를 통째로 재전송하기 때문이다. 이것이 긴 채팅 세션이 점점 더 비싸게 느껴지는 이유 중 하나다. 서버 쪽에서도, 해당 시퀀스 길이에 대한 모델의 어텐션 비용이 시퀀스 길이의 제곱에 비례해서 증가하기 때문에, 캐싱이 중요해진다.
OpenAI가 이 문제를 해결하려고 시도한 방법 중 하나가 Responses API다. (적어도 saved state 플래그가 켜진 버전에서는) 서버 측에서 대화 히스토리를 유지한다. 하지만 이렇게 되면, 정말 기묘한 상태 동기화 상황에 놓이게 된다. 서버에는 숨겨진 상태가 있고, 클라이언트인 우리 쪽에도 상태가 있지만, API가 허용하는 동기화 수단은 극도로 제한적이다.
현재 시점에서, 그 대화를 실제로 얼마나 오래 이어갈 수 있는지는 여전히 불분명하다. 상태가 엇갈리거나, 손상되면 정확히 무슨 일이 벌어지는지도 모호하다. 내가 본 바로는, Responses API가 어떤 이상한 상태에 빠져서 도저히 회복이 안 되는 경우도 있었다. 네트워크가 분할(partition)되었을 때, 혹은 한쪽만 상태 업데이트를 받고 다른 쪽은 못 받았을 때 어떻게 되는지도 분명치 않다. saved state가 켜진 Responses API는, 지금 노출된 방식대로라면, 사용하는 쪽에서 훨씬 다루기 어려운 편이다.
물론 OpenAI 입장에서는 아주 좋다. 예전 같으면 매 대화 메시지에 함께 흘려보내야 했을 뒤편의 상태를 서버 쪽에 더 많이 숨겨 둘 수 있기 때문이다.
Completion 스타일 API를 쓰든 Responses API를 쓰든, 제공업체는 항상 장막 뒤에서 추가적인 컨텍스트를 주입해야 한다. 프롬프트 템플릿, 역할 마커, 시스템/도구 정의, 심지어는 제공업체 측 도구 출력까지도 그렇다. 이들은 전부, 당신이 보는 메시지 목록에는 드러나지 않는다. 각 제공업체는 이 숨겨진 컨텍스트를 제각기 다른 방식으로 처리하고, 그것이 어떻게 표현되고 동기화되는지에 대한 공통 표준은 없다.
하지만 밑바닥의 현실은, 메시지 기반 추상화가 보이는 것보다 훨씬 단순하다. 오픈 웨이트(open-weights) 모델을 직접 돌리면, 토큰 시퀀스를 직접 밀어 넣어 모델을 구동할 수 있고, 우리가 표준처럼 여기는 JSON 메시지 인터페이스보다 훨씬 더 깔끔한 API를 설계할 수 있다. 여기에 OpenRouter 같은 중간 레이어나 Vercel AI SDK 같은 SDK까지 끼어들면 복잡성은 더 악화된다. 이런 것들은 제공업체별 차이를 가리는 시도를 하지만, 각 제공업체가 관리하는 숨겨진 상태까지 완전히 통합하는 데에는 실패할 수밖에 없다. 실제로 LLM API를 통합하는 데 가장 어려운 부분은 사용자에게 보이는 메시지를 맞추는 게 아니다. 각 제공업체가 자기 마음대로 운영하는, 부분적으로 숨겨진 상태들이 서로 양립 불가능하다는 게 진짜 난제다.
결국 핵심은, 이 숨겨진 상태를 어떻게든 한 형태로 주고받느냐의 문제다. 모델 제공업체의 관점에서 보면, 사용자에게서 무언가를 숨길 수 있다는 것은 매력적인 속성이다. 하지만 숨겨진 상태를 동기화하는 일은 까다롭고, 내가 보기에는 어떤 API도 처음부터 이런 관점으로 설계된 것 같지 않다. 어쩌면 이제는 메시지 기반 API 대신, 상태 동기화 API가 어떤 모습일지 생각해 볼 때가 된 건지도 모른다.
이런 에이전트들을 다룰수록, 나는 사실 통합된 메시지 API가 꼭 필요하지는 않은 것 같다는 생각이 든다. 지금처럼 메시지 기반이라는 핵심 아이디어 자체가, 시간이 지나면 견디지 못할지도 모르는 하나의 추상화일 뿐일 수 있다.
이런 종류의 난장판을 이미 오래전에 겪고 대응해 온 생태계가 하나 있다. 바로 로컬 퍼스트(local-first) 운동이다. 그쪽 사람들은 서로를 신뢰하지 않고, 오프라인이 되기도 하고, 포크가 나고, 다시 머지하며, 복구도 해야 하는 클라이언트와 서버 사이의 분산 상태를 어떻게 동기화할지 10년 가까이 고민해 왔다. 피어 투 피어 동기화와, 분산 환경에서도 충돌 없는 복제 저장소 엔진(CRDT 등)이 등장한 이유도, “공유 상태인데 중간에 구멍과 분기가 생기는” 문제가 너무 어렵고, 순진한 메시지 패싱만으로는 해결되지 않았기 때문이다.
그들의 아키텍처는 정식으로 “정준 상태(canonical state)”, “파생 상태(derived state)”, “전송 메커니즘(transport mechanics)”을 분리한다. 오늘날 대부분의 LLM API에 빠져 있는 바로 그 분리다.
이 아이디어 중 일부는 모델에도 놀랄 만큼 잘 대응된다. KV 캐시는 체크포인트를 찍고 다시 이어붙일 수 있는 파생 상태처럼 볼 수 있다. 프롬프트 히스토리는, 통째로 다시 보내는 대신 점진적으로 동기화할 수 있는 append-only 로그에 가깝다. 제공업체 측의 보이지 않는 컨텍스트는, 숨겨진 필드를 가진 복제 문서처럼 행동한다.
동시에, 원격 사이트가 오랫동안 상태를 들고 있기를 원치 않아 원격 상태를 지워버리는 상황도 상정해야 한다. 그럴 때는 전체를 처음부터 다시 재생(replay)할 수 있어야 한다. 그런데 예를 들어 현재의 Responses API는 그런 식의 완전한 재생을 허용하지 않는다.
MCP(Model Context Protocol) 등장 이후, 메시지 기반 API를 통합하자는 이야기는 이미 많이 나왔다. 하지만 정말로 무언가를 표준화한다면, 지금 우리가 물려받은 표면적 관습에서 출발할 게 아니라, 모델이 실제로 어떻게 동작하는지에서 시작해야 한다. 좋은 표준이라면 숨겨진 상태, 동기화 경계(synchronization boundary), 재생 의미론(replay semantics), 실패 모드까지 다룰 수 있어야 한다. 그것들이 실제로 중요한 이슈이기 때문이다.
우리가 현재의 추상화를 성급히 형식화해서, 그 약점과 결함까지 그대로 고착화해 버릴 위험은 항상 있다. 나는 올바른 추상화가 정확히 어떤 모습인지 알지는 못하지만, 현 상태의 해결책들이 적절한 핏인지에 대해서는 점점 더 회의적이 되어 가고 있다.
이 글의 태그: ai