DSPy 3.4 LM 마이그레이션

DSPy 3.4 LM 마이그레이션 (DSPy 3.4 LM migration)

DSPy 3.4부터 LM 레이어가 DSPy에 번들된 lm15 객체를 쓰는 변화를 설명하는 페이지예요. 3.5에서 기존 LM 통합 인터페이스가 제거되기 때문에, 지금부터 무엇을 어떻게 바꿔야 할지 정리했어요.

출처: 문서

본문

DSPy의 LM 레이어가 이제 DSPy 안에 번들된 lm15 객체를 사용해요. dspy.lm15에서 import하면 되고 별도 설치는 필요 없어요. 이들은 원본 lm15 클래스이지 DSPy 래퍼나 하위 클래스가 아니에요.

3.5 절단선 (The 3.5 cutoff)

3.4는 전환 릴리스이고, 3.5에서 기존 LM 통합 인터페이스가 제거돼요.

인터페이스 DSPy 3.4 DSPy 3.5
lm("Hello") 목록 반환 편의 기능 정식 엔진 경로 위의 편의 기능으로 유지
lm(Request(...)) / await lm.acall(Request(...)) lm15 Response 반환 어댑터와 통합 계약
OpenAI 스타일 lm(messages=[{"role": ..., "content": ...}]) Deprecated; 명시적 lm15 요청 사용 제거
커스텀 BaseLM.forward() / aforward() 구현 Deprecated; 엔진으로 마이그레이션 통합 인터페이스로 제거
LegacyEngine / AsyncLegacyEngine 래퍼 Deprecated 전환 도구 제거
커스텀 엔진 complete_legacy() 단축키 Deprecated 제거

3.5에서는 DSPy 어댑터가 lm15 요청을 만들고 lm15 응답을 직접 소비해요. 목록 반환 편의 경로를 쓰지 않아요. Provider-wire 사전은 엔진 안에 있어야 하고, LiteLLM 자체는 deprecated가 아니에요. LiteLLM 엔진도 동일한 요청/응답 계약을 따라야 해요.

이 변화는 3.4에서 조용히 일어나지 않아요. Deprecated 인터페이스는 여전히 실행되고, DeprecationWarning이 절단선을 알리며 대체재로 연결해줘요. Python은 기본적으로 이 경고를 숨길 수 있어요. 개발 중에 검토하려면 다음을 사용하세요.

python -W default::DeprecationWarning your_program.py

일반 DSPy 프로그램 호출은, 3.4에서 내장 어댑터가 여전히 내부 사전 경계를 쓰기 때문에 경고하지 않아요. 그 구현은 DSPy의 마이그레이션 책임이에요. 레거시 커스텀 LM을 쓰면 프로그램을 통해서도 여전히 경고해요.

일반 프로그램 (Ordinary programs)

일반 호출과 DSPy 모듈을 계속 사용하면 돼요.

import dspy

lm = dspy.LM("openai/gpt-4o-mini")
outputs = lm("Hello")

일반 호출은 문자열이나 사전의 목록을 반환해요. experimental=True는 더 이상 반환 타입을 바꾸지 않고, 다른 실험 기능만 제어해요. 다중 턴 호출은 OpenAI 스타일 메시지 사전 대신 명시적 요청을 쓰세요.

엔진 선택:

  • engine="auto" (기본): 지원 경로와 표현 가능한 입력에는 네이티브 lm15를 선호. 지원되지 않는 경로와 정확히 표현할 수 없는 일반 제공자별 입력은 실행 전에 LiteLLM을 선택해요.
  • engine="lm15": 네이티브 백엔드를 요구. 지원되지 않는 매핑은 raise.
  • engine="litellm": 명시적으로 호환 백엔드 사용.

인증 실패, 타임아웃, 제공자 오류는 절대 다른 백엔드로 전환을 일으키지 않아요. 네이티브 통합이 구현하지 않은 텍스트 완성과 클라이언트 설정은 LiteLLM에 남아요. timeout 설정(초 단위 또는 httpx.Timeout)은 네이티브로 유지돼요. 답변의 다음 바이트를 기다리는 시간(헤더 먼저, 그다음 각 스트리밍 청크), 전송, 여유 연결을 제한하죠. 네이티브 기본값은 LiteLLM과 같은 600초예요. httpx.Timeout 구성 요소를 None으로 두면 "영원히 기다림"을 뜻하는데, 네이티브 엔진은 이를 지킬 수 없어요. engine="auto"에서는 그 호출이 LiteLLM을 쓰고, engine="lm15"에서는 기본값을 조용히 대체하는 대신 LMUnsupportedFeatureError(feature timeout)를 raise해요.

