원문: LLM APIs are a Synchronization Problem

2025년 11월 22일 작성

대형 언어 모델(LLM)을 제공업체가 노출한 API를 통해 작업하면 할수록, 우리가 상당히 불행한 API 표면 영역에 갇혀 있다는 느낌이 든다. 이는 실제로 내부에서 일어나는 일에 대한 올바른 추상화가 아닐 수 있다. 내가 이제 이 문제를 생각하는 방식은, 이것이 실제로는 분산 상태 동기화(distributed state synchronization) 문제라는 것이다.

근본적으로 대형 언어 모델(large language model)은 텍스트를 받아서 숫자로 토큰화(tokenize)하고, 그 토큰(token)들을 GPU의 행렬 곱셈(matrix multiplication)과 attention layer(어텐션 레이어) 스택을 통해 전달한다. 고정된 대규모 가중치(weight) 세트를 사용하여 활성화(activation)를 생성하고 다음 토큰을 예측한다. temperature(온도, 무작위성)가 없다면, 적어도 원칙적으로는 훨씬 더 결정론적인 시스템의 잠재력을 가진 것으로 생각할 수 있다.

핵심 모델에 관한 한, “사용자 텍스트”와 “어시스턴트 텍스트” 사이에 마법 같은 구분은 없다. 모든 것이 단지 토큰일 뿐이다. 유일한 차이는 역할(system, user, assistant, tool)을 인코딩하는 특수 토큰(special token)과 형식화(formatting)에서 비롯되며, 이는 prompt template(프롬프트 템플릿)을 통해 스트림에 주입된다. 다양한 모델에 대한 system prompt template(시스템 프롬프트 템플릿)을 Ollama에서 확인하여 개념을 파악할 수 있다.

기본 에이전트 상태(Basic Agent State)

이미 존재하는 API가 무엇인지는 잠시 무시하고 일반적으로 agentic system(에이전트 시스템)에서 무슨 일이 일어나는지 생각해 보자. LLM을 같은 머신에서 로컬로 실행한다면, 여전히 유지해야 할 상태(state)가 있지만 그 상태는 나에게 매우 로컬하다. 대화 기록(conversation history)을 RAM의 토큰으로 유지하고, 모델은 GPU에 파생된 “작업 상태(working state)“를 유지한다. 주로 그 토큰들로부터 구축된 attention key/value cache(어텐션 키/밸류 캐시)다. 가중치 자체는 고정되어 있고, 단계마다 변하는 것은 활성화와 KV cache(KV 캐시)다.

한 가지 추가 설명: 내가 상태에 대해 이야기할 때 단순히 보이는 토큰 기록만을 의미하는 것이 아니다. 모델은 단순히 토큰을 재전송하는 것으로는 포착되지 않는 내부 작업 상태도 유지하기 때문이다. 다시 말해, 토큰을 재생하여 텍스트 콘텐츠를 되찾을 수는 있지만, 모델이 구축했던 정확한 파생 상태를 복원하지는 못한다.

멘탈 모델 관점에서 caching(캐싱)은 “주어진 prefix(접두사)에 대해 이미 수행한 계산을 기억하여 다시 수행할 필요가 없도록 하는 것”을 의미한다. 내부적으로 이는 일반적으로 서버에 해당 접두사 토큰에 대한 attention KV cache를 저장하고 재사용할 수 있게 하는 것을 의미하며, 말 그대로 원시 GPU 상태를 넘겨주는 것은 아니다.

이에 대해 내가 놓치고 있는 미묘한 부분이 있을 수 있지만, 이것이 생각하기에 꽤 좋은 모델이라고 생각한다.

Completion API(완성 API)

OpenAI나 Anthropic의 것과 같은 completion-style API(완성 스타일 API)로 작업하는 순간, 이 매우 단순한 시스템과 조금 다르게 만드는 추상화가 적용된다. 첫 번째 차이점은 실제로 원시 토큰을 주고받는 것이 아니라는 점이다. GPU가 대화 기록을 보는 방식과 당신이 그것을 보는 방식은 근본적으로 다른 추상화 수준에 있다. 방정식의 한쪽에서 토큰을 세고 조작할 수 있지만, 보이지 않는 추가 토큰이 스트림에 주입되고 있다. 이러한 토큰 중 일부는 JSON 메시지 표현을 머신에 공급되는 기본 입력 토큰으로 변환하는 과정에서 나온다. 그러나 tool definition(도구 정의)과 같은 것들도 있으며, 이는 독점적인 방식으로 대화에 주입된다. 그런 다음 cache point(캐시 포인트)와 같은 out-of-band(대역 외) 정보도 있다.

