최근 DSPy 공식 문서의 “Diving Deeper(심층 탐구)” 섹션에 있는 13개 문서를 모두 꼼꼼히 읽어보았다. 시그니처(Signatures), 어댑터(Adapters), 모듈(Modules), 메트릭(Metrics), 옵티마이저(Optimizers), GEPA, 도구(Tools), ReAct, 설정(Settings), 저장/로드(Saving), Flex, RLM, 그리고 내장 모듈 변형들까지. 이를 통해 얻은 핵심 인사이트를 정리해 본다.

많은 이들이 오해하는 멘탈 모델

DSPy는 단순한 프롬프트 엔지니어링 라이브러리가 아니다. 언어 모델을 사용하는 프로그램을 위한 **컴파일러(Compiler)**다. 이 차이는 매우 중요하다.

프롬프트 엔지니어링 라이브러리는 개발자가 프롬프트를 더 잘 작성하도록 돕는다. 반면 DSPy는 원하는 입출력을 선언하고(Signatures), 이를 도출하는 과정을 모듈로 조합한 뒤(Modules), 개발자가 정의한 평가지표(Metrics)에 맞춰 프롬프트, 예시(demos), 심지어 모델 가중치까지 자동으로 최적화해준다(Optimizers). 코드는 한 번만 작성하면 된다. 최적의 형태를 찾아내는 것은 컴파일러의 몫이다.

시그니처: 타입 시스템

모든 것은 시그니처(Signature)에서 시작한다. 시그니처는 입력 필드, 출력 필드, 그리고 작업 지시문(task instructions)을 선언하는 Pydantic 모델이다. "question -> answer"라는 문자열 표현은 축약형일 뿐이며, 내부적으로 모든 시그니처는 타입이 지정된 필드와 설명(description), 그리고 지시문이 되는 독스트링(docstring)을 갖춘 클래스다.

여기서 중요한 통찰이 있다. 옵티마이저가 자동으로 수정하는 대상은 오직 **독스트링(지시문)**뿐이라는 점이다. 필드 이름, 타입, 설명은 고정된다. 이는 의도된 설계다. 필드 이름은 프로그램의 공개 인터페이스(public interface)이기 때문이다. 다른 모듈이나 호출자 코드, 다운스트림 서비스는 result.answer처럼 정해진 이름으로 결과를 읽는다. 옵티마이저가 필드 이름을 임의로 바꿔버리면 주변 시스템 전체가 망가지고 만다.

모든 변형 메서드(with_instructions, with_updated_fields, prepend, append, delete)는 원본을 깊은 복사(deep copy)하여 새 클래스를 반환한다. 어떤 것도 제자리에서 직접 변경(in-place mutation)되지 않는다. 이 불변성 덕분에 옵티마이저는 수백 개의 후보군을 서로 간섭 없이 병렬로 탐색할 수 있다.

어댑터: 프롬프트 계층