어떤 제공자도 받을 수 없는 텍스트(고아 서로게이트 문자)는 엔진이 선택되기 전에, 모든 엔진 설정에서 일반 ValueError를 raise해요. 그래서 예상치 못한 엔진 실패로 보고되거나 재시도되지 않아요.

LiteLLM 엔진을 통한 Typed Requests는 Chat Completions 경로에서 top_k를 LiteLLM 인자로 전달하고(제공자별로 LiteLLM이 번역), top_k 필드가 없는 Responses 경로에서는 버려지고 "adaptations" 아래에 기록돼요.

네이티브 경로가 정확히 그대로 담을 수 없는 설정은 적응되고 기록되며, 조용히 무시되거나 불필요하게 거부되지 않아요(lm15 MAP-13). Anthropic의 seed는 빠지고, Anthropic의 temperature=1.5는 1.0이 되며, wire에 없는 thinking-summary 레벨은 auto가 돼요. 각 히스토리 항목은 "adaptations" 아래(field, action, asked, applied, reason)에 기록을 담고, 항목의 lm15 Response도 동일하게 담아요. 아무것도 출력하지 않아요. lm15가 여전히 거부하는 것 — Chat Completions wire의 도구 결과 안 이미지, 제공자에 없는 저장 캐시 객체, n > 1 — 은 요청이 보내지기 전에 알려지고, engine="auto"에서는 실패한 네이티브 시도 없이 그런 호출이 LiteLLM으로 가요. engine="lm15"에서는 error.feature에 필드 이름을 담아 raise해요.

Responses API의 stop 시퀀스에 대해서는 엔진이 중요해요.

  • 네이티브 lm15: 내부적으로 스트리밍하고 stop에서 닫아요. 조기 절단 후에는 최종 사용량을 얻을 수 없고, 닫는다고 해서 과금이 멈추는 게 보장되지는 않아요.
  • LiteLLM을 통한 Typed Requests: lm15의 stop 헬퍼로 완성된 답변을 다듬어요. 생성이 조기 중단되지는 않아서 전체 제공자 사용량이 유지돼요. response.adaptations와 히스토리에 기록돼요. LiteLLM을 통한 Typed Responses 스트리밍은 미지원이므로 네이티브 lm15를 쓰세요.

둘 다 보존된 전체 토큰의 점수를 유지해요. stop이 토큰을 끊으면 response.logprobs_complete가 false예요. 보존된 부분 토큰에는 점수가 없어요. 이 플래그는 저장-응답 캐싱을 통과해 살아남아요. Chat Completions는 여전히 stop 시퀀스를 제공자에게 직접 보내요.

캐싱, 재시도, 사용량, 스트리밍

DSPy는 응답 캐싱, 재시도, 후보 팬아웃, 콜백, 히스토리를 소유해요. 일반 호출은 기존 캐시 키 형식을 유지하고 기존 SDK 응답 항목을 읽을 수 있어요. 캐시 읽기는 엔진 구성 전에 일어나요. 새 네이티브 항목은 평범한 직렬화 데이터를 저장하고 제한된 캐시 역직렬화에서 작동해요. 기존 캐시 재작성은 필요 없어요. 명시적 typed 호출은 별도의 캐시 네임스페이스를 가져요.

네이티브 n 답변은 n개의 별도 요청을 순차적으로 사용하고, LiteLLM 호환 호출은 백엔드의 네이티브 n 동작을 유지해요. 별도 요청은 더 오래 걸리고 입력 토큰을 여러 번 청구할 수 있어요. 실패한 후보는 앞선 성공 후보를 다시 시작하지 않고, 불완전한 집합은 캐시되지 않아요. 제한된 병렬 실행은 후보 루프 옆에서 추적되는 후속 작업이고, 지연을 줄일 수 있지만 별도 요청의 입력 토큰 비용을 없애지는 않아요.

