Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 55

Crafting Dynamic LLM Prompts

Recommended resources

Hello! Welcome back to our project.

In the last lesson, you built a vital connection by enabling our Rust backend to communicate with a local LLM. You now have a function that can send a text prompt and get a response. However, the real magic of a life simulation game comes from its characters feeling unique and alive. A generic response to a generic prompt won't achieve that.

Today, we'll transform our simple Q&A machine into a dynamic character-dialogue generator. Our learning outcome is to construct dynamic LLM prompts that include character personality, mood, and relationship context. We will give the LLM a "script" that tells it who is speaking, how they feel, and who they are talking to, so its responses reflect the rich inner lives of our game's characters.

1. From Prompt Engineering to Context Engineering

What we are about to do is a practical application of a field called prompt engineering. The idea is to carefully craft the input to a language model to guide its output. Think of it as giving an actor not just their line, but also their motivation, backstory, and emotional state.

To get a quick overview of the techniques involved, please watch the first part of this video from IBM Technology.

Context Engineering vs. Prompt Engineering: Smarter AI with RAG & Agents

This video introduces foundational prompt engineering techniques like role assignment and constraint setting, which are the building blocks for what we're about to implement.

Watch the segment from 02:24 to 04:06, which covers key techniques for guiding an LLM's behavior.

As the video explains, by assigning a role and setting constraints, we can coax a specific persona out of the model. We're going to take this a step further by dynamically generating these roles and constraints based on our game's state. This is sometimes called Context Engineering: we are engineering the context that the LLM receives.


2. Defining Character Context in Rust

Before we can inject context into a prompt, we need to represent that context in our Rust code. Let's define the data structures that will hold the essential information about a character's current state.

In your project, let's create structs to represent personality, relationships, and the overall context.

A. Personality: The HEXACO Model
As planned in our course syllabus, we're using the HEXACO model for personality. It has six dimensions, each represented as a floating-point number (e.g., from 0.0 to 1.0).

HEXACO Personality Model Overview
The HEXACO model provides six dimensions to define a character's core personality: Honesty-Humility, Emotionality, eXtraversion, Agreeableness, Conscientiousness, and Openness to Experience.

Let's create a struct for it. Add this to your main.rs:




#[derive(Debug)]
struct HexacoPersonality {
    honesty_humility: f32,
    emotionality: f32,
    extraversion: f32,
    agreeableness: f32,
    conscientiousness: f32,
    openness: f32,
}

B. Relationships and Overall Context
Now, let's define a simple struct for relationship context and a primary struct to bundle everything together.




#[derive(Debug)]
struct RelationshipContext {
    friendship_score: f32, // e.g., -100 to 100
    romance_score: f32,    // e.g., -100 to 100
}




#[derive(Debug)]
struct CharacterContext {
    name: String,
    personality: HexacoPersonality,
    mood: f32, // e.g., -1.0 (very negative) to 1.0 (very positive)
    relationship_with_target: RelationshipContext,
}

These structs give us a clear, organized way to hold all the information we need to build our dynamic prompt.


3. The Art of the Dynamic Prompt

Our goal is to combine the data from our CharacterContext structs into a single, comprehensive text prompt. This is often done using a prompt template. A template is a pre-written block of text with placeholders for dynamic data.

The following video from Sam Witteveen discusses a Python framework called "Prompt Poet," but the core idea is universal: treating prompts as a function of state and using templates to inject that state.

Prompt Poet - Character AI's Prompting Framework

This video explains the concept of using prompt templates to dynamically inject state (like character data) into a prompt. While it uses Python examples, the philosophy directly applies to what we will do in Rust.

Watch the sections from 03:51-07:18 and 11:41-16:00. Focus on the core concepts: how variables (template data) are substituted into a raw template, and how you can conditionally include different information (like 'in-context examples' or 'modality') based on the situation. This is analogous to us adding personality or mood information.

Inspired by this, we'll create a prompt with a few key sections:

  1. Persona: "You are [Character Name]. Your personality is..."
  2. Situation: "You are feeling [Mood]. You are talking to [Target Name], who is your [Relationship]."
  3. Instruction: "Generate a single line of dialogue for the action: '[Interaction]'."

This approach of fusing character profiles, memories, and situational context into a prompt is a well-established technique in academic research for creating believable NPC dialogue.

Memory-Driven NPC Behavior: Context-Aware and Emotion-Based...

To see how our approach fits into a larger system, let's look at this research paper. It describes a framework for generating NPC dialogue that's remarkably similar to what we are building, using a local model, memory, and prompt fusion.

Read the sections titled 'Bridging Sentiment, Memory, and Generative Models' (2.1.3), 'LLM Dialogue Generation via Prompt Fusion' (5.3.3), and 'System Workflow and Execution Pipeline' (5.4). Notice how they describe a pipeline that constructs a 'composite prompt' from personality metadata, memory, user input, and sentiment—this validates the architecture we're implementing.


4. Implementation in Rust

Let's translate this theory into code. We'll create a function generate_character_dialogue that takes the context of the speaker and the target, and generates the final prompt string.

First, let's add some helper functions to translate our numeric scores into human-readable descriptions for the LLM.

// Add this function to describe a personality score
fn describe_personality_trait(value: f32) -> &'static str {
    if value > 0.8 { "very high" }
    else if value > 0.6 { "high" }
    else if value > 0.4 { "average" }
    else if value > 0.2 { "low" }
    else { "very low" }
}

// Add this function to describe a mood score
fn describe_mood(mood: f32) -> &'static str {
    if mood > 0.7 { "ecstatic" }
    else if mood > 0.3 { "happy" }
    else if mood > -0.3 { "neutral" }
    else if mood > -0.7 { "unhappy" }
    else { "miserable" }
}

