LLM Adapter의 정의와 기술적 고려사항
LLM을 사용하는 코드베이스를 열어 보면 프로바이더를 갈아 끼우는 지점이 거의 예외 없이 존재한다. 이름은 제각각이다. LLMClient, ModelBackend, Completion, 혹은 그냥 chat() 하나만 덩그러니 있기도 하다. 그런데 이 계층이 정확히 무엇을 책임지고 무엇을 책임지지 않는지 명시적으로 정의된 경우는 의외로 드물다. 처음에는 프로바이더가 하나뿐이라 경계를 확정할 필요가 없었고, 두 번째 프로바이더를 붙일 때는 이미 늦어서 if provider == "..." 분기가 호출 경로 곳곳에 스며든 뒤이기 때문이다.
이 글은 그 계층, 즉 LLM Adapter를 가능한 한 정확하게 정의하고, OpenAI와 Anthropic, Google의 상용 API와 vLLM으로 서빙하는 자체 호스팅 모델을 하나의 계층 뒤에 통합할 때 실제로 판단이 필요한 지점들을 정리한 기록이다. 예시는 대부분 MicroVM 안에서 도는 에이전트 런타임에 이 계층을 직접 구현하고 실제 엔드포인트를 상대로 검증하면서 확인한 것들이다.
정의: 어댑터가 정규화하는 것은 데이터가 아니라 의미론이다
LLM Adapter는 애플리케이션이 정의한 프로바이더 중립 요청·응답 모델과 특정 프로바이더의 실제 와이어 프로토콜 사이를 양방향으로 매핑하되, 성공 경로의 자료구조뿐 아니라 실패 의미론과 스트리밍 의미론, 그리고 왕복 불변식까지 함께 정규화하는 구성 요소이다.
정의에서 뒤쪽 절반이 핵심이다. JSON 필드 이름을 옮겨 담는 일은 어댑터가 하는 일 중 가장 쉽고 가장 덜 중요한 부분이다. 요청 본문을 만드는 코드는 한 번 쓰면 거의 바뀌지 않는다. 반면 429가 아닌 상태 코드로 도착하는 속도 제한, 200 응답 뒤에 실려 오는 오류, 두 번째 턴에서만 나타나는 거부는 어댑터가 안정적으로 동작하는데 걸림돌이 된다. 어댑터의 가치는 이런 이슈들을 상위 계층이 알 필요 없는 형태로 흡수하는 데 있다.
그래서 어댑터가 책임지는 영역을 정리해 보면 다음과 같이 다섯 가지 파트로 나눠 볼 수 있다.
- 요청 매핑 — 프로바이더에 중립적인 요청을 프로바이더에 맞는 요청 본문으로 직렬화한다. 시스템 지시의 배치, 도구 선언 형식, 파라미터 이름이 여기에 속한다.
- 응답 정규화 — 프로바이더 응답을 중립적인 응답 형태로 전환한 후 회신한다. 텍스트, 도구 호출, 종료 이유, 토큰 사용량이 응답에 담기는 내용들이다.
- 오류 분류 — 실패 내용을 호출자가 행동을 결정할 수 있게 카테고리를 나눠 분류한다.
- 스트림 재조립 — 스트리밍을 켜면 응답이 완성된 형태로 한 번에 오지 않고 잘게 쪼개진 이벤트로 흘러 들어온다. 어댑터는 이 조각들을 중립 델타로 바꾼 뒤 다시 하나로 합쳐서, 스트리밍을 껐을 때와 완전히 같은 응답을 만들어 내야 한다. 예를 들어 OpenAI 형식은 도구 호출의 인자를
{"pa·th": "/e·tc"}같은 문자열 조각으로 나누어 보내면서 조각마다index만 붙여 주므로, 어댑터가 index별 버퍼에 이어 붙여야 비로소 하나의 도구 호출이 완성된다. 이 재조립이 어긋나면 스트리밍 설정을 바꾸는 것만으로 대화 히스토리가 달라진다. - 원본 상태 반환 — 프로바이더가 응답에 얹어 보내는 값 중에는 어댑터가 의미를 알 필요는 없지만 다음 요청에 반드시 그대로 실어 보내야 하는 것이 있다. 어댑터는 이런 값을 해석하지 않은 채 중립 모델에 보관해 두었다가 다음 턴에 원본 그대로 돌려보낸다. Gemini 3가 함수 호출마다 붙여 보내는
thought_signature가 대표적인데, 이 값이 빠진 채로 도구 실행 결과를 보내면 요청 자체가400으로 거부된다.
마지막 항목은 좀 더 구체적인 설명이 필요하기 때문에 뒤에서 별도로 상세하게 다루겠다. 특히 이것은 실무에서 가장 늦게 발견되고 수정 비용이 가장 큰 항목이기 때문이다.
반면에 LLM 어댑터의 역할이라고 오해할 수도 있는 어댑터가 지원하지 않는 기능들도 정리해 보면 다음과 같다.
- 재시도와 백오프 정책 — 어댑터는 실패를 분류해 올려 보낼 뿐 다시 시도할지 말지, 얼마나 기다릴지는 결정하지 않는다. 속도 제한에 걸렸을 때 몇 초를 기다리고 몇 번까지 다시 시도할지는 프로바이더가 결정하는 것이 아니라 애플리케이션이 정할 문제이기 때문이다. 만일 이 정책을 어댑터 안에 넣으면 프로바이더를 바꾸는 것만으로 재시도 횟수와 대기 시간이 함께 바뀌는데, 그 차이는 설정 어디에도 드러나지 않아 같은 작업이 프로바이더에 따라 다르게 실패할 것이다.
- 도구 실행 — 어댑터는 모델이 요청한 도구 호출을 정규화된 값으로 넘길 뿐 실행하지 않는다.
- 대화 상태와 컨텍스트 관리 — 히스토리 보관, 압축, 요약은 상위 계층의 일이다.
- 모델 선택과 라우팅 — 어느 모델로 보낼지는 정책 영역이지 프로토콜이 다룰 내용은 아니다.
이렇게 어댑터의 역할을 명확히 해 놓고 보면 어댑터는 입력은 중립 요청이고 출력은 중립 응답 또는 분류된 오류이며 그 사이에 시간을 소모하는 판단이 포함되지 않은 함수 호출과 같아진다.
어댑터의 경계는 인접한 세 가지와 대비하면 더 분명해진다. SDK 래퍼는 벤더가 제공한 클라이언트에 호출 편의를 더한 것으로, 프로바이더의 요청·응답 타입이 호출자에게 그대로 드러나며 따라서 프로바이더를 바꾸면 호출자 코드도 함께 바뀐다. 게이트웨이나 프록시는 앞의 다섯 영역을 동일하게 수행하지만 그것을 별도 프로세스에서 네트워크 너머로 수행하므로, 그 가용성과 버전은 우리 배포가 통제하지 못하고 자격 증명과 호출 기록도 우리 프로세스 밖에 남는다. 프레임워크의 모델 추상화는 어댑터를 포함하되 프롬프트 구성과 호출 연결, 대화 기억까지 함께 규정하므로 프로토콜 변환보다 넓은 범위를 강제한다. 이 셋과 대비하면 어댑터는 프로바이더의 타입을 호출자에게 노출하지 않고, 애플리케이션과 같은 프로세스 안에서 동작하며, 프로토콜 변환 외에는 어떤 사용 방식도 규정하지 않는 계층으로 정의된다.
두 종류의 어댑터 아키텍처 검토
프로바이더를 여러 개 지원하기로 하면 선택지는 사실상 둘뿐이다.
방식 A는 프로바이더마다 네이티브 클라이언트를 소유하는 것이다. 각 벤더가 공식 API로 문서화한 형식을 그대로 쓰므로 충실도가 가장 높고 벤더 고유 기능에 즉시 접근할 수 있다. 대신 비용이 프로바이더 수에 비례한다. 여기서 비용을 코드 줄 수로 계산하면 과소평가하게 된다. 실제 비용은 검증해야 하는 조합의 수다. 프로바이더 N개와 시나리오 M개면 N × M이고, 스트리밍과 버퍼링을 각각 확인해야 하므로 다시 두 배가 된다. 게다가 어댑터는 코드를 눈으로 훑어 맞고 틀림을 가릴 수 있는 종류의 코드가 아니다. 우리가 만들어 낸 요청을 상대 서버가 받아들이는지는 명세를 아무리 정독해도 확정되지 않고, 실제 엔드포인트로 보내 응답을 받아 봐야 비로소 확인된다.
방식 B는 하나의 와이어 포맷을 정하고 프로바이더를 베이스 URL의 차이로 환원하는 것이다. OpenAI의 chat-completions 형식이 사실상의 상호운용 포맷이 되면서 이 선택이 현실적으로 가능해졌다. Groq는 api.groq.com/openai/v1을, Google은 generativelanguage.googleapis.com/v1beta/openai를 노출하고, vLLM과 llama.cpp의 서버, Ollama는 모두 같은 형식을 기본으로 서빙한다. 이렇게 하면 프로바이더를 하나 더 지원하는 데 드는 비용의 성격 자체가 달라진다. 방식 A에서는 프로바이더를 추가할 때마다 구현과 검증이 통째로 한 벌씩 늘어나지만, 방식 B에서는 목록에 주소 한 줄이 늘어날 뿐이다. 클라이언트는 끝까지 하나이고 프로바이더 프리셋은 이름이 붙은 베이스 URL의 목록에 지나지 않는다.
방식 B의 대가는 분명하다. 호환 계층의 충실도를 그대로 물려받는다. 그 계층은 우리 것이 아니고, 벤더가 그것을 어느 수준으로 보증하는지도 제각각이다. 그래서 판단 기준은 형식이 같은지가 아니라 벤더가 그 호환 경로를 정식 제품 경로로 보증하는지, 아니면 모델을 시험하고 비교해 보라는 용도로만 열어 둔 것인지가 되어야 한다.
이 기준을 대입하면 세 상용 프로바이더가 서로 다른 자리에 놓인다. 다만 아래 정리는 이 글을 쓰는 2026년 8월 현재 각 벤더가 공개한 문서를 근거로 한 것이며, 호환 계층의 위상은 벤더의 정책에 따라 언제든 달라질 수 있으므로 도입을 결정하기 전에 최신 문서를 다시 확인하기 바란다.
Google은 OpenAI 호환 엔드포인트를 공개된 제품 경로로 제공하므로 베이스 URL 하나를 추가하는 것으로 편입된다. Groq도 마찬가지다. 반면 Anthropic은 OpenAI SDK 호환 계층을 제공하되 그 용도를 모델의 시험과 비교로 한정해 안내하고 있다. 이 차이는 사소해 보이지만 결정을 완전히 바꾼다. Anthropic을 지원한다는 것은 베이스 URL을 하나 더 추가하는 일이 아니라 네이티브 어댑터를 하나 더 소유하는 일이다. 그리고 그 어댑터는 앞에서 말한 다섯 개 영역 전부를 새로 구현해야 한다. 뒤에서 보겠지만 Anthropic의 네이티브 형식은 필드 이름이 다른 정도가 아니라 대화 구조 자체가 다르다.
여기서 실무적인 권고를 하나 덧붙인다. 방식 B를 택하더라도 내부 중립 모델은 방식 A를 나중에 수용할 수 있는 모양으로 설계해야 한다. 중립 모델이 OpenAI 형식을 그대로 차용한 것이라면 그것은 중립 모델이 아니라 OpenAI 모델에 다른 이름을 붙인 것이고, 네이티브 어댑터를 추가하는 순간 중립 계층 전체를 다시 써야 한다.
중립 모델의 설계
중립 모델이 감당해야 하는 차이를 구체적으로 보면 설계 지침이 저절로 도출된다.
시스템 지시의 위치가 프로바이더마다 다르다. OpenAI 형식에서는 messages 배열의 첫 원소이고, Anthropic에서는 요청 최상위의 별도 파라미터이며, Gemini 네이티브 API에서는 system_instruction이다. 따라서 중립 요청은 시스템 지시를 메시지 목록과 분리해서 들고 있어야 한다. 만약 중립 모델이 시스템 지시를 별도 필드로 두지 않고 메시지 배열의 한 원소로 저장한다면, Anthropic 요청을 만들 때 어댑터는 그 배열을 뒤져 어느 원소가 최상위 파라미터로 올라갈 시스템 지시인지 골라내야 한다. 그런데 배열에는 대화 도중 삽입된 다른 system 역할 메시지가 함께 섞여 있을 수 있고 둘을 구분할 근거는 남아 있지 않다. 결국 "맨 앞의 system 메시지를 시스템 지시로 본다" 같은 규칙을 임의로 정하게 되는데, 그 가정이 어긋나는 순간 엉뚱한 문장이 시스템 지시 자리를 차지한다.
도구 결과의 역할이 다르다. OpenAI 형식에서 도구 호출은 assistant 메시지의 tool_calls 배열이고 그 결과는 role: "tool"이라는 독립된 역할의 메시지로 돌아간다. Anthropic에서 도구 호출은 assistant 콘텐츠 배열 안의 tool_use 블록이고 그 결과는 다음 user 턴에 실려 오는 tool_result 블록이다. 즉 한쪽에서는 도구 결과가 1급 역할이고 다른 쪽에서는 사용자 턴의 일부다. 중립 모델이 도구 역할을 1급으로 두면 Anthropic 어댑터가 그것을 user 턴 안으로 옮겨 담아야 하고, 반대로 두면 OpenAI 어댑터가 그것을 별도 역할로 떼어 내야 한다. 어느 쪽이든 한 번은 형태를 바꿔야 하므로 중요한 것은 그 변환이 일어나는 자리가 어댑터 안이라는 사실을 명시하는 것이다.
도구 인자의 인코딩이 다르다. OpenAI 형식은 arguments를 JSON 문자열로 실어 보내고 Anthropic은 이미 파싱된 오브젝트로 보낸다. 문자열을 오브젝트로 풀었다가 다시 문자열로 만드는 변환은 얼핏 무해해 보이지만, 그 과정에서 내용은 같아도 바이트는 달라진다. 키의 순서가 뒤바뀌거나 공백이 사라지고, 숫자 표기와 한글 같은 비ASCII 문자의 이스케이프 방식이 원본과 어긋나기 때문이다. 문제는 프롬프트 캐시가 요청 앞부분의 바이트가 완전히 일치할 때만 적중한다는 데 있다. 대화 히스토리를 주고받을 때마다 인자를 다시 직렬화하는 구현은 매번 조금씩 다른 바이트를 만들어 내고, 그만큼 캐시를 놓치면서도 그 사실을 알아차릴 방법이 없다. 그래서 중립 모델은 인자를 받은 그대로의 바이트로 보관하는 편이 안전하다.
종료 이유의 어휘가 다르다. stop, length, tool_calls와 end_turn, max_tokens, tool_use는 같은 사건을 다르게 부른다. 정규화하되 원본 문자열을 버리지 않는 것이 좋다. 로그에서 원인을 되짚을 때 필요한 것은 우리가 붙인 이름이 아니라 서버가 말한 이름이다.
이 네 가지를 반영하면 중립 모델은 대체로 다음 모양에 수렴한다. 시스템 지시는 메시지와 분리하고, 메시지는 역할과 텍스트와 도구 호출과 도구 결과를 담을 수 있으며, 도구 호출은 식별자와 이름과 원본 인자 바이트를 갖는다. 여기까지는 대부분의 구현이 비슷하게 도달한다. 그런데 실제 운영에서 발목을 잡는 필드는 이 목록에 없다.
정규화는 손실 압축이다: 해석하지 않는 값을 위한 자리
정규화는 서로 다른 표현을 하나의 표현으로 통일하는 일이고, 통일하는 순간 어느 한쪽에만 있던 것은 사라진다. 문제는 프로바이더가 자기가 붙여 보낸 것을 다음 턴에 그대로 돌려받아야 한다고 요구할 때 발생한다.
가장 대표적인 사례가 Gemini 3의 thought_signature다. 모델이 함수 호출을 반환할 때 호출마다 서명을 함께 실어 보내고, 도구 결과를 담은 다음 요청에서 그 서명이 돌아오지 않으면 400으로 거부한다.
- 첫 턴은 완벽하게 정상으로 보인다. 텍스트를 요청하면 텍스트가 오고, 도구 호출을 유도하면 도구 호출이 온다.
- 깨지는 것은 도구 결과를 되돌려 주는 두 번째 턴이다. 그런데 이 턴이야말로 에이전트가 실제로 하는 일의 전부다.
- 따라서 한 번 호출해 응답을 확인하는 스모크 테스트는 이 결함을 절대 잡지 못한다.
Anthropic의 확장 사고 블록에 붙는 서명도 같은 부류이고, 앞으로도 이런 필드는 늘어날 것이다. 모델이 자기 내부 상태의 무결성을 검증하려 할수록 클라이언트에게 보관과 반환을 요구할 이유가 많아지기 때문이다.
프로바이더마다 무엇을 덧붙여 보내고 무엇을 돌려받기를 요구하는지가 이렇게 제각각인 이상, 값의 종류를 하나하나 알아보고 대응하는 방식으로는 새 프로바이더가 등장할 때마다 중립 모델을 다시 손봐야 한다. 이 복잡함을 어댑터 밖으로 밀어내는 방법은 값의 내용을 아예 알려고 하지 않는 것이다. 구체적으로는 프로바이더가 값을 덧붙일 수 있는 자리마다, 즉 도구 호출과 메시지와 콘텐츠 블록 각각에, 어댑터가 읽지 않고 보관만 하는 필드를 하나씩 마련해 둔다. 어댑터는 응답을 해석할 때 그 자리에 들어 있던 값을 통째로 떼어 중립 모델에 담아 두었다가, 다음 요청을 만들 때 같은 자리에 받은 그대로 실어 보낸다.
type ToolCall struct {
ID string
Name string
Args json.RawMessage
// 프로바이더가 붙여 보냈고 다음 턴에 그대로 돌려주기를 요구하는 데이터.
// 어댑터는 이 값을 해석하지 않고 그대로 돌려보내기만 한다.
ProviderMeta json.RawMessage
}다만 이 규칙에는 단서가 하나 붙는데, 필드 안을 들여다보고 그 내용에 따라 분기하는 순간 프로바이더가 형식을 바꿀 때마다 코드가 함께 깨지므로, 이 필드의 가치는 무엇을 담느냐가 아니라 끝까지 읽지 않는 데 있다는 것이다.
실패 의미론: 상태 코드는 분류가 아니다
어댑터의 가치가 가장 크게 드러나는 곳이 오류 처리다. 그리고 이 영역의 첫 번째 교훈은 HTTP 상태 코드를 그대로 분류로 쓰면 안 된다는 것이다.
| 관측되는 신호 | 코드 그대로 해석 | 올바른 분류 | 호출자의 행동 |
|---|---|---|---|
| 413 + "tokens per minute" | 요청이 너무 큼 | 속도 제한 | 대기 후 재시도 |
| 413 (그 밖의 경우) | 요청이 너무 큼 | 컨텍스트 초과 | 히스토리 압축 후 재시도 |
| 400 + "valid API key" | 잘못된 요청 | 인증 실패 | 즉시 실패, 재시도 금지 |
| 400 + "maximum context length" | 잘못된 요청 | 컨텍스트 초과 | 히스토리 압축 후 재시도 |
| 5xx | 서버 오류 | 일시적 오류 | 지수 백오프 후 재시도 |
첫 번째 행은 Groq에서 실제로 관측되는 형태다. 무료 등급의 분당 토큰 한도를 초과하면 413으로 돌아오는데, 이것을 상태 코드만 보고 컨텍스트 초과로 분류하면 상위 계층은 잠시 기다리는 대신 대화 히스토리를 압축하기 시작한다. 즉 멀쩡한 대화를 잘라 내고, 그러고도 다음 호출이 같은 이유로 실패한다. 세 번째 행은 Google에서 관측되는 형태다. 잘못된 API 키를 400 INVALID_ARGUMENT와 "Please pass a valid API key"라는 메시지로 알려 오므로, 400을 일괄해서 잘못된 요청으로 분류하는 구현은 운영자에게 키 문제를 요청 형식 문제로 보고한다.
여기서 분류 체계의 좋고 나쁨을 가르는 기준이 하나 나오는데, 호출자의 행동을 바꾸지 않는 분류는 로그를 보기 좋게 만들 뿐이므로 오류의 종류는 위 표의 오른쪽 열이 달라지는 만큼만 나누면 된다는 것이다.
재시도 힌트에 관해서도 두 가지를 확인해야 한다.
첫째, 힌트가 헤더에만 있다고 가정하면 안 된다. 표준 경로는 Retry-After 헤더지만 Google은 본문의 error.details[] 안에 RetryInfo 항목을 두고 retryDelay로 정확한 대기 시간을 알려 주면서 헤더는 자주 생략한다. 헤더만 보는 구현은 서버가 정확히 알려 준 값을 버리고 일반적인 지수 백오프로 되돌아간다.
둘째, 서버가 명시한 지연은 존중할 필요가 있다. 클라이언트의 백오프 상한으로 그것을 깎아 내리면 같은 쿼터 창으로 곧장 되돌아가 다시 거부당한다. 상한은 악의적이거나 잘못된 값을 막기 위한 안전장치로만 두고, 정상 범위의 값은 그대로 존중해야 한다.
오류 본문을 감싸고 있는 바깥 구조도 확인 대상이다. Google의 OpenAI 호환 엔드포인트는 오류를 최상위 배열로 한 겹 더 감싸 [{"error": {...}}] 형태로 돌려주는 경우가 있다. 응답이 오브젝트라고 가정하고 읽으면 역직렬화가 실패하지도 않은 채 어떤 필드도 채워지지 않는다. 그 결과 오류 메시지 자리에는 응답 본문이 통째로 들어가고 서버가 알려 준 대기 시간은 사라진다. 예외도 발생하지 않고 로그에도 흔적이 남지 않는 조용한 기능 저하이며, 이런 종류의 결함은 실제 엔드포인트를 상대로 호출해 보지 않으면 발견되지 않는다.
스트리밍: 재조립하면 같아야 한다
토큰 스트리밍을 붙이면 어댑터의 표면적이 사실상 두 배가 된다. 여기서 지켜야 할 불변식은 하나로 요약된다. 스트리밍 실행과 버퍼링 실행은 동일한 대화 히스토리로 수렴해야 한다. 이것이 깨지면 스트리밍을 켜고 끄는 설정이 대화의 의미를 바꾸는 셈이 되고, 재현 불가능한 버그가 그 틈에서 나온다. 구현상으로는 프로바이더의 증분 이벤트를 중립 델타로 바꾼 뒤, 그 델타 열을 재조립해 버퍼링 응답과 같은 구조를 만드는 누산기를 두는 형태가 된다.
구체적으로 주의할 지점은 다음과 같다.
- 도구 인자는 조각으로 도착한다. OpenAI 형식은 함수 인자를 문자열 조각으로 나누어 보내고, 어느 조각이 어느 호출에 속하는지는
index값으로만 구분된다. 이름과 식별자는 대개 첫 조각에만 실린다. 반면 한 번에 완결된 호출을 보내는 구현도 있다. 누산기는 두 경우를 모두 같은 결과로 재조립해야 한다. - 전체 요청 타임아웃을 스트리밍에 걸면 안 된다. 몇 분에 걸쳐 토큰이 꾸준히 오는 정상적인 긴 생성을 한복판에서 잘라 버리기 때문이다. 필요한 것은 응답 헤더를 기다리는 단계의 데드라인과, 본문에서 바이트가 오지 않는 시간을 재는 유휴 감시다. 재야 하는 것은 총 지속 시간이 아니라 침묵의 길이다.
- 200 이후에도 오류가 도착한다. 스트리밍 도중 쿼터가 소진되면 오류가 평범한 데이터 프레임으로 실려 온다. 이것을 평범한 청크로 알고 역직렬화하면
choices도usage도finish_reason도 없는 빈 객체가 되어 조용히 건너뛰어지고, 스트림은 그대로 끝난다. 속도 제한이 빈 답변으로 보인다. 대역 내 오류를 버퍼링 경로와 동일한 분류기에 태워야 한다. - 재시도 여부는 출력이 이미 나갔는지로 갈린다. 첫 토큰이 나가기 전의 실패는 버퍼링 경로와 똑같이 재시도하면 된다. 그러나 토큰이 이미 사용자 화면에 도달한 뒤라면 재시도는 출력을 중복시킨다. 이 경우에는 누적된 부분 출력을 결과로 살리고 중단 사실을 별도로 알리는 편이 낫다. 다만 절반만 도착한 도구 호출은 인자가 불완전한 JSON일 수 있으므로 버려야 한다.
자체 호스팅 모델: 베이스 URL 치환이 전부라는 말은 절반만 맞다
vLLM으로 서빙하는 모델을 상용 프로바이더와 같은 어댑터 뒤에 두는 것은 방식 B의 가장 큰 실익이다. 와이어 포맷이 같으므로 클라이언트를 다시 쓸 필요가 없고, 자체 호스팅 모델과 로컬 실행 모델, 결정론적 테스트 더블이 모두 상용 서비스의 자리에 그대로 끼워진다. 그러나 같은 형식이라는 사실이 같은 기능을 뜻하지는 않는다.
- 인증이 없을 수 있고, 이때는 헤더를 아예 보내지 않아야 한다. 값이 빈
Authorization: Bearer를 붙이면 그것을 형식 오류로 거부하는 서버가 있다. 키가 비어 있으면 헤더 자체를 생략하는 분기가 필요하다. - 모델 식별자는 서버 기동 인자에 달려 있다. 상용 프로바이더처럼 안정된 이름 목록이 있는 것이 아니라 운영자가 무엇으로 띄웠는지에 좌우되므로, 잘못된 이름을 그대로 보내 실패하기보다
/v1/models로 확인하는 경로를 두는 편이 낫다. - 도구 호출은 조건부 기능이다. vLLM에서 자동 도구 선택을 쓰려면 서버를
--enable-auto-tool-choice와 함께 띄우고 모델에 맞는--tool-call-parser를 지정해야 한다. 스펙에 있으니 동작하리라 가정하면, 실제로는 도구를 선언해도 모델이 텍스트로 도구 이름을 출력하는 상태를 만나게 된다. - 스펙 밖의 확장과 스펙 밖의 필드가 있다. 구조화 출력을 위한 유도 디코딩은 서버별 확장이고, 추론 모델이 내보내는
reasoning_content는 OpenAI 스펙에 없는 필드다. 후자는 특히 성가신데, 히스토리에 그대로 담아 되돌려 보내면 거부하는 서버가 있어 왕복 자체가 깨진다. - 파라미터 이름이 이동 중이다. OpenAI는 chat-completions에서
max_tokens를max_completion_tokens로 대체했고 추론 모델 계열은 후자를 요구한다. 반면 다수의 호환 서버는 여전히 전자만 인식한다. 하나의 클라이언트로 양쪽을 상대하려면 이 차이를 어딘가에서 흡수해야 한다.
여기서 도출되는 원칙은 기능을 가정하지 말고 확인하며, 지원되지 않으면 그 사실을 드러낸 채 대체 동작으로 내려가라는 것이다. 가장 나쁜 처리는 지원되지 않는 파라미터를 조용히 무시하는 것이다. 요청은 200으로 성공하고 결과만 기대와 다르므로, 원인을 찾는 데 며칠이 걸린다.
검증: 어댑터는 단위 테스트로 증명되지 않는다
이 글에서 언급한 결함들에는 공통점이 있다. 전부 실제 엔드포인트를 호출해서 발견한 것이고, 페이크 서버를 상대로 한 단위 테스트로는 하나도 잡히지 않았다는 점이다. 이유는 명확하다. 페이크 기반 테스트는 클라이언트가 자기 자신과 일관됨을 증명한다. 서비스가 우리가 보내는 것을 받아 주는지는 증명하지 못한다. 페이크는 우리가 이해한 대로 만들어지므로, 우리가 잘못 이해한 부분은 페이크에도 똑같이 잘못 들어가 있다.
그래서 어댑터에는 실제 엔드포인트를 상대로 도는 적합성 하네스가 필요하다. 설계 요건은 다음과 같다.
시나리오 행렬은 최소이되 왕복 턴을 반드시 포함한다. 단순 완성, 도구 호출, 도구 결과를 되돌려 주는 두 번째 턴, 스트리밍, 잘못된 키, 과대 컨텍스트면 충분하다. 이 중 세 번째가 핵심이다. 서명 왕복 부류의 결함은 첫 턴에서 보이지 않으므로, 왕복 턴이 없는 행렬은 그 결함을 통과시킨다.
비용과 실행 조건을 설계에 넣는다. 프롬프트를 의도적으로 아주 작게 유지하고, 실행은 명시적 옵트인으로 만든다. 키가 설정되어 있다는 이유만으로 도는 하네스는 모든 일상적인 테스트 실행이 돈을 쓰고 무료 등급 한도를 소진하게 만든다. 이 하네스는 상시 실행되는 회귀 테스트가 아니라 엔드포인트나 모델이 바뀌었을 때 확보하는 증거다.
결과는 행렬로 출력한다. 프로바이더별로 어느 시나리오가 통과했는지를 한 화면에서 볼 수 있어야 판단이 가능하다. 실패 하나가 전체를 중단시키는 형태보다, 어느 조합이 되고 어느 조합이 안 되는지를 그리는 형태가 낫다. 지원 여부는 참과 거짓이 아니라 행렬이기 때문이다.
정리
LLM Adapter를 프로바이더 API의 얇은 래퍼로 이해하면 이 계층은 지루한 연결 작업으로 보인다. 그러나 실제로 이 계층이 하는 일은 여러 서비스가 제각기 다르게 표현하는 실패와 지연과 왕복 요구를, 상위 계층이 하나의 규칙으로 다룰 수 있는 형태로 환원하는 것이다. 상위 계층이 프로바이더 이름을 알 필요가 없어지는 정도가 곧 어댑터의 완성도다.
앞의 논의를 점검 목록으로 압축하면 다음과 같다.
- 다섯 개 영역(요청 매핑, 응답 정규화, 오류 분류, 스트림 재조립, 원본 상태 반환)을 어댑터 안에 두고, 재시도 정책과 도구 실행과 대화 상태는 밖에 둔다.
- 중립 모델은 시스템 지시를 메시지와 분리하고, 도구 인자는 받은 그대로의 바이트로 보관하며, 프로바이더가 값을 덧붙일 수 있는 자리마다 해석하지 않는 필드를 둔다.
- 오류는 상태 코드가 아니라 호출자의 행동으로 분류한다. 행동을 바꾸지 않는 분류는 만들지 않는다.
- 재시도 힌트는 헤더와 본문 양쪽에서 찾고, 서버가 말한 지연은 클라이언트 상한으로 깎지 않는다.
- 스트리밍 경로와 버퍼링 경로는 같은 히스토리로 수렴해야 하고, 스트리밍의 타임아웃은 총 시간이 아니라 침묵을 재야 하며, 200 이후의 대역 내 오류를 처리해야 한다.
- 호환 엔드포인트의 기능은 가정하지 말고 탐지하고, 지원되지 않으면 조용히 무시하는 대신 명시적으로 낮춰 동작한다.
- 실제 엔드포인트를 상대로 도는 옵트인 적합성 행렬을 두고, 그 행렬에 도구 결과 왕복 턴을 반드시 포함한다.
이 목록의 대부분은 첫 프로바이더를 붙일 때는 필요 없어 보인다. 두 번째와 세 번째에서 하나씩 드러나고, 자체 호스팅 모델을 추가할 때 한꺼번에 드러난다. 그때 중립 모델의 구조를 바꾸는 일은 어댑터 하나를 추가하는 것보다 소요 비용이 훨씬 크므로, 경계를 확정하는 일과 해석하지 않는 필드를 두는 일만큼은 처음부터 해 두기를 권한다.