시그니처 심화 (Signatures in depth)
시그니처 심화 (Signatures in depth)
이 문서가 설명하는 것
시그니처는 프로그램과 언어 모델 사이의 **선언적 계약(contract)**이에요. 받아들이는 입력 필드, 만들어내는 출력 필드, 그리고 작업을 설명하는 지시사항까지를 정의하죠. 이 페이지에서는 클래스 기반 형태가 문자열 형태보다 무엇을 더 주는지, 타입이 붙은 필드가 DSPy 안에서 어떻게 흘러가는지, 런타임에 시그니처를 어떻게 수정하는지, 그리고 이를 뒷받침하는 설계 결정을 다룹니다.
문자열 미니 언어 "a, b -> c"가 부족해진 느낌이 들 때 읽으면 좋아요. 클래스 기반 형태가 무엇을 추가하는지, 타입 필드가 어떻게 변환되는지, 옵티마이저와 모듈이 시그니처를 뒤에서 어떻게 다루는지 궁금할 때가 바로 이 문서를 찾을 타이밍이에요.
설계 결정
1. 바닥은 Pydantic
시그니처의 모든 필드는 pydantic FieldInfo이고, 기본 Signature 클래스는 pydantic.BaseModel을 상속해요. DSPy 고유의 메타데이터는 각 필드의 json_schema_extra에 담깁니다. pydantic은 DSPy가 손으로 만들었어야 할 것들 — 타입 검증, JSON-스키마 생성, 그리고 어댑터 계층이 LM 출력을 선언된 타입으로 변환할 때 쓰는 TypeAdapter — 을 이미 제공해 줘요.
여기서 얻는 실용적인 이점이 하나 있어요. gt, lt, min_length 같은 pydantic 제약은 그대로 필드에 적용됩니다. 이들은 json_schema_extra["constraints"]에 문자열로 들어가고, 어댑터가 프롬프트에서 이 제약을 언급해 주죠.
2. 하나의 클래스로 두 가지 API
문자열 형태(dspy.Signature("a, b -> c"))와 클래스 형태(class HaikuBot(dspy.Signature))는 같은 종류의 객체를 만듭니다. 바로 Signature 서브클래스죠. 인라인 호출은 한 줄짜리를 원하고, 더 풍부한 명세는 docstring과 타입이 붙은 InputField/OutputField 속성, 때로는 import된 타입을 원해요. 둘 다 단일 클래스 타입으로 수렴시키면, 모듈·어댑터·옵티마이저는 단 한 가지 모양만 다루면 됩니다.
메타클래스 SignatureMeta가 dspy.Signature("...") 호출을 가로채서 make_signature로 라우팅해요. 여러분이 Signature 인스턴스를 직접 만들 일은 없고, 클래스 자체가 전달되는 객체입니다.
3. docstring이 작업 지시사항이 된다
메타클래스가 클래스 docstring을 정리(cleandoc)해서 __doc__로 저장하고, Signature.instructions 속성이 이를 읽어 돌려줘요. docstring이 없으면 DSPy가 "Given the fields X, produce the fields Y."를 생성해서 씁니다. 어댑터는 이 문자열을 필드 스키마 위의 시스템 메시지로 프롬프트에 렌더링해요.
지시사항은 그걸 설명하는 필드 옆에 있는 게 맞고, 옵티마이저는 다시 쓰기 좋은 하나의 명확한 문자열이 필요합니다. docstring을 작업 의도를 산문으로 담는 자리로 쓰고, 필드 이름과 타입은 짧고 구조적으로 유지하되 docstring에 안 들어가는 건 그 안에 넣으세요.
4. 지시사항은 모듈이 아니라 시그니처에 있다
시그니처는 자기만의 지시사항과 필드 스키마를 가져요. 모듈(Predict, ChainOfThought, ReAct)은 호출 시점의 전략 — LM 호출을 몇 번 할지, 무엇에 대해 추론할지, 언제 멈출지 — 을 제공합니다. 같은 시그니처를 아무 모듈에나 넘길 수 있어서, 같은 작업에서 모듈을 비교하는 게 싸고, 호출 방식을 바꾸지 않고 지시사항만 다시 쓸 수도 있어요.
모듈을 바꿀 때마다 docstring을 다시 쓰고 있다면, 지시사항에 모듈에 속해야 할 제어 흐름 세부가 들어있는 것일 수 있어요.
5. deepcopy를 통한 불변성
모든 수정 메서드(with_instructions, with_updated_fields, prepend, append, insert, delete)는 기존 필드 딕셔너리를 deep-copy해서 변경을 적용하고 새로운 Signature 클래스를 돌려줍니다. 원본은 그대로 남아요.
옵티마이저는 같은 프로그램의 후보 변형을 여러 개 실행하는데, 예측기가 제자리에서 시그니처를 바꾼다면 후보들이 공유 상태를 통해 서로를 덮어쓰게 됩니다. Signature.fields에 직접 손대서 필드를 바꾸려 하지 말고 메서드를 통과하세요. 같음 비교는 Signature.equals(other)를 쓰고, 일반 ==는 클래스 정체성을 비교해서 보통 원하는 게 아닙니다.
6. 필드 순서는 의미가 있다
메타클래스는 클래스 네임스페이스에서 필드 순서를 추출해 입력을 먼저, 출력을 나중으로 Signature.fields에 노출해요. 어댑터는 프롬프트를 렌더링할 때 그 같은 딕셔너리를 따라가므로, Signature.fields.keys()에 보이는 순서가 곧 LM이 보는 순서입니다. 입력이나 출력을 재배열하면 프롬프트도 바뀌어요.
prepend / append / insert로 새 필드를 넣을 때, 위치 인자는 전체 목록이 아니라 해당 필드가 속한 구간(입력 또는 출력)을 대상으로 합니다.
7. 타입은 여기서 선언하고 저기서 변환한다
시그니처는 어노테이션만 선언해요. season: Literal["spring", ...], haikus: list[str], 커스텀 Pydantic 모델 같은 것들이죠. 파싱은 dspy/adapters/utils.py::parse_value에서 일어나는데, 이 함수는 json_repair를 시도하고 ast.literal_eval로 폴백한 뒤 TypeAdapter(annotation)으로 검증합니다. dspy.Type 서브클래스의 경우, pydantic 검증이 실패하면 어댑터가 원시 문자열로 재시도해서 타입의 커스텀 파서가 맡게 합니다.
이 분리가 존재하는 이유는 여러 어댑터(Chat, JSON, XML, TwoStep)가 같은 시그니처를 서로 다르게 변환해야 하기 때문이에요. JSON 어댑터는 모델의 JSON 모드에 기댈 수 있고, XML 어댑터는 태그에서 파싱합니다. 변환을 어댑터 계층에 두면 시그니처는 가볍게 유지되고 파서 로직도 중복되지 않아요. 타입 필드가 파싱 시점에 이상하게 동작한다면 시그니처가 아니라 **어댑터의 출력과 parse_value**를 살펴보세요. 시그니처가 프롬프트가 되는 법: 어댑터를 참고하세요.
8. 커스텀 타입은 호출자의 스택 프레임을 걸어 찾는다
문자열 파서가 subject: MyType을 보고 MyType이 내장이나 dspy.* 클래스가 아니면, SignatureMeta._detect_custom_types_from_caller가 콜 스택을 최대 100프레임 거슬러 올라가며 각 프레임의 locals와 globals에서 이름을 찾아요. 덕분에 dspy.Predict("subject: MyType -> output")을 import나 등록 없이 인라인으로 쓸 수 있습니다.
100프레임 제한은 안전장치예요. 프레임이 없는 stripped·optimized Python에서는 경고가 뜨고 make_signature에 custom_types={"MyType": MyType}을 명시적으로 넘겨야 합니다. 프레임 인트로스펙션에 의존하고 싶지 않을 때도 같은 폴백이 동작해요.
9. 필드 이름과 설명은 옵티마이저가 건드리지 않는다
GEPA와 다른 지시사항 옵티마이저는 시그니처의 docstring을 다시 씁니다. 필드 이름, desc, prefix는 손대지 않아요. 그게 바뀌는 유일한 방법은 with_updated_fields입니다.
필드 이름은 프로그램의 공개 인터페이스의 일부예요. 호출 코드는 result.haiku를 읽고, 다운스트림 모듈은 "haiku"를 이름으로 찾습니다. 옵티마이저가 이름을 바꾸면 주변 프로그램이 깨지겠죠. 그러니 필드 이름을 신중하게 지으세요 — 옵티마이저가 나중에 result를 haiku로 고쳐줄 수 없으니까요. 필드 설명을 작성할 때도 마찬가지입니다.
API 둘러보기
하려는 일 기준으로 묶었어요.
시그니처 선언
dspy.Signature
필드가 두세 개를 넘거나, docstring을 원하거나, 더 풍부한 타입이 필요해지면 서브클래스를 쓰세요. dspy.Signature("a, b -> c")를 직접 호출하는 건 축약형이에요. 메타클래스가 호출을 가로채 문자열 파서로 라우팅합니다. 둘 다 실제 Signature 서브클래스(인스턴스 아님)를 만들며, 인스턴스화하지는 않아요.
dspy.InputField(*, desc=None, prefix=None, **pydantic_kwargs)
dspy.OutputField(*, desc=None, prefix=None, **pydantic_kwargs)
pydantic.Field를 감싼 얇은 팩토리입니다. 모든 pydantic 제약(gt, min_length, …)과 DSPy 고유의 desc, prefix를 받아요. prefix=는 자동 추론된 라벨을 덮어쓰고, 생략하면 infer_prefix가 속성 이름에서 유도합니다. 지원 중단된 format과 parser 인자는 여전히 값을 받지만 더는 아무것도 하지 않아요 — 이들은 어댑터 계층보다 앞선 시절의 잔재입니다.
dspy.make_signature(signature, instructions=None, signature_name="StringSignature", custom_types=None)
실제 생성자입니다. 입력 형태는 두 가지 — 문자열(_parse_signature가 파싱) 또는 {name: (type, FieldInfo)} 딕셔너리(수정 메서드가 클래스를 자신의 조각으로 다시 만들 때 사용)예요. signature_name은 결과 클래스의 __name__을 정하며, 로그와 검사 도구에 표시됩니다.
dspy.ensure_signature(signature, instructions=None)
이미 만들어진 Signature 클래스에는 no-op이고, 문자열이면 make_signature를 실행합니다. 두 형태를 모두 받아야 하는 모듈을 만들 때 써요. 메모이즈하지 않으므로, 같은 문자열을 핫 루프에서 호출하지 마세요 — 호출마다 새 클래스가 생성됩니다.
SignatureMeta
Signature 뒤에 있는 메타클래스입니다. 서브클래스나 인스턴스화할 일은 없어요. 하는 일: docstring을 지시사항으로 파싱하고, 모든 필드가 input 또는 output으로 태그됐는지 검증하고, 빠진 prefix= 값을 추론하며, 문자열 파싱 중 커스텀 타입 프레임 탐색을 실행합니다. "이상한 시그니처" 에러의 대부분이 여기서 시작돼요.
시그니처 조사
Signature.input_fields / Signature.output_fields / Signature.fields → dict[str, FieldInfo]
필드 이름을 키로 하는 딕셔너리입니다. FieldInfo의 .annotation은 선언된 타입이고, json_schema_extra는 __dspy_field_type 태그와 IS_TYPE_UNDEFINED 마커(문자열 형태 필드가 명시적 타입이 없을 때 설정 — Predict는 이를 이용해 None을 빈 문자열로 변환)를 포함한 DSPy 메타데이터를 담아요.
Signature.instructions → str
cleandoc된 docstring입니다. 설정 가능한 속성이지만, 새 클래스를 받으려면 with_instructions(...)를 쓰는 게 낫습니다.
Signature.signature → str
클래스를 문자열 형태(예: "location, season, mood -> haiku")로 왕복시켜 보여줍니다. 로그와 검사에 유용하고, 파싱에는 쓰지 않아요.
Signature.equals(other) → bool
지시사항과 모든 필드의 json_schema_extra를 비교합니다. 옵티마이저와 테스트가 쓰는 같음 술어예요. 일반 ==는 클래스 정체성을 비교해서 보통 원하는 게 아닙니다.
시그니처 수정
여기 나오는 모든 메서드는 deep-copy를 통해 새 Signature 클래스를 돌려줍니다.
Signature.with_instructions(instructions: str)
새 클래스, 같은 필드, docstring만 교체.
Signature.append_instructions(instructions: str)
새 클래스, 같은 필드, instructions를 docstring에(빈 줄로 이어 붙여) 확장. 기존 시그니처(일명 docstring)의 지시사항을 다시 말하지 않고 그 위에 추가 지침을 얹고 싶을 때 씁니다.
Signature.with_updated_fields(name, type_=None, **json_schema_extra)
필드의 메타데이터를 교체합니다. 키워드 인자를 기존 json_schema_extra에 병합하죠. desc=...로 설명을 바꾸고, prefix=...로 라벨을 바꾸며, 어댑터나 옵티마이저가 읽는 어떤 커스텀 키든 넘길 수 있어요. type_을 바꾸면 새 어노테이션이 기존 것을 대체하고, IS_TYPE_UNDEFINED=False도 넘기면 Predict가 타입 없는 문자열 형태 필드에 의존하는 "None을 빈 문자열로 취급" 동작을 해제합니다.
Signature.prepend(name, field, type_=None)
Signature.append(name, field, type_=None)
Signature.insert(index, name, field, type_=None)
필드의 구간(입력 또는 출력)은 InputField/OutputField로 필드를 만들 때 설정된 __dspy_field_type 태그로 결정됩니다. 위치 인자는 전체 필드를 대상으로 하지 않고 그 구간 안에서 동작합니다. insert는 음수 인덱스를 받고, 범위를 벗어나면 ValueError를 던져요.
Signature.delete(name)
필드가 없으면 조용히 no-op입니다. 실패를 크게 알리고 싶으면 먼저 name in cls.fields를 확인하세요.
시그니처 영속화
Signature.dump_state() → dict
Signature.load_state(state) → Signature 클래스
최적화 실행 간에 바뀌는 부분 — 지시사항과 각 필드의 어노테이션, prefix, desc, __dspy_field_type 태그 — 을 왕복시킵니다. Signature 클래스 자체는 직렬화되지 않아요. 소비 프로세스가 먼저 재인스턴스화한다는 전제(보통 부모 모듈의 저장 상태를 통해)가 있기 때문입니다. Module.save()와 dspy.load()가 내부에서 이것들을 쓰며, 직접 호출할 일은 드뭅니다.
이름 짓기 유틸리티
dspy.infer_prefix(attribute_name) → str
이름을 camelCase와 snake_case 경계에서 나누어 대문자화합니다. 필드가 prefix=를 생략하면 메타클래스가 이걸 호출해요. 단독으로 쓸 일은 드뭅니다.
관련 문서
- 어댑터: 시그니처가 프롬프트가 되는 법 —
desc,prefix, 타입이 실제 요청에서 어떻게 보이는지,parse_value가 어디서 일하는지. - 모듈: 직접 조합하기 —
Predict,ChainOfThought, 여러분의 서브클래스가 시그니처를 어떻게 소비하는지. - GEPA와 옵티마이저 선택 가이드 — docstring이 어떻게 다시 쓰이고, 필드 이름이 왜 고정으로 남는지.