블로그로 돌아가기

TextGrad 뜯어보기: 텍스트를 위한 자동 미분 프레임워크

변수·계산 그래프·언어 모델 엔진·텍스트 손실·역전파·최적화기까지, TextGrad의 핵심 개념을 장별로 따라가며 실제 코드와 함께 이해합니다.

18 min2026년 6월 23일

TextGrad텍스트를 위한 자동 미분 프레임워크입니다. PyTorch가 숫자를 다루는 것처럼, TextGrad는 언어 모델(LLM)을 이용해 텍스트에 대한 피드백, 즉 '텍스트 그래디언트'를 계산합니다. 이 피드백을 바탕으로, 계산 그래프를 따라 역전파를 수행하고 최적화기를 통해 프롬프트나 답변과 같은 텍스트 변수들을 자동으로 개선하고 최적화할 수 있습니다.

Source Repository: https://github.com/zou-group/textgrad

Loading diagram…

Chapters

  1. 변수 (Variable)
  2. 연산 및 계산 그래프 (Operations & Computation Graph)
  3. 언어 모델 엔진 (EngineLM)
  4. 텍스트 손실 함수 (TextLoss)
  5. 역전파 (Backward Propagation)
  6. 최적화기 (Optimizer, TGD)

함께 살펴볼 4가지 실전 예제

각 장의 마지막에는 실전 예제 속 [개념] 섹션이 있습니다. TextGrad 공식 저장소의 대표 예제 4가지를 가지고, 그 장에서 배운 기능이 실제 코드에서 어떻게 쓰이는지 보여줍니다. 먼저 네 가지 예제를 간단히 소개합니다.

예제무엇을 최적화하나요?핵심 포인트
① 솔루션 최적화
(Tutorial-Solution-Optimization)
수학 문제(3x² - 7x + 2 = 0)에 대한 풀이 답안가장 기본적인 최적화 루프. TextLoss로 답안을 평가하고 TGD로 고쳐 씀
② 프롬프트 최적화
(Tutorial-Prompt-Optimization)
모델에게 주는 시스템 프롬프트정답 데이터셋으로 평가하며 여러 번 반복 학습. 답변이 아니라 프롬프트 자체를 개선
③ 코드 최적화 & 새 손실 정의
(Tutorial-Test-Time-Loss-for-Code)
비효율적인 코드 스니펫 (O(n²) → O(n log n))표준 TextLoss 대신 FormattedLLMCall나만의 손실 함수를 정의
④ 멀티모달 최적화
(Tutorial-MultiModal)
이미지에 대한 질문의 답변텍스트를 넘어 이미지까지. MultimodalLLMCallImageQALoss 사용

아래 각 장의 실전 예제 속 [개념] 섹션에서, 위 네 예제(①~④)가 그 장의 기능을 어떻게 활용하는지 코드와 함께 확인할 수 있습니다.


Chapter 1: 변수 (Variable)

TextGrad의 세계에 오신 것을 환영합니다!

기존의 머신러닝(예: PyTorch)에서는 숫자로 이루어진 데이터를 최적화하여 모델을 학습시킵니다. 그렇다면 글, 아이디어, 코드와 같은 '텍스트' 자체를 수학 문제처럼 개선하고 최적화할 수는 없을까요?

TextGrad는 바로 이 질문에 대한 답을 제시합니다. 그리고 텍스트 최적화라는 흥미로운 여정의 가장 기본이 되는 첫걸음이 바로 **변수(Variable)**입니다.

변수란 무엇일까요? '똑똑한 포스트잇' 비유

변수를 이해하는 가장 좋은 방법은 '똑똑한 포스트잇'이라고 생각하는 것입니다. 여러분이 어떤 아이디어나 질문에 대한 답변을 포스트잇에 적는다고 상상해 보세요.

TextGrad의 Variable은 이 포스트잇처럼 텍스트 정보를 담는 기본 단위입니다. 하지만 일반 포스트잇보다 훨씬 더 많은 정보를 가지고 있습니다.

  • 내용 (Value): 포스트잇에 적힌 실제 텍스트입니다. 예를 들어, "셔츠를 말리는 데는 1시간이 걸립니다."
  • 용도 (Role Description): 포스트잇 상단에 "사용자 질문에 대한 답변" 또는 "프로그램의 시스템 프롬프트"처럼 이 텍스트의 역할과 목적을 명확히 적어두는 것입니다. 이 설명은 나중에 언어 모델(LLM)이 피드백을 줄 때 매우 중요한 기준이 됩니다.
  • 최적화 여부 (Requires Gradient): 포스트잇에 "수정 가능!" 또는 "수정 불가!" 스티커를 붙이는 것과 같습니다. 우리가 개선하고 싶은 텍스트(예: 답변)는 '수정 가능'으로, 바뀌면 안 되는 텍스트(예: 원본 질문)는 '수정 불가'로 표시합니다.
  • 연결 관계 (Predecessors): 이 포스트잇이 어떤 다른 포스트잇으로부터 나왔는지 그 관계를 기록합니다. 이를 통해 나중에 피드백을 받았을 때, 어떤 정보로부터 이 결과가 나왔는지 거슬러 올라가며 원인을 파악할 수 있습니다.

이처럼 Variable은 단순한 텍스트 조각이 아니라, 최적화 과정에 필요한 모든 맥락을 담고 있는 체계적인 데이터 컨테이너입니다.

변수 직접 만들어보기

말로만 듣는 것보다 직접 코드를 보며 이해하는 것이 훨씬 빠릅니다. TextGrad를 사용해 간단한 질문과 답변 Variable을 만들어 보겠습니다.

먼저, TextGrad 라이브러리를 가져옵니다.

import textgrad as tg

질문 변수: 우리가 바꾸지 않을 텍스트

우리가 최적화하고 싶은 것은 '답변'이지 '질문'이 아닙니다. 따라서 질문은 고정된 값으로 만들어야 합니다.

question_string = ("해가 쨍쨍할 때 셔츠 25개를 말리는 데 1시간이 걸린다면, "
                   "셔츠 30개를 말리는 데는 얼마나 걸릴까요?")

question = tg.Variable(
    value=question_string,
    role_description="LLM에게 전달할 질문",
    requires_grad=False  # 질문은 바꿀 필요가 없으므로 False
)

위 코드의 각 인자를 살펴보겠습니다.

  • value: Variable이 담을 실제 텍스트 내용입니다.
  • role_description: 이 Variable의 역할을 "LLM에게 전달할 질문"이라고 명확하게 설명해 줍니다.
  • requires_grad=False: '그래디언트(Gradient)가 필요 없다'는 뜻입니다. 즉, 이 변수는 최적화 과정에서 값이 변경되지 않는다는 것을 의미합니다. 문제지는 그대로 두고 답안지만 고치는 것과 같은 이치입니다.

답변 변수: 우리가 개선하고 싶은 텍스트

이제 언어 모델이 내놓았을 법한, 어딘가 어색하거나 틀린 초기 답변을 Variable로 만들어 봅시다. 이 변수는 우리가 앞으로 개선해 나갈 대상입니다.

initial_answer_string = "1.2시간이 걸립니다. 셔츠가 많아질수록 시간이 비례해서 늘어나기 때문입니다."

answer = tg.Variable(
    value=initial_answer_string,
    role_description="질문에 대한 간결하고 정확한 답변",
    requires_grad=True  # 이 답변은 개선해야 하므로 True
)

답변 Variable은 질문과 약간 다릅니다.

  • role_description: 이 변수가 어떤 상태가 되어야 하는지("간결하고 정확한 답변") 이상적인 목표를 설명합니다. 이 설명은 나중에 피드백(텍스트 그래디언트)을 생성할 때 중요한 평가 기준이 됩니다.
  • requires_grad=True: 이 변수는 최적화, 즉 개선의 대상이므로 '그래디언트가 필요하다'고 설정합니다. 이 옵션이 True로 설정된 변수만이 나중에 피드백을 받고 자신의 값을 업데이트할 수 있습니다.

내부 동작 원리 엿보기

tg.Variable(...)을 호출하면 내부에서는 어떤 일이 일어날까요? TextGrad는 단순한 문자열이 아니라, 여러 정보를 담는 특별한 객체(Object)를 생성합니다.

Loading diagram…

위 다이어그램처럼 Variable 객체 안에는 우리가 지정한 value, role_description, requires_grad 외에도 앞으로의 연산을 위해 중요한 정보들이 준비됩니다.

  • predecessors: 이 변수가 어떤 다른 변수로부터 만들어졌는지 기록하는 '가계도' 같은 정보입니다. 지금은 직접 만들었으니 비어있지만, 나중에 연산을 통해 변수가 만들어지면 이 부분이 채워집니다.
  • gradients: 나중에 피드백(텍스트 그래디언트)이 계산되면 여기에 저장될 공간입니다. 처음에는 비어 있습니다.