num_retries는 추가 시도 횟수를 세고, 1, 2, 4… 초의 지수 지연을 쓰며, 제공자가 retry_after를 지정하지 않으면 60초에서 상한이 돼요. 백엔드 재시도는 관리형 LiteLLM 경로에서 비활성화돼요. 스트림은 호출자에게 청크가 보내진 후에는 재시도되지 않아요. 완성된 스트림을 캐싱해도 나중 일반 캐시 히트에서 가짜 토큰 타이밍을 재생하지 않아요.

dspy.streamify는 기존 리스너를 계속 사용해요. 네이티브 이벤트는 lm15 응답으로 조립되고, 추론을 답변 텍스트에 섞지 않고 리스너용 청크로 적응해요. LiteLLM의 일반 스트리밍 청크는 원래 모양을 유지해요. 커스텀 청크 소비자는 모든 네이티브 청크가 LiteLLM 클래스라고 가정하면 안 돼요. 공통 필드는 여전히 사용 가능해요.

streamify는 프로그램 수준 API예요. predictor 필드를 고르고, 도구·모듈 상태 메시지를 내보내며, 최종 Prediction을 전달해요. lm15 이벤트는 모델 호출 하나를 설명하므로 그 책임을 대체하지 않아요. 3.5 마이그레이션이 내부 LM 경계를 정식 요청/응답과 이벤트로 옮기지만, streamify를 제거하거나 그 프로그램 수준 역할을 대체하자는 건 아니에요.

토큰 사용량은 공개 호출당 한 번 기록되고, 캐시 히트는 청구된 사용량을 더하지 않아요. 네이티브 응답 카운터는 Response.usage에서 제공자 그대로 유지되고, DSPy의 레거시 프롬프트/완성 카운터는 별도로 파생돼요. 네이티브 비용 추정은 신뢰할 만한 가격 책정이 없을 때 0이 아니라 unknown이에요. 기존 캐시된 SDK 응답은 과거 비용 메타데이터를 유지해요.

OpenAI 스타일 메시지 마이그레이션

공개 messages= 인자는 제공자 SDK 메시지 객체와 사전 모두를 포함해 deprecated예요. 이는 정식 lm15 Message 객체를 담는 Request.messages를 deprecated하지 않아요. 예를 들어 이 형태는 3.4에서 여전히 실행돼요.

outputs = lm(messages=[
    {"role": "system", "content": "Be concise."},
    {"role": "user", "content": "What is DSPy?"},
])

대신 아래 명시적 요청을 쓰세요. 시스템 지침은 Request.system에, 대화 턴은 lm15 Message 객체에, 생성 옵션은 Config에 넣어요. 결과는 목록이 아니라 Response예요. 상황에 맞게 response.text, response.message, response.tool_calls를 읽으면 돼요. 비동기 호출도 같은 타입을 사용해요.

명시적 요청 (Explicit requests)

from dspy.lm15 import Config, Message, Request

request = Request(
    model=lm.model,
    system="Be concise.",
    messages=(Message.user("What is DSPy?"),),
    config=Config(max_tokens=200),
)
response = lm(request)
print(response.text)
# Async equivalent: response = await lm.acall(request)

요청의 모델은 LM과 일치해야 해요. 생성 옵션은 그 config에 있고, LM 생성 기본값은 추가되지 않아요. 클라이언트 구성은 여전히 LM에서 온답니다. lm(request, cache=False, rollout_id=...)는 DSPy의 응답 캐시를 제어해요. Config.cache는 대신 제공자 측 프롬프트 캐싱을 제어해요.

typed 요청 하나는 보조 메시지 하나를 담은 응답 하나를 반환해요. n 답변에는 일반 호출을 쓰세요. 대화를 계속하려면 다음 요청의 메시지에 response.message를 추가해요. LiteLLM typed 스트리밍은 현재 Chat Completions만 지원하고, 네이티브 Responses 엔진은 Responses 스트리밍을 지원해요.

오류와 재시도 소유권 (Errors and retry ownership)

