RLM: 코드로 큰 컨텍스트 탐험하기

RLM: 코드로 큰 컨텍스트 탐험하기 (RLM: exploring large contexts with code)

RLM("Recursive Language Model")은 컨텍스트가 너무 크거나, 지저분하거나, 관련성이 고르지 않아 모델에 직접 넣기 어려운 작업을 위한 것입니다. 컨텍스트 전체를 프롬프트에 넣는 대신, dspy.RLM 은 컨텍스트가 변수로 존재하는 Python REPL을 모델에 건네주고, 모델이 코드를 쓰고 서브-LLM을 호출해 입력을 탐험하고 조작하게 합니다. 프로그램이 컨텍스트 썩음(context rot)으로 고생하고 있거나, 모델이 문제를 분해할 방법을 스스로 결정하길 원할 때 dspy.RLM 을 시도해 보세요.

출처: 문서

본문

설계 결정

1. RLM은 컨텍스트를 변수 공간과 토큰 공간으로 나눈다

평범한 Predict 호출은 컨텍스트 전체를 프롬프트에 넣기 때문에, 모든 토큰이 모델의 주의를 두고 경쟁하고 컨텍스트가 커질수록 정확도가 떨어집니다. dspy.RLM 은 컨텍스트를 REPL 안에 변수로 두고 모델에게 메타데이터만 보여줍니다 — 각 변수의 이름, 타입, 길이, 짧은 미리보기요. 모델은 필요한 조각을 print() 와 코드를 써서 요청 시점에 프롬프트로 끌어옵니다. 모델은 필요할 때, 필요한 것을 읽습니다.

2. RLM은 어떤 시그니처에도 추론 시점 전략으로 끼어든다

dspy.RLM 은 Predict 나 ChainOfThought 에 건넬 것과 같은 시그니처를 받아요. 입력 필드는 REPL 변수가 되고, 출력 필드는 모델이 무엇을 제출해야 하고 각 값이 어떻게 타입 되는지를 정의합니다. 모듈을 바꿔도 작업 정의는 아무것도 바뀌지 않습니다. 기존 프로그램에서 한 줄만 고쳐 dspy.RLM 을 시도하고, 그만큼 값싸게 되돌릴 수 있어요. 그래서 이것이 재작성이 아니라 추론 시점 전략인 것입니다.

3. LLM은 반복적인 REPL 루프를 구동한다

dspy.RLM 은 단일 호출이 아닙니다. forward() 는 최대 max_iters 턴까지 실행합니다. 각 턴에 내부 generate_action predictor가 변수 메타데이터와 이전 REPL 히스토리를 보고, 추론과 하나의 Python 코드 블록을 만들어 냅니다. 코드가 실행되고, 출력이 REPLHistory 에 합류하며, 다음 턴이 시작됩니다. 실행 전체가 하나의 인터프리터 세션을 공유하므로 상태는 턴을 넘어 지속돼요. 이것이 지시문이 모델에게 먼저 탐험하고 작은 걸음을 걷도록 압박하는 이유입니다. 각 턴은 지난 턴이 출력한 것 위에 구축되도록 설계되어 있으니까요.

4. 내장 llm_query 도구가 루프에 재귀를 준다

RLM의 재귀는 모델이 자기 코드 안에서 모델을 호출하는 능력입니다. dspy.RLM 은 샌드박스에 두 함수를 주입합니다. llm_query(prompt) 는 한 번 호출, llm_query_batched(prompts) 는 동시 호출용이에요. 예를 들어 바깥 모델은 코드로 관련 텍스트를 찾아 조각내고, 초점 맞춘 조각을 의미론적 읽기를 위해 서브-LLM에 넘길 수 있습니다. 결과는 컨텍스트 창에 강제로 넣은 텍스트 덩어리가 아니라 저장하고 결합할 수 있는 Python 값으로 돌아옵니다. 하나의 긴 컨텍스트 질문이 많은 짧은 컨텍스트 질문이 됩니다.

5. 공유 카운터가 실행당 서브-LLM 호출을 제한한다