그리고 그 이상으로, 결코 볼 수 없는 토큰들이 있다. 예를 들어, reasoning model(추론 모델)의 경우 실제 추론 토큰을 전혀 볼 수 없는 경우가 많은데, 일부 LLM 제공업체가 사용자가 자신들의 추론 상태로 자체 모델을 재훈련할 수 없도록 최대한 숨기려고 하기 때문이다. 반면에, 사용자에게 보여줄 무언가가 있도록 다른 정보 텍스트를 제공할 수도 있다. 모델 제공업체는 또한 검색 결과와 그 결과가 토큰 스트림에 주입된 방식을 숨기는 것을 좋아한다. 대신 대화를 계속하기 위해 다시 보내야 하는 암호화된 blob(블롭)만 받는다. 갑자기 당신 측의 일부 정보를 가져와서 서버로 다시 전달해야 하므로 양쪽 끝에서 상태를 조정할 수 있다.

completion-style API에서는 각 새로운 턴(turn)마다 전체 prompt history(프롬프트 기록)를 다시 전송해야 한다. 각 개별 요청의 크기는 턴 수에 따라 선형적으로 증가하지만, 긴 대화에서 전송되는 누적 데이터 양은 각 선형 크기의 기록이 매 단계마다 재전송되기 때문에 2차적으로 증가한다. 이것이 긴 채팅 세션이 점점 더 비싸게 느껴지는 이유 중 하나다. 서버에서 해당 시퀀스에 대한 모델의 attention cost(어텐션 비용)도 시퀀스 길이에 따라 2차적으로 증가하므로 캐싱이 중요해지기 시작한다.

Responses API(응답 API)

OpenAI가 이 문제를 해결하려고 시도한 방법 중 하나는 Responses API를 도입한 것인데, 이는 서버에서 대화 기록을 유지한다(적어도 saved state flag(저장된 상태 플래그)가 있는 버전에서). 그러나 이제 당신은 완전히 state synchronization(상태 동기화)을 다루는 기묘한 상황에 처하게 된다. 서버에 hidden state(숨겨진 상태)가 있고 당신 측에 상태가 있지만, API는 매우 제한된 동기화 기능을 제공한다. 이 시점에서 실제로 얼마나 오래 그 대화를 계속할 수 있는지 불분명하다. state divergence(상태 분기) 또는 corruption(손상)이 발생하면 어떻게 되는지도 불분명하다. 나는 Responses API가 복구할 수 없는 방식으로 멈추는 것을 본 적이 있다. network partition(네트워크 파티션)이 있거나, 한쪽이 상태 업데이트를 받았지만 다른 쪽은 받지 못한 경우 어떻게 되는지도 불분명하다. saved state(저장된 상태)가 있는 Responses API는 적어도 현재 노출된 방식으로는 사용하기가 상당히 어렵다.

분명히 OpenAI에게는 좋은데, 모든 대화 메시지마다 전달해야 했던 더 많은 behind-the-scenes state(백그라운드 상태)를 숨길 수 있기 때문이다.

State Sync API(상태 동기화 API)

completion-style API를 사용하든 Responses API를 사용하든, 제공업체는 항상 백그라운드에서 추가 컨텍스트를 주입해야 한다. prompt template, role marker(역할 마커), system/tool definition, 때로는 제공업체 측 tool output(도구 출력)까지도, 이것들은 보이는 메시지 목록에 절대 나타나지 않는다. 다양한 제공업체는 이 hidden context(숨겨진 컨텍스트)를 서로 다른 방식으로 처리하며, 이를 표현하거나 동기화하는 방법에 대한 공통 표준이 없다. 기본 현실은 message-based abstraction(메시지 기반 추상화)이 보이게 하는 것보다 훨씬 간단하다. open-weights model(오픈 가중치 모델)을 직접 실행하면 토큰 시퀀스로 직접 구동하고 우리가 표준화한 JSON-message interface(JSON 메시지 인터페이스)보다 훨씬 깔끔한 API를 설계할 수 있다. OpenRouter와 같은 intermediary(중개자)나 Vercel AI SDK와 같은 SDK를 거치면 복잡성이 더욱 악화되는데, 이들은 제공업체별 차이를 masking(마스킹)하려고 시도하지만 각 제공업체가 유지하는 hidden state를 완전히 통합할 수 없다. 실제로 LLM API를 통합하는 데 가장 어려운 부분은 사용자에게 보이는 메시지가 아니라, 각 제공업체가 자체적으로 부분적으로 숨겨진 상태를 호환되지 않는 방식으로 관리한다는 것이다.