엔진은 실패를 보고하고, DSPy는 재시도·폴백 정책을 소유해요. 새 엔진은 dspy.lm15에서 특정 오류를 raise해요. 요청·응답과 같은 번들 어휘예요. 별도로 설치한 lm15 패키지(Python 클래스 identity가 다른) 대신 이 import를 사용하세요.

from dspy.lm15 import RateLimitError

# In an engine, after recognizing the backend's actual rate-limit response:
raise RateLimitError("Provider rate limit", provider="my-backend", retry_after=2.0)

오류 사전이나 가짜 성공 Response를 반환하지 마세요. LiteLLM 엔진은 SDK의 예외 클래스와 문서화된 제공자 코드를 lm15 오류로 번역해요. 네이티브 엔진은 lm15 오류를 전파해요. 임의의 커스텀 예외는 "network"나 "timeout" 같은 단어로 분류되지 않아요.

DSPy는 소유한 엔진/기능 경계에서 정식 오류를 기존 공개 오류 계열로 번역해요. 애플리케이션은 계속 dspy.LMError, dspy.LMAuthError, dspy.ContextWindowExceededError와 그 형제들을 잡을 수 있어요. 이 공개 오류 이름은 3.5 인터페이스 제거의 일부가 아니에요. 이미 공개된 DSPy 오류(레거시 플러그인의 것 포함)는 identity로 보존돼요.

  • LMLockTimeoutError는 제공자 타임아웃이나 잘못된 자격증명이 아니라 로컬 자격증명 락 경합을 식별해요. 일시적이며 관리형 재시도 대상이에요.
  • LMStreamAssemblyError는 불완전하거나 유효하지 않은 스트림을 식별해요. 자동 재시도되지 않고, partial은 성공 결과가 아니라 구할 수 있는 내용을 담을 수 있어요. 알 수 없는 사용량은 알 수 없는 채로 있어요.
  • LMCollectionLimitError는 로컬 수집 예산에 도달했음을 식별해요. 재시도할 수 없어요. partial_events, rejected_event, limit, maximum, retained_bytes, retained_events는 엔진 경계를 통과해 살아남아요. partial을 읽으면 불완전한 턴을 요청 시 조립하고, 오류를 감싸도 텍스트/오디오를 합치지 않아요. DSPy는 여기에 라이브 세션 API를 추가하지 않아요.
  • LMUnsupportedFeatureError.feature는 lm15가 보고한 지원되지 않는 설정 경로를 보존해요. 명시적 목록이 없으면 features도 채워요.
  • 알 수 없는 엔진 실패는 LMUnexpectedError가 되고, 원래 예외는 __cause__로 남아요. 정식 원인은 정확한 lm15 코드와 SDK 원인을 유지하고, 공개 오류는 유용한 요청 ID, 제공자 코드, 재시도 힌트, 라우팅 진단, 가능한 부분 응답을 유지해요.
  • 잘못된 Python API 인자는 실행 전에 여전히 TypeError/ValueError를 raise해요. 누락된 의존성은 ImportError를 유지해요. 취소, 키보드 인터럽트, 오류로 승격된 경고는 재시도 가능한 LM 실패로 변환되지 않아요.

엔진 시도만 재시도할 수 있어요. 가격 책정, 캐시 쓰기, 히스토리, 콜백은 완료된 생성을 다시 실행하게 해서는 안 돼요. 완료된 후보의 보고된 사용량은 이후 작업이 실패해도 유지돼요. 유효하지 않거나 유한하지 않은 재시도 힌트는 일반 백오프를 사용하고, 유효한 제공자 힌트는 HTTP-date 헤더를 포함해 존중돼요. 네트워크 재시도는 제공자가 이미 처리하거나 청구한 요청을 반복할 수 있어요. 이는 정확히 한 번 보장이 아니에요. 재생이 안전하지 않으면 num_retries=0을 사용하세요.

모든 엔진 스트림은 같은 가드(하나의 선두 시작, 마지막 끝, 완료 후 이벤트 없음)를 통과해요. raise된 오류와 정식 StreamErrorEvent 모두 호출을 실패시켜요. 불완전한 결과는 성공으로 캐시되지 않아요. 정리 오류는 활성 실패나 취소를 대체하지 않고, 보조 진단은 예외가 지원하는 곳의 cleanup_errors에 유지돼요.

