요청은 성공했습니다. 응답은 유효한 JSON이었습니다. SDK는 아무런 경고도 하지 않았습니다. 하지만 애플리케이션은 붕괴했습니다.

이것은 LLM 제공업체 교체를 구조적 도박이 아닌 단순한 설정 변경으로 취급했을 때 벌어지는 일에 대한 이야기입니다. 새로운 base URL을 붙여넣고, API 키를 교체하고, 문서에서 OpenAI 호환 엔드포인트를 약속했으므로 요청 본문(request body)은 동일하게 유지합니다. 기본적인 "hello world" 프롬프트의 경우, 잘 작동합니다. 당신은 축하합니다. 그러다 실제 트래픽이 유입되면, 균열이 생기기 시작합니다.

통신 호환성이라는 환상

HTTP 계층에서의 호환성은 얕습니다. 200 상태 코드와 JSON 본문은 서버가 메시지를 수락했음을 의미할 뿐입니다. 그것이 서버가 이전 서버와 동일한 방식으로 생각한다는 것을 의미하지는 않습니다. OpenAI 호환 엔드포인트는 요청의 형태(shape)를 공유하지만, 동작 규약(behavioral contract)을 공유하지는 않습니다. 두 제공업체는 동일한 페이로드를 입력받아 미묘하고 파괴적인 방식으로 서로 다른 답변을 반환할 수 있습니다.

당신의 코드는 가정을 합니다. 이전에는 항상 그랬기 때문에 message.content가 문자열일 것이라고 가정합니다. 도구 호출(tool call)이 깨끗하고 파싱 가능한 JSON으로 전달될 것이라고 가정합니다. finish_reason이 당신이 생각하는 대로 신호를 보낼 것이라고 가정합니다. 이러한 가정들은 치명적인 오류가 발생하기 전까지는 보이지 않습니다.

모든 문제의 시작이었던 충돌 사례를 살펴보겠습니다:

const text = response.choices[0].message.content.trim();

이 코드는 무해해 보입니다. 몇 주 동안 잘 작동했습니다. 그러다 새로운 제공업체가 도구 호출을 반환했습니다. 그 순간, message.content는 빈 문자열이 아니었습니다. null이었습니다. 실제 페이로드는 message.tool_calls 안에 있었지만, 파서는 이미 다음 단계로 넘어가 아무것도 없는 값에 .trim()을 호출하고 있었습니다. API는 에러를 던지지 않았습니다. 네트워크 계층도 불평하지 않았습니다. 당신의 파서가 요청을 죽인 것입니다.

제공업체들이 조용히 갈라지는 지점들

차이점은 변경 로그(changelog)에 공지되지 않습니다. 응답 객체의 여백에 숨어 있다가 엣지 케이스(edge case)를 기다립니다.

도구 호출(Tool-call) 형식. 한 제공업체는 도구 인수를 미리 검증된 JSON 객체로 보냅니다. 다른 업체는 필드 내에 이스케이프된 문자열로 보냅니다. 세 번째 업체는 긴 도구 호출을 여러 개의 스트리밍 델타(streaming deltas)로 나눌 수도 있어, 구조가 유효한지 확인하기도 전에 청크(chunk)를 버퍼링해야 할 수도 있습니다. 애플리케이션이 단일 파싱 가능한 블롭(blob)을 기대한다면, 제대로 처리하지 못하고 멈춰버립니다.

종료 사유(Finish reasons). OpenAI는 "stop", "length", "tool_calls", "content_filter"와 같은 특정 문자열을 사용합니다. 호환되는 제공업체는 "end_turn"을 반환하거나, 모델이 토큰 한계에 도달했을 때 해당 필드를 아예 생략할 수도 있습니다. 만약 재시도(retry)나 폴백(fallback) 로직이 잘림을 감지하기 위해 "length"를 기다리고 있다면, 사용자가 답변이 중간에 끊긴 것을 보는 동안 로직은 아무것도 하지 않고 대기하게 될 것입니다.