실제 TextGrad 소스 코드(textgrad/variable.py)의 일부를 통해 Variable이 어떻게 구성되는지 더 명확하게 확인할 수 있습니다.

# textgrad/variable.py 파일의 일부를 단순화한 버전
class Variable:
    def __init__(
        self,
        value: str = "",
        predecessors: List['Variable']=None,
        requires_grad: bool=True,
        *,
        role_description: str):
        
        self.value = value  # 텍스트 값 저장
        self.role_description = role_description  # 역할 설명 저장
        self.requires_grad = requires_grad  # 최적화 필요 여부 저장

        # 나중에 계산 그래프와 역전파에 사용될 속성들
        self.predecessors = set(predecessors) if predecessors else set()
        self.gradients: Set[Variable] = set() # 피드백(그래디언트)을 저장할 공간
        self.grad_fn = None # 이 변수를 생성한 연산을 가리킴

보시다시피, Variable 객체는 우리가 전달한 값들을 자신의 속성(attribute)으로 차곡차곡 저장합니다. gradients처럼 처음에는 비어 있지만 나중에 채워질 공간도 미리 마련해 둡니다. 이처럼 Variable은 단순한 텍스트가 아니라, 최적화 과정에 필요한 모든 정보를 담는 체계적인 컨테이너 역할을 합니다.

실전 예제 속 Variable

지금까지 배운 Variable이 TextGrad 공식 예제 4가지(개요의 '함께 살펴볼 4가지 실전 예제' 표 참고)에서 실제로 어떻게 쓰이는지 살펴봅시다. 핵심은 무엇을 requires_grad=True로 둘 것인가입니다. 바로 그 변수가 최적화의 대상이 됩니다.

① 솔루션 최적화 — 개선 대상은 '풀이 답안'입니다.

solution = tg.Variable(initial_solution,
                       requires_grad=True,
                       role_description="solution to the math question")

② 프롬프트 최적화 — 흥미롭게도 개선 대상이 '답변'이 아니라 모델에게 주는 '시스템 프롬프트'입니다. 입력(x)과 정답(y)은 바뀌면 안 되므로 requires_grad=False로 둡니다.

system_prompt = tg.Variable(STARTING_SYSTEM_PROMPT,
                            requires_grad=True,
                            role_description="structured system prompt to a ... language model")

③ 코드 최적화 — 개선 대상은 '코드' 그 자체입니다.

code = tg.Variable(value=initial_solution,
                   requires_grad=True,
                   role_description="code instance to optimize")

④ 멀티모달 최적화Variable은 텍스트뿐 아니라 **이미지 데이터(바이트)**도 담을 수 있습니다. 이미지는 질문의 '재료'이므로 고정(requires_grad=False)합니다.

image_variable = tg.Variable(image_data,
                             role_description="image to answer a question about",
                             requires_grad=False)

이처럼 네 예제 모두 같은 Variable을 사용하지만, requires_grad 플래그 하나로 "무엇을 개선할지"를 자유롭게 바꿉니다. 답변, 프롬프트, 코드, 그리고 이미지에 대한 답변까지 — 텍스트로 표현할 수 있는 것은 무엇이든 최적화 대상이 될 수 있습니다.

정리하며

이번 장에서는 TextGrad의 가장 기본적인 구성 요소인 Variable에 대해 배웠습니다. Variable은 단순한 텍스트가 아니라, 값(value), 역할(role_description), 그리고 **최적화 여부(requires_grad)**를 함께 담는 '똑똑한 포스트잇'과 같다는 것을 기억해주세요. 우리는 이 Variable을 사용해 최적화하고 싶은 텍스트(답변)와 고정된 텍스트(질문)를 정의하는 방법을 살펴보았습니다.

하지만 변수 하나만으로는 아무것도 할 수 없습니다. 이 '포스트잇'들을 서로 연결하고, 하나의 포스트잇에서 다른 포스트잇을 만들어내는 '작업'이 필요합니다. 다음 장에서는 이 변수들을 어떻게 조합하고 연산하여 더 복잡한 흐름을 만드는지에 대해 알아보겠습니다.


다음 장으로 이동: 2장: 연산 및 계산 그래프 (Operations & Computation Graph)


Chapter 2: 연산 및 계산 그래프 (Operations & Computation Graph)

이전 1장: 변수 (Variable)에서는 TextGrad의 가장 기본 단위인 '똑똑한 포스트잇', 즉 Variable을 만드는 법을 배웠습니다. 우리는 텍스트와 그 역할, 그리고 최적화 여부를 담는 컨테이너를 준비했습니다.

하지만 포스트잇만 잔뜩 가지고 있다고 해서 문제가 저절로 해결되지는 않겠죠? 질문이 적힌 포스트잇을 누군가에게 주고, 답변이 적힌 새 포스트잇을 받아오는 '행동'이 필요합니다. TextGrad에서는 이 '행동'을 **연산(Operation)**이라고 부릅니다. 그리고 이 연산들이 연결된 흐름 전체를 **계산 그래프(Computation Graph)**라고 합니다.

연산이란 무엇일까요? '요리 레시피' 비유

연산을 이해하는 가장 쉬운 방법은 '요리 레시피'를 떠올리는 것입니다.

  • Variable (변수): 요리의 '재료'입니다. (예: '양파', '당근', '질문 텍스트')
  • Operation (연산): 재료를 가공하는 '조리법'입니다. (예: '다지기', '볶기', 'LLM에게 질문하기')
  • 새로운 Variable: 조리법을 거쳐 나온 '중간 요리' 또는 '완성된 요리'입니다. (예: '다진 양파', '볶은 야채', 'LLM의 답변 텍스트')

LLMCall과 같은 연산은 하나 이상의 Variable(재료)을 입력으로 받아, 특정 작업(조리법)을 수행한 후, 그 결과를 담은 새로운 Variable(요리)을 출력합니다. TextGrad의 가장 큰 장점은 이 모든 과정을 자동으로 추적하고 기록한다는 점입니다. "이 볶은 야채는 아까 그 다진 양파와 당근으로 만들었지"라는 관계를 기억하는 것과 같습니다.

가장 중요한 연산: LLMCall

TextGrad에서 가장 중요하고 자주 사용되는 연산은 단연 LLMCall입니다. 이름에서 알 수 있듯이, 이 연산은 언어 모델(Large Language Model)을 호출하는 역할을 합니다.

LLMCallVariable에 담긴 텍스트를 LLM에게 프롬프트로 전달하고, LLM이 생성한 답변을 받아 새로운 Variable에 담아 반환합니다. 이것이 바로 우리가 텍스트를 '생성'하거나 '변환'하는 핵심 과정입니다.

연산을 통해 변수 연결하기

이제 직접 코드를 통해 질문 VariableLLMCall 연산에 넣어 답변 Variable을 만들어 보겠습니다.

먼저, 필요한 라이브러리와 함께 LLMCall을 가져옵니다.

import textgrad as tg
from textgrad.autograd.llm_ops import LLMCall

LLMCall을 사용하려면 어떤 LLM을 쓸지 알려줘야 합니다. 여기서는 OpenAI의 "gpt-3.5-turbo" 모델을 사용하겠습니다. (이 '엔진'에 대한 자세한 내용은 다음 3장: 언어 모델 엔진 (EngineLM)에서 다룹니다.)

# 참고: 이 코드를 실행하려면 OpenAI API 키가 환경 변수로 설정되어 있어야 합니다.
engine = tg.get_engine("gpt-3.5-turbo")

이제 이 엔진을 사용하는 LLMCall 연산을 만듭니다. '질문하면 답변해주는 기계'를 하나 준비한다고 생각하세요.

llm_caller = LLMCall(engine)

1장에서 만들었던 질문 Variable을 다시 준비합니다.

question_string = "프랑스의 수도는 어디인가요?"
question = tg.Variable(
    value=question_string,
    role_description="사용자의 질문",
    requires_grad=False  # 질문은 바꿀 필요가 없으므로 False
)

드디어 준비된 '재료'(question)를 '조리법'(llm_caller)에 넣어 '요리'를 만들어 보겠습니다.

# llm_caller에 question을 입력으로 전달합니다.
response = llm_caller(question, response_role_description="질문에 대한 LLM의 답변")

response 변수에는 어떤 값이 들어 있을까요? value 속성을 통해 LLM이 생성한 답변을 확인할 수 있습니다.

print(response.value)
프랑스의 수도는 파리입니다.

정말 중요한 부분은 지금부터입니다. response는 어떻게 만들어졌을까요? TextGrad는 이 responsequestion으로부터 왔다는 사실을 기억하고 있습니다. predecessors 속성을 확인해 봅시다.

print(response.predecessors)
{Variable(value='프랑스의 수도는 어디인가요?', role_description='사용자의 질문')}

보시는 것처럼, response 변수의 predecessors (부모) 속성 안에 question 변수가 들어있습니다. 이 연결 고리가 바로 계산 그래프의 시작입니다.