어댑터는 시그니처가 실제 프롬프트로 변환되는 곳이다. ChatAdapter는 [[ ## field ## ]] 마커를 사용하고, JSONAdapter는 구조화된 JSON 형식을 취하며, XMLAdapter는 태그를 사용한다. TwoStepAdapter는 포맷 준수가 불안정한 추론형 모델을 위해 생성 단계와 추출 단계를 분리하여 처리한다.

어댑터의 생명주기는 엄격히 고정되어 있다: 전처리(preprocess) → 포맷팅(format) → 언어 모델 호출(LM call) → 후처리(postprocess) → 파싱(parse). 어떤 어댑터든 이 5단계를 차례로 따라가며 디버깅할 수 있다.

타입 강제 변환(type coercion)은 parse_value(value, annotation)라는 단 하나의 함수로 중앙 집중화되어 있다. 모든 어댑터가 이 함수를 거친다. 타입 지정 필드가 예상과 다르게 동작한다면 바로 이 함수를 살펴보면 된다.

모듈: 올바른 컴포지션 방식

모듈(Module) 구조는 직관적이다. 서브클래싱하고, __init__에서 하위 모듈들을 정의한 뒤, forward()를 구현하면 된다. 하위 모듈은 self.__dict__를 순회하며 자동으로 감지된다. 즉, 속성에 할당하는 행위 자체가 등록(registration)이다. 번거로운 register_module() 호출이나 데코레이터, 상속 트릭이 전혀 필요 없다.

__call__ 메서드는 forward()를 사용량 추적, 콜백, 호출자 모듈 스택 등의 인프라 로직으로 감싼다. 따라서 모듈을 실행할 때는 결코 forward()를 직접 부르지 말고 항상 module(...) 형태로 호출해야 한다. 설정값들은 생성자 인자가 아니라 컨텍스트를 통해 전파된다. 예를 들어 with dspy.context(lm=other_lm) 블록을 사용하면 내부의 모든 하위 모듈의 언어 모델이 한 번에 교체되며, 파이프라인을 재구성할 필요가 없다.

_compiled 플래그는 다단계 최적화의 핵심 열쇠다. 옵티마이저가 하위 모듈을 컴파일하고 나면 _compiled = True를 설정한다. 이후의 옵티마이저는 이 모듈을 건너뛴다. 덕분에 ’내부 모듈 최적화 → 외부 모듈에 포함 → 외부 모듈 전체 최적화’라는 단계별 파이프라인 최적화가 가능해진다.

다양한 모듈 생태계 (Module Zoo)

기본적인 Predict, ChainOfThought, ReAct 외에도 흥미롭고 깊이 있는 모듈들이 존재한다:

BestOfN과 Refine: 동일한 모듈을 서로 다른 rollout ID와 temperature 1.0으로 여러 번 샘플링한 뒤, 보상 함수로 점수를 매긴다. Refine은 시도 사이에 피드백을 추가한다. 모듈의 소스 코드와 입출력 스냅샷을 만들어 내부 예측기(predictor)에 전달하고, 거기서 생성된 조언을 다음 시도의 힌트 필드로 주입한다.

ProgramOfThought와 CodeAct: 파이썬 코드를 생성하고 이를 격리된 Deno/WASM 인터프리터 환경에서 실행한다. 특히 CodeAct는 ReAct와 ProgramOfThought 양쪽 모두를 상속받는다. 즉, 도구 호출 자체가 파이썬 코드가 되는 도구 사용 루프다.

RLM (Recursive Language Model): 방대한 컨텍스트 처리를 위한 모듈이다. 프롬프트에 모든 문맥을 욱여넣는 대신, 모델에 컨텍스트가 변수로 적재된 파이썬 REPL 환경을 넘겨준다. 모델은 데이터를 탐색하는 코드를 작성하고 llm_query()를 호출해 하위 질문을 던진다. 하나의 거대한 롱 컨텍스트 문제가 여러 개의 작은 숏 컨텍스트 문제로 쪼개져 해결된다.

Flex: 가장 급진적인 모듈이다. 모듈의 소스 코드 자체를 최적화 탐색 공간에 올려놓는다. GEPA 옵티마이저가 프롬프트만 고치는 것이 아니라 모듈의 전체 구현 코드를 다시 쓴다. 예측기를 바꾸고, 제어 흐름을 수정하며, 파이썬 로직을 증감시킨다. 모듈의 코드 구조 자체가 최적화의 대상이 되는 것이다.

메트릭: 가장 본질적인 기준

메트릭은 (gold, pred) -> score 형태의 모든 호출 가능한 객체(callable)다. 덕타이핑(duck-typed) 기반이라 베이스 클래스 상속도, 데코레이터도 없다. 이러한 단순함은 의도된 것이다. 기존 NLP 라이브러리의 평가 함수도 단 한 줄이면 DSPy 메트릭으로 감쌀 수 있다.

반환 타입은 bool, float, 또는 dspy.Prediction(score, feedback)이 될 수 있다. 각각 처리 방식이 다르다. 불리언은 성공 백분율로 집계되고, 부동소수점은 평균을 내며, Prediction 형태는 GEPA의 성찰(reflection) 루프에 피드백 문자열을 직접 전달한다.

trace 매개변수는 하나의 메트릭 함수가 두 가지 역할을 수행하게 만드는 지렛대다. trace is None(단순 평가 모드)일 때 SemanticF1은 연속적인 점수를 반환한다. 반면 trace is not None(최적화 모드)일 때는 합격/불합격의 이진화된 결과를 반환한다. 동일한 함수가 상황에 따라 유연하게 동작하는 것이다.

옵티마이저 지형도

DSPy는 십여 종의 옵티마이저를 기본 제공한다. 선택의 기준은 결국 두 가지 질문으로 요약된다: 병목 지점이 어디인가, 그리고 예산이 얼마나 되는가?

데모 튜닝 (Demo-tuning, BootstrapFewShot 계열): 메트릭을 통과한 성공적인 실행 궤적(trace)을 수집하여 예측기에 퓨샷 데모로 주입한다. BootstrapRS는 이를 서로 다른 시드로 N회 실행하여 최상의 결과를 고른다. KNNFewShot은 임베딩 검색을 통해 추론 시점에 가장 적절한 데모를 동적으로 선택한다.

지시문 튜닝 (Instruction-tuning, COPRO, GEPA, MIPROv2): 각 예측기의 시그니처에 정의된 독스트링을 최적화하여 다시 쓴다. COPRO는 너비 우선 탐색을 수행한다. MIPROv2는 지시문과 데모의 결합 공간에서 베이지안 최적화(Bayesian optimization)를 수행한다. GEPA는 메트릭의 자연어 피드백을 기반으로 진화적 탐색(evolutionary search)을 진행한다.

가중치 튜닝 (Weight-tuning, BootstrapFinetune): 성공적인 궤적을 모아 학습 데이터로 변환한 뒤 언어 모델 자체를 미세조정(fine-tuning)한다. 가장 마지막에 고려해야 할 강력한 수단이다.

실무적인 접근법은 다음과 같다: 먼저 BootstrapFewShot으로 시작한다. 지시문 자체가 미흡해 보인다면 COPRO나 GEPA를 추가한다. 지시문과 예시 모두를 동시에 조율해야 한다면 MIPROv2를 쓴다. 프롬프트 최적화가 한계에 다다랐고 모델 파인튜닝이 가능한 환경이라면 BootstrapFinetune을 고려한다. 대부분의 프로젝트는 프롬프트 레벨 최적화만으로도 충분한 성과를 거둔다.

GEPA: 피드백 기반 옵티마이저

GEPA는 가장 흥미로운 옵티마이저다. 후보 프로그램들의 집단을 유지하면서 검증 데이터셋에 대해 점수를 매긴다. 그리고 성찰 언어 모델(reflection LM)을 사용하여 메트릭이 제공한 예측기별 상세 피드백을 기반으로 지시문 수정을 제안한다.

여기서 핵심은 메트릭이 단순한 숫자 점수가 아니라 Prediction(score, feedback)을 반환해야 한다는 점이다. 이 피드백 문자열이 성찰 프롬프트에 그대로 전달된다. 단순 부동소수점 점수만 넘기면 옵티마이저의 성능이 크게 떨어진다. 성찰 모델이 “답변은 사실에 부합했으나 시간적 한정 조건을 누락했음” 같은 구체적인 원인 대신 “점수가 0.6점이었음”이라는 정보밖에 보지 못하기 때문이다.

GEPA는 탐색을 위해 파레토 프론티어(Pareto frontier)에서 샘플링을 진행하며, 최종 선택 시에는 가장 높은 종합 점수를 얻은 프로그램을 반환한다. 이때 성찰 모델 호출 비용이 전체 비용의 대부분을 차지하므로 예산 계획을 잘 세워야 한다. 예측기 2개, 예제 100개 규모의 작업에서 중간 정도의 탐색 예산만 잡아도 약 12~36회의 성찰 모델 호출이 발생한다.

도구와 ReAct

dspy.Tool은 함수(callable)를 감싸는 Pydantic 모델이다. 스키마, 타입, 설명은 기본적으로 inspect.signature를 통해 자동 추출된다. ReAct 모듈은 생각(thought) → 도구 이름 → 도구 인자 → 관찰(observation)로 이어지는 루프를 실행하며, finish 도구를 만나면 종료된다.

도구 실행 중 오류가 발생해도 프로그램이 중단되지 않고 관찰 결과로 변환된다. 언어 모델은 다음 반복에서 오류 메시지를 확인하고 스스로 복구할 수 있다. 컨텍스트가 넘칠 경우 가장 오래된 도구 호출 이력부터 버리는 궤적 절단(trajectory truncation) 메커니즘도 갖추고 있다.

MCP 도구는 Tool.from_mcp_tool(session, tool)을 통해 매끄럽게 연결된다. MCP 클라이언트 세션 자체가 비동기 네이티브이므로 항상 async 형태로 동작한다.

설정 관리: 암묵적이면서도 조립 가능한 구조

프로세스 전체 설정에는 dspy.configure()를, 특정 스코프 내 오버라이드에는 dspy.context()를 사용한다. 설정값은 파이썬의 contextvars를 통해 전파되므로 await나 asyncio.create_task 환경에서도 안전하게 격리된다.

주의할 점은 일반 threading.Thread는 이 오버라이드를 자동으로 상속하지 않는다는 것이다. 따라서 dspy.Parallel을 사용하거나 thread_local_overrides를 수동으로 캡처하여 다시 적용해야 한다.

보안상 API 키는 절대 직렬화되지 않는다. allow_pickle의 기본값은 False이며, allow_unsafe_lm_state는 기본적으로 엔드포인트 정보를 제거한다. 보안 모델은 의도적으로 안전 장치를 두어 호출 시점에 명시적인 신뢰 결정을 내리도록 유도한다.

저장과 로드

저장 방식은 두 가지다. 상태만 저장하는 방식(JSON 형식, 사람이 읽을 수 있고 git diff 가능)과 전체 프로그램을 저장하는 방식(cloudpickle 형식, 로드 환경에 원본 코드가 없을 때 유용)이다. load_state는 트랜잭션 방식으로 동작하여, 먼저 깊은 복사본에 시험 삼아 로드해 보고 성공할 때만 최종 반영된다.

한 번 컴파일하고, 저장한 뒤, 필요할 때 로드해서 쓰는 패턴이다. 최적화 과정에서 발생한 비용은 이후 수많은 추론 호출을 통해 상쇄되어야 경제적 타당성을 갖는다.

맺으며

DSPy는 컴파일러다. 프로그램의 구조를 선언하고(Signatures + Modules), 무엇이 좋은 결과인지 정의하면(Metrics), 옵티마이저가 최적의 버전을 찾아낸다. 지시문, 데모, 가중치라는 세 가지 조절 손잡이는 프레임워크가 제공하는 모든 탐색 공간을 포괄한다.

특히 인상 깊었던 점은 옵티마이저의 병렬성을 안전하게 보장하는 딥카피 불변성 패턴, 단 하나의 함수로 평가와 최적화를 모두 지원하는 트레이스 기반 메트릭 스위칭, 그리고 모듈 소스 코드 자체를 최적화 매개변수로 취급하는 Flex의 발상이었다. Flex는 아직 실험적이고 인터페이스가 변하고 있지만, “프롬프팅이 아닌 프로그래밍으로의 전환”이라는 철학이 가장 극명하게 드러나는 지점이다.

언어 모델을 활용해 진지하고 복잡한 시스템을 만들고 있다면, 더 이상 프롬프트를 일일이 손으로 고치지 말자. 시그니처를 작성하고, 메트릭을 정의한 뒤, 나머지는 컴파일러에 맡겨보는 것을 추천한다.