사용량(Usage) 필드. 일부 제공업체는 지연 시간을 몇 밀리초라도 줄이기 위해 스트리밍 응답에서 토큰 수를 제거합니다. 다른 업체는 마지막 청크에만 사용량을 추가하거나, 비스트리밍(non-streaming) 호출에서는 아예 생략하기도 합니다. 고객에게 토큰당 비용을 청구하는데, 정산 코드가 모든 응답 객체에 usage.total_tokens가 존재할 것이라고 기대한다면, 결제 파이프라인은 조용히 0을 기록하게 될 것입니다.

스트리밍 동작. 서버 전송 이벤트(Server-sent events)는 표준이어야 하지만, 제공업체마다 버퍼를 비우는(flush) 빈도가 다릅니다. 이벤트 경계도 제각각입니다. 어떤 업체는 [DONE] 신호로 스트림을 종료합니다. 다른 업체는 아무런 표식 없이 연결을 깔끔하게 끊어버립니다. 클라이언트가 특정 종료 마커를 기다리며 블로킹(blocking)된다면, 프로그램은 멈춰버립니다.

오류 및 타임아웃. 속도 제한(rate limit)이 어떤 업체에서는 retry-after 헤더가 포함된 429 오류로 오고, 다른 업체에서는 모호한 502 오류로 올 수 있습니다. 어떤 업체는 요청을 수락한 뒤 네트워크 타임아웃이 발생하기 전까지 2분 동안 아무런 응답이 없을 수도 있습니다. OpenAI SDK가 이러한 차이점들을 당신의 로그가 기대하는 예외(exception) 유형으로 마법처럼 정규화해주지는 않습니다.

예측 불가능한 형태에 대비한 방어적 파싱

해결책은 스키마를 신뢰하는 것이 아닙니다. 모든 응답을 잠재적 용의자로 취급하는 것입니다.

content가 문자열이라고 가정하지 마십시오. 만지기 전에 확인하십시오.

const content = response.choices?.[0]?.message?.content;
const text = typeof content === "string" ? content.trim() : "";

도구 인수가 유효한 JSON이라고 가정하지 마십시오. 모델은 동작을 제안할 뿐입니다. 그 제안이 실행하기에 충분히 안전한지는 코드가 결정해야 합니다. 모든 도구 인수 파싱을 try-catch로 감싸십시오. JSON.parse에서 예외가 발생하면, 해당 도구 호출을 잘못된 형식의 쓰레기로 취급하고 실패 핸들러(failure handler)로 보내십시오. 환각(hallucination)으로 인한 괄호 오류나 누락된 따옴표가 처리되지 않은 예외로 전파되어서는 안 됩니다.

tool_calls는 존재하지만 content가 없는 경우, 애플리케이션은 상태 전환을 인식해야 합니다. 사용자는 채팅 답변을 받은 것이 아니라, 시스템이 작업 명령(work order)을 받은 것입니다. 이 둘은 서로 다른 경로이며, 라우터는 문자열 조작을 시도하기 전에 그 차이를 알아야 합니다.

배포 전 행동 테스트(Behavioral Tests)를 수행하십시오.

"hi" 메시지로 엔드포인트를 핑(ping)하는 것은 네트워크가 작동함을 증명할 뿐입니다. 여러분의 애플리케이션에 대해서는 아무것도 증명하지 못합니다.