한눈에 보는 계산 그래프

방금 우리가 한 작업을 그림으로 표현하면 다음과 같습니다. question이라는 재료가 LLMCall이라는 조리법을 거쳐 response라는 요리가 되는 과정입니다.

Loading diagram…

이 간단한 흐름이 바로 계산 그래프입니다. TextGrad는 Variable과 연산이 어떻게 연결되어 최종 결과물을 만들어냈는지 그 '가계도'를 자동으로 그려줍니다.

이 그래프가 왜 중요할까요? 나중에 우리가 response에 대해 "이 답변은 너무 딱딱해. 좀 더 친절하게 바꿔줘"라는 피드백을 주면, TextGrad는 이 그래프를 거꾸로 거슬러 올라가 어떤 입력을 바꿔야 할지 알아낼 수 있습니다. 이 과정이 바로 5장: 역전파 (Backward Propagation)의 핵심 원리입니다.

내부 동작 원리 엿보기

llm_caller(question)을 호출했을 때, TextGrad 내부에서는 정확히 어떤 일이 일어났을까요?

Loading diagram…
  1. forward 메서드 실행: llm_caller가 호출되면 내부적으로 forward라는 메서드가 실행됩니다.
  2. 값 추출 및 LLM 호출: question 변수에서 실제 텍스트 값(value)을 꺼내 engine으로 보냅니다.
  3. 응답 수신: engine은 LLM으로부터 텍스트 응답을 받아옵니다.
  4. Variable 생성: 받은 응답 텍스트를 value로 하는 새로운 response 변수를 만듭니다.
  5. 연결 고리 기록: 가장 중요한 단계입니다. 새로운 response 변수의 predecessors 속성에 입력으로 사용된 question 변수를 저장합니다. 이로써 두 변수 간의 부모-자식 관계가 형성됩니다.
  6. 역전파 함수 저장: response 변수에 나중에 피드백을 계산하는 방법(backward 함수)을 '메모'해 둡니다.

이 과정은 TextGrad 소스 코드(textgrad/autograd/llm_ops.py)의 LLMCall 클래스에 잘 나타나 있습니다.

# textgrad/autograd/llm_ops.py의 LLMCall 클래스 일부를 단순화한 버전
class LLMCall(Function):
    # ... (생성자 __init__ 등은 생략) ...

    def forward(self, input_variable: Variable, response_role_description: str) -> Variable:
        # 1. LLM 엔진을 호출하여 텍스트 응답을 받습니다.
        response_text = self.engine(input_variable.value)

        # 2. 새로운 Variable 객체를 생성합니다.
        response = Variable(
            value=response_text,
            # 3. '부모'가 누구인지 기록합니다. 이것이 그래프를 만듭니다!
            predecessors=[input_variable],
            role_description=response_role_description
        )
        
        # 4. 나중에 피드백을 계산할 방법을 저장해둡니다. (역전파)
        response.set_grad_fn(...)
        
        return response

코드에서 볼 수 있듯이, LLMCall은 단순히 LLM을 호출하는 것을 넘어, 그 결과로 나온 새로운 Variable이 어떤 Variable로부터 만들어졌는지 체계적으로 기록하는 역할까지 수행합니다.

실전 예제 속 연산과 계산 그래프

LLMCall은 가장 기본적인 연산일 뿐입니다. 실전 예제들은 저마다 다른 연산으로 계산 그래프를 만듭니다.

① 솔루션 최적화 — 손실 함수 호출 한 번이 solution → loss로 이어지는 그래프를 만듭니다.

loss = loss_fn(solution)   # solution을 입력으로 받아 loss(Variable)를 생성

② 프롬프트 최적화BlackboxLLM으로 답변을 만들고, 평가 결과(손실)들을 tg.sum으로 하나로 합칩니다. 배치 안의 여러 갈래가 tg.sum이라는 한 노드로 모여 단일 그래프가 됩니다.

model = tg.BlackboxLLM(llm_api_test, system_prompt)
# ...
response = model(x)                       # 시스템 프롬프트로 답변 생성
eval_output_variable = eval_fn(inputs=dict(prediction=response, ground_truth_answer=y))
losses.append(eval_output_variable)
total_loss = tg.sum(losses)               # 여러 손실을 하나의 그래프로 합침

③ 코드 최적화 — 여러 입력 필드(problem, code)를 한 번에 받는 FormattedLLMCall 연산을 사용합니다.

formatted_llm_call = tg.autograd.FormattedLLMCall(
    engine=llm_engine,
    format_string="{instruction}\nProblem: {problem}\nCurrent Code: {code}",
    fields={"problem": None, "code": None},
    system_prompt=loss_system_prompt)

④ 멀티모달 최적화MultimodalLLMCall은 이미지와 질문, 두 Variable을 함께 입력으로 받는 연산입니다.

from textgrad.autograd import MultimodalLLMCall
response = MultimodalLLMCall("gpt-4o")([image_variable, question_variable])

연산의 종류는 달라도 원리는 같습니다. 모든 연산은 입력 Variable(들)로부터 새로운 Variable을 만들고, 그 부모-자식 관계를 기록하여 계산 그래프를 키워 나갑니다. 이 그래프가 있어야 다음에 배울 역전파가 가능해집니다.

정리하며

이번 장에서는 Variable들을 서로 연결하여 작업 흐름을 만드는 **연산(Operation)**에 대해 배웠습니다. 특히 가장 핵심적인 연산인 LLMCall을 통해 입력 Variable(질문)로부터 출력 Variable(답변)을 생성하는 방법을 살펴보았습니다.

가장 중요한 점은, 이 과정에서 TextGrad가 Variable과 연산 간의 연결 관계를 자동으로 추적하여 계산 그래프를 만든다는 것입니다. 이 그래프는 마치 요리 레시피의 각 단계가 어떻게 연결되는지 보여주는 순서도와 같으며, 나중에 텍스트를 개선하기 위한 피드백을 전파하는 데 필수적인 역할을 합니다.

지금까지 우리는 '재료'(Variable)와 '기본 조리법'(LLMCall)을 배웠습니다. 그런데 LLMCall을 사용할 때 지정했던 '엔진'은 정확히 무엇일까요? 다음 장에서는 다양한 언어 모델을 TextGrad에 연결하는 관문인 '엔진'에 대해 자세히 알아보겠습니다.


다음 장으로 이동: 3장: 언어 모델 엔진 (EngineLM)


Chapter 3: 언어 모델 엔진 (EngineLM)

이전 2장: 연산 및 계산 그래프 (Operations & Computation Graph)에서는 Variable들을 연결하여 작업 흐름을 만드는 LLMCall 연산에 대해 배웠습니다. LLMCall을 만들 때 engine = tg.get_engine("gpt-3.5-turbo")와 같이 '엔진'을 지정했던 것을 기억하시나요? 이 엔진이 바로 TextGrad가 외부 언어 모델과 소통하는 창구입니다.

이번 장에서는 텍스트 생성의 핵심 동력, 즉 '두뇌' 역할을 하는 **언어 모델 엔진(EngineLM)**에 대해 자세히 알아보겠습니다.

엔진이란 무엇일까요? '계산기' 비유

여러분에게 복잡한 수학 문제가 주어졌다고 상상해 보세요. 문제를 풀기 위해 계산기를 사용할 겁니다. 이때 어떤 계산기를 써도 덧셈, 뺄셈, 곱셈, 나눗셈 버튼은 거의 똑같이 생겼습니다. 카시오 계산기를 쓰다가 샤프 계산기로 바꿔도 사용법이 크게 다르지 않죠.

TextGrad의 EngineLM이 바로 이 '계산기'와 같은 역할을 합니다.

  • 다양한 계산기 브랜드: OpenAI의 GPT-4, Anthropic의 Claude, Google의 Gemini 등 세상에는 다양한 언어 모델(LLM)이 있습니다. 이들은 각기 다른 '계산기 브랜드'와 같습니다.
  • 표준 인터페이스 EngineLM: TextGrad의 EngineLM은 이 모든 다양한 LLM들을 표준화된 방식으로 사용할 수 있게 해주는 '공통 버튼' 역할을 합니다. 어떤 LLM을 쓰든 TextGrad는 동일한 방식으로 "이 텍스트를 처리해 줘"라고 요청할 수 있습니다.
  • 손쉬운 교체: 덕분에 우리는 마치 계산기 브랜드를 바꾸듯, 코드 몇 줄만 수정하여 OpenAI 엔진을 쓰다가 Anthropic 엔진으로 쉽게 바꿀 수 있습니다.

이처럼 EngineLM은 복잡한 LLM API 호출 과정을 단순하게 추상화하여, 우리가 텍스트 최적화라는 본질에만 집중할 수 있도록 돕는 매우 중요한 도구입니다.

다양한 엔진 사용해보기: get_engine 함수