재귀는 가장 값비싼 부분이라 dspy.RLM 은 그것을 경계 짓습니다. max_llm_calls 는 모델이 실행 전체에 걸쳐 할 수 있는 서브-LLM 호출 수를 제한합니다. llm_query_batched 리스트의 각 프롬프트는 개별적으로 집계되므로 llm_query_batched([p1, p2, p3]) 는 예산 3단위를 소모해요. 상한을 넘으면 모델에 되먹이는 오류가 발생하며, 더 많은 호출을 쓰는 대신 평범한 Python에서 집계하도록 유도합니다. 예산은 매 forward() 마다 초기화되므로, 통제 불능의 루프가 조용히 수백 건의 유료 호출로 퍼져 나갈 수 없습니다.

6. 별도의 sub_lm 이 재귀 호출을 처리할 수 있다

루프를 조종하는 모델과 스니펫에 답하는 모델은 같을 필요가 없어요. sub_lm 은 llm_query 의 모델을 설정하고, 설정되지 않으면 dspy.settings.lm 으로 폴백합니다. 흔한 분할은 계획에는 강한 모델, 추출에는 값싼 모델을 짝짓는 것입니다. 스니펫 하나를 읽는 것은 전체 탐색을 조율하는 것보다 단순하니까요. 당신은 판단이 필요한 호출에만 강한 모델 비용을 지불합니다.

7. 생성된 코드는 플러그 가능한 인터프리터에서 실행된다

기본 PythonInterpreter 는 파일시스템·네트워크 접근이 없는 Deno와 Pyodide WASM 샌드박스에서 Python을 실행합니다. 각 forward() 는 인터프리터 하나를 만들고 REPL 루프 내내 사용한 뒤 종료합니다. 다른 CodeInterpreter 를 쓰려면 interpreter_factory= 를 주거나, 모든 코드 실행 모듈에 한 번에 하나를 고르려면 dspy.configure(interpreter_factory=...) 를 쓰세요. Deno를 실행할 수 없는 배포는 그런 식으로 기본값을 교체합니다. LocalInterpreter 는 별도의 로컬 프로세스에서 보통 CPython을 제공하지만, 호스트 사용자의 파일시스템·환경·자격증명·네트워크·프로세스 권한을 유지하므로 신뢰할 수 있는 코드 전용입니다.

8. SandboxSerializable 이 큰 입력을 샌드박스에 한 번 로드한다

어떤 입력은 평범한 문자열이 아니에요. DataFrame, 파싱된 코퍼스, 또는 이진 blob을 매 턴 코드로 다시 마샬하는 것은 낭비되고 손실도 있습니다. SandboxSerializable 은 입력이 네 개의 훅을 통해 샌드박스로 건너가는 방법을 정의하게 합니다. sandbox_setup() 은 임포트를, to_sandbox() 는 페이로드를, sandbox_assignment() 는 재구성을, rlm_preview() 는 모델이 보는 메타데이터를 담당합니다. dspy.RLM 은 forward() 시작에 이것을 한 번 실행하므로, 값은 원래 이름으로 재구축되어 턴을 넘어 살아 있습니다. 모델은 문자열화된 복사본이 아니라 실제 pandas DataFrame으로 작업합니다.

9. 루프가 일찍 끝나면 추출 패스가 출력을 구제한다

모델은 최종 출력을 제출하지 않고 max_iters 를 소진할 수 있어요. 아무것도 반환하지 않는 대신 dspy.RLM 은 두 번째 predictor인 extract 단계로 폴백하는데, 변수 메타데이터와 전체 REPL 히스토리를 읽고 시그니처의 출력 필드를 직접 만들어 냅니다. 모델이 스스로 완료를 선언하지 않아도 trajectory가 뒷받침하는 최고의 답을 얻습니다.

10. RLM은 다른 모듈처럼 최적화된다

