LLM 제공업체를 변경한 후에만 발생했던 버그
요약
OpenAI 호환 엔드포인트를 사용하는 다른 LLM 제공업체로 전환할 때 발생할 수 있는 잠재적 버그와 주의사항을 다룹니다. API 규격이 같더라도 응답 데이터의 구조나 세부 동작 방식의 차이로 인해 애플리케이션 파서가 실패할 수 있음을 경고합니다.
핵심 포인트
- OpenAI 호환 API라고 해서 모든 동작 방식이 동일하지는 않음
- content 필드의 null 처리 및 tool_calls 구조 변화 주의
- 응답 데이터의 타입(string vs array)과 usage 필드 포함 여부 확인 필요
- 도구 호출(Tool calling) 시 인자(arguments)의 JSON 직렬화 방식 차이 유의
요청은 성공했습니다.
응답은 유효한 JSON이었습니다.
SDK는 예외(exception)를 발생시키지 않았습니다.
하지만 애플리케이션은 여전히 망가졌습니다.
이 버그는 LLM 애플리케이션을 OpenAI 호환 엔드포인트(endpoint)를 가진 다른 제공업체로 전환한 후에 나타났습니다. 통합 과정은 거의 너무나 쉬워 보였습니다. 기본 URL(base URL)을 변경하고, API 키를 교체하고, 요청 본문(request body)을 그대로 유지한 채 배포하면 끝이었습니다.
가장 단순한 프롬프트(prompt)에는 그것이 작동했습니다.
하지만 그것이 제공업체들이 동일하게 동작한다는 것을 의미하지는 않았습니다.
실패는 HTTP 규약(contract)에 있었던 것이 아닙니다. 그것은 제 애플리케이션이 한 제공업체의 동작을 바탕으로 조용히 쌓아 올렸던 가정(assumptions)들에 있었습니다.
호환 가능한 요청이 호환 가능한 동작을 보장하지는 않는다
어떤 API가 스스로를 OpenAI 호환이라고 설명할 때, 이는 대개 익숙한 요청들이 수락된다는 것을 의미합니다:
{
"model": "some-model",
"messages": [
...
이는 유용한 호환성입니다. 이를 통해 클라이언트(client), 인증 패턴, 그리고 통합 코드의 상당 부분을 재사용할 수 있습니다.
하지만 프로덕션 애플리케이션은 서버가 요청을 수락하는지 여부 그 이상에 의존합니다.
애플리케이션은 또한 다음과 같은 사항들에 의존할 수 있습니다:
content가 문자열(string)인지, 배열(array)인지, 빈 값인지, 또는null인지 여부- 도구 호출(tool-call) 인자(arguments)가 직렬화(serialized)되는 방식
- 어떤
finish_reason값들이 나타날 수 있는지 - 사용량(usage) 필드가 항상 포함되는지 여부
- 스트리밍 완료(streaming completion)가 어떻게 신호로 전달되는지
- 구조화된 출력(structured-output) 제약 조건이 강제되는지 여부
- 오류(errors), 타임아웃(timeouts), 그리고 지원되지 않는 파라미터(parameters)가 어떻게 보고되는지
두 제공업체는 동일한 요청을 수락하고 구문론적으로(syntactically) 유효하지만 운영상으로는(operationally) 다른 응답을 반환할 수 있습니다.
그곳에 흥미로운 버그들이 숨어 있습니다.
내 파서(Parser)에 숨겨진 가정
저의 기존 응답 처리기(response handler)는 사실상 다음과 같이 가정하고 있었습니다:
const text = response.choices[0].message.content.trim();
선택된 모델이 도구 호출(tool call)을 반환하기 전까지는 잘 작동했습니다.
해당 응답에서 message.content는 null이었고, 실제 출력은 message.tool_calls 안에 들어 있었습니다.
API는 실패하지 않았습니다. 제 파서(parser)가 실패한 것이었습니다.
첫 번째 수정은 간단했습니다:
function parseAssistantMessage(response) {
const choice = response?.choices?.[0];
...
그것으로 한 가지 증상은 해결되었지만, 더 큰 문제점이 드러났습니다. 저는 평소에 보던 응답 형태(response shape)가 전체 프로토콜인 것처럼 취급하고 있었습니다.
도구 호출 (Tool Calls)은 특히 취약한 경계입니다
도구 호출 (Tool calling)은 최소 세 가지의 호환성 문제를 발생시킵니다.
1. 인자(arguments)가 유효한 JSON인가?
도구 호출은 다음과 같은 인자 문자열을 포함할 수 있습니다:
{
"name": "create_ticket",
"arguments": "{\"priority\":\"high\",\"summary\":\"Login failure\"}"
...
외부 응답은 유효한 JSON일 수 있지만, 중첩된 arguments 문자열은 그렇지 않을 수 있습니다.
절대로 이를 도구에 직접 전달하지 마세요:
function parseToolArguments(toolCall) {
const raw = toolCall?.function?.arguments;
...
2. 인자가 비즈니스 계약(business contract)을 충족하는가?
유효한 JSON이 곧 유효한 동작을 의미하지는 않습니다.
function validateCreateTicket(args) {
const allowedPriorities = new Set(["low", "medium", "high"]);
...
모델은 동작을 제안합니다. 애플리케이션 코드는 해당 동작이 허용되는지 결정합니다.
3. 생성(generation)이 완료되었음을 애플리케이션에 알리는 것은 무엇인가?
일부 통합 환경에서는 특정 finish_reason이 도구 호출과 항상 동반된다고 가정합니다. 그러한 가정은 단일 제공업체로부터 물려받는 것이 아니라, 테스트를 통해 검증되어야 합니다.
이제 저는 tool_calls의 존재와 유효성을 하나의 신호로 취급하고, finish_reason은 별도의 진단 정보로 취급합니다. 만약 두 정보가 일치하지 않으면, 응답을 로그에 기록하고 부작용(side effects)을 일으키는 어떤 것도 실행하기 전에 중단합니다.
저는 단순히 가용성(availability)이 아닌, 제공업체의 동작(behavior)을 테스트하기 시작했습니다
일반적인 상태 확인(health check)은 다음과 같이 묻습니다:
이 엔드포인트가 응답을 반환할 수 있는가?
이는 필요하긴 하지만, 제공업체를 전환하기에는 너무 약한 기준입니다.
호환성 체크는 다음과 같이 물어야 합니다:
이 제공업체가 내 애플리케이션이 의존하는 동작들을 유지하는가?
다음은 제가 시작점으로 사용하는 작은 Node.js 스크립트입니다.
이 스크립트는 Node.js 18 이상을 필요로 하며 외부 패키지는 필요하지 않습니다.
// provider-compatibility-test.mjs
const config = {
...
현재 운영 중인 제공업체(provider)를 대상으로 실행합니다:
PROVIDER_NAME=current \
LLM_BASE_URL=https://current-provider.example/v1 \
LLM_API_KEY=your-key \
...
그 다음, 후보(candidate) 제공업체를 대상으로 동일한 스크립트를 실행합니다:
PROVIDER_NAME=candidate \
LLM_BASE_URL=https://candidate-provider.example/v1 \
LLM_API_KEY=your-key \
...
목표는 단순히 두 결과 모두 통과(green)하는 것이 아닙니다. 관찰된 내용(observations)을 저장하고 비교하십시오.
다른 contentType, 누락된 사용량 정보(usage information), 예상치 못한 종료 사유(finish reason), 또는 변경된 도구 호출(tool-call) 형태는 무해할 수도 있습니다. 하지만 이는 애플리케이션의 다른 부분에서 문제를 일으킬 수 있는 가정을 드러낼 수도 있습니다.
현재 제가 전환 전에 테스트하는 항목들
저는 배포 중에 실행할 수 있을 만큼 테스트 스위트(suite)를 작게 유지하면서도, 애플리케이션을 충분히 대표할 수 있을 만큼 구체적으로 유지합니다.
텍스트 전용 애플리케이션의 경우, 다음 항목들이 포함될 수 있습니다:
- 일반적인 텍스트 응답
- 의도적으로 잘못된 요청
- 타임아웃(timeout) 또는 취소
- 출력 토큰 제한(output-token limit)에 근접한 응답
에이전트(agent) 또는 워크플로(workflow)의 경우, 다음 항목들도 테스트합니다:
- 강제된 도구 호출 (forced tool call)
- 사용 가능한 여러 개의 도구
- 잘못되었거나 불완전한 도구 인자(tool arguments)
- 모델로 반환된 도구 결과
- 스트리밍 도구 호출 (streaming tool call)
- 두 번 실행되어서는 안 되는 액션
구조화된 데이터(structured data)의 경우, 문자열 필드 두 개만 있는 장난감 객체가 아니라 애플리케이션에서 실제로 사용하는 스키마(schema)를 테스트합니다.
중요한 질문은 모든 제공업체가 동일한 JSON을 생성하느냐가 아닙니다. 아마 그렇지 않을 것입니다.
중요한 질문은 그러한 차이점들이 애플리케이션이 처리할 수 있는 경계(boundaries) 내에 머무느냐 하는 것입니다.
경계에서 정규화하기 (Normalize at the Boundary)
어떤 차이점이 의도된 것인지 파악하고 나면, 애플리케이션의 나머지 부분이 이를 확인하기 전에 정규화(normalize)합니다.
function normalizeCompletion(response) {
const choice = response?.choices?.[0];
...
애플리케이션의 나머지 부분은 모든 제공업체의 응답 세부 사항에 직접 의존하는 대신, 이 내부 계약(internal contract)을 소비합니다.
그렇게 하면 제공업체별 동작(provider-specific behavior)이 한 곳에서 명확히 드러납니다. 또한 실패 사례를 더 쉽게 로그로 남기고, 테스트하며, 안전하게 중단할 수 있게 해줍니다.
제공업체 전환은 의존성 업그레이드입니다
LLM 제공업체를 변경하는 것은 단순한 설정 업데이트처럼 보일 수 있습니다:
- LLM_BASE_URL=https://provider-a.example/v1
+ LLM_BASE_URL=https://provider-b.example/v1
운영 측면에서 저는 이를 주요 의존성(dependency)을 업그레이드하는 것에 더 가깝게 취급합니다.
엔드포인트(endpoint)는 호환될 수 있지만, 경계 영역(edges)에서의 동작은 호환되지 않을 수 있습니다. 이러한 경계 영역에는 스트리밍 (streaming), 도구 호출 (tool calls), 구조화된 출력 (structured outputs), 토큰 제한 (token limits), 취소 (cancellation), 에러 의미론 (error semantics), 그리고 사용량 보고 (usage reporting)가 포함됩니다.
이러한 점들이 OpenAI 호환 API를 나쁜 아이디어로 만드는 것은 아닙니다. 공유 인터페이스 (shared interfaces)는 불필요한 통합 작업을 많이 줄여줍니다. 저는 TokenBay에서 일하고 있는데, 이곳에서는 다양한 모델 간에 요청을 라우팅(routing)해야 하므로 이러한 호환 계층 (compatibility layer)이 특히 유용합니다.
하지만 공유 인터페이스는 전환을 테스트 가능하게 만들어야지, 보이지 않게 만들어서는 안 됩니다.
이제, 운영 환경(production)에서 기본 URL을 변경하기 전에, 저는 후보 제공업체를 현재 제공업체와 동일한 동작 테스트 (behavioral tests)를 통해 실행합니다.
요청이 수락되는 것은 단지 첫 번째 테스트일 뿐입니다.
그것이 호환성 (compatibility)의 정의는 아닙니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기