TextGrad에서 특정 LLM을 사용하려면 tg.get_engine() 함수를 호출하기만 하면 됩니다. 마치 가게에 가서 "GPT-4 계산기 주세요" 또는 "Claude 계산기 주세요"라고 말하는 것과 같습니다.

먼저, TextGrad 라이브러리를 가져옵니다.

import textgrad as tg

OpenAI 엔진 사용하기

가장 널리 쓰이는 OpenAI의 gpt-3.5-turbo 모델을 엔진으로 가져와 보겠습니다.

# 참고: 이 코드를 실행하려면 OpenAI API 키가 환경 변수로 설정되어 있어야 합니다.
openai_engine = tg.get_engine("gpt-3.5-turbo")

이제 이 엔진을 사용하여 간단한 질문에 대한 답변을 생성해 볼 수 있습니다. 엔진 객체는 함수처럼 호출할 수 있습니다.

response_from_openai = openai_engine("파이썬에서 리스트의 길이를 어떻게 구하나요?")
print(response_from_openai)
파이썬에서 리스트의 길이를 구하려면 `len()` 함수를 사용하면 됩니다. 예를 들어, `my_list`라는 리스트가 있다면 `len(my_list)`와 같이 호출하여 길이를 얻을 수 있습니다.

Anthropic 엔진으로 교체하기

이제 Anthropic의 Claude 3 Haiku 모델로 바꿔보겠습니다. 코드가 얼마나 간단하게 바뀌는지 확인해 보세요.

# 참고: 이 코드를 실행하려면 Anthropic API 키가 환경 변수로 설정되어 있어야 합니다.
# "haiku"는 "claude-3-haiku-20240307"의 단축어입니다.
anthropic_engine = tg.get_engine("haiku")

get_engine의 인자만 바꿨을 뿐, 사용하는 방식은 완전히 동일합니다.

response_from_anthropic = anthropic_engine("파이썬에서 리스트의 길이를 어떻게 구하나요?")
print(response_from_anthropic)
파이썬에서 리스트의 길이를 구하는 가장 간단한 방법은 내장 함수인 `len()`을 사용하는 것입니다.

예를 들어, `fruits = ['apple', 'banana', 'cherry']`라는 리스트가 있다면, `len(fruits)`를 호출하면 `3`이 반환됩니다.

두 엔진이 생성한 답변의 스타일이 약간 다른 것을 볼 수 있습니다. 하지만 TextGrad 사용자 입장에서는 get_engine에 들어가는 모델 이름만 바꾸면 되기 때문에, 여러 LLM의 장점을 손쉽게 테스트하고 프로젝트에 가장 적합한 모델을 선택할 수 있습니다.

이 엔진들은 2장: 연산 및 계산 그래프에서 본 것처럼 LLMCall 연산에 전달되어 계산 그래프의 일부로 동작하게 됩니다.

from textgrad.autograd.llm_ops import LLMCall

# 어떤 엔진을 사용하든 LLMCall을 사용하는 방식은 동일합니다.
llm_caller = LLMCall(openai_engine) 
# 또는 llm_caller = LLMCall(anthropic_engine)

내부 동작 원리 엿보기

tg.get_engine("gpt-3.5-turbo")를 호출하고, 그 엔진으로 텍스트를 생성할 때 내부에서는 어떤 일이 일어날까요?

Loading diagram…
  1. 엔진 조회 및 생성: get_engine 함수는 마치 안내 데스크처럼, 입력된 모델 이름("gpt-3.5-turbo")에 해당하는 적절한 엔진 클래스(ChatOpenAI)를 찾아줍니다. 그리고 해당 클래스의 객체(인스턴스)를 생성하여 반환합니다. 이 객체는 OpenAI API와 통신하는 데 필요한 모든 정보(API 키 등)를 가지고 있습니다.

  2. API 호출: 사용자가 엔진 객체를 함수처럼 호출하면(engine(...)), 내부의 generate 메서드가 작동합니다. 이 메서드는 입력받은 텍스트를 OpenAI API가 이해할 수 있는 형식(JSON)으로 변환하여 요청을 보냅니다.

  3. 응답 처리: OpenAI로부터 복잡한 JSON 형식의 응답을 받으면, 엔진은 그중에서 우리가 실제로 필요한 답변 텍스트 부분만 깔끔하게 추출하여 문자열(string)로 반환합니다.

이러한 과정은 textgrad/engine/__init__.py 파일의 get_engine 함수에서 시작됩니다. 이 함수는 모델 이름에 따라 어떤 클래스를 사용할지 결정하는 '교통정리' 역할을 합니다.

# textgrad/engine/__init__.py 파일의 get_engine 함수 일부 (단순화 버전)
def get_engine(engine_name: str, **kwargs) -> EngineLM:
    # ... (단축어 처리 등) ...
    
    if (("gpt-4" in engine_name) or ("gpt-3.5" in engine_name)):
        # "gpt-"가 포함된 이름이면, OpenAI 엔진 클래스를 가져옵니다.
        from .openai import ChatOpenAI
        return ChatOpenAI(model_string=engine_name, **kwargs)
    elif "claude" in engine_name:
        # "claude"가 포함된 이름이면, Anthropic 엔진 클래스를 가져옵니다.
        from .anthropic import ChatAnthropic
        return ChatAnthropic(model_string=engine_name, **kwargs)
    # ... (다른 엔진들에 대한 처리) ...
    else:
        raise ValueError(f"엔진 {engine_name}을 찾을 수 없습니다.")

그리고 ChatOpenAIChatAnthropic 같은 각 엔진 클래스는 EngineLM이라는 공통의 '설계도'를 따릅니다. 이 설계도(textgrad/engine/base.py에 정의)는 모든 엔진이 최소한 generate라는 기능을 가져야 한다고 약속합니다. 이 덕분에 우리는 어떤 엔진이든 똑같은 방식으로 사용할 수 있는 것입니다.

# textgrad/engine/base.py 파일의 일부
from abc import ABC, abstractmethod

class EngineLM(ABC):  # 모든 엔진의 부모가 되는 설계도 클래스
    @abstractmethod
    def generate(self, prompt, system_prompt=None, **kwargs):
        # 이 메서드는 자식 클래스(ChatOpenAI 등)에서 반드시 구현되어야 합니다.
        pass

실전 예제 속 엔진

실전 예제들은 엔진을 어떻게 지정할까요? 대부분 역전파/평가에 쓸 엔진을 전역으로 한 번 정해 둡니다.

① 솔루션 · ③ 코드 · ④ 멀티모달 최적화set_backward_engine으로 피드백 생성에 사용할 엔진을 전역 지정합니다.

tg.set_backward_engine(tg.get_engine("gpt-4o"))   # 솔루션 / 코드 예제
tg.set_backward_engine("gpt-4o")                   # 멀티모달 예제 (문자열로도 가능)

② 프롬프트 최적화 — 여기서는 엔진을 두 개로 분리하는 실전 기법이 등장합니다. 채점·피드백에는 똑똑한 모델을, 실제 태스크 수행에는 더 저렴하고 빠른 모델을 씁니다.

llm_api_eval = tg.get_engine(engine_name="gpt-4o")              # 평가/역전파용 (강력한 모델)
llm_api_test = tg.get_engine(engine_name="gpt-3.5-turbo-0125")  # 실제 태스크 수행용 (저렴한 모델)
tg.set_backward_engine(llm_api_eval, override=True)

④ 멀티모달 최적화의 경우, 이미지를 이해해야 하므로 반드시 gpt-4o처럼 비전(vision)을 지원하는 엔진을 골라야 합니다. 이 장 앞부분의 '계산기 비유'처럼 모델 이름만 바꿔 끼우면 되지만, 작업(텍스트/이미지)에 맞는 '계산기'를 골라야 한다는 점을 기억하세요.

이처럼 엔진은 단순히 "어떤 LLM을 쓸까"를 넘어, 누가 답을 만들고(test) 누가 채점할지(eval)를 나누는 설계 도구이기도 합니다.

정리하며

이번 장에서는 TextGrad가 다양한 언어 모델(LLM)과 소통하는 창구인 **엔진(EngineLM)**에 대해 배웠습니다. EngineLM은 각기 다른 LLM API들을 '표준화된 계산기'처럼 쓸 수 있게 해주는 편리한 인터페이스라는 것을 이해했습니다.

가장 중요한 점은 tg.get_engine() 함수를 통해 모델 이름만 바꿔주면, 코드의 다른 부분을 전혀 수정하지 않고도 GPT, Claude 등 다양한 '두뇌'를 손쉽게 교체하며 실험할 수 있다는 것입니다.

이제 우리는 텍스트를 담는 그릇(Variable), 텍스트를 생성하는 행동(LLMCall), 그리고 그 행동의 주체인 두뇌(EngineLM)까지 모두 갖추었습니다. 하지만 생성된 텍스트가 좋은지 나쁜지 어떻게 평가할 수 있을까요? 최적화를 하려면 '정답'에 얼마나 가까운지를 측정할 '점수'가 필요합니다.