결국 이 hidden state를 어떤 형태로든 어떻게 전달하느냐의 문제로 귀결된다. 모델 제공업체의 관점에서 사용자로부터 것들을 숨길 수 있는 것이 좋다는 것을 이해한다. 그러나 hidden state를 동기화하는 것은 까다롭고, 내가 아는 한 이러한 API 중 어느 것도 그러한 마인드셋으로 구축되지 않았다. 아마도 message-based API보다는 state synchronization API가 어떤 모습일지 생각하기 시작할 때가 되었을 것이다.

이러한 에이전트들과 작업하면 할수록, 실제로 unified message API(통합 메시지 API)가 필요하지 않다는 느낌이 더 든다. 현재 형태로 message-based(메시지 기반)라는 핵심 아이디어 자체가 시간이 지나면서 살아남지 못할 수 있는 추상화다.

Local First(로컬 우선)에서 배우기?

이전에 이런 종류의 혼란을 다룬 전체 생태계가 있다. local-first movement(로컬 우선 운동)다. 그 사람들은 서로 신뢰하지 않고, 오프라인이 되고, 분기(fork)하고, 병합(merge)하고, 치유(heal)하는 클라이언트와 서버 간에 distributed state(분산 상태)를 동기화하는 방법을 알아내는 데 10년을 보냈다. Peer-to-peer sync(P2P 동기화)와 conflict-free replicated storage engine(무충돌 복제 스토리지 엔진)이 모두 존재하는 이유는 “gap과 divergence가 있는 shared state(공유 상태)“가 순진한 message passing(메시지 전달)으로는 아무도 해결할 수 없는 어려운 문제이기 때문이다. 그들의 아키텍처는 canonical state(정규 상태), derived state(파생 상태), transport mechanic(전송 메커니즘)을 명시적으로 분리한다. 이는 오늘날 대부분의 LLM API에서 누락된 정확히 그런 종류의 분리다.

이러한 아이디어 중 일부는 놀랍게도 모델에 잘 매핑된다. KV cache는 checkpoint(체크포인트)되고 재개될 수 있는 derived state와 유사하다. prompt history는 본질적으로 전체를 재전송하는 대신 점진적으로 동기화될 수 있는 append-only log(추가 전용 로그)다. 제공업체 측의 invisible context(보이지 않는 컨텍스트)는 hidden field(숨겨진 필드)가 있는 replicated document(복제된 문서)처럼 동작한다.

동시에 remote site(원격 사이트)가 그렇게 오래 보관하고 싶지 않아서 remote state(원격 상태)가 삭제되면, 처음부터 완전히 재생할 수 있는 상황에 있기를 원할 것이다. 예를 들어 오늘날 Responses API는 이를 허용하지 않는다.

미래의 통합 API(Future Unified APIs)

특히 MCP(Model Context Protocol) 이후로 message-based API를 통합하는 것에 대한 많은 논의가 있었다. 그러나 무언가를 표준화한다면, 우리가 물려받은 표면적 관례가 아니라 이러한 모델이 실제로 어떻게 동작하는지에서 시작해야 한다. 좋은 표준은 hidden state, synchronization boundary(동기화 경계), replay semantic(재생 의미론), failure mode(실패 모드)를 인정할 것이다. 왜냐하면 이것들이 실제 문제이기 때문이다. 현재의 추상화를 서둘러 공식화하고 그 약점과 결함을 고착시킬 위험이 항상 있다. 올바른 추상화가 어떤 모습인지 모르겠지만, 현상 유지 솔루션이 적합하다는 점에 대해 점점 더 의심스럽다.