refers to the careful design and optimization of inputs (e.g., queries or instructions) for guiding the behavior and responses of generative AI models. Prompts are typically structured using either the declarative or imperative paradigm, or a mixture of both. The choice of paradigm can have a big impact on the accuracy and relevance of the resulting model output. This article provides a conceptual overview of declarative and imperative prompting, discusses advantages and limitations of each paradigm, and considers the practical implications.
The What and the How
In simple terms, declarative prompts express what should be done, while imperative prompts specify how something should be done. Suppose you are at a pizzeria with a friend. You tell the waiter that you will have the Neapolitan. Since you only mention the type of pizza you want without specifying exactly how you want it prepared, this is an example of a declarative prompt. Meanwhile, your friend — who has some very particular culinary preferences and is in the mood for a bespoke pizza alle quattro stagioni — proceeds to tell the waiter exactly how she would like it made; this is an example of an imperative prompt.
Declarative and imperative paradigms of expression have a long history in computing, with some programming languages favoring one paradigm over the other. A language such as C tends to be used for imperative programming, while a language like Prolog is geared towards declarative programming. For example, consider the following problem of identifying the ancestors of a person named Charlie. We happen to know the following facts about Charlie’s relatives: Bob is Charlie’s parent, Alice is Bob’s parent, Susan is Dave’s parent, and John is Alice’s parent. Based on this information, the code below shows how we can identify Charlie’s ancestors using Prolog.
parent(alice, bob).
parent(bob, charlie).
parent(susan, dave).
parent(john, alice).
ancestor(X, Y) :- parent(X, Y).
ancestor(X, Y) :- parent(X, Z), ancestor(Z, Y).
get_ancestors(Person, Ancestors) :- findall(X, ancestor(X, Person), Ancestors).
?- get_ancestors(charlie, Ancestors).
Although the Prolog syntax may seem strange at first, it actually expresses the problem we wish to solve in a concise and intuitive way. First, the code lays out the known facts (i.e., who is whose parent). It then recursively defines the predicate ancestor(X, Y)
, which evaluates to true if X
is an ancestor of Y
. Finally, the predicate findall(X, Goal, List)
triggers the Prolog interpreter to repeatedly evaluate Goal
and store all successful bindings of X
in List
. In our case, this means identifying all solutions to ancestor(X, Person)
and storing them in the variable Ancestors
. Notice that we do not specify the implementation details (the “how”) of any of these predicates (the “what”).
In contrast, the C implementation below identifies Charlie’s ancestors by describing in painstaking detail exactly how this should be done.
#include <stdio.h>
#include <string.h>
#define MAX_PEOPLE 10
#define MAX_ANCESTORS 10
// Structure to represent parent relationships
typedef struct {
char parent[20];
char child[20];
} ParentRelation;
ParentRelation relations[] = {
{"alice", "bob"},
{"bob", "charlie"},
{"susan", "dave"},
{"john", "alice"}
};
int numRelations = 4;
// Check if X is a parent of Y
int isParent(const char *x, const char *y) {
for (int i = 0; i < numRelations; ++i) {
if (strcmp(relations[i].parent, x) == 0 && strcmp(relations[i].child, y) == 0) {
return 1;
}
}
return 0;
}
// Recursive function to check if X is an ancestor of Y
int isAncestor(const char *x, const char *y) {
if (isParent(x, y)) return 1;
for (int i = 0; i < numRelations; ++i) {
if (strcmp(relations[i].child, y) == 0) {
if (isAncestor(x, relations[i].parent)) return 1;
}
}
return 0;
}
// Get all ancestors of a person
void getAncestors(const char *person, char ancestors[][20], int *numAncestors) {
*numAncestors = 0;
for (int i = 0; i < numRelations; ++i) {
if (isAncestor(relations[i].parent, person)) {
strcpy(ancestors[*numAncestors], relations[i].parent);
(*numAncestors)++;
}
}
}
int main() {
char person[] = "charlie";
char ancestors[MAX_ANCESTORS][20];
int count;
getAncestors(person, ancestors, &count);
printf("Ancestors of %s:\n", person);
for (int i = 0; i < count; ++i) {
printf("%s\n", ancestors[i]);
}
return 0;
}
Nowadays, the increasing availability of feature-rich libraries and APIs, and the consolidation of architectural best practices (e.g., programming to interfaces, using configuration over code), allows us to focus on the “what” without having to worry about the “how” in many cases. Indeed, as suggested in the short video below, a lot of code can — and perhaps should — be written in a declarative manner.
From Programming to Prompting
If we think of prompt engineering as the next evolutionary step in programming — tracing the journey from machine-centric languages such as Assembly and C to more human-centric languages like Java and Python — then the relevance of declarative and imperative paradigms to prompt engineering becomes apparent. Although we can now instruct a generative AI model simply using natural language, we still need to decide on the extent to which the instruction should express what is to be achieved versus how it is to be achieved.
Consider the scenario of a developer who would like to use an AI assistant to generate code — specifically, Python code to compute the cosine distance between two vectors. Following are three versions of a prompt that the developer might write to achieve her goal. Version 1 is declarative, Version 2 is imperative, and Version 3 is a hybrid of the two. The code generated by OpenAI’s GPT-4 model in response to each of these prompts is also provided.
Prompt Version 1 (Declarative)
Write a short and fast function in Python to compute the cosine distance between two input vectors.
Result:
import numpy as np
def cosine_distance(u, v):
u = np.array(u)
v = np.array(v)
return 1 - np.dot(u, v) / (np.linalg.norm(u) * np.linalg.norm(v))
Prompt Version 2 (Imperative)
Write a Python function cosine_distance(v1, v2) that computes the cosine distance between two vectors. Follow these steps using only built-in functions:
1. Raise a ValueError exception if v1 or v2 are empty, or if v1 and v2 are of different lengths.
2. Compute the dot product of v1 and v2.
3. Compute the magnitude of each vector and raise a ValueError if either magnitude is zero.
4. Divide the dot product by the product of the two magnitudes to get the cosine similarity.
5. Do 1 minus the cosine similarity to obtain cosine distance.
6. Return the resulting distance.
Result:
import math
def cosine_distance(v1, v2):
# Step 1: Input validation
if not v1 or not v2:
raise ValueError("Input vectors must not be empty.")
if len(v1) != len(v2):
raise ValueError("Vectors must be of the same length.")
# Step 2: Compute dot product
dot_product = sum(a * b for a, b in zip(v1, v2))
# Step 3: Compute magnitudes
mag_v1 = math.sqrt(sum(a ** 2 for a in v1))
mag_v2 = math.sqrt(sum(b ** 2 for b in v2))
if mag_v1 == 0 or mag_v2 == 0:
raise ValueError("Vectors must not have zero magnitude.")
# Step 4 & 5: Compute cosine similarity and distance
cosine_similarity = dot_product / (mag_v1 * mag_v2)
cosine_distance = 1 - cosine_similarity
# Step 6: Return result
return cosine_distance
Prompt Version 3 (Hybrid)
Write a fast Python function that computes the cosine distance between two vectors v1 and v2 using relevant optimized functions from numpy and scipy. The function should validate the input vectors (non-empty, equal length) and gracefully handle invalid inputs with exceptions.
Result:
import numpy as np
from scipy.spatial.distance import cosine
def cosine_distance(v1, v2):
# Convert inputs to NumPy arrays
v1 = np.asarray(v1, dtype=np.float64)
v2 = np.asarray(v2, dtype=np.float64)
# Validation checks
if v1.size == 0 or v2.size == 0:
raise ValueError("Input vectors must be non-empty.")
if v1.shape != v2.shape:
raise ValueError("Vectors must have the same shape.")
if not np.any(v1) or not np.any(v2):
raise ValueError("Vectors must not be zero vectors.")
# Compute cosine distance using optimized function
return cosine(v1, v2)
Horses for Courses
The three prompts and their respective AI-generated implementations shown above imply different trade-offs and may be suited to different personas and scenarios in practice.
The declarative prompt (Version 1) is short and simple. It does not specify details of the exact algorithmic approach to be taken, expressing instead the high-level task only. As such, it promotes creativity and flexibility in implementation. The downside of such a prompt, of course, is that the result may not always be reproducible or robust; in the above case, the code generated by the declarative prompt could vary significantly across inference calls, and does not handle edge cases, which could be a problem if the code is intended for use in production. Despite these limitations, typical personas who may favor the declarative paradigm include product managers, UX designers, and business domain experts who lack coding expertise and may not need production-grade AI responses. Software developers and data scientists may also use declarative prompting to quickly generate a first draft, but they would be expected to review and refine the code afterward. Of course, one must keep in mind that the time needed to improve AI-generated code may cancel out the time saved by writing a short declarative prompt in the first place.
By contrast, the imperative prompt (Version 2) leaves very little to chance — each algorithmic step is specified in detail. Dependencies on non-standard packages are explicitly avoided, which can sidestep certain problems in production (e.g., breaking changes or deprecations in third-party packages, difficulty debugging strange code behavior, exposure to security vulnerabilities, installation overhead). But the greater control and robustness come at the cost of a verbose prompt, which may be almost as effort-intensive as writing the code directly. Typical personas who opt for imperative prompting may include software developers and data scientists. While they are quite capable of writing the actual code from scratch, they may find it more efficient to feed pseudocode to a generative AI model instead. For example, a Python developer might use pseudocode to quickly generate code in a different and less familiar programming language, such as C++ or Java, thereby reducing the likelihood of syntactic errors and the time spent debugging them.
Finally, the hybrid prompt (Version 3) seeks to combine the best of both worlds, using imperative instructions to fix key implementation details (e.g., stipulating the use of NumPy and SciPy), while otherwise employing declarative formulations to keep the overall prompt concise and easy to follow. Hybrid prompts offer freedom within a framework, guiding the implementation without completely locking it in. Typical personas who may lean toward a hybrid of declarative and imperative prompting include senior developers, data scientists, and solution architects. For example, in the case of code generation, a data scientist may wish to optimize an algorithm using advanced libraries that a generative AI model might not select by default. Meanwhile, a solution architect may need to explicitly steer the AI away from certain third-party components to comply with architectural guidelines.
Ultimately, the choice between declarative and imperative prompt engineering for generative AI should be a deliberate one, weighing the pros and cons of each paradigm in the given application context.