다음 장에서는 바로 이 '점수'를 매기는 방법, 즉 **텍스트 손실 함수(TextLoss)**에 대해 알아보겠습니다.


다음 장으로 이동: 4장: 텍스트 손실 함수 (TextLoss)


Chapter 4: 텍스트 손실 함수 (TextLoss)

이전 3장: 언어 모델 엔진 (EngineLM)에서는 LLMCall을 통해 텍스트를 생성하는 '두뇌'인 엔진을 어떻게 선택하고 교체하는지 배웠습니다. 이제 우리는 질문에 대한 답변을 생성할 수 있는 완벽한 도구를 갖추었습니다. 하지만 생성된 답변이 좋은지, 나쁜지, 개선할 점은 없는지 어떻게 알 수 있을까요?

최적화를 위해서는 '정답'에 얼마나 가까운지를 측정할 기준이 필요합니다. 기존 딥러닝에서는 이 기준을 '손실(loss)'이라는 숫자로 표현합니다. 하지만 텍스트의 좋고 나쁨을 단순히 숫자 하나로 평가하기는 어렵습니다. TextGrad는 바로 이 지점에서 매우 독창적인 해법을 제시합니다. 바로 **텍스트 손실 함수 (TextLoss)**입니다.

손실 함수란 무엇일까요? '서술형 답안 채점' 비유

일반적인 딥러닝의 손실 함수는 정답과 예측값의 차이를 계산하여 숫자(예: 0.83)를 반환합니다. 점수가 0에 가까울수록 좋다는 것은 알지만, '왜' 점수가 나쁜지, '어떻게' 고쳐야 하는지에 대한 정보는 주지 않습니다.

TextGrad의 TextLoss는 이와 다릅니다. '깐깐한 선생님이 학생의 서술형 답안을 채점하는 방식'이라고 생각하면 쉽습니다.

  • 학생의 답안: 우리가 평가하고 싶은 Variable (예: LLM이 생성한 답변)
  • 채점 기준표: TextLoss를 만들 때 제공하는 평가 지침(evaluation instruction)
  • 선생님의 피드백: TextLoss가 최종적으로 생성하는 결과물. "결론은 맞지만, 근거가 부족하구나. 이 부분을 보충하면 좋겠어." 와 같은 구체적인 '자연어 피드백'

이 '자연어 피드백'이 바로 TextGrad 세계의 '손실' 또는 '텍스트 그래디언트'입니다. 이 피드백에는 무엇이 잘못되었는지, 그리고 어떻게 개선해야 하는지에 대한 방향성이 명확하게 담겨 있습니다.

TextLoss 직접 사용해보기

간단한 예시를 통해 TextLoss가 어떻게 동작하는지 살펴보겠습니다. "해가 쨍쨍할 때 셔츠 25개를 말리는 데 1시간이 걸린다면, 셔츠 30개를 말리는 데는 얼마나 걸릴까요?"라는 질문에 대한 잘못된 답변을 평가해 보겠습니다.

먼저, 필요한 라이브러리를 가져옵니다.

import textgrad as tg
from textgrad.loss import TextLoss

평가하고 싶은, 약간은 어설픈 초기 답변을 1장: 변수 (Variable)에서 배운 대로 Variable로 만듭니다. 이 변수는 개선의 대상이므로 requires_grad=True로 설정해야 합니다.

# 잘못된 초기 답변
initial_answer = tg.Variable(
    value="1.2시간이 걸립니다. 셔츠가 많아질수록 건조 시간이 비례해서 늘어나기 때문입니다.",
    role_description="질문에 대한 답변",
    requires_grad=True
)

이제 '채점 기준표'에 해당하는 평가 지침을 만듭니다. 이 지침은 평가를 수행할 LLM에게 어떤 관점에서 답변을 비판적으로 봐야 할지 알려주는 역할을 합니다.

evaluation_instruction = (
    "주어진 질문과 답변을 논리적으로 평가하세요. "
    "특히 가정에 오류가 없는지 비판적으로 검토하고, "
    "개선을 위한 구체적인 피드백을 한두 문장으로 간결하게 제공하세요."
)

이 평가 지침을 사용하여 TextLoss 객체, 즉 '채점 선생님'을 만듭니다.

# 평가 지침을 사용하여 손실 함수(평가자)를 정의합니다.
loss_fn = TextLoss(eval_system_prompt=evaluation_instruction)

드디어 '채점 선생님'에게 '학생 답안'을 제출하여 피드백을 받을 차례입니다.

# 손실 함수에 답변을 전달하여 평가를 수행합니다.
loss = loss_fn(initial_answer)

# 생성된 피드백(손실)을 출력합니다.
print(loss.value)
답변의 논리에는 치명적인 오류가 있습니다. 셔츠 여러 개를 동시에 햇볕에 말릴 경우, 건조 시간은 셔츠 개수에 정비례하지 않습니다.

보시다시피, loss 변수에는 점수 대신 구체적인 문제점과 개선 방향을 담은 텍스트가 들어있습니다. 이 텍스트가 바로 initial_answer를 더 나은 방향으로 이끌어 줄 소중한 '그래디언트'가 됩니다.

내부 동작 원리 엿보기

loss_fn(initial_answer)를 호출했을 때, 내부에서는 어떤 일이 일어났을까요? TextLoss는 사실 우리가 앞에서 배운 개념들의 영리한 조합입니다.

Loading diagram…
  1. 평가 프롬프트 생성: TextLoss 객체는 내부적으로 자신이 가지고 있는 evaluation_instruction(채점 기준표)과 입력으로 받은 initial_answer의 텍스트 값(학생 답안)을 조합하여 평가를 위한 새로운 프롬프트를 만듭니다.
  2. LLM 호출: 이 프롬프트를 3장: 언어 모델 엔진 (EngineLM)에 전달하여 자연어 피드백 생성을 요청합니다. (이때 사용되는 엔진을 '역전파 엔진'이라고 부르며, 나중에 자세히 다룹니다.)
  3. 피드백 수신: LLM은 지침에 따라 답변을 평가하고, 그 결과를 텍스트로 반환합니다.
  4. Variable 생성: TextLoss는 이 피드백 텍스트를 value로 하는 새로운 loss 변수를 생성합니다.
  5. 계산 그래프 연결: 가장 중요한 부분입니다. loss 변수가 initial_answer로부터 계산되었다는 사실을 기억하기 위해, losspredecessors 속성에 initial_answer를 추가합니다. 이로써 2장: 연산 및 계산 그래프에서 배운 계산 그래프가 initial_answer에서 loss로 이어지게 됩니다.

이 과정은 TextGrad 소스 코드(textgrad/loss.py)의 TextLoss 클래스에 구현되어 있습니다. 사실 TextLoss의 핵심은 내부적으로 LLMCall 연산을 사용하는 것입니다.

# textgrad/loss.py의 TextLoss 클래스 일부를 단순화한 버전
from textgrad.autograd import LLMCall

class TextLoss(Module):
    def __init__(self, eval_system_prompt: str, engine=None):
        # ... (엔진 설정 부분) ...
        
        # 1. 평가 지침을 시스템 프롬프트로 사용하는 LLMCall 객체를 미리 만들어 둡니다.
        self.eval_system_prompt = Variable(eval_system_prompt, ...)
        self.llm_call = LLMCall(engine, self.eval_system_prompt)

    def forward(self, instance: Variable):
        # 2. loss_fn(instance)가 호출되면,
        #    미리 만들어 둔 llm_call을 사용하여 평가를 수행합니다.
        #    이것이 바로 계산 그래프를 생성하고 연결하는 과정입니다.
        return self.llm_call(instance)

코드를 보면 TextLoss가 하는 일은 명확합니다. 생성자(__init__)에서 평가 지침을 시스템 프롬프트로 사용하는 LLMCall을 준비해두고, forward 메서드가 호출되면 이 LLMCall을 실행하여 입력된 Variable을 평가하고 그 결과를 새로운 Variable로 반환합니다. 이처럼 TextLoss는 TextGrad의 기본 구성 요소들을 활용하여 '평가'라는 특화된 작업을 수행하는 편리한 도구입니다.

실전 예제 속 손실 함수: 표준 손실부터 '나만의 손실'까지

TextLoss는 손실을 정의하는 가장 기본적인 방법이지만, 유일한 방법은 아닙니다. 예제마다 문제에 맞는 손실을 다르게 정의합니다.

① 솔루션 최적화 — 표준 TextLoss를 그대로 사용합니다. 평가 지침을 담은 Variable을 넘겨주는 방식입니다.

loss_system_prompt = tg.Variable(
    "You will evaluate a solution to a math question. "
    "Do not attempt to solve it yourself, do not give a solution, only identify errors. Be super concise.",
    requires_grad=False,
    role_description="system prompt")
loss_fn = tg.TextLoss(loss_system_prompt)
loss = loss_fn(solution)