어댑터 폴백은 생성 재시도와 별개예요. ChatAdapter는 AdapterParseError에 대해 JSON 형식 호출을 한 번 더 할 수 있지만, 보이는 스트림 출력 후에는 절대 그러지 않아요. 예상치 못한 파서 버그와 엔진/설정 실패는 전파돼요. JSON 스키마 폴백은 실패한 실행 후가 아니라 모델 호출 전에 결정돼요.

커스텀 엔진과 레거시 플러그인 (Custom engines and legacy plugins)

새 커스텀 백엔드는 작은 엔진 인터페이스를 구현해요.

from dspy.lm15 import Message, Response, Usage

class EchoEngine:
    def complete(self, request):
        return Response(
            id=None, model=request.model, message=Message.assistant("hello"),
            finish_reason="stop", usage=Usage(),
        )

lm = dspy.LM("custom/echo", engine=EchoEngine())

스트리밍을 지원하려면 정식 lm15 이벤트를 내는 stream(request)를 구현해요. 비동기 호출을 위해 async complete(request)와 stream(request)(비동기 이터레이터 반환)를 갖는 async_engine=를 제공하세요. DSPy는 비동기 경로에서 동기 백엔드를 조용히 실행하지 않아요. 엔진은 DSPy 캐시나 재시도 루프를 또 추가하면 안 돼요. 커스텀 엔진은 호출자 소유이고 DSPy가 닫지 않아요.

커스텀 엔진은 자신의 연결을 소유해요. api_key, api_base, timeout, extra_headers와 다른 클라이언트 설정은 dspy.LM(engine=...), copy(), 모든 호출에서 버려지는 대신 거부돼요. copy(engine=...)는 엔진 쌍을 교체하고 각 측은 자기 종류여야 해요(비동기 측은 코루틴 함수). 엔진은 자신의 dump_state()/load_state()로 저장되고, 로딩은 커스텀 LM 클래스처럼 allow_unsafe_lm_state=True로 게이트돼요. lm15가 말할 수 있는 HTTP 제공자라면 엔진을 작성하는 대신 dspy.lm15.register_provider(...)로 선언해요. 각 dspy.LM은 생성 시 존재하는 등록을 바인딩하고, LiteLLM으로의 폴백이 선언된 주소와 자격증명을 유지해요. custom-engine 튜토리얼을 참고하세요.

BaseLM.forward(prompt=None, messages=None, **kwargs)나 aforward로 커스텀 LM을 구현하는 것은 deprecated예요. 위와 custom-engine 튜토리얼처럼 엔진을 구현하세요. 기존 하위 클래스 인터페이스는 DSPy 3.4 내내 지원되고, 두 레거시 엔진 래퍼와 complete_legacy() 단축키와 함께 3.5에서 제거될 예정이에요.

그 기존 인터페이스를 통한 호출은 Python의 일반 경고 필터에 따라 DeprecationWarning을 내보내요. 3.4에서 기존 플러그인은 여전히 레거시 엔진으로 자동 감싸져 일반 입력·출력을 보존해요. DSPy는 자동으로 감싸진 플러그인 주변에 캐싱이나 재시도를 추가하지 않아요. 이미 그 동작을 소유했을 수 있기 때문이에요. 레거시 플러그인의 프라이빗 스트리밍 구현은 그 책임으로 남아요.

dspy.clients.engines의 명시적 LegacyEngine·AsyncLegacyEngine 래퍼는 3.4 전환 도구일 뿐 영구 탈출구가 아니에요. 둘 중 하나를 만들면 deprecation 경고가 나고, 둘 다 3.5에서 제거 예정이에요. 감싸기만 하지 말고 기반 구현을 마이그레이션하세요. 전환 중에 플러그인이 이미 캐싱이나 재시도를 처리한다면 바깥 dspy.LM에서 cache=False, num_retries=0으로 꺼서 이중 레이어를 피하세요.

커스텀 엔진은 complete(Request) -> Response와 선언된 비동기/스트리밍 대응물을 구현해야 해요. DSPy가 일반 호출에서 커스텀 엔진의 complete_legacy() 단축키를 선택하면 경고해요. 그 단축키는 3.5에 없어요.