// Add this function to describe a relationship
fn describe_relationship(relationship: &RelationshipContext) -> String {
    if relationship.romance_score > 50.0 {
        return "your romantic partner".to_string();
    }
    if relationship.friendship_score > 50.0 {
        return "a good friend".to_string();
    }
    if relationship.friendship_score < -50.0 {
        return "your rival".to_string();
    }
    "an acquaintance".to_string()
}

Now, for the main prompt-building function. This function will use Rust's format! macro as our simple templating engine.

fn construct_prompt(
    speaker: &CharacterContext,
    target_name: &str,
    interaction_name: &str,
) -> String {
    let persona_prompt = format!(
        "You are {}. Your personality traits are: \
        Honesty-Humility is {}. \
        Emotionality is {}. \
        Extraversion is {}. \
        Agreeableness is {}. \
        Conscientiousness is {}. \
        Openness to Experience is {}.",
        speaker.name,
        describe_personality_trait(speaker.personality.honesty_humility),
        describe_personality_trait(speaker.personality.emotionality),
        describe_personality_trait(speaker.personality.extraversion),
        describe_personality_trait(speaker.personality.agreeableness),
        describe_personality_trait(speaker.personality.conscientiousness),
        describe_personality_trait(speaker.personality.openness),
    );

    let situation_prompt = format!(
        "You are currently feeling {}. You are talking to {}, who is {}.",
        describe_mood(speaker.mood),
        target_name,
        describe_relationship(&speaker.relationship_with_target)
    );

    let instruction_prompt = format!(
        "You are performing the social interaction: '{}'. \
        Based on your personality and mood, generate a single, short line of dialogue (less than 20 words) that fits this interaction. \
        Do not add any actions or descriptions in parentheses. Just give the dialogue.",
        interaction_name
    );

    // Combine all parts into a final prompt
    format!("{}\n{}\n{}", persona_prompt, situation_prompt, instruction_prompt)
}

5. Putting It All Together: A Test Scenario

Let's update our main function to test this. We'll create two distinct characters and see how their dialogue differs for the same interaction.

Update your main function and add the new function calls. Make sure all the structs and helper functions from above are also in your main.rs file.




#[tokio::main]
async fn main() -> Result<()> {
    // --- Character Definitions ---
    let alex = CharacterContext {
        name: "Alex".to_string(),
        personality: HexacoPersonality {
            honesty_humility: 0.7,
            emotionality: 0.6,
            extraversion: 0.9,
            agreeableness: 0.8,
            conscientiousness: 0.5,
            openness: 0.7,
        },
        mood: 0.8, // Happy
        relationship_with_target: RelationshipContext {
            friendship_score: 75.0,
            romance_score: 10.0,
        },
    };

    let bob = CharacterContext {
        name: "Bob".to_string(),
        personality: HexacoPersonality {
            honesty_humility: 0.4,
            emotionality: 0.3,
            extraversion: 0.2,
            agreeableness: 0.1,
            conscientiousness: 0.8,
            openness: 0.3,
        },
        mood: -0.5, // Unhappy
        relationship_with_target: RelationshipContext {
            friendship_score: 20.0,
            romance_score: 0.0,
        },
    };

    // --- Scenario 1: Cheerful Alex greets his friend Bob ---
    let interaction = "Friendly Greeting";
    let alex_prompt = construct_prompt(&alex, "Bob", interaction);
    
    println!("--- SCENARIO 1: Alex greets Bob ---");
    println!("\n[Generated Prompt for Alex]:\n{}\n", alex_prompt);

    let alex_response = llm_completion(&alex_prompt).await?;
    println!("\n[Alex's Dialogue]:\n{}\n", alex_response);


    // --- Scenario 2: Grumpy Bob greets acquaintance Alex ---
    let bob_prompt = construct_prompt(&bob, "Alex", interaction);

    println!("--- SCENARIO 2: Bob greets Alex ---");
    println!("\n[Generated Prompt for Bob]:\n{}\n", bob_prompt);
    
    let bob_response = llm_completion(&bob_prompt).await?;
    println!("\n[Bob's Dialogue]:\n{}", bob_response);

    Ok(())
}

// Don't forget to include your llm_completion function from the previous lesson!
// And the new structs and helper functions from this lesson.

Now, make sure your Ollama server is running and execute the code:

cargo run

You should see two very different scenarios play out. The prompts will be explicitly tailored to each character, and the resulting dialogue should reflect their personalities and moods. Alex, being extroverted and happy, will likely give an enthusiastic greeting. Bob, being introverted and grumpy, will probably offer a much more reserved or even curt response.



Conclusion

Excellent work today! You have made a significant leap from simply getting a response from an LLM to generating context-aware, in-character dialogue. This is a cornerstone of building a believable and engaging life simulation game.

Key Takeaways:

  • Prompt Engineering is Key: The quality of an LLM's output is highly dependent on the quality and detail of its input prompt.
  • Context is Everything: By defining a character's personality, mood, and relationships in structured data, we can dynamically build rich prompts.
  • Templating is a Powerful Tool: Using format! or other templating methods allows us to fuse static instructions with dynamic game data to create comprehensive prompts.
  • Descriptive Language Works: Translating numerical stats (like a 0.9 in extraversion) into descriptive words ("very high") helps the LLM better understand the context.

In our next lesson, we will make our conversations even more intelligent by giving our characters memory. We will refine our prompt to include the recent conversation history, allowing characters to have coherent, multi-turn dialogues that don't forget what was just said.