③ 코드 최적화 — '새로운 손실 함수' 직접 정의하기. 코드를 평가하려면 '문제 설명'과 '현재 코드'를 함께 LLM에게 보여줘야 합니다. 입력이 두 개이므로 표준 TextLoss로는 부족하죠. 이럴 때 FormattedLLMCall로 여러 필드를 받는 나만의 손실 함수를 만듭니다.

loss_system_prompt = tg.Variable(
    "You are a smart language model that evaluates code snippets. You do not solve problems "
    "or propose new code snippets, only evaluate existing solutions critically and give very concise feedback.",
    requires_grad=False,
    role_description="system prompt to the loss function")

format_string = "{instruction}\nProblem: {problem}\nCurrent Code: {code}"
formatted_llm_call = tg.autograd.FormattedLLMCall(
    engine=llm_engine,
    format_string=format_string,
    fields={"problem": None, "code": None},
    system_prompt=loss_system_prompt)

def loss_fn(problem: tg.Variable, code: tg.Variable) -> tg.Variable:
    inputs = {"problem": problem, "code": code}
    return formatted_llm_call(
        inputs=inputs,
        response_role_description=f"evaluation of the {code.get_role_description()}")

② 프롬프트 최적화 — 데이터셋에 정답이 있는 경우입니다. 자유 서술형 평가 대신, 모델의 예측을 정답과 비교하는 평가 함수(eval_fn)를 손실로 사용합니다.

eval_output_variable = eval_fn(inputs=dict(prediction=response, ground_truth_answer=y))

④ 멀티모달 최적화 — 이미지 기반 질의응답에는 전용 손실 ImageQALoss가 준비되어 있습니다.

from textgrad.loss import ImageQALoss

정리하면, 손실은 "텍스트(또는 이미지)를 평가해 피드백(그래디언트)을 만드는 무언가"이기만 하면 됩니다. 정답이 없으면 TextLoss, 정답이 있으면 평가 함수, 입력이 여럿이면 FormattedLLMCall, 이미지에는 ImageQALoss — 문제에 맞춰 손실을 자유롭게 갈아 끼울 수 있다는 것이 TextGrad의 강력함입니다.

정리하며

이번 장에서는 텍스트의 품질을 평가하는 독특한 방법인 **텍스트 손실 함수(TextLoss)**에 대해 배웠습니다. TextLoss는 숫자 점수가 아닌, 구체적인 **'자연어 피드백'**을 생성한다는 점이 가장 큰 특징입니다. 우리는 '채점 기준표' 역할을 하는 evaluation_instruction을 제공하여 TextLoss 객체를 만들고, 이를 통해 Variable을 평가하여 개선 방향이 담긴 '손실'을 얻는 방법을 실습했습니다.

이제 우리는 초기 답변(Variable)을 가지고 있고, 그 답변의 문제점을 지적하는 피드백(loss)도 얻었습니다. 그렇다면 이 피드백을 어떻게 활용하여 원래의 답변을 실제로 '개선'할 수 있을까요? 마치 선생님의 피드백을 보고 학생이 답안을 고쳐 쓰는 과정이 필요합니다.

다음 장에서는 이 피드백(손실)을 계산 그래프를 따라 거슬러 올라가며 관련 변수들에게 전달하는 과정인 **역전파(Backward Propagation)**에 대해 알아보겠습니다.


다음 장으로 이동: 5장: 역전파 (Backward Propagation)


Chapter 5: 역전파 (Backward Propagation)

이전 4장: 텍스트 손실 함수 (TextLoss)에서는 LLM이 생성한 텍스트 답변의 품질을 평가하고, "논리적 오류가 있다"와 같은 구체적인 '자연어 피드백(손실)'을 얻는 방법을 배웠습니다. 이제 우리 손에는 개선이 필요한 답변과, 그 답변의 문제점을 지적하는 날카로운 피드백이 들려 있습니다.

하지만 피드백만 가지고는 아무것도 변하지 않습니다. 이 피드백을 바탕으로 근본적인 원인을 찾아내고, 초기 입력을 어떻게 수정해야 할지 알아내는 과정이 필요합니다. TextGrad에서는 이 과정을 **역전파(Backward Propagation)**라고 부릅니다.

역전파란 무엇일까요? '요리 실패 원인 찾기' 비유

여러분이 레시피를 따라 케이크를 만들었는데, 맛이 너무 짰다고 상상해 봅시다. (이 '너무 짜다'는 피드백이 바로 4장: 텍스트 손실 함수 (TextLoss)에서 얻은 loss입니다.) 이제 여러분은 무엇을 해야 할까요?

당연히 레시피의 어느 단계에서 소금을 너무 많이 넣었는지 거슬러 올라가며 원인을 찾을 것입니다.

  1. 완성된 케이크 (최종 결과): "너무 짜다!" (피드백)
  2. 굽기 직전의 반죽 (중간 결과): 케이크가 짜다면, 반죽도 짰을 것이다.
  3. 밀가루, 설탕, 소금 섞기 (초기 입력): 아하! 반죽이 짰던 이유는 이 단계에서 설탕 대신 소금을 넣었기 때문이다!

이처럼 최종 결과에 대한 피드백을 바탕으로, 과정을 거꾸로 거슬러 올라가 각 단계의 책임과 원인을 밝혀내는 것이 바로 역전파입니다. TextGrad는 2장: 연산 및 계산 그래프 (Operations & Computation Graph)에서 만들어진 '요리 레시피(계산 그래프)'를 따라 이 과정을 자동으로 수행합니다.

backward() 메서드: 피드백 전파 시작하기

역전파를 시작하는 방법은 놀랍도록 간단합니다. 바로 loss 변수에 대해 .backward() 메서드를 호출하는 것입니다.

이전 장에서 사용했던 '셔츠 건조 시간' 예시를 다시 가져와 보겠습니다.

import textgrad as tg
from textgrad.loss import TextLoss

# 개선하고 싶은 초기 답변 (requires_grad=True)
initial_answer = tg.Variable(
    value="1.2시간이 걸립니다. 셔츠가 많아질수록 건조 시간이 비례해서 늘어나기 때문입니다.",
    role_description="질문에 대한 답변",
    requires_grad=True
)

# 채점 기준
evaluation_instruction = (
    "주어진 답변의 논리적 오류를 비판적으로 검토하고, "
    "개선을 위한 구체적인 피드백을 제공하세요."
)
loss_fn = TextLoss(eval_system_prompt=evaluation_instruction)

# 답변을 평가하여 피드백(손실) 생성
loss = loss_fn(initial_answer)
print(f"생성된 피드백(손실): {loss.value}")

이제 마법의 주문, .backward()를 호출할 차례입니다.

# 피드백을 계산 그래프를 따라 거꾸로 전파합니다.
loss.backward()

이 한 줄의 코드는 무엇을 할까요? 눈에 보이는 출력은 없지만, 내부적으로는 매우 중요한 일이 일어났습니다. loss.backward()loss를 만드는 데 기여한 모든 Variable들을 찾아가, "이 loss는 너 때문이야. 어떻게 바뀌어야 할까?"라고 물으며 피드백을 계산하고, 그 결과를 각 Variable.gradients 속성에 저장합니다.

우리의 경우, lossinitial_answer로부터 만들어졌으므로 initial_answer에 피드백이 저장되었을 것입니다. 확인해 볼까요?

# initial_answer에 저장된 피드백(그래디언트)을 확인합니다.
# get_gradient_text()는 저장된 모든 그래디언트를 하나의 텍스트로 합쳐줍니다.
feedback_on_answer = initial_answer.get_gradient_text()

print(feedback_on_answer)
답변의 핵심 가정, 즉 셔츠 개수와 건조 시간이 정비례한다는 것은 잘못되었습니다. 많은 셔츠를 동시에 널면 햇빛, 바람 등 제한된 자원을 공유하게 되므로, 건조 시간은 개수에 비례하여 늘어나지 않습니다. 이 잘못된 비례 관계 가정을 수정해야 합니다.

보시다시피, initial_answer는 이제 자신이 왜 비판받았는지(loss.value), 그리고 어떻게 개선되어야 하는지에 대한 구체적인 피드백(그래디언트)을 갖게 되었습니다. 이것이 바로 역전파의 결과물입니다.

한눈에 보는 역전파 흐름

우리가 지금까지 한 작업을 그림으로 표현하면 다음과 같습니다.

  1. 순전파 (Forward Pass): initial_answer에서 loss로 계산 그래프가 만들어집니다.
Loading diagram…
  1. 역전파 (Backward Pass): loss.backward()를 호출하면, 피드백이 loss에서 initial_answer로 거꾸로 전파됩니다.
Loading diagram…

내부 동작 원리 엿보기

loss.backward()를 호출했을 때, 이 똑똑한 피드백은 어떻게 생성되는 걸까요? TextGrad는 이 과정 또한 언어 모델(LLM)을 영리하게 활용합니다.