프로덕션 트래픽을 리다이렉트하기 전에, 새로운 제공업체(provider)를 대상으로 타겟팅된 행동 테스트 스위트(behavioral test suite)를 실행하십시오.

  • 일반 텍스트 응답. content가 존재하고, 문자열이며, 캐스팅 오류 없이 sanitization 파이프라인을 통과할 수 있는지 확인하십시오.
  • 강제 도구 호출(Forced tool call). tool_choice를 required로 설정하십시오. 제공업체가 이를 준수하는지 확인하고, contentnull, 빈 문자열 또는 누락된 키로 전달되는지 확인하십시오. 각 상태에 대해서는 별도의 핸들러가 필요합니다.
  • 잘못된 형식의 도구 인자(Malformed tool arguments). 모델이 도구 인자 내에 깨진 JSON을 반환하는 시나리오를 주입하십시오. 파서가 워커(worker)를 중단시키는 대신 이를 우아하게 거부(reject)하는지 확인하십시오.
  • 토큰 제한에 근접한 응답. 컨텍스트 윈도우(context window)를 한계까지 밀어붙여 보십시오. finish_reason을 확인하십시오. 절단(truncation)이 발생할 때 제공업체가 예상치 못한 값을 반환한다면, 요약 또는 재시도 로직이 어떻게 대응해야 할지 알고 있어야 합니다.

이것은 단위 테스트(unit test)가 아니라 통합 테스트(integration test)입니다. 코드와 제공업체의 동작 특성(personality) 사이의 실제 관계를 시험하는 과정입니다. 마이그레이션이 완료되었다고 판단하기 전에 이 테스트들을 통과시키십시오.

내부 계약(Internal Contract) 구축

제공업체 간의 차이는 네트워크 경계에서 멈춰야 합니다. 비즈니스 로직으로 흘러 들어가지 않게 하십시오.

원시 SDK 응답을 소비하여 애플리케이션이 실제로 소유하는 객체를 내보내는 정규화 계층(normalization layer)을 만드십시오. 제공업체별 특이 사항을 안정적인 내부 형식으로 매핑하십시오. 만약 제공업체 A는 도구 인자를 문자열로 반환하고 제공업체 B는 객체로 반환한다면, 매퍼(mapper)가 두 경우 모두를 자체적인 ToolRequest 구조로 평탄화(flatten)해야 합니다. 사용량(usage) 정보가 누락된 경우, 매퍼가 이를 추정하거나 공백을 표시해야 하며, undefined가 비용 추적 모듈로 스며들게 해서는 안 됩니다.

finish_reason이 표준이 아니라면, 이를 자체적인 종료 상태 열거형(enum)인 COMPLETE, TRUNCATED, TOOL_CALL, FILTERED로 변환하십시오. 애플리케이션은 제3자 서버의 원시 문자열을 직접 읽어 들여 판단하는 것이 아니라, 이러한 깔끔한 추상화 모델을 기반으로 무엇을 할지 결정해야 합니다.

이 계층을 통해 제공업체를 교체하는 작업은 '두더지 잡기' 게임이 아닌 단일 파일 수정 작업이 됩니다. 매퍼를 다시 작성하고, 행동 테스트를 실행한 뒤 다음 단계로 넘어가면 됩니다. 애플리케이션은 그대로 유지됩니다.

설정 변경이 아닌 의존성 업그레이드

LLM 제공업체를 전환하는 것은 CDN 엔드포인트를 바꾸는 것과 같지 않습니다. 이는 데이터베이스를 PostgreSQL에서 MySQL로 변경하는 것에 더 가깝습니다. 동일한 연결 문자열(connection string)이 동일한 쿼리 동작을 의미한다고 결코 가정하지 않을 것입니다. 잠금 의미론(locking semantics), 마이그레이션 경로, 인덱싱의 특이 사항 등을 테스트할 것입니다. LLM도 그와 동일한 존중을 받아야 합니다. LLM은 표준 API로 위장한 확률적 시스템이며, 그 응답에는 포맷팅, 절단, 제어 흐름에 대한 가정이 포함되어 있어 단 하나의 네트워크 오류도 발생시키지 않고 애플리케이션을 망가뜨릴 수 있습니다.

버그는 결코 연결 문제 때문이 아니었습니다. 호환성이 곧 동일함을 의미한다는 가정에 있었습니다. 그렇지 않습니다. 형태(shape)를 검증하십시오. 경계값(edges)을 테스트하십시오. 계약(contract)을 직접 관리하십시오.


Source: The Bug Only Happened After I Switched LLM Providers

Community: GyaanSetu AI on Telegram