TextGrad 뜯어보기: 텍스트를 위한 자동 미분 프레임워크
변수·계산 그래프·언어 모델 엔진·텍스트 손실·역전파·최적화기까지, TextGrad의 핵심 개념을 장별로 따라가며 실제 코드와 함께 이해합니다.
TextGrad is an automatic differentiation framework for text. Just as PyTorch works with numbers, TextGrad uses a language model (LLM) to compute feedback on text — what it calls "text gradients." Based on this feedback, it backpropagates through a computation graph and, via an optimizer, automatically improves and optimizes text variables such as prompts and answers.
Source Repository: https://github.com/zou-group/textgrad
Chapters
- Variable
- Operations & the Computation Graph
- Language Model Engine (EngineLM)
- Text Loss Function (TextLoss)
- Backward Propagation
- Optimizer (TGD)
Four hands-on examples we'll follow along the way
At the end of each chapter there's a [Concept] in a real example section. Using four representative examples from TextGrad's official repository, it shows how the feature you learned in that chapter actually gets used in real code. Let's start with a quick introduction to the four examples.
| Example | What does it optimize? | Key takeaway |
|---|---|---|
| ① Solution Optimization (Tutorial-Solution-Optimization) | The solution answer to a math problem (3x² - 7x + 2 = 0) | The most basic optimization loop. Evaluate the answer with TextLoss and rewrite it with TGD |
| ② Prompt Optimization (Tutorial-Prompt-Optimization) | The system prompt given to the model | Evaluated against a labeled dataset and trained over multiple iterations. Improves the prompt itself, not the answer |
| ③ Code Optimization & Defining a New Loss (Tutorial-Test-Time-Loss-for-Code) | An inefficient code snippet (O(n²) → O(n log n)) | Instead of the standard TextLoss, define your own loss function with FormattedLLMCall |
| ④ Multimodal Optimization (Tutorial-MultiModal) | The answer to a question about an image | Going beyond text to images. Uses MultimodalLLMCall and ImageQALoss |
In the [Concept] in a real example section of each chapter below, you'll see — with code — how the four examples above (①–④) put that chapter's feature to use.
Chapter 1: Variable
Welcome to the world of TextGrad!
In traditional machine learning (PyTorch, for example), you train a model by optimizing data made of numbers. So what if we could take "text" itself — writing, ideas, code — and improve and optimize it just like a math problem?
TextGrad offers an answer to exactly that question. And the very first step in the fascinating journey of text optimization is the Variable.
What is a Variable? The "smart sticky note" analogy
The best way to understand a Variable is to think of it as a "smart sticky note." Imagine you jot down an answer to some idea or question on a sticky note.
TextGrad's Variable is the basic unit that holds text information, just like that sticky note. But it carries far more information than an ordinary sticky note.
- Value: The actual text written on the sticky note. For example, "It takes 1 hour to dry the shirts."
- Role Description: Like writing "answer to the user's question" or "the program's system prompt" at the top of the sticky note, this clearly states the text's role and purpose. This description later becomes a crucial reference point when the language model (LLM) gives feedback.
- Requires Gradient: Like putting an "Editable!" or "Do not edit!" sticker on the note. Text we want to improve (e.g., an answer) is marked "editable," while text that must not change (e.g., the original question) is marked "not editable."
- Predecessors: This records which other sticky notes this note came from. That way, when feedback arrives later, you can trace back to see what information produced this result and pin down the cause.
In this sense, a Variable is not just a scrap of text but a structured data container that holds all the context needed for the optimization process.
Building a Variable yourself
Hearing about it only goes so far — it's much faster to understand by looking at code directly. Let's use TextGrad to create simple question and answer Variables.
First, import the TextGrad library.
import textgrad as tg
The question variable: text we won't change
What we want to optimize is the "answer," not the "question." So the question should be created as a fixed value.
question_string = ("If it takes 1 hour to dry 25 shirts under the bright sun, "
"how long does it take to dry 30 shirts?")
question = tg.Variable(
value=question_string,
role_description="A question to send to the LLM",
requires_grad=False # The question doesn't need to change, so False
)
Let's look at each argument in the code above.
value: The actual text content theVariablewill hold.role_description: Clearly describes thisVariable's role as "A question to send to the LLM."requires_grad=False: Means "no gradient needed." In other words, this variable's value will not change during optimization. It's the same idea as leaving the question sheet untouched and only editing the answer sheet.
The answer variable: text we want to improve
Now let's create, as a Variable, an initial answer that a language model might have produced — something awkward or wrong. This variable is what we'll go on to improve.
initial_answer_string = "It takes 1.2 hours, because the more shirts there are, the more the time increases proportionally."
answer = tg.Variable(
value=initial_answer_string,
role_description="A concise and accurate answer to the question",
requires_grad=True # This answer needs improving, so True
)
The answer Variable differs slightly from the question.
role_description: Describes the ideal target for what this variable should become ("a concise and accurate answer"). This description later becomes an important evaluation criterion when generating feedback (the text gradient).requires_grad=True: Since this variable is the target of optimization — i.e., improvement — we set it to "gradient needed." Only variables with this option set toTruecan later receive feedback and update their own value.
A peek at the internals
When you call tg.Variable(...), what happens inside? TextGrad doesn't create a plain string but a special object that holds several pieces of information.
As the diagram shows, inside the Variable object — besides the value, role_description, and requires_grad we specified — important information is prepared for the operations to come.
predecessors: A "family tree" of sorts that records which other variables this one was made from. It's empty for now since we created it by hand, but once a variable is produced through an operation later, this gets filled in.gradients: A space where feedback (the text gradient) will be stored once it's computed later. It starts out empty.
We can see more clearly how a Variable is constructed by looking at part of TextGrad's actual source code (textgrad/variable.py).
# A simplified version of part of the textgrad/variable.py file
class Variable:
def __init__(
self,
value: str = "",
predecessors: List['Variable']=None,
requires_grad: bool=True,
*,
role_description: str):
self.value = value # Store the text value
self.role_description = role_description # Store the role description
self.requires_grad = requires_grad # Store whether optimization is needed
# Attributes that will be used later for the computation graph and backpropagation
self.predecessors = set(predecessors) if predecessors else set()
self.gradients: Set[Variable] = set() # Space to store feedback (gradients)
self.grad_fn = None # Points to the operation that created this variable
As you can see, the Variable object neatly stores the values we passed into its own attributes. It also reserves space — like gradients — that starts out empty but will be filled in later. In this way, a Variable is not just plain text but acts as a structured container holding all the information needed for the optimization process.
Variable in a real example
Let's see how the Variable we've learned about so far is actually used across TextGrad's four official examples (see the "Four hands-on examples we'll follow along the way" table in the overview). The key question is what to set as requires_grad=True — that variable is precisely what gets optimized.
① Solution Optimization — the target of improvement is the "solution answer."
solution = tg.Variable(initial_solution,
requires_grad=True,
role_description="solution to the math question")
② Prompt Optimization — interestingly, the target of improvement is not the "answer" but the "system prompt" given to the model. The input (x) and the ground truth (y) must not change, so they're set to requires_grad=False.
system_prompt = tg.Variable(STARTING_SYSTEM_PROMPT,
requires_grad=True,
role_description="structured system prompt to a ... language model")
③ Code Optimization — the target of improvement is the "code" itself.
code = tg.Variable(value=initial_solution,
requires_grad=True,
role_description="code instance to optimize")
④ Multimodal Optimization — a Variable can hold not just text but also image data (bytes). The image is the "ingredient" of the question, so it stays fixed (requires_grad=False).
image_variable = tg.Variable(image_data,
role_description="image to answer a question about",
requires_grad=False)
All four examples use the same Variable, but with a single requires_grad flag they freely change "what to improve." An answer, a prompt, code, even the answer to a question about an image — anything you can express as text can become a target for optimization.
Wrapping up
In this chapter we learned about Variable, TextGrad's most basic building block. Remember that a Variable is not just plain text but more like a "smart sticky note" that holds a value, a role (role_description), and whether to optimize it (requires_grad) all together. We looked at how to use this Variable to define both the text we want to optimize (the answer) and the text we keep fixed (the question).
But a single variable can't do anything on its own. We need an "action" that connects these "sticky notes" to one another and produces one note from another. In the next chapter we'll learn how to combine and operate on these variables to build more complex flows.
Go to the next chapter: Chapter 2: Operations & the Computation Graph
Chapter 2: Operations & the Computation Graph
In the previous Chapter 1: Variable, we learned how to create a Variable — TextGrad's most basic unit, the "smart sticky note." We prepared a container that holds text, its role, and whether to optimize it.
But just having a pile of sticky notes won't solve a problem on its own, right? We need an "action" — handing the sticky note with the question to someone and getting back a new sticky note with the answer. In TextGrad, this "action" is called an Operation. And the entire flow of connected operations is called a Computation Graph.
What is an Operation? The "cooking recipe" analogy
The easiest way to understand an operation is to picture a "cooking recipe."
Variable: The "ingredients" of the dish. (e.g., "onion," "carrot," "question text")- Operation: The "cooking method" that processes the ingredients. (e.g., "chopping," "stir-frying," "asking the LLM")
- A new
Variable: The "intermediate dish" or "finished dish" that comes out of the cooking method. (e.g., "chopped onion," "stir-fried vegetables," "the LLM's answer text")
An operation like LLMCall takes one or more Variables (ingredients) as input, performs a particular task (the cooking method), and then outputs a new Variable (the dish) holding the result. TextGrad's greatest strength is that it automatically tracks and records this entire process. It's like remembering the relationship, "These stir-fried vegetables were made from that chopped onion and carrot from earlier."
The most important operation: LLMCall
By far the most important and frequently used operation in TextGrad is LLMCall. As the name suggests, this operation's job is to call a Large Language Model.
LLMCall passes the text held in a Variable to the LLM as a prompt, takes the answer the LLM generates, and returns it wrapped in a new Variable. This is exactly the core process by which we "generate" or "transform" text.
Connecting variables through operations
Now let's use code to put a question Variable into an LLMCall operation and produce an answer Variable.
First, import LLMCall along with the libraries we need.
import textgrad as tg
from textgrad.autograd.llm_ops import LLMCall
To use LLMCall, you have to tell it which LLM to use. Here we'll use OpenAI's "gpt-3.5-turbo" model. (We'll cover this "engine" in detail in the next Chapter 3: Language Model Engine (EngineLM).)
# Note: to run this code, your OpenAI API key must be set as an environment variable.
engine = tg.get_engine("gpt-3.5-turbo")
Now let's create an LLMCall operation that uses this engine. Think of it as setting up a "machine that answers when you ask."
llm_caller = LLMCall(engine)
Let's get the question Variable from Chapter 1 ready again.
question_string = "What is the capital of France?"
question = tg.Variable(
value=question_string,
role_description="the user's question",
requires_grad=False # The question doesn't need to change, so False
)
Finally, let's put the prepared "ingredient" (question) into the "cooking method" (llm_caller) and make the "dish."
# Pass question as input to llm_caller.
response = llm_caller(question, response_role_description="the LLM's answer to the question")
What value ends up in the response variable? You can check the answer the LLM generated through its value attribute.
print(response.value)
The capital of France is Paris.
The really important part comes now. How was response created? TextGrad remembers the fact that this response came from question. Let's check the predecessors attribute.
print(response.predecessors)
{Variable(value='What is the capital of France?', role_description='the user's question')}
As you can see, the question variable is inside the predecessors (parent) attribute of the response variable. This link is exactly where the computation graph begins.
The computation graph at a glance
If we draw out what we just did, it looks like the following. The ingredient called question goes through the cooking method called LLMCall and becomes the dish called response.
This simple flow is exactly the computation graph. TextGrad automatically draws the "family tree" of how Variables and operations are connected to produce the final result.
Why does this graph matter? Later, if we give response feedback like "This answer is too stiff. Make it friendlier," TextGrad can trace this graph backward to figure out which input it should change. This process is precisely the core principle of Chapter 5: Backward Propagation.
A peek at the internals
When you call llm_caller(question), what exactly happens inside TextGrad?
- Run the
forwardmethod: Whenllm_calleris called, a method calledforwardruns internally. - Extract the value and call the LLM: It pulls the actual text value (
value) from thequestionvariable and sends it to theengine. - Receive the response: The
enginegets a text response back from the LLM. - Create a new
Variable: It creates a newresponsevariable whosevalueis the response text it received. - Record the link: This is the most important step. It stores the
questionvariable used as input in the newresponsevariable'spredecessorsattribute. This establishes a parent-child relationship between the two variables. - Store the backward function: It "jots down" on the
responsevariable how to compute feedback later (thebackwardfunction).
This process is laid out clearly in the LLMCall class of TextGrad's source code (textgrad/autograd/llm_ops.py).
# A simplified version of part of the LLMCall class in textgrad/autograd/llm_ops.py
class LLMCall(Function):
# ... (the constructor __init__ and so on are omitted) ...
def forward(self, input_variable: Variable, response_role_description: str) -> Variable:
# 1. Call the LLM engine to get a text response.
response_text = self.engine(input_variable.value)
# 2. Create a new Variable object.
response = Variable(
value=response_text,
# 3. Record who the "parent" is. This is what builds the graph!
predecessors=[input_variable],
role_description=response_role_description
)
# 4. Store how to compute feedback later. (backpropagation)
response.set_grad_fn(...)
return response
As the code shows, LLMCall goes beyond simply calling the LLM — it also systematically records which Variable the resulting new Variable was made from.
Operations and the computation graph in a real example
LLMCall is just the most basic operation. The real examples each build their computation graph with different operations.
① Solution Optimization — a single call to the loss function builds a graph that goes solution → loss.
loss = loss_fn(solution) # takes solution as input and produces loss (a Variable)
② Prompt Optimization — answers are generated with BlackboxLLM, and the evaluation results (losses) are combined into one with tg.sum. The multiple branches in the batch converge into a single node called tg.sum, becoming one unified graph.
model = tg.BlackboxLLM(llm_api_test, system_prompt)
# ...
response = model(x) # generate the answer with the system prompt
eval_output_variable = eval_fn(inputs=dict(prediction=response, ground_truth_answer=y))
losses.append(eval_output_variable)
total_loss = tg.sum(losses) # merge multiple losses into one graph
③ Code Optimization — uses the FormattedLLMCall operation, which takes several input fields (problem, code) at once.
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)
④ Multimodal Optimization — MultimodalLLMCall is an operation that takes two Variables, the image and the question, together as input.
from textgrad.autograd import MultimodalLLMCall
response = MultimodalLLMCall("gpt-4o")([image_variable, question_variable])
The kind of operation may differ, but the principle is the same. Every operation creates a new Variable from its input Variable(s) and records the parent-child relationship, growing the computation graph. Without this graph, the backpropagation we'll learn next wouldn't be possible.
Wrapping up
In this chapter we learned about Operations, which connect Variables to one another to build a workflow. In particular, through LLMCall — the most central operation — we saw how to produce an output Variable (the answer) from an input Variable (the question).
The most important point is that, during this process, TextGrad automatically tracks the connections between Variables and operations to build a computation graph. This graph is like a flowchart showing how each step of a cooking recipe connects to the next, and it plays an essential role later when propagating feedback to improve the text.
So far we've learned about "ingredients" (Variable) and the "basic cooking method" (LLMCall). But what exactly was that "engine" we specified when using LLMCall? In the next chapter we'll take a closer look at the "engine" — the gateway that connects various language models to TextGrad.
Go to the next chapter: Chapter 3: Language Model Engine (EngineLM)
Chapter 3: Language Model Engine (EngineLM)
In the previous Chapter 2: Operations & the Computation Graph, we learned about the LLMCall operation, which connects Variables to build a workflow. Remember how, when creating LLMCall, we specified an "engine" with engine = tg.get_engine("gpt-3.5-turbo")? That engine is exactly TextGrad's window for communicating with an external language model.
In this chapter we'll take a closer look at the Language Model Engine (EngineLM) — the core power source of text generation, playing the role of the "brain."
What is an engine? The "calculator" analogy
Imagine you're handed a complicated math problem. To solve it, you'd reach for a calculator. And no matter which calculator you use, the addition, subtraction, multiplication, and division buttons all look pretty much the same. Switch from a Casio to a Sharp and the way you use it hardly changes.
TextGrad's EngineLM plays exactly this "calculator" role.
- Different calculator brands: There are many language models (LLMs) out there — OpenAI's GPT-4, Anthropic's Claude, Google's Gemini, and so on. These are like different "calculator brands."
- The standard interface
EngineLM: TextGrad'sEngineLMacts as the "common buttons" that let you use all these different LLMs in a standardized way. No matter which LLM you use, TextGrad can request "process this text for me" the exact same way. - Easy swapping: Thanks to this, just like swapping calculator brands, we can switch from an OpenAI engine to an Anthropic engine by changing only a few lines of code.
In this way, EngineLM is a crucial tool that abstracts away the complicated process of calling LLM APIs, letting us focus only on the essence — text optimization.
Trying out different engines: the get_engine function
To use a particular LLM in TextGrad, you just call the tg.get_engine() function. It's like walking into a store and saying, "I'd like a GPT-4 calculator, please," or "A Claude calculator, please."
First, import the TextGrad library.
import textgrad as tg
Using the OpenAI engine
Let's bring in the most widely used model, OpenAI's gpt-3.5-turbo, as our engine.
# Note: to run this code, your OpenAI API key must be set as an environment variable.
openai_engine = tg.get_engine("gpt-3.5-turbo")
Now we can use this engine to generate an answer to a simple question. The engine object can be called like a function.
response_from_openai = openai_engine("How do I get the length of a list in Python?")
print(response_from_openai)
To get the length of a list in Python, use the `len()` function. For example, if you have a list called `my_list`, you can call `len(my_list)` to get its length.
Switching to the Anthropic engine
Now let's switch to Anthropic's Claude 3 Haiku model. Notice how simply the code changes.
# Note: to run this code, your Anthropic API key must be set as an environment variable.
# "haiku" is a shorthand for "claude-3-haiku-20240307".
anthropic_engine = tg.get_engine("haiku")
We only changed the argument to get_engine — the way we use it is exactly the same.
response_from_anthropic = anthropic_engine("How do I get the length of a list in Python?")
print(response_from_anthropic)
The simplest way to get the length of a list in Python is to use the built-in function `len()`.
For example, if you have a list `fruits = ['apple', 'banana', 'cherry']`, calling `len(fruits)` returns `3`.
You can see that the style of the answers generated by the two engines differs slightly. But from the TextGrad user's perspective, you only need to change the model name passed to get_engine, so you can easily test the strengths of various LLMs and pick the model best suited to your project.
As we saw in Chapter 2: Operations & the Computation Graph, these engines get passed into the LLMCall operation and become part of the computation graph.
from textgrad.autograd.llm_ops import LLMCall
# No matter which engine you use, the way you use LLMCall is the same.
llm_caller = LLMCall(openai_engine)
# or llm_caller = LLMCall(anthropic_engine)
A peek at the internals
When you call tg.get_engine("gpt-3.5-turbo") and then generate text with that engine, what happens inside?
-
Look up and create the engine: Like an information desk, the
get_enginefunction finds the appropriate engine class (ChatOpenAI) corresponding to the model name you entered ("gpt-3.5-turbo"). It then creates an object (instance) of that class and returns it. This object holds everything needed to communicate with the OpenAI API (the API key and so on). -
Call the API: When the user calls the engine object like a function (
engine(...)), the internalgeneratemethod kicks in. This method converts the input text into a format the OpenAI API understands (JSON) and sends the request. -
Process the response: When it receives a complex JSON-formatted response from OpenAI, the engine neatly extracts only the answer text we actually need and returns it as a string.
This whole process starts in the get_engine function in the file textgrad/engine/__init__.py. This function plays the role of "traffic control," deciding which class to use based on the model name.
# Part of the get_engine function in textgrad/engine/__init__.py (simplified version)
def get_engine(engine_name: str, **kwargs) -> EngineLM:
# ... (handling shorthands, etc.) ...
if (("gpt-4" in engine_name) or ("gpt-3.5" in engine_name)):
# If the name contains "gpt-", import the OpenAI engine class.
from .openai import ChatOpenAI
return ChatOpenAI(model_string=engine_name, **kwargs)
elif "claude" in engine_name:
# If the name contains "claude", import the Anthropic engine class.
from .anthropic import ChatAnthropic
return ChatAnthropic(model_string=engine_name, **kwargs)
# ... (handling for other engines) ...
else:
raise ValueError(f"Could not find engine {engine_name}.")
And each engine class, like ChatOpenAI or ChatAnthropic, follows a common "blueprint" called EngineLM. This blueprint (defined in textgrad/engine/base.py) promises that every engine must have at minimum a generate capability. Thanks to this, we can use any engine the exact same way.
# Part of the textgrad/engine/base.py file
from abc import ABC, abstractmethod
class EngineLM(ABC): # The blueprint class that is the parent of every engine
@abstractmethod
def generate(self, prompt, system_prompt=None, **kwargs):
# This method must be implemented in child classes (ChatOpenAI, etc.).
pass
The engine in a real example
How do the real examples specify the engine? Most of them set the engine for backpropagation/evaluation globally, once.
① Solution · ③ Code · ④ Multimodal Optimization — use set_backward_engine to globally specify the engine for generating feedback.
tg.set_backward_engine(tg.get_engine("gpt-4o")) # solution / code examples
tg.set_backward_engine("gpt-4o") # multimodal example (a string also works)
② Prompt Optimization — here a real-world technique appears: splitting the engine into two. A smart model is used for grading and feedback, while a cheaper, faster model performs the actual task.
llm_api_eval = tg.get_engine(engine_name="gpt-4o") # for evaluation/backpropagation (a powerful model)
llm_api_test = tg.get_engine(engine_name="gpt-3.5-turbo-0125") # for performing the actual task (a cheap model)
tg.set_backward_engine(llm_api_eval, override=True)
For ④ Multimodal Optimization, since the model has to understand images, you must choose an engine that supports vision, such as gpt-4o. As with the "calculator analogy" earlier in this chapter, you only swap the model name — but remember you have to pick the "calculator" that fits the task (text/image).
In this way, the engine is more than just "which LLM should I use" — it's also a design tool for splitting who produces the answer (test) and who grades it (eval).
Wrapping up
In this chapter we learned about the engine (EngineLM), TextGrad's window for communicating with various language models (LLMs). We came to understand that EngineLM is a convenient interface that lets you use different LLM APIs like a "standardized calculator."
The most important point is that through the tg.get_engine() function, by changing only the model name, you can easily swap and experiment with different "brains" — GPT, Claude, and more — without modifying any other part of your code.
By now we have it all: a vessel that holds text (Variable), the action that generates text (LLMCall), and the brain behind that action (EngineLM). But how can we tell whether generated text is good or bad? To optimize, we need a "score" that measures how close it is to the "right answer."
In the next chapter we'll learn how to assign exactly that "score" — that is, the Text Loss Function (TextLoss).
Go to the next chapter: Chapter 4: Text Loss Function (TextLoss)
Chapter 4: Text Loss Function (TextLoss)
In the previous Chapter 3: Language Model Engine (EngineLM), we learned how to select and swap the engine — the "brain" that generates text via LLMCall. By now we have the complete tools to generate an answer to a question. But how can we tell whether a generated answer is good, bad, or has room for improvement?
To optimize, we need a yardstick for measuring how close something is to the "right answer." In traditional deep learning, this yardstick is expressed as a number called "loss." But it's hard to judge whether text is good or bad with just a single number. This is exactly where TextGrad offers a highly original solution: the Text Loss Function (TextLoss).
What is a loss function? The "grading an essay answer" analogy
A typical deep learning loss function computes the difference between the ground truth and the prediction and returns a number (say, 0.83). You know that a score closer to 0 is better, but it gives you no information about why the score is bad or how to fix it.
TextGrad's TextLoss is different. It's easiest to think of it as "the way a strict teacher grades a student's essay answer."
- The student's answer: the
Variablewe want to evaluate (e.g., an answer generated by the LLM) - The grading rubric: the evaluation instruction you provide when creating
TextLoss - The teacher's feedback: the result
TextLossultimately produces. Concrete "natural-language feedback" like, "The conclusion is right, but your reasoning is thin. It would be better to flesh out this part."
This "natural-language feedback" is exactly the "loss," or "text gradient," of the TextGrad world. This feedback clearly carries direction on what went wrong and how to improve it.
Trying out TextLoss yourself
Let's look at how TextLoss works through a simple example. We'll evaluate a wrong answer to the question, "If it takes 1 hour to dry 25 shirts under the bright sun, how long does it take to dry 30 shirts?"
First, import the libraries we need.
import textgrad as tg
from textgrad.loss import TextLoss
Following what we learned in Chapter 1: Variable, let's turn the slightly clumsy initial answer we want to evaluate into a Variable. Since this variable is the target of improvement, we need to set requires_grad=True.
# The wrong initial answer
initial_answer = tg.Variable(
value="It takes 1.2 hours, because the more shirts there are, the more the drying time increases proportionally.",
role_description="answer to the question",
requires_grad=True
)
Now let's create the evaluation instruction that serves as the "grading rubric." This instruction tells the LLM doing the evaluation from what perspective it should critically examine the answer.
evaluation_instruction = (
"Evaluate the given question and answer logically. "
"In particular, critically examine whether there are any errors in the assumptions, "
"and provide concrete feedback for improvement in one or two concise sentences."
)
Using this evaluation instruction, we create the TextLoss object — that is, the "grading teacher."
# Define the loss function (the evaluator) using the evaluation instruction.
loss_fn = TextLoss(eval_system_prompt=evaluation_instruction)
Finally, it's time to submit the "student's answer" to the "grading teacher" and get feedback.
# Pass the answer to the loss function to perform the evaluation.
loss = loss_fn(initial_answer)
# Print the generated feedback (the loss).
print(loss.value)
There is a fatal flaw in the logic of the answer. When drying multiple shirts in the sun at the same time, the drying time is not directly proportional to the number of shirts.
As you can see, the loss variable holds text — describing the specific problem and the direction for improvement — instead of a score. This text becomes the precious "gradient" that will guide initial_answer in a better direction.
A peek at the internals
When you call loss_fn(initial_answer), what happens inside? TextLoss is actually a clever combination of the concepts we've learned so far.
- Build the evaluation prompt: Internally, the
TextLossobject combines theevaluation_instruction(the grading rubric) it holds with the text value of the inputinitial_answer(the student's answer) to build a new prompt for the evaluation. - Call the LLM: It passes this prompt to the Chapter 3: Language Model Engine (EngineLM) and requests that natural-language feedback be generated. (The engine used here is called the "backward engine," which we'll cover in detail later.)
- Receive the feedback: The LLM evaluates the answer according to the instruction and returns the result as text.
- Create a new
Variable:TextLosscreates a newlossvariable whosevalueis this feedback text. - Connect the computation graph: This is the most important part. To remember that the
lossvariable was computed frominitial_answer, it addsinitial_answertoloss'spredecessorsattribute. This extends the computation graph we learned about in Chapter 2: Operations & the Computation Graph frominitial_answertoloss.
This process is implemented in the TextLoss class of TextGrad's source code (textgrad/loss.py). In fact, the core of TextLoss is that it uses an LLMCall operation internally.
# A simplified version of part of the TextLoss class in textgrad/loss.py
from textgrad.autograd import LLMCall
class TextLoss(Module):
def __init__(self, eval_system_prompt: str, engine=None):
# ... (engine setup) ...
# 1. Create, in advance, an LLMCall object that uses the evaluation instruction as the system prompt.
self.eval_system_prompt = Variable(eval_system_prompt, ...)
self.llm_call = LLMCall(engine, self.eval_system_prompt)
def forward(self, instance: Variable):
# 2. When loss_fn(instance) is called,
# it performs the evaluation using the pre-built llm_call.
# This is exactly the process that creates and connects the computation graph.
return self.llm_call(instance)
Looking at the code, what TextLoss does is clear. In the constructor (__init__) it prepares an LLMCall that uses the evaluation instruction as the system prompt, and when the forward method is called, it runs this LLMCall to evaluate the input Variable and returns the result as a new Variable. In this way, TextLoss is a convenient tool that leverages TextGrad's basic building blocks to perform the specialized task of "evaluation."
The loss function in a real example: from the standard loss to "your own loss"
TextLoss is the most basic way to define a loss, but it's not the only way. Each example defines a loss differently, tailored to its problem.
① Solution Optimization — uses the standard TextLoss as is. The approach is to pass in a Variable holding the evaluation instruction.
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)
③ Code Optimization — defining "a new loss function" yourself. To evaluate code, you have to show the LLM both the "problem description" and the "current code." Since there are two inputs, the standard TextLoss isn't enough. In a case like this, you build your own loss function that takes multiple fields using 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()}")
② Prompt Optimization — this is the case where the dataset has ground-truth answers. Instead of free-form essay-style evaluation, an evaluation function (eval_fn) that compares the model's prediction against the ground truth is used as the loss.
eval_output_variable = eval_fn(inputs=dict(prediction=response, ground_truth_answer=y))
④ Multimodal Optimization — for image-based question answering, a dedicated loss, ImageQALoss, is provided.
from textgrad.loss import ImageQALoss
In short, a loss only needs to be "something that evaluates text (or an image) and produces feedback (a gradient)." No ground truth? Use TextLoss. Have ground truth? Use an evaluation function. Multiple inputs? Use FormattedLLMCall. Images? Use ImageQALoss — being able to freely swap the loss to fit the problem is exactly what makes TextGrad so powerful.
Wrapping up
In this chapter we learned about the Text Loss Function (TextLoss), a distinctive way of evaluating the quality of text. The defining feature of TextLoss is that it produces concrete "natural-language feedback" rather than a numeric score. We practiced creating a TextLoss object by providing an evaluation_instruction that acts as a "grading rubric," and using it to evaluate a Variable and obtain a "loss" that carries the direction for improvement.
By now we have an initial answer (Variable), and we've also obtained feedback (loss) that points out the answer's problems. So how can we use this feedback to actually "improve" the original answer? Much like a student rewriting an answer after looking at the teacher's feedback, we need a process for that.
In the next chapter we'll learn about Backward Propagation — the process of carrying this feedback (the loss) back up along the computation graph and delivering it to the relevant variables.
Go to the next chapter: Chapter 5: Backward Propagation
Chapter 5: Backward Propagation
In the previous Chapter 4: Text Loss Function (TextLoss), we learned how to evaluate the quality of a text answer generated by the LLM and obtain concrete "natural-language feedback (loss)" like "there is a logical flaw." Now we hold in our hands an answer that needs improving, along with sharp feedback pointing out the answer's problems.
But feedback alone changes nothing. We need a process that takes this feedback, finds the root cause, and figures out how to fix the original input. In TextGrad, this process is called Backward Propagation.
What is backpropagation? The "finding the cause of a cooking failure" analogy
Imagine you made a cake following a recipe, but it turned out way too salty. (This "too salty" feedback is exactly the loss we obtained in Chapter 4: Text Loss Function (TextLoss).) Now what should you do?
Naturally, you'd trace back through the recipe to find at which step you added too much salt.
- The finished cake (final result): "Too salty!" (the feedback)
- The batter right before baking (intermediate result): If the cake is salty, the batter must have been salty too.
- Mixing flour, sugar, and salt (initial input): Aha! The batter was salty because at this step you added salt instead of sugar!
In this way, tracing the process backward — based on feedback about the final result — to uncover the responsibility and cause at each step is exactly backpropagation. TextGrad automatically performs this process by following the "cooking recipe (computation graph)" built in Chapter 2: Operations & the Computation Graph.
The backward() method: starting feedback propagation
The way to start backpropagation is astonishingly simple. You just call the .backward() method on the loss variable.
Let's bring back the "shirt drying time" example we used in the previous chapter.
import textgrad as tg
from textgrad.loss import TextLoss
# The initial answer we want to improve (requires_grad=True)
initial_answer = tg.Variable(
value="It takes 1.2 hours, because the more shirts there are, the more the drying time increases proportionally.",
role_description="answer to the question",
requires_grad=True
)
# The grading criteria
evaluation_instruction = (
"Critically examine the logical errors in the given answer, "
"and provide concrete feedback for improvement."
)
loss_fn = TextLoss(eval_system_prompt=evaluation_instruction)
# Evaluate the answer to generate feedback (the loss)
loss = loss_fn(initial_answer)
print(f"Generated feedback (loss): {loss.value}")
Now it's time to cast the magic spell, .backward().
# Propagate the feedback backward along the computation graph.
loss.backward()
What does this single line of code do? There's no visible output, but something very important happens internally. loss.backward() visits every Variable that contributed to producing loss, asks each one, "This loss is because of you. How should you change?", computes feedback, and stores the result in each Variable's .gradients attribute.
In our case, since loss was made from initial_answer, feedback should have been stored on initial_answer. Shall we check?
# Check the feedback (gradient) stored on initial_answer.
# get_gradient_text() merges all stored gradients into a single text.
feedback_on_answer = initial_answer.get_gradient_text()
print(feedback_on_answer)
The core assumption of the answer — that the number of shirts and the drying time are directly proportional — is wrong. When you hang many shirts at once, they share limited resources like sunlight and wind, so the drying time does not increase in proportion to the count. This incorrect proportionality assumption needs to be fixed.
As you can see, initial_answer now has concrete feedback (the gradient) about why it was criticized (loss.value) and how it should be improved. This is exactly the product of backpropagation.
The backpropagation flow at a glance
If we draw out what we've done so far, it looks like the following.
- Forward Pass: A computation graph is built from
initial_answertoloss.
- Backward Pass: When you call
loss.backward(), feedback propagates backward fromlosstoinitial_answer.
A peek at the internals
When you call loss.backward(), how is this smart feedback generated? TextGrad cleverly leverages a language model (LLM) for this process too.
- Start backpropagation: The user calls
loss.backward(). - Traverse the graph: TextGrad walks backward through the computation graph.
loss's parent isinitial_answer, and the operation that connected the two wasTextLoss(internally,LLMCall). - Call the backward function: The
lossvariable "remembers" thebackwardfunction of the operation that created it (LLMCall). This function runs. - Build the 'backward prompt': The
LLMCall.backwardfunction builds a very special prompt. This prompt contains the following information.- The original situation: "Originally this input (
initial_answer) went in and produced this result (the answer thatlossevaluated)." - Feedback on the result: "And about that result, we received the feedback that 'there is a logical flaw' (
loss.value)." - The request: "To resolve this feedback, please generate concrete criticism and feedback on how the original input (
initial_answer) should be improved."
- The original situation: "Originally this input (
- Call the LLM and generate the gradient: This prompt is passed to an LLM called the "backward engine." The LLM answers this composite question and generates tailored feedback (the text gradient) for
initial_answer. - Store the gradient: The generated feedback is stored as a new
Variableininitial_answer.gradients.
The core of this process lies in the backward method inside the LLMCall class in the file textgrad/autograd/llm_ops.py, and in the prompt templates defined in textgrad/autograd/llm_backward_prompts.py.
For example, part of the OBJECTIVE_INSTRUCTION_CHAIN template looks like this.
# Part of 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"
)
This template gives the LLM a clear instruction: "Your goal is to give feedback to the variable; that feedback is meant to address the following feedback ({response_gradient}) on the LM_OUTPUT." Through this, feedback on the final result is converted, link by link, into feedback on earlier variables.
Backpropagation in a real example
The .backward() that kicks off backpropagation appears identically in every example, but what it's called on differs slightly.
① Solution · ③ Code · ④ Multimodal Optimization — call .backward() directly on a single loss.
loss = loss_fn(solution)
loss.backward()
② Prompt Optimization — the losses from multiple examples in a batch are merged into one with the tg.sum we saw in Chapter 2, and then backpropagation is run on that single summed loss. This causes the feedback from multiple examples to converge into system_prompt at once. Also, zero_grad() is called at every step to clear the previous gradients so feedback doesn't accumulate incorrectly.
optimizer.zero_grad() # clear the previous step's gradients
# ... compute the loss for each example in the batch ...
total_loss = tg.sum(losses)
total_loss.backward() # backpropagate once from the merged loss
The key point is that even when there are multiple branches of feedback, they're ultimately merged into a single loss and backpropagated once. It's just like consolidating a cake's various problems ("too salty and too sweet") and tracing backward to figure out which step of the recipe to fix.
Wrapping up
In this chapter we learned about Backward Propagation — propagating the feedback obtained via TextLoss backward along the computation graph to find the direction for improving the root-cause Variable.
We saw how, with just the simple .backward() method call, TextGrad internally uses an LLM to generate feedback link by link and fill in each variable's .gradients attribute.
We're almost there! We now have all of the following.
- The text we want to improve:
initial_answer(Chapter 1: Variable) - Concrete feedback on the direction for improvement:
initial_answer.gradients
The last remaining question is this: "Using this feedback, how do we actually edit and update the text of initial_answer?"
In the next chapter we'll learn about the "action commander" that applies this feedback to actually update the variable — the Optimizer.
Go to the next chapter: Chapter 6: Optimizer (TGD)
Chapter 6: Optimizer (TGD)
In the previous Chapter 5: Backward Propagation, we learned how to call the .backward() method to compute, from the final feedback (loss), a concrete direction for improving the root-cause Variable — that is, the "text gradient." Now, inside our initial_answer variable, the .gradients attribute is full of detailed comments on "how it could get better."
But these comments haven't changed the variable itself yet. It's like a manuscript on which an editor has scribbled plenty of revision notes in red pen, but the author hasn't rewritten it yet. The one that performs this final step — looking at the feedback and actually revising the manuscript — is exactly the Optimizer.
What is an optimizer? The "author who rewrites the text reflecting the feedback" analogy
TextGrad's optimizer can be likened to "an author who rewrites a manuscript after reading the editor's comments."
- The manuscript to improve: the
Variablewe want to optimize (e.g.,initial_answer) - The editor's comments: the "text gradient" stored in the
Variable's.gradientsattribute through backpropagation - The author (the optimizer): the one who reads the manuscript and the comments together and writes a new, better version of the manuscript reflecting the comments
TextGrad's default optimizer, TGD, stands for Textual Gradient Descent. As the name says, it uses the computed text gradient to "descend" (revise) the Variable's text value in a better direction. This process, too, is carried out intelligently by leveraging a powerful language model (LLM).
Trying out the TGD optimizer yourself
Now let's actually update the initial_answer variable — which holds feedback — using TGD. This is the moment we complete the final step of the whole optimization process.
First, let's revisit the full code we covered in the previous chapters.
import textgrad as tg
from textgrad.loss import TextLoss
# 1. Prepare the initial answer we want to improve
initial_answer = tg.Variable(
value="It takes 1.2 hours, because the more shirts there are, the more the drying time increases proportionally.",
role_description="answer to the question",
requires_grad=True
)
# 2. Generate feedback (the loss) with the loss function
loss_fn = TextLoss(eval_system_prompt="Find the logical errors in the answer and give feedback for improvement.")
loss = loss_fn(initial_answer)
# 3. Propagate the feedback (gradient) to initial_answer via backpropagation
loss.backward()
After running this far, the direction for improvement is stored in initial_answer.gradients. Now it's time to create and run the optimizer. Let's bring in TGD.
from textgrad.optimizer import TGD
# 4. Create the optimizer, specifying which variable to update
# It's like telling the author which manuscript to revise.
optimizer = TGD(parameters=[initial_answer])
When creating TGD, you pass a list of the Variables you want to update to the parameters argument. Now let's shout "Start revising!" to the author. That command is exactly the .step() method.
# 5. Run an optimization step to update the variable's value
optimizer.step()
When the .step() method runs, the optimizer reads the gradient stored on initial_answer and, reflecting its content, overwrites initial_answer's .value with new text. So how did it change?
# Check the updated answer
print(initial_answer.value)
If you hang them in a spot with plenty of sunlight, drying 30 shirts will still take 1 hour. As long as all the shirts are exposed to sunlight and wind at the same time, the number of shirts doesn't significantly affect the drying time.
Remarkably, the logical error in the initial answer ("the number of shirts and the drying time are proportional") has been fixed and turned into a more accurate, sensible answer. With this, we've gone all the way around TextGrad's full optimization cycle!
A peek at the internals
When you call optimizer.step(), what thought process did the "author" go through internally to revise the writing? The core of this process, too, lies in intelligent prompt construction using an LLM.
- Iterate over update targets: When
.step()is called, the optimizer goes through theparameterslist it manages ([initial_answer]) one by one. - Gather information: For each
Variable(here,initial_answer), it collects the original text value (.value), the role description (.role_description), and all the gradients gathered through backpropagation (.gradients). - Build the 'revision prompt': The optimizer combines the collected information to build an elaborate prompt to send to the LLM. This prompt roughly has the following structure.
- The goal: "You are part of an optimization system that improves text."
- The existing text and its role: "The role of the variable to improve is 'answer to the question,' and its current content is 'It takes 1.2 hours...'."
- The feedback: "We received the following feedback (gradient) about this variable: 'The assumption that the number of shirts and the drying time are directly proportional is wrong...'"
- The request: "Reflecting this feedback, improve the variable and respond with the full revised text wrapped in
<IMPROVED_VARIABLE>tags."
- Call the LLM and parse the response: This prompt is sent to the optimizer's LLM engine. The LLM generates new text per the instruction, and the optimizer neatly extracts only the content between the
<IMPROVED_VARIABLE>tags from the response. - Update the value: It replaces
initial_answer's.valuewith the extracted new text. With this, one optimization step is complete.
The core logic of this process is implemented in the TextualGradientDescent class in the file textgrad/optimizer/optimizer.py and in the file textgrad/optimizer/optimizer_prompts.py, which defines the prompt templates.
Looking at part of the prompt template in optimizer_prompts.py makes its intent clear.
# Part of the textgrad/optimizer/optimizer_prompts.py file (simplified version)
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"
)
This template clearly conveys to the LLM the variable's role (ROLE), its current value (VARIABLE), and the feedback (CONTEXT), prompting an accurate, context-appropriate revision.
The optimizer in a real example
Finally, let's see how the examples run TGD. For reference, tg.TGD is a short alias for tg.TextualGradientDescent — the two are exactly the same.
① Solution Optimization — the simplest form. Improve the answer with a single step().
optimizer = tg.TGD([solution])
loss = loss_fn(solution)
loss.backward()
optimizer.step()
③ Code Optimization — call step() repeatedly to progressively refine the code. Before each iteration, clear the gradients with zero_grad().
optimizer = tg.TGD(parameters=[code])
loss = loss_fn(problem, code)
loss.backward()
optimizer.step() # first improvement
optimizer.zero_grad()
loss = loss_fn(problem, code)
loss.backward()
optimizer.step() # second improvement
In this example, after iterating like this, the O(n²) code was turned into an O(n log n) algorithm, cutting the execution time from 4.24 seconds to 0.004 seconds.
② Prompt Optimization — create a TextualGradientDescent with the engine explicitly specified, and build a full-fledged "training loop" that runs over the dataset for several epochs, repeating 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()
# ... compute the loss, then create total_loss ...
total_loss.backward()
optimizer.step()
④ Multimodal Optimization — improving the answer Variable about an image with TGD works exactly the same as in the text examples. Only the input changed to an image; the structure of the optimization loop stays identical.
One step() is essentially "one rewrite." You can run it just once, like in the solution example, or repeat it dozens of times over a dataset to gradually push it up, like in the prompt example. In either case, the rhythm of zero_grad → backward → step stays the same.
Wrapping up
In this chapter we learned about the last puzzle piece of TextGrad's optimization process — the Optimizer, and TGD in particular. The optimizer takes the "text gradient" computed through backpropagation and turns it into concrete action, actually improving and updating the Variable's text value.
We confirmed that with just two simple steps — creating a TGD object and calling the .step() method — we can obtain a new version of the text that reflects the feedback. With this, TextGrad's core optimization loop is complete.
- Forward pass: Compute a result through Variables and Operations.
- Loss computation: Evaluate the result with the Text Loss Function (TextLoss) to obtain feedback (the loss).
- Backward pass: Propagate the feedback to the relevant variables as gradients via
loss.backward(). - Update: Reflect the gradients to revise the variable's value with
optimizer.step().
Repeat these four steps, and just like an author and editor passing a manuscript back and forth to refine the writing, our text develops into an ever more polished, higher-quality result.
This concludes the tutorial on TextGrad's core concepts. You now hold a powerful tool that treats text like a variable and automatically improves it through a language model's feedback. Now go try applying TextGrad to the limitless world of text-based problems — prompts, code, logical reasoning, and beyond — through the various examples.
Adapted from an auto-generated walkthrough by AI Codebase Knowledge Builder.
TextGrad는 텍스트를 위한 자동 미분 프레임워크입니다. PyTorch가 숫자를 다루는 것처럼, TextGrad는 언어 모델(LLM)을 이용해 텍스트에 대한 피드백, 즉 '텍스트 그래디언트'를 계산합니다. 이 피드백을 바탕으로, 계산 그래프를 따라 역전파를 수행하고 최적화기를 통해 프롬프트나 답변과 같은 텍스트 변수들을 자동으로 개선하고 최적화할 수 있습니다.
Source Repository: https://github.com/zou-group/textgrad
Chapters
- 변수 (Variable)
- 연산 및 계산 그래프 (Operations & Computation Graph)
- 언어 모델 엔진 (EngineLM)
- 텍스트 손실 함수 (TextLoss)
- 역전파 (Backward Propagation)
- 최적화기 (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) | 이미지에 대한 질문의 답변 | 텍스트를 넘어 이미지까지. MultimodalLLMCall과 ImageQALoss 사용 |
아래 각 장의 실전 예제 속 [개념] 섹션에서, 위 네 예제(①~④)가 그 장의 기능을 어떻게 활용하는지 코드와 함께 확인할 수 있습니다.
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)를 생성합니다.
위 다이어그램처럼 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)을 호출하는 역할을 합니다.
LLMCall은 Variable에 담긴 텍스트를 LLM에게 프롬프트로 전달하고, LLM이 생성한 답변을 받아 새로운 Variable에 담아 반환합니다. 이것이 바로 우리가 텍스트를 '생성'하거나 '변환'하는 핵심 과정입니다.
연산을 통해 변수 연결하기
이제 직접 코드를 통해 질문 Variable을 LLMCall 연산에 넣어 답변 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는 이 response가 question으로부터 왔다는 사실을 기억하고 있습니다. predecessors 속성을 확인해 봅시다.
print(response.predecessors)
{Variable(value='프랑스의 수도는 어디인가요?', role_description='사용자의 질문')}
보시는 것처럼, response 변수의 predecessors (부모) 속성 안에 question 변수가 들어있습니다. 이 연결 고리가 바로 계산 그래프의 시작입니다.
한눈에 보는 계산 그래프
방금 우리가 한 작업을 그림으로 표현하면 다음과 같습니다. question이라는 재료가 LLMCall이라는 조리법을 거쳐 response라는 요리가 되는 과정입니다.
이 간단한 흐름이 바로 계산 그래프입니다. TextGrad는 Variable과 연산이 어떻게 연결되어 최종 결과물을 만들어냈는지 그 '가계도'를 자동으로 그려줍니다.
이 그래프가 왜 중요할까요? 나중에 우리가 response에 대해 "이 답변은 너무 딱딱해. 좀 더 친절하게 바꿔줘"라는 피드백을 주면, TextGrad는 이 그래프를 거꾸로 거슬러 올라가 어떤 입력을 바꿔야 할지 알아낼 수 있습니다. 이 과정이 바로 5장: 역전파 (Backward Propagation)의 핵심 원리입니다.
내부 동작 원리 엿보기
llm_caller(question)을 호출했을 때, TextGrad 내부에서는 정확히 어떤 일이 일어났을까요?
forward메서드 실행:llm_caller가 호출되면 내부적으로forward라는 메서드가 실행됩니다.- 값 추출 및 LLM 호출:
question변수에서 실제 텍스트 값(value)을 꺼내engine으로 보냅니다. - 응답 수신:
engine은 LLM으로부터 텍스트 응답을 받아옵니다. - 새
Variable생성: 받은 응답 텍스트를value로 하는 새로운response변수를 만듭니다. - 연결 고리 기록: 가장 중요한 단계입니다. 새로운
response변수의predecessors속성에 입력으로 사용된question변수를 저장합니다. 이로써 두 변수 간의 부모-자식 관계가 형성됩니다. - 역전파 함수 저장:
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")를 호출하고, 그 엔진으로 텍스트를 생성할 때 내부에서는 어떤 일이 일어날까요?
-
엔진 조회 및 생성:
get_engine함수는 마치 안내 데스크처럼, 입력된 모델 이름("gpt-3.5-turbo")에 해당하는 적절한 엔진 클래스(ChatOpenAI)를 찾아줍니다. 그리고 해당 클래스의 객체(인스턴스)를 생성하여 반환합니다. 이 객체는 OpenAI API와 통신하는 데 필요한 모든 정보(API 키 등)를 가지고 있습니다. -
API 호출: 사용자가 엔진 객체를 함수처럼 호출하면(
engine(...)), 내부의generate메서드가 작동합니다. 이 메서드는 입력받은 텍스트를 OpenAI API가 이해할 수 있는 형식(JSON)으로 변환하여 요청을 보냅니다. -
응답 처리: 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}을 찾을 수 없습니다.")
그리고 ChatOpenAI나 ChatAnthropic 같은 각 엔진 클래스는 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는 사실 우리가 앞에서 배운 개념들의 영리한 조합입니다.
- 평가 프롬프트 생성:
TextLoss객체는 내부적으로 자신이 가지고 있는evaluation_instruction(채점 기준표)과 입력으로 받은initial_answer의 텍스트 값(학생 답안)을 조합하여 평가를 위한 새로운 프롬프트를 만듭니다. - LLM 호출: 이 프롬프트를 3장: 언어 모델 엔진 (EngineLM)에 전달하여 자연어 피드백 생성을 요청합니다. (이때 사용되는 엔진을 '역전파 엔진'이라고 부르며, 나중에 자세히 다룹니다.)
- 피드백 수신: LLM은 지침에 따라 답변을 평가하고, 그 결과를 텍스트로 반환합니다.
- 새
Variable생성:TextLoss는 이 피드백 텍스트를value로 하는 새로운loss변수를 생성합니다. - 계산 그래프 연결: 가장 중요한 부분입니다.
loss변수가initial_answer로부터 계산되었다는 사실을 기억하기 위해,loss의predecessors속성에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입니다.) 이제 여러분은 무엇을 해야 할까요?
당연히 레시피의 어느 단계에서 소금을 너무 많이 넣었는지 거슬러 올라가며 원인을 찾을 것입니다.
- 완성된 케이크 (최종 결과): "너무 짜다!" (피드백)
- 굽기 직전의 반죽 (중간 결과): 케이크가 짜다면, 반죽도 짰을 것이다.
- 밀가루, 설탕, 소금 섞기 (초기 입력): 아하! 반죽이 짰던 이유는 이 단계에서 설탕 대신 소금을 넣었기 때문이다!
이처럼 최종 결과에 대한 피드백을 바탕으로, 과정을 거꾸로 거슬러 올라가 각 단계의 책임과 원인을 밝혀내는 것이 바로 역전파입니다. 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 속성에 저장합니다.
우리의 경우, loss는 initial_answer로부터 만들어졌으므로 initial_answer에 피드백이 저장되었을 것입니다. 확인해 볼까요?
# initial_answer에 저장된 피드백(그래디언트)을 확인합니다.
# get_gradient_text()는 저장된 모든 그래디언트를 하나의 텍스트로 합쳐줍니다.
feedback_on_answer = initial_answer.get_gradient_text()
print(feedback_on_answer)
답변의 핵심 가정, 즉 셔츠 개수와 건조 시간이 정비례한다는 것은 잘못되었습니다. 많은 셔츠를 동시에 널면 햇빛, 바람 등 제한된 자원을 공유하게 되므로, 건조 시간은 개수에 비례하여 늘어나지 않습니다. 이 잘못된 비례 관계 가정을 수정해야 합니다.
보시다시피, initial_answer는 이제 자신이 왜 비판받았는지(loss.value), 그리고 어떻게 개선되어야 하는지에 대한 구체적인 피드백(그래디언트)을 갖게 되었습니다. 이것이 바로 역전파의 결과물입니다.
한눈에 보는 역전파 흐름
우리가 지금까지 한 작업을 그림으로 표현하면 다음과 같습니다.
- 순전파 (Forward Pass):
initial_answer에서loss로 계산 그래프가 만들어집니다.
- 역전파 (Backward Pass):
loss.backward()를 호출하면, 피드백이loss에서initial_answer로 거꾸로 전파됩니다.
내부 동작 원리 엿보기
loss.backward()를 호출했을 때, 이 똑똑한 피드백은 어떻게 생성되는 걸까요? TextGrad는 이 과정 또한 언어 모델(LLM)을 영리하게 활용합니다.
- 역전파 시작: 사용자가
loss.backward()를 호출합니다. - 그래프 순회: TextGrad는 계산 그래프를 거슬러 올라갑니다.
loss의 부모는initial_answer이고, 둘을 연결한 연산은TextLoss(내부적으로는LLMCall)였습니다. - 역전파 함수 호출:
loss변수에는 자신을 만든 연산(LLMCall)의backward함수가 '기억'되어 있습니다. 이 함수가 실행됩니다. - '역전파 프롬프트' 생성:
LLMCall.backward함수는 매우 특별한 프롬프트를 만듭니다. 이 프롬프트는 다음과 같은 정보를 담고 있습니다.- 원래 상황: "원래 이런 입력(
initial_answer)이 들어가서 이런 결과(loss가 평가한 답변)가 나왔습니다." - 결과에 대한 피드백: "그리고 그 결과에 대해 '논리적 오류가 있다'는 피드백(
loss.value)을 받았습니다." - 요청: "이 피드백을 해결하려면, 원래 입력(
initial_answer)을 어떻게 개선해야 할지 구체적인 비평과 피드백을 생성해주세요."
- 원래 상황: "원래 이런 입력(
- LLM 호출 및 그래디언트 생성: 이 프롬프트가 '역전파 엔진'이라고 불리는 LLM에게 전달됩니다. LLM은 이 복합적인 질문에 답하며
initial_answer를 위한 맞춤형 피드백(텍스트 그래디언트)을 생성합니다. - 그래디언트 저장: 생성된 피드백은
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을 활용한 지능적인 프롬프트 생성에 있습니다.
- 업데이트 대상 순회:
.step()이 호출되면 최적화기는 자신이 관리하는parameters리스트([initial_answer])를 하나씩 살펴봅니다. - 정보 수집: 각
Variable(여기서는initial_answer)에 대해, 원래 텍스트 값(.value), 역할 설명(.role_description), 그리고 역전파를 통해 수집된 모든 그래디언트(.gradients)를 한데 모읍니다. - '수정 프롬프트' 생성: 최적화기는 수집된 정보들을 조합하여 LLM에게 보낼 정교한 프롬프트를 만듭니다. 이 프롬프트는 대략 다음과 같은 구조를 가집니다.
- 목표: "당신은 텍스트를 개선하는 최적화 시스템의 일부입니다."
- 기존 텍스트와 역할: "개선할 변수의 역할은 '질문에 대한 답변'이고, 현재 내용은 '1.2시간이 걸립니다...'입니다."
- 피드백: "이 변수에 대해 다음과 같은 피드백(그래디언트)을 받았습니다: '셔츠 개수와 건조 시간이 정비례한다는 가정은 잘못되었습니다...'"
- 요청: "이 피드백을 반영하여 변수를 개선하고, 수정된 전체 텍스트를
<IMPROVED_VARIABLE>태그 안에 담아 응답해주세요."
- LLM 호출 및 응답 파싱: 이 프롬프트가 최적화기의 LLM 엔진으로 전송됩니다. LLM은 지시에 따라 새로운 텍스트를 생성하고, 최적화기는 응답에서
<IMPROVED_VARIABLE>태그 사이의 내용만 깔끔하게 추출합니다. - 값 업데이트: 추출된 새로운 텍스트로
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.TGD는 tg.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()
④ 멀티모달 최적화 — 이미지에 대한 답변 Variable을 TGD로 개선하는 방식은 텍스트 예제와 똑같습니다. 입력이 이미지로 바뀌었을 뿐, 최적화 루프의 구조는 동일하게 유지됩니다.
step() 한 번은 곧 '한 번의 고쳐 쓰기'입니다. 솔루션 예제처럼 한 번만 돌릴 수도 있고, 프롬프트 예제처럼 데이터셋 위에서 수십 번 반복하며 점진적으로 끌어올릴 수도 있습니다. 어느 경우든 zero_grad → backward → step의 리듬은 변하지 않습니다.
정리하며
이번 장에서는 TextGrad 최적화 과정의 마지막 퍼즐 조각인 최적화기(Optimizer), 특히 TGD에 대해 배웠습니다. 최적화기는 역전파를 통해 계산된 '텍스트 그래디언트'를 실질적인 행동으로 옮겨, Variable의 텍스트 값을 실제로 개선하고 업데이트하는 역할을 합니다.
우리는 TGD 객체를 생성하고 .step() 메서드를 호출하는 간단한 두 단계만으로, 피드백을 반영한 새로운 버전의 텍스트를 얻을 수 있음을 확인했습니다. 이로써 TextGrad의 핵심적인 최적화 루프가 완성되었습니다.
- 순전파: 변수(Variable)와 연산(Operations)을 통해 결과를 계산합니다.
- 손실 계산: 텍스트 손실 함수(TextLoss)로 결과물을 평가하여 피드백(손실)을 얻습니다.
- 역전파:
loss.backward()를 통해 피드백을 관련 변수들에게 그래디언트로 전파합니다. - 업데이트:
optimizer.step()으로 그래디언트를 반영하여 변수 값을 수정합니다.
이 4단계를 반복하면, 마치 작가와 편집자가 여러 번 원고를 주고받으며 글을 다듬어 가듯, 우리의 텍스트는 점점 더 정교하고 완성도 높은 결과물로 발전하게 됩니다.
이것으로 TextGrad의 핵심 개념에 대한 튜토리얼을 마칩니다. 여러분은 이제 텍스트를 변수처럼 다루고, 언어 모델의 피드백을 통해 그 텍스트를 자동으로 개선하는 강력한 도구를 손에 넣었습니다. 이제 다양한 예제를 통해 프롬프트, 코드, 논리적 추론 등 무한한 텍스트 기반 문제에 TextGrad를 적용해 보세요
이 글은 AI Codebase Knowledge Builder로 생성한 코드베이스 분석을 블로그용으로 다듬은 글입니다.