Loading diagram…
  1. 역전파 시작: 사용자가 loss.backward()를 호출합니다.
  2. 그래프 순회: TextGrad는 계산 그래프를 거슬러 올라갑니다. loss의 부모는 initial_answer이고, 둘을 연결한 연산은 TextLoss (내부적으로는 LLMCall)였습니다.
  3. 역전파 함수 호출: loss 변수에는 자신을 만든 연산(LLMCall)의 backward 함수가 '기억'되어 있습니다. 이 함수가 실행됩니다.
  4. '역전파 프롬프트' 생성: LLMCall.backward 함수는 매우 특별한 프롬프트를 만듭니다. 이 프롬프트는 다음과 같은 정보를 담고 있습니다.
    • 원래 상황: "원래 이런 입력(initial_answer)이 들어가서 이런 결과(loss가 평가한 답변)가 나왔습니다."
    • 결과에 대한 피드백: "그리고 그 결과에 대해 '논리적 오류가 있다'는 피드백(loss.value)을 받았습니다."
    • 요청: "이 피드백을 해결하려면, 원래 입력(initial_answer)을 어떻게 개선해야 할지 구체적인 비평과 피드백을 생성해주세요."
  5. LLM 호출 및 그래디언트 생성: 이 프롬프트가 '역전파 엔진'이라고 불리는 LLM에게 전달됩니다. LLM은 이 복합적인 질문에 답하며 initial_answer를 위한 맞춤형 피드백(텍스트 그래디언트)을 생성합니다.
  6. 그래디언트 저장: 생성된 피드백은 initial_answer.gradients에 새로운 Variable로 저장됩니다.

이 과정의 핵심은 textgrad/autograd/llm_ops.py 파일의 LLMCall 클래스 안에 있는 backward 메서드와 textgrad/autograd/llm_backward_prompts.py에 정의된 프롬프트 템플릿에 있습니다.

예를 들어, OBJECTIVE_INSTRUCTION_CHAIN 템플릿의 일부는 다음과 같습니다.

# textgrad/autograd/llm_backward_prompts.py의 일부
OBJECTIVE_INSTRUCTION_CHAIN = (
    # ...
    "<OBJECTIVE_FUNCTION>Your goal is to give feedback to the variable to address the following feedback on the LM_OUTPUT: {response_gradient} </OBJECTIVE_FUNCTION>\n\n"
)

이 템플릿은 "당신의 목표는 변수(variable)에게 피드백을 주는 것입니다. 그 피드백은 LM_OUTPUT에 대한 다음 피드백({response_gradient})을 해결하기 위함입니다."라고 LLM에게 명확한 지시를 내립니다. 이를 통해 최종 결과에 대한 피드백이 연쇄적으로 이전 변수에 대한 피드백으로 변환되는 것입니다.

실전 예제 속 역전파

역전파를 시작하는 .backward()는 모든 예제에서 똑같이 등장하지만, 무엇에 대해 호출하는지가 조금씩 다릅니다.

① 솔루션 · ③ 코드 · ④ 멀티모달 최적화 — 손실 하나에 대해 곧바로 .backward()를 호출합니다.

loss = loss_fn(solution)
loss.backward()

② 프롬프트 최적화 — 배치 안의 여러 예시에서 나온 손실들을 2장에서 본 tg.sum으로 하나로 합친 뒤, 그 합계 손실 하나에 대해 역전파합니다. 그러면 여러 예시의 피드백이 동시에 system_prompt로 모입니다. 또한 매 스텝마다 zero_grad()로 이전 그래디언트를 비워, 피드백이 엉뚱하게 누적되지 않게 합니다.

optimizer.zero_grad()          # 이전 스텝의 그래디언트 초기화
# ... 배치의 각 예시에 대해 손실 계산 ...
total_loss = tg.sum(losses)
total_loss.backward()          # 합쳐진 손실에서 한 번에 역전파

여러 갈래의 피드백이 있더라도 결국 하나의 손실로 모은 뒤 한 번 역전파한다는 점이 핵심입니다. 마치 케이크의 여러 문제점("너무 짜고, 너무 달다")을 종합해 레시피의 어느 단계를 고칠지 거꾸로 추적하는 것과 같습니다.

정리하며

이번 장에서는 TextLoss를 통해 얻은 피드백을 계산 그래프를 따라 거꾸로 전파하여 근본 원인이 되는 Variable에 대한 개선 방향을 찾아내는 **역전파(Backward Propagation)**에 대해 배웠습니다.

.backward()라는 간단한 메서드 호출만으로, TextGrad가 내부적으로 LLM을 사용하여 어떻게 연쇄적으로 피드백을 생성하고 각 변수의 .gradients 속성을 채우는지 살펴보았습니다.

이제 거의 다 왔습니다! 우리는 다음과 같은 것들을 모두 갖추었습니다.

  • 개선하고 싶은 텍스트: initial_answer (1장: 변수 (Variable))
  • 개선 방향에 대한 구체적인 피드백: initial_answer.gradients

마지막으로 남은 질문은 이것입니다. "이 피드백을 가지고, 어떻게 initial_answer의 텍스트를 실제로 수정하고 업데이트할 것인가?"

다음 장에서는 이 피드백을 적용하여 변수를 실제로 업데이트하는 '행동대장', 즉 **최적화기(Optimizer)**에 대해 알아보겠습니다.


다음 장으로 이동: 6장: 최적화기 (Optimizer, TGD)


Chapter 6: 최적화기 (Optimizer, TGD)

이전 5장: 역전파 (Backward Propagation)에서, 우리는 .backward() 메서드를 호출하여 최종 피드백(loss)으로부터 근본 원인이 되는 Variable에 대한 구체적인 개선 방향, 즉 '텍스트 그래디언트'를 계산하는 방법을 배웠습니다. 이제 우리 initial_answer 변수 안에는 "어떻게 하면 더 좋아질 수 있는지"에 대한 상세한 코멘트가 .gradients 속성에 가득 담겨 있습니다.

하지만 이 코멘트는 아직 변수 자체를 바꾸지는 못했습니다. 마치 편집자가 빨간 펜으로 수정 의견을 잔뜩 적어준 원고를 작가가 아직 고쳐 쓰지 않은 상태와 같습니다. 이 마지막 단계, 즉 피드백을 보고 실제로 원고를 수정하는 행동을 수행하는 주체가 바로 **최적화기(Optimizer)**입니다.

최적화기란 무엇일까요? '피드백을 반영해 글을 고쳐 쓰는 작가' 비유

TextGrad의 최적화기는 '편집자의 코멘트를 보고 원고를 다시 쓰는 작가'에 비유할 수 있습니다.

  • 개선할 원고: 우리가 최적화하고 싶은 Variable (예: initial_answer)
  • 편집자의 코멘트: 역전파를 통해 Variable.gradients 속성에 저장된 '텍스트 그래디언트'
  • 작가 (최적화기): 원고와 코멘트를 함께 읽고, 코멘트의 내용을 반영하여 더 나은 버전의 원고를 새로 작성하는 역할

TextGrad의 기본 최적화기인 TGD는 **텍스트 그래디언트 하강법(Textual Gradient Descent)**의 약자입니다. 이름 그대로, 계산된 텍스트 그래디언트를 사용하여 Variable의 텍스트 값을 더 나은 방향으로 '하강(수정)'시키는 역할을 합니다. 이 과정 역시 강력한 언어 모델(LLM)을 활용하여 지능적으로 수행됩니다.

TGD 최적화기 직접 사용해보기

이제 피드백이 담긴 initial_answer 변수를 TGD를 사용해 실제로 업데이트해 보겠습니다. 전체 최적화 과정의 마지막 단계를 완성하는 순간입니다.

먼저, 이전 장들에서 다뤘던 전체 코드를 다시 한번 살펴봅시다.

import textgrad as tg
from textgrad.loss import TextLoss

# 1. 개선하고 싶은 초기 답변 준비
initial_answer = tg.Variable(
    value="1.2시간이 걸립니다. 셔츠가 많아질수록 건조 시간이 비례해서 늘어나기 때문입니다.",
    role_description="질문에 대한 답변",
    requires_grad=True
)

# 2. 손실 함수로 피드백(손실) 생성
loss_fn = TextLoss(eval_system_prompt="답변의 논리적 오류를 찾아내고 개선 피드백을 주세요.")
loss = loss_fn(initial_answer)

# 3. 역전파를 통해 피드백(그래디언트)을 initial_answer에 전파
loss.backward()

여기까지 실행하면 initial_answer.gradients에 개선 방향이 저장됩니다. 이제 최적화기를 만들고 실행할 차례입니다. TGD를 가져와 봅시다.

from textgrad.optimizer import TGD

# 4. 어떤 변수를 업데이트할지 지정하여 최적화기 생성
# 마치 작가에게 어떤 원고를 수정할지 알려주는 것과 같습니다.
optimizer = TGD(parameters=[initial_answer])