action와 extract 단계는 평범한 dspy.Predict 인스턴스이고, dspy.RLM 은 그것들을 named_predictors 로 노출하며 다른 모듈처럼 컴파일됩니다. 작업 지시문, 출력 필드 설명, 도구 문서는 모듈을 구성할 때 action 프롬프트로 포맷되는데, 그것이 최적화기가 튜닝하는 표면입니다. 메트릭에 대해 GEPA나 MIPROv2를 돌리면 작업 지시문뿐 아니라 루프의 동작이 개선됩니다.

11. RLM은 실험적 표시가 있고 인터페이스가 변동 중이다

클래스는 @experimental 데코레이터를 답니다. 어려운 부분이 여전히 가라앉고 있습니다: 호출 예산, 샌드박스 수명, 오류 복구, 그리고 action 프롬프트 자체요. API를 릴리스 사이에 바뀔 수 있는 것으로 취급하고, 그것에 의존한다면 버전을 고정하세요.

API 살펴보기

RLM 정의하고 실행하기

dspy.RLM(signature, max_iters=20, max_llm_calls=50, max_output_chars=10_000, verbose=False, tools=None, sub_lm=None, interpreter_factory=PythonInterpreter) 생성자는 시그니처를 파싱하고 두 내부 predictor를 만듭니다. 작업 지시문, 입력 이름, 출력 필드 타입을 action 프롬프트로 포맷하고 폴백용 별도 extract 시그니처를 만듭니다. 예산과 샌드박스 구성이 모두 여기서 고정되므로, 하나의 인스턴스가 하나의 구성을 지닙니다.

__call__(**inputs, interpreter_factory=...) / acall(**inputs, interpreter_factory=...) 공개 호출은 입력을 시그니처에 대해 검증하고, 변수 리스트를 만들고, 인터프리터를 열고, 루프를 실행합니다. 각 턴은 action predictor에 코드를 요청하고 실행하며, 모델이 제출하거나 루프가 max_iters 에 도달할 때까지 결과를 히스토리에 추가합니다. acall() 은 async 쌍이며 predictor들에 acall 을 사용합니다. 둘 다 Prediction 을 반환합니다. interpreter_factory= 로 전달된 선택적 무인자(0-argument) 팩토리는 이 호출을 위해 생성자와 구성된 팩토리를 오버라이드합니다. RLM은 인터프리터 하나를 만들고 성공이든 실패든 종료합니다.

내장 도구로 루프 프로그래밍하기

모델이 코드 블록 안에 이것들을 씁니다. 당신이 호출하는 게 아니라, 알면 모델이 무엇을 할 수 있는지 알게 됩니다.

llm_query(prompt) 서브-LLM 호출 하나입니다. 프롬프트를 sub_lm 이나 구성된 LM으로 보내고, 카운터를 올리며, 응답 텍스트를 반환합니다. 프롬프트가 비었거나 예산이 소진되면 예외를 던집니다.

llm_query_batched(prompts) 리스트에 대한 동시 서브-LLM 호출로, 8개 워커 스레드 풀에서 실행되고 입력 순서로 반환됩니다. 실패한 호출은 배치를 중단하는 대신 해당 슬롯에 [ERROR] ... 문자열로 돌아옵니다. 모델이 읽을 독립 스니펫이 많을 때 Python llm_query 루프보다 낫습니다.

SUBMIT(...) 실행을 끝내고 최종 출력을 반환합니다. RLM은 제출된 dict를 시그니처의 출력 필드에 대해 검증하고 각 값을 선언된 타입으로 파싱합니다. 타입 오류나 누락 필드가 있으면 호출을 실패시키지 않고 모델에 메시지를 다시 보내 다른 시도를 하게 합니다.

print(...) 모델이 결과를 보는 유일한 방법입니다. REPL stdout이 캡처되고 max_output_chars 로 잘려 다음 턴의 히스토리에 표시됩니다. 출력하지 않고 계산만 하는 코드 블록은 (no output - did you forget to print?) 를 반환하는데, 그래서 지시문이 print를 강조하는 것이에요.

반복과 서브-LLM 호출 예산