기존의 forward_contract="legacy" 선언은 무해해요. 실험적 forward_contract="typed_lm" 계약은 제거되고 명시적으로 거부돼요.

DummyLM은 이제 정식 엔진을 사용하지만 스크립트된 답변 모드, 추론 옵션, 어댑터 포맷팅, 반복 호출의 캐시 없는 소비를 유지해요.

복사, 저장, 정리 (Copying, saving and cleanup)

lm.copy()는 런타임 자원을 공유하면서 DSPy의 히스토리·콜백·kwargs를 분리해요. 모델이나 클라이언트 설정 변경은 별개의 네이티브 풀을 선택해요. 네이티브 비동기 풀은 이벤트 루프별로 분리돼요. 호출이 끝난 후 소유한 동기 풀은 lm.close()로, 이 루프의 비동기 풀은 await lm.aclose()로 닫으세요. 복사본은 소유권을 공유해요. 하나를 닫으면 공유 풀이 해제되고, 이후 호출에서 재생성될 수 있어요.

JSON LM 상태는 명명된 엔진 선택을 보존하고 API 키를 제외해요. 임의의 커스텀 엔진은 추측된 재구성 대신 커스텀 dump_state/load_state 메서드가 필요해요. 전체 프로그램 pickle 저장은 네이티브 전송 풀과 락을 제외하고, 로딩 후 지연 생성돼요. 기존 신뢰할 수 있는 로딩 안전장치는 계속 유효해요.

실험적 3.3 타입의 브레이킹 대체 (Breaking replacement of the experimental 3.3 types)

기존 dspy.core.types import는 마이그레이션 오류를 raise해요. 옛 타입 이름은 더 이상 dspy나 dspy.core에서 내보내지지 않아요. 드롭인 이름 변경이 아니에요.

옛 실험적 API 새 API
dspy.LMRequest dspy.lm15.Request
dspy.LMResponse dspy.lm15.Response
dspy.LMMessage, dspy.LMConfig dspy.lm15.Message, dspy.lm15.Config
dspy.System(text) Request(system=text, ...)
dspy.User(text), dspy.Assistant(text), dspy.Developer(text) Message.user(text), Message.assistant(text), Message.developer(text)
dspy.ToolCall(id=..., name=..., args=...) ToolCallPart(id=..., name=..., input=...)
dspy.ToolResult(content, call_id=...) Message.tool(call_id, content)
LMTextPart, LMImagePart, 등 TextPart, ImagePart, 등 dspy.lm15에서
response.outputs[0].parts response.message.parts

lm15는 Pydantic 모델이 아니라 frozen dataclass를 사용해요. 필수 identity, role, 비어 있지 않은 메시지, 미디어 소스 검증이 다를 수 있어요. 파일 이름, 문서 인용 설정, 임의의 부분 메타데이터는 모두 직접적인 정식 등가물이 있는 건 아니에요. 일반 호환성 호출은 그 제공자별 입력을 유지해요.

제거된 클래스를 담은 옛 pickle 객체는 자동으로 마이그레이션되지 않아요. 원래 환경에서 먼저 로드하고 내보내세요. 이는 일반 제공자 응답 캐시와 일반 저장 프로그램 구성과는 별개예요.

커스텀 어댑터는 lm(request) / await lm.acall(request)로 마이그레이션하고 반환된 lm15 Response를 파싱해야 해요. 3.5에서는 이것이 DSPy 전반의 내부 계약이에요. 어댑터는 OpenAI 스타일 사전을 넘기거나 목록 반환 프롬프트 편의 기능을 쓰면 안 돼요. 3.4 어댑터는 마이그레이션 중에도 계속 실행될 수 있지만, 제거된 dspy.clients.openai_format 모듈에 의존하면 안 돼요.

내장 어댑터 마이그레이션 전체는 3.5로 예정돼 있고, 3.4 구현은 여전히 사전 메시지를 렌더링하고 목록 출력을 파싱해요. 임시 내부 경고 마커는 커스텀 어댑터가 쓸 공개 API가 아니에요.

DSPy의 시그니처 타입(Image, Audio, File, Tool, ToolCalls, History, Reasoning 등)과 오류 클래스는 제거되지 않아요.

더 알아보기 (Learn more)