TGD를 생성할 때는 parameters 인자에 우리가 업데이트하고 싶은 Variable들의 리스트를 전달합니다. 이제 작가에게 "수정 시작!"이라고 외쳐봅시다. 이 명령이 바로 .step() 메서드입니다.

# 5. 최적화 단계를 실행하여 변수의 값을 업데이트
optimizer.step()

.step() 메서드가 실행되면, 최적화기는 initial_answer에 저장된 그래디언트를 읽고, 그 내용을 반영하여 initial_answer.value를 새로운 텍스트로 덮어씁니다. 과연 어떻게 바뀌었을까요?

# 업데이트된 답변을 확인
print(initial_answer.value)
햇볕이 잘 드는 곳에 널어놓는다면, 셔츠 30개를 말리는 데도 여전히 1시간이 걸릴 것입니다. 모든 셔츠가 동시에 햇빛과 바람에 노출된다면 셔츠의 개수는 건조 시간에 큰 영향을 주지 않기 때문입니다.

놀랍게도, 초기 답변의 논리적 오류("셔츠 개수와 건조 시간이 비례한다")가 수정되고, 더 정확하고 합리적인 답변으로 바뀐 것을 확인할 수 있습니다. 이것으로 우리는 TextGrad의 전체 최적화 사이클을 한 바퀴 모두 돌았습니다!

내부 동작 원리 엿보기

optimizer.step()을 호출했을 때, '작가'는 내부적으로 어떤 사고 과정을 거쳐 글을 수정했을까요? 이 과정의 핵심 역시 LLM을 활용한 지능적인 프롬프트 생성에 있습니다.

Loading diagram…
  1. 업데이트 대상 순회: .step()이 호출되면 최적화기는 자신이 관리하는 parameters 리스트([initial_answer])를 하나씩 살펴봅니다.
  2. 정보 수집: 각 Variable(여기서는 initial_answer)에 대해, 원래 텍스트 값(.value), 역할 설명(.role_description), 그리고 역전파를 통해 수집된 모든 그래디언트(.gradients)를 한데 모읍니다.
  3. '수정 프롬프트' 생성: 최적화기는 수집된 정보들을 조합하여 LLM에게 보낼 정교한 프롬프트를 만듭니다. 이 프롬프트는 대략 다음과 같은 구조를 가집니다.
    • 목표: "당신은 텍스트를 개선하는 최적화 시스템의 일부입니다."
    • 기존 텍스트와 역할: "개선할 변수의 역할은 '질문에 대한 답변'이고, 현재 내용은 '1.2시간이 걸립니다...'입니다."
    • 피드백: "이 변수에 대해 다음과 같은 피드백(그래디언트)을 받았습니다: '셔츠 개수와 건조 시간이 정비례한다는 가정은 잘못되었습니다...'"
    • 요청: "이 피드백을 반영하여 변수를 개선하고, 수정된 전체 텍스트를 <IMPROVED_VARIABLE> 태그 안에 담아 응답해주세요."
  4. LLM 호출 및 응답 파싱: 이 프롬프트가 최적화기의 LLM 엔진으로 전송됩니다. LLM은 지시에 따라 새로운 텍스트를 생성하고, 최적화기는 응답에서 <IMPROVED_VARIABLE> 태그 사이의 내용만 깔끔하게 추출합니다.
  5. 값 업데이트: 추출된 새로운 텍스트로 initial_answer.value를 교체합니다. 이로써 한 번의 최적화 단계가 완료됩니다.

이 과정의 핵심 로직은 textgrad/optimizer/optimizer.py 파일의 TextualGradientDescent 클래스와, 프롬프트 템플릿을 정의하는 textgrad/optimizer/optimizer_prompts.py 파일에 구현되어 있습니다.

optimizer_prompts.py에 있는 프롬프트 템플릿의 일부를 살펴보면 그 의도를 명확히 알 수 있습니다.

# textgrad/optimizer/optimizer_prompts.py 파일의 일부 (단순화 버전)

TGD_PROMPT_PREFIX = (
    "Here is the role of the variable you will improve: <ROLE>{variable_desc}</ROLE>.\n\n"
    "The variable is the text within the following span: <VARIABLE> {variable_short} </VARIABLE>\n\n"
    "Here is the context and feedback we got for the variable:\n\n"
    "<CONTEXT>{variable_grad}</CONTEXT>\n\n"
    "Improve the variable ({variable_desc}) using the feedback provided in <FEEDBACK> tags.\n"
)

이 템플릿은 LLM에게 변수의 역할(ROLE), 현재 값(VARIABLE), 그리고 **피드백(CONTEXT)**을 명확하게 전달하여, 맥락에 맞는 정확한 수정을 유도합니다.

실전 예제 속 최적화기

마지막으로, 예제들이 TGD를 어떻게 돌리는지 봅시다. 참고로 tg.TGDtg.TextualGradientDescent의 짧은 별칭으로, 둘은 완전히 같습니다.

① 솔루션 최적화 — 가장 단순한 형태. 한 번의 step()으로 답안을 개선합니다.

optimizer = tg.TGD([solution])
loss = loss_fn(solution)
loss.backward()
optimizer.step()

③ 코드 최적화step()여러 번 반복하여 코드를 점점 다듬습니다. 매 반복 전에 zero_grad()로 그래디언트를 초기화합니다.

optimizer = tg.TGD(parameters=[code])

loss = loss_fn(problem, code)
loss.backward()
optimizer.step()               # 1차 개선

optimizer.zero_grad()
loss = loss_fn(problem, code)
loss.backward()
optimizer.step()               # 2차 개선

이 예제에서는 이렇게 반복한 결과, O(n²) 코드가 O(n log n) 알고리즘으로 바뀌어 실행 시간이 4.24초 → 0.004초로 줄었습니다.

② 프롬프트 최적화 — 엔진을 명시한 TextualGradientDescent를 만들고, 데이터셋을 여러 epoch 동안 돌며 zero_grad → backward → step을 반복하는 본격적인 '학습 루프'를 구성합니다.

optimizer = tg.TextualGradientDescent(engine=llm_api_eval, parameters=[system_prompt])

for epoch in range(3):
    for steps, (batch_x, batch_y) in enumerate(train_loader):
        optimizer.zero_grad()
        # ... 손실 계산 후 total_loss 생성 ...
        total_loss.backward()
        optimizer.step()

④ 멀티모달 최적화 — 이미지에 대한 답변 VariableTGD로 개선하는 방식은 텍스트 예제와 똑같습니다. 입력이 이미지로 바뀌었을 뿐, 최적화 루프의 구조는 동일하게 유지됩니다.

step() 한 번은 곧 '한 번의 고쳐 쓰기'입니다. 솔루션 예제처럼 한 번만 돌릴 수도 있고, 프롬프트 예제처럼 데이터셋 위에서 수십 번 반복하며 점진적으로 끌어올릴 수도 있습니다. 어느 경우든 zero_grad → backward → step의 리듬은 변하지 않습니다.

정리하며

이번 장에서는 TextGrad 최적화 과정의 마지막 퍼즐 조각인 최적화기(Optimizer), 특히 TGD에 대해 배웠습니다. 최적화기는 역전파를 통해 계산된 '텍스트 그래디언트'를 실질적인 행동으로 옮겨, Variable의 텍스트 값을 실제로 개선하고 업데이트하는 역할을 합니다.

우리는 TGD 객체를 생성하고 .step() 메서드를 호출하는 간단한 두 단계만으로, 피드백을 반영한 새로운 버전의 텍스트를 얻을 수 있음을 확인했습니다. 이로써 TextGrad의 핵심적인 최적화 루프가 완성되었습니다.

  1. 순전파: 변수(Variable)연산(Operations)을 통해 결과를 계산합니다.
  2. 손실 계산: 텍스트 손실 함수(TextLoss)로 결과물을 평가하여 피드백(손실)을 얻습니다.
  3. 역전파: loss.backward()를 통해 피드백을 관련 변수들에게 그래디언트로 전파합니다.
  4. 업데이트: optimizer.step()으로 그래디언트를 반영하여 변수 값을 수정합니다.

이 4단계를 반복하면, 마치 작가와 편집자가 여러 번 원고를 주고받으며 글을 다듬어 가듯, 우리의 텍스트는 점점 더 정교하고 완성도 높은 결과물로 발전하게 됩니다.

이것으로 TextGrad의 핵심 개념에 대한 튜토리얼을 마칩니다. 여러분은 이제 텍스트를 변수처럼 다루고, 언어 모델의 피드백을 통해 그 텍스트를 자동으로 개선하는 강력한 도구를 손에 넣었습니다. 이제 다양한 예제를 통해 프롬프트, 코드, 논리적 추론 등 무한한 텍스트 기반 문제에 TextGrad를 적용해 보세요


이 글은 AI Codebase Knowledge Builder로 생성한 코드베이스 분석을 블로그용으로 다듬은 글입니다.