max_iters, max_llm_calls, max_output_chars 세 개의 독립적인 제한입니다. max_iters 는 extract 폴백이 발화하기 전 REPL 턴을 경계 짓고, max_llm_calls 는 실행 전체에 걸친 재귀 서브-LLM 호출을 경계 지으며, max_output_chars 는 각 REPL 출력 중 모델에 닿는 양을 경계 지어 시끄러운 print가 프롬프트를 범람하는 걸 막습니다.

sub_lm llm_query 와 llm_query_batched 의 모델입니다. 미설정이면 dspy.settings.lm 으로 폴백하므로, 기본적으로 루프와 그 서브 호출은 하나의 모델을 공유합니다.

샌드박스를 도구와 입력으로 확장하기

tools=[...] 평범한 함수나 dspy.Tool 객체의 리스트입니다. RLM은 각각을 Tool 로 정규화하고, 유효한 식별자가 아니거나 내장과 충돌하는 이름은 거부하며, 그 시그니처를 action 프롬프트에 문서화합니다. 모델은 코드 안에서 평범한 Python으로 그것들을 호출합니다.

interpreter_factory=... 한 번의 호출을 위한 새 CodeInterpreter 를 반환하는 무인자 callable입니다. RLM은 팩토리를 동시에 호출할 수 있고, 반환된 인터프리터는 항상 종료합니다. PythonInterpreter 나 LocalInterpreter 같은 클래스는 이미 팩토리입니다. 구성이 필요하면 functools.partial 이나 callable provider 객체를 사용하세요. RLM은 호출-범위 도구를 반환된 인터프리터의 변경 가능한 tools 사전에 추가하므로, 원격 샌드박스는 그 프로토콜을 지원하는 CodeInterpreter 어댑터가 필요합니다. dspy.configure(interpreter_factory=...) 는 매 forward() 에서 기본값을 교체하므로 dspy.context(interpreter_factory=...) 가 선택을 범위 짓습니다. 활성 팩토리가 execution_instructions 문자열을 노출하면 RLM은 각 action 호출에 대해 그것을 갱신해 프롬프트가 생성된 코드를 실행하는 런타임과 일치하게 합니다.

__call__(**inputs, interpreter_factory=...) / acall(**inputs, interpreter_factory=...) 키워드로 공급되는, 호출별 팩토리 오버라이드입니다. 예를 들어 rlm(query=query, interpreter_factory=dspy.PythonInterpreter) 는 다른 팩토리가 구성돼 있어도 PythonInterpreter를 명시적으로 고릅니다. 팩토리는 새 인터프리터를 반환해야 하고, RLM은 도구와 출력 메타데이터를 주입하며 항상 종료합니다. 그것의 execution_instructions 메타데이터는 공유 predictor를 바꾸지 않고 그 호출에 대한 런타임 지침을 제어합니다. 호출 시점에 라이브 인터프리터 인스턴스는 더 이상 받지 않습니다. 팩토리 옵션은 키워드 전용이고, interpreter_factory 는 시그니처 입력이 아니라 런타임 구성으로 예약되어 있습니다.

dspy.SandboxSerializable 커스텀 로딩이 필요한 입력의 기본 클래스입니다. sandbox_setup, to_sandbox, sandbox_assignment, rlm_preview 를 구현하세요. 또한 Pydantic 스키마 훅을 정의하므로, data: DataFrame = dspy.InputField() 처럼 서브클래스가 시그니처의 타입 필드가 될 수 있습니다.

trajectory 살펴보기

Prediction 필드: 출력 필드, trajectory, final_reasoning forward() 는 시그니처의 출력 필드에 더해 두 개의 디버깅 필드를 담은 Prediction 을 반환합니다. trajectory 는 턴당 하나씩 {reasoning, code, output} dict들의 리스트입니다. final_reasoning 은 마지막 단계에서의 모델 추론입니다. trajectory 를 읽으면 어떤 코드가 왜 실행됐는지 정확히 볼 수 있어요.

RLM.tools 내장 llm_query 와 llm_query_batched 는 제외하고, 사용자 제공 도구를 이름-to-Tool dict로 반환하는 속성입니다. 모델이 무엇을 호출할 수 있는지 확인하는 데 씁니다.

크로스링크

더 알아보기 (Learn more)