Module 심화: 나만의 모듈 조합하기
Module 심화: 나만의 모듈 조합하기
dspy.Module은 DSPy에서 조합의 단위예요. Predict·ChainOfThought·ReAct만으로는 부족해질 때, 이 클래스를 상속해 서브모듈을 정의하고 forward()에서 입력을 흘려보내면 커스텀 로직이 프레임워크 생태계에 자연스럽게 들어옵니다. 이 페이지는 그 설계 결정과 전체 API를 다룹니다.
출처: 공식문서
의도 (Intent)
dspy.Module은 조합의 단위입니다. 상속해서 __init__에 서브모듈 몇 개를 정의하고, forward()에서 입력을 그 서브모듈들을 통해 흘려보내면 돼요. 커스텀 로직(제어 흐름, 분기, 여러 번의 LM 호출, 후처리)이 옵티마이저·세이브/로드·설정 오버라이드·병렬 실행과 모두 잘 동작하는 방식으로 DSPy 생태계에 들어옵니다.
이 페이지는 Predict/ChainOfThought/ReAct 각각으로는 부족해 이것들을 조합하고 싶을 때, 또는 __call__이 뒤에서 forward()에 뭘 하는지 이해하고 싶을 때 읽으면 돼요.
설계 결정 (Design decisions)
DSPy의 Module은 몇 가지 분명한 설계 원칙 위에 서 있어요. 하나씩 살펴볼게요.
1. 상속하고 forward()를 오버라이드한다
계약은 간단해요. __init__에 서브모듈을 정의하고, forward에서 작업을 수행하며, __call__이 그것을 감싼다. 이렇게 나누는 이유는 프레임워크가 인프라를 붙일 예측 가능한 지점을 필요로 하기 때문이에요. __init__은 서브모듈이 트리 워킹과 직렬화에 보이는 지점이고, forward는 런타임 관심사(설정 스택, 사용량 추적, 콜백)가 코드를 감싸는 지점입니다. 둘을 섞으면 옵티마이저가 보는 것과 dump_state가 저장하는 것이 흐려집니다.
2. 서브모듈은 등록이 아니라 self.__dict__ 워킹으로 발견된다
할당 자체가 등록이에요. register_module() 호출이나 상속 트릭이 없습니다. 트리 워킹 메서드(named_predictors, named_parameters, named_sub_modules)는 호출 시점에 self.__dict__를 조사해 리스트와 딕셔너리, 그리고 직접 속성까지 재귀합니다. 발견이 지연(lazy)되므로 등록을 잊을 일이 없지만, 반대로 클로저 안이나 워킹되지 않는 컨테이너에 숨겨진 서브모듈은 옵티마이저와 dump_state에 보이지 않는다는 뜻도 됩니다.
3. Parameter가 속성을 옵티마이저 가시화로 표시한다
self.max_iters = 5처럼 할당하면 트리 워킹이 건너뛰어요. self.predict = dspy.Predict(...)처럼 할당하면 그것은 Parameter가 되어 포함됩니다. 이렇게 하면 dump_state를 작게 유지하고 옵티마이저 검색 공간을 집중시키지요 — 배워야 할 것만 노출되는 거예요. Predict는 Parameter이고, Retrieve도 그렇습니다. 평범한 Python 속성은 아닙니다.
4. __call__이 forward()를 인프라로 감싼다
__call__을 통과해야 self를 settings.caller_modules에 푸시하고, settings.track_usage가 켜져 있을 때 사용량 추적 컨텍스트를 열며, 콜백 데코레이터 체인을 실행해요. forward()를 직접 호출하면 이 모두를 우회합니다 — 사용량이 집계되지 않고, 콜백이 발화하지 않으며, 호출자 스택이 갱신되지 않지요. 직접 forward 호출에 대한 deprecation 경고가 바로 그 이유입니다. 항상 module(...)로 호출하세요.
5. Async는 acall()/aforward()로 sync 곁에 자리한다
acall은 async를 위한 __call__의 인프라를, aforward는 forward의 async 대응물을 제공해요. aforward를 정의하지 않은 모듈은 asyncify를 통해 worker 스레드에서 sync forward를 실행하는 폴백을 씁니다. 이는 "sync 전용"에서 "완전 async"까지 세계를 다시 쓰지 않고 갈 수 있는 경로를 줘요 — 서브모듈 하나씩 async로 만들고 나머지는 그대로 두면 됩니다.
6. 설정은 생성자 인자가 아니라 컨텍스트로 전파된다
서브모듈은 호출 시점에 dspy.settings.lm(그리고 .adapter, .callbacks)을 읽어요. 그 결과, with dspy.context(lm=other_lm): result = my_module(...)를 쓰면 내부의 모든 서브모듈에 대해 LM이 교체돼요 — 재배선이 필요 없습니다. 대가(가격)는 암묵적 상태입니다. 서브모듈의 동작은 __init__에서 할당된 것뿐 아니라 누가 호출하느냐에도 의존하지요. 조합이라는 이점이 그 트레이드오프를 충분히 보상합니다.
7. _compiled=True는 서브모듈 파라미터를 옵티마이저로부터 동결한다
named_parameters는 _compiled 플래그를 확인해 컴파일된 자식을 건너뛰어요. 이렇게 하면 서브모듈 하나를 독립적으로 최적화해 저장하고, 더 큰 프로그램에 넣은 뒤 바깥 프로그램에 새 옵티마이저를 돌려도 안쪽 것을 방해하지 않습니다. 이건 손으로 설정할 일이 거의 없어요 — teleprompt가 컴파일을 마칠 때 설정합니다.
8. 메타클래스 ProgramMeta가 super().__init__()를 강제한다
ProgramMeta는 여러분의 __init__보다 먼저 실행되는 폴백 _base_init을 설치해서, 서브클래스가 super().__init__()을 잊더라도 북키핑(히스토리, 콜백, _compiled 플래그)이 자리 잡도록 해요. 이 메타클래스를 여러분이 상속하거나 인스턴스화하지는 않습니다. 그냥 가드레일이에요. 오류 트레이스가 그 안에 들어오면 대개 super().__init__() 호출이 빠진 게 원인입니다.
9. deepcopy()는 손으로 작성되어 있다
모듈은 Python의 기본 copy.deepcopy를 헷갈리게 하는 클로저와 콜백을 담을 수 있어요. Module.deepcopy()는 copy.deepcopy를 먼저 시도하고, TypeError가 나면 속성별 복사로 폴백합니다. 옵티마이저는 이걸로 후보 프로그램을 포크하는데, 그 폴백 덕분에 Module 서브클래스가 담을 수 있는 다양한 것들에 걸쳐 신뢰성 있게 동작합니다.
API 둘러보기 (API walkthrough)
목적별로 묶어서 살펴볼게요.
모듈 정의하기
dspy.Module(callbacks=None)
상속하세요. 베이스는 callbacks, history, _compiled 플래그만 저장합니다 — 그 외엔 없어요. 흥미로운 동작은 __call__과 트리 워킹 메서드에 있습니다.
Module.__call__(*args, **kwargs)
self를 settings.caller_modules에 푸시하고, settings.track_usage가 켜져 있으면 사용량 추적 컨텍스트를 열며, @with_callbacks로 데코레이션된 forward를 실행한 뒤 Prediction을 반환해요. forward가 Prediction이 아닌 것을 반환하면 경고가 나옵니다 — 사용량 추적이 Prediction 반환 타입에 의존하기 때문이에요. module.forward(...) 직접 호출은 deprecation 경고를 남깁니다.
Module.forward(**kwargs)
여러분이 오버라이드하는 메서드예요. 시그니처 입력을 키워드 인자로 받아 작업을 수행하고 Prediction을 반환합니다. __init__에는 설정 읽기를 넣지 마세요 — 현재 LM·adapter를 생성 시점이 아니라 호출 시점에 잡도록 forward 안에서 읽어야 해요.
Module.acall(*args, **kwargs) / Module.aforward(**kwargs)
async 변형이에요. acall이 공개 진입점이고 aforward가 오버라이드 대상입니다. aforward를 정의하지 않으면 acall은 asyncify 안에서 forward를 실행하는 폴백을 써요. 서브모듈 하나가 async 전용일 때 유용합니다 — 프로그램 나머지를 async로 만들지 않고도 aforward에서 await 할 수 있어요.
ProgramMeta
Module 위의 메타클래스입니다. 상속하거나 인스턴스화하지 않아요. _base_init을 폴백으로 설치해 super().__init__()을 엄격히 요구하지 않게 합니다. 오류 트레이스가 그 안에 들어오면 대개 super().__init__()이 빠진 게 원인입니다.
모듈 트리 워킹하기
이것들은 모두 같은 원시 요소에 의존해요 — self.__dict__를 워킹하고, 리스트·딕셔너리로 재귀하며, 대상 타입과 일치하는 것을 내보냅니다.
Module.named_predictors() / Module.predictors()
이 모듈 아래의 모든 Predict 인스턴스를 발견해요. 옵티마이저가 무엇을 최적화할지 찾는 데 씁니다. 순서는 발견 순서(__dict__를 통한 깊이 우선)예요.
Module.named_parameters() / Module.parameters()
모든 Parameter를 발견합니다. _compiled를 존중해서, 컴파일된 서브모듈은 건너뛰고 옵티마이저는 그 파라미터를 건드리지 않아요.
Module.named_sub_modules(type_=BaseModule, skip_compiled=False)
다른 것들이 기반으로 삼는 제너레이터입니다. 커스텀 워킹이 필요할 때 직접 써요 — 모든 ReAct 인스턴스, 특정 사용자 클래스의 모든 서브모듈처럼 type_으로 필터링할 수 있는 무엇이든요.
Module.map_named_predictors(func)
func를 각 predictor에 적용하고 제자리에서 교체해요. teleprompt가 최적화된 predictor를 바꿔 끼울 때 쓰는 훅입니다. 직접 쓸 일은 거의 없어요.
LM 설정·조회하기
Module.set_lm(lm)
트리의 모든 predictor를 워킹하며 lm을 할당해요. 한 LM으로 프로그램을 만들고, 전역 설정을 바꾸지 않고 다른 LM으로 재평가하고 싶을 때 유용합니다.
Module.get_lm() → LM
모든 predictor가 한 LM에 동의하면 그 LM을 반환하고, 섞여 있으면 ValueError를 던져요. 파인튜닝·저장 전에 전체 프로그램이 한 모델인지 확인하는 안전장치입니다.
상태와 복사
세이브/로드 표면입니다. JSON vs PKL, 전체 프로그램 vs 상태만, save_program=True 같은 전체 생명주기는 Saving and loading에 있고, 여기 메서드들은 그 페이지가 다루는 것입니다.
Module.dump_state(json_mode=True) / Module.load_state(state, allow_unsafe_lm_state=False)
옵티마이저 가시 상태(predictor별 데모, 트레이스, 시그니처 지시, LM 설정)를 왕복시켜요. Python 클래스 자체는 아니에요.
Module.save(path, save_program=False, modules_to_serialize=None) / Module.load(path, allow_pickle=False, allow_unsafe_lm_state=False)
사용자용 한 쌍입니다. save_program=True는 전체 모듈을 디렉토리에 cloudpickle하고, 기본은 상태만 JSON 또는 PKL로 씁니다.
Module.deepcopy()
copy.deepcopy를 시도하고 TypeError가 나면 속성별 복사로 폴백합니다. 옵티마이저가 후보 프로그램을 포크하는 데 씁니다.
Module.reset_copy()
deepcopy() 다음에 모든 파라미터에 reset()을 실행해요. 같은 구조에 옵티마이저 상태 없이 새 프로그램을 만들 때 씁니다.
배치 실행
Module.batch(examples, num_threads=None, max_errors=None, return_failed_examples=False, ...)
많은 예시를 병렬로 실행합니다. 내부적으로 dspy.Parallel을 감싸는데, 이는 dspy.settings를 스냅샷하고 각 워커 스레드 안에서 다시 적용해요(그래서 바깥의 dspy.context(lm=...)가 존중됩니다). 입력 순서대로 예측을 반환합니다. 평가·데모 수집·그리고 멍하니 병렬화 가능한 추론에 써요.
디버깅
Module.history / Module.inspect_history(n=1, file=None)
history는 이 모듈에 붙은 LM 호출 레코드의 리스트이고, inspect_history는 마지막 n개를 보기 좋게 출력합니다. 둘 다 pickle·JSON 상태에서 제외되므로, 수 메가바이트짜리 히스토리가 저장된 프로그램에 딸려가지 않아요.
Module.callbacks
등록된 콜백 핸들러입니다. 저장 상태에서도 제외돼요.
반환 타입
dspy.Prediction(**fields)
필드 컨테이너입니다. result.haiku나 result["haiku"]로 접근해요. dspy.Example의 저장소를 상속하되 입력/출력 분리를 없앱니다.
dspy.Prediction.from_completions(list_or_dict, signature=None)
여러 LM 완성본으로 Prediction을 만듭니다. BestOfN, MultiChainComparison, 그리고 한 번 이상 샘플링하는 어떤 모듈도 이걸 써요.
Prediction.completions
예측이 n > 1 샘플에서 나왔을 때 Completions 객체입니다. pred.completions[i]는 i번째 완성본을 각자의 Prediction으로 반환해요. 단일 샘플 호출에선 None입니다.
Prediction.get_lm_usage() / Prediction.set_lm_usage(tokens_dict)
토큰 사용량 회계입니다. 예측이 dspy.settings(track_usage=True) 아래 실행됐을 때 채워집니다.
dspy.Example와의 비교. 같은 저장 레이어를 쓰지만 의도가 달라요. Example은 훈련 데이터용입니다 — _demos와 _input_keys를 지니고 .with_inputs가 필터링된 복사본을 반환하지요. Prediction은 모듈 출력용입니다 — 입력/출력 분리가 없고 완성본과 사용량을 지닙니다. 들어가는 것엔 Example, 나오는 것엔 Prediction을 쓰세요.
더 알아보기 (Learn more)
- Saving and loading — 지속성 이야기 전체.
- Settings and context() — LM·adapter·콜백 오버라이드가 모듈의 서브모듈로 전파되는 법.