- Eoin Travers | AI & Data Science/
- Blog Posts/
- LLMs and Structured Conversation Graphs: The best of both worlds/
LLMs and Structured Conversation Graphs: The best of both worlds
Table of Contents
The Problem #
Creating open-ended LLM-powered chatbots is easy: you write a system prompt, stick it at the start of the conversation history, send the messages to an LLM, and away you go. Doing this gets you something that’s very flexible, and quick to build, but lose a lot of control over the structure of your chatbot’s conversations. By contrast, in traditional pre-LLM chatbots typically follow a an explicit, structured set of rules, powered by a dialogue management system to decide which pre-written message to show the user on each turn. These give you control, but not flexibility, and they require a lot of manual work. Can we have the best both worlds?
Meanwhile, it’s Q1 2025, and all the talk in AI engineering circles (at least the circles I like to spend time in) is about graphs/state machines, e.g. in pydantic.ai (❤️) LangGraph (🤔), and, at a lower level of abstraction, the state machines that power Outlines (🧠). Do you see where I’m going with this?
The Idea #
A structured conversation can be defined in terms of a graph.
Each node of the graph consists of:
- Instructions for what the chatbot should say. In traditional chatbots, these would be pre-written, but with LLMs we can use system prompts instead.
- Conditional edges that that take you to the next node, depending on the user’s response. Traditional chatbots might use keywords, regular expressions, or standalone machine learning/intent detection models to decide which edge to follow. We get to just use LLMs.
Step 1: Define Structures #
To start, let’s create pydantic models that can be used to define these conversation graphs.
I’ll also add some methods to the ChatGraph
class to make it easier to work with.
from pydantic import BaseModel, Field
from graphviz import Digraph
from textwrap import wrap as twrap
class ChatGraph(BaseModel):
"""Stores the full graph for a given conversation"""
nodes: list["ChatNode"]
start_node: str = Field(description="Label of the node to begin from")
Expand to see utility methods
def wrap(s, w=20):
"""Wrap text to a given width"""
return "\n".join(twrap(s, w))
class ChatGraph(BaseModel):
"""Stores the full graph for a given conversation"""
nodes: list["ChatNode"]
start_node: str = Field(description="Label of the node to begin from")
def write_json(self, path: str):
with open(path, "w") as f:
f.write(self.model_dump_json(indent=2))
@classmethod
def read_json(cls, path: str):
with open(path, "r") as f:
return cls.model_validate_json(f.read())
def visualize(self, render: bool = False):
"""Visualise the graph using graphviz"""
dot = Digraph(graph_attr={"rankdir": "LR"})
for node in self.nodes:
src = node.node_label
dot.node(src, wrap(node.instructions, 20))
for rc in node.response_categories:
dot.edge(src, rc.goto, wrap(rc.description, 20))
if render:
dot.render(view=True, format="svg")
return dot
class ChatNode(BaseModel):
"""Individual nodes"""
node_label: str = Field(description="A unique, informative ID for this node")
instructions: str = Field(
description=(
"Instructions for the chatbot loosely outlining what it should say at this step. "
"The chatbot will use these instructions plus background knowledge about the user to personalise its actual message."
)
)
response_categories: list["ChatResponseCategory"] = Field(
description="A list of possible categories of user responses"
)
class ChatResponseCategory(BaseModel):
"""A possible classification for the user's message"""
description: str = Field(description="A clear description of the category")
label: str = Field(description="A simple, unambiguous label for this response category")
goto: str = Field(description="The label of the ChatNode to go to if this response is given")
Step 2: Cheat by using AI to create my graph #
Now, in principle these conversation graphs can be created by hand, by editing the JSON directly, but that wouldn’t be very AI. Instead, let’s take advantage of the fact that we can get an LLM to generate data that is structured according to our schema, using either the Instructor package, or OpenAI’s structured outputs.
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
# This takes about 14 seconds
generated_chat_model_response = client.beta.chat.completions.parse(
model="gpt-4o",
response_format=ChatGraph,
messages=[
{
"role": "system",
"content": (
"Use the schema provided to generate a short ChatModel for a basic CBT chatbot that helps the user deal with anxiety. "
"Each node should include at least two response categories. "
"Instructions for non-terminal nodes should always include questions for the user to keep the conversation moving. "
"Aim for around 7 nodes in the conversation."
),
}
],
)
generated_chat_model = generated_chat_model_response.choices[0].message.parsed
!mkdir -p chats
generated_chat_model.write_json("chats/cbt1.json")
## Uncomment to load from file
# generated_chat_model = ChatGraph.read_json("chats/cbt1.json")
print(
generated_chat_model.model_dump_json(indent=2)
)
{
"nodes": [
{
"node_label": "introduction",
"instructions": "Greet the user and acknowledge that they are seeking help for anxiety. Ask them to describe their current feeling or situation.",
"response_categories": [
{
"description": "User provides a general description or elaborates on their anxiety",
"label": "User elaborates on anxiety",
"goto": "explore_causes"
},
{
"description": "User expresses uncertainty or is unable to specify what they're feeling",
"label": "User uncertain about feelings",
"goto": "explore_causes"
}
]
},
{
"node_label": "explore_causes",
"instructions": "Ask the user about potential factors contributing to their anxiety. Encourage them to consider recent events, ongoing stressors, or specific triggers.",
"response_categories": [
{
"description": "User identifies specific triggers or stressors",
"label": "User identifies triggers",
"goto": "discuss_thoughts"
},
{
"description": "User is unsure or cannot identify specific triggers",
"label": "User unsure of triggers",
"goto": "suggest_journaling"
}
]
},
Expand for full output
{
"nodes": [
{
"node_label": "introduction",
"instructions": "Greet the user and acknowledge that they are seeking help for anxiety. Ask them to describe their current feeling or situation.",
"response_categories": [
{
"description": "User provides a general description or elaborates on their anxiety",
"label": "User elaborates on anxiety",
"goto": "explore_causes"
},
{
"description": "User expresses uncertainty or is unable to specify what they're feeling",
"label": "User uncertain about feelings",
"goto": "explore_causes"
}
]
// Note: We shouldn't have two edges leading to the same node here.
// These edge labels are too long, should have specified to make them one word
},
{
"node_label": "explore_causes",
"instructions": "Ask the user about potential factors contributing to their anxiety. Encourage them to consider recent events, ongoing stressors, or specific triggers.",
"response_categories": [
{
"description": "User identifies specific triggers or stressors",
"label": "User identifies triggers",
"goto": "discuss_thoughts"
},
{
"description": "User is unsure or cannot identify specific triggers",
"label": "User unsure of triggers",
"goto": "suggest_journaling"
}
]
},
{
"node_label": "discuss_thoughts",
"instructions": "Ask the user to share their thoughts when they feel anxious. Suggest identifying any negative or automatic thoughts.",
"response_categories": [
{
"description": "User shares negative or automatic thoughts",
"label": "User shares negative thoughts",
"goto": "challenge_thoughts"
},
{
"description": "User is unsure what thoughts they have",
"label": "User unsure about thoughts",
"goto": "suggest_journaling"
}
]
},
{
"node_label": "suggest_journaling",
"instructions": "Encourage the user to keep a journal to track their feelings and thoughts, which might help in identifying patterns or triggers. Ask if they think this would be useful.",
"response_categories": [
{
"description": "User agrees that journaling might be helpful",
"label": "User agrees to journal",
"goto": "provide_reassurance"
},
{
"description": "User skeptical about journaling",
"label": "User skeptical about journaling",
"goto": "explore_causes"
}
]
},
{
"node_label": "challenge_thoughts",
"instructions": "Guide the user on how to challenge negative thoughts by questioning their validity and looking for evidence. Ask if they've tried this before.",
"response_categories": [
{
"description": "User has tried challenging thoughts before",
"label": "User has tried before",
"goto": "explore_successes"
},
{
"description": "User has not tried this method",
"label": "User has not tried before",
"goto": "provide_reassurance"
}
]
},
{
"node_label": "provide_reassurance",
"instructions": "Provide reassurance about making progress at their own pace. Suggest small, practical steps they can take to manage anxiety.",
"response_categories": [
{
"description": "User is willing to try suggested steps",
"label": "User willing to try steps",
"goto": "explore_successes"
},
{
"description": "User expresses doubt about managing anxiety",
"label": "User doubtful about managing anxiety",
"goto": "introduction"
}
]
},
{
"node_label": "explore_successes",
"instructions": "Ask the user about any small successes or improvements they've noticed recently. Encourage them by acknowledging these positive changes.",
"response_categories": [
{
"description": "User identifies recent progress or success",
"label": "User identifies progress",
"goto": "provide_reassurance"
},
{
"description": "User struggles to identify successes",
"label": "User struggles to identify success",
"goto": "suggest_journaling"
}
]
}
],
"start_node": "introduction"
}
generated_chat_model.visualize()
This is already pretty nice, if I do say so myself.
Step 3: A Chat Graph Runner #
Running this conversation is just a case of traversing the graph, generating the appropriate chatbot messages at each node, and deciding where to go next based on the user’s response. Here’s some code. This clearly isn’t production-ready, but I think it illustrates the idea. I’ve also added support for additional personalisation through a “user profile”, just because.
from typing import Literal
bot_turn_prompt = """
You are a personalised, rule-based chatbot.
Follow the message instructions below to generate a personalised message for the user.
In writing this message, take into account the information you have available to you
from the previous messages from the user in this conversation,
and from the user profile below.
<general instructions>
Always try to keep the conversation moving forward.
Ask relevent follow-up questions.
</general instructions>
<message instructions>
{instructions}
</message instructions>
<user profile>
{user_profile}
</user profile>
"""
user_turn_prompt = """
You are a personalised, rule-based chatbot.
Your instructions at this stage in the conversation were as follows:
<message instructions>
{instructions}
</message instructions>
Based on what the user has just said in the conversation history below, categorise their response into one of the following classes:
<possible categories>
{classes}
</possible categories>
Respond with the class label only.
If it is not yet clear how to categorise the user's response, of if it is appropriate to send the user a follow-up message
before moving on to the next stage in the conversation, respond "UNKNOWN" instead.
"""
class ChatGraphRunner:
"""Run a chat graph"""
def __init__(self, chat_graph: ChatGraph, user_profile: str = ""):
self.nodes: list[ChatNode] = {
node.node_label: node for node in chat_graph.nodes
}
self.current_node: str = chat_graph.start_node
self.messages: list[dict] = []
self.user_profile = user_profile
def bot_turn(self, node_label: str):
self.current_node = self.nodes[node_label]
instructions = self.current_node.instructions
classes = "\n".join([cat.label for cat in self.current_node.response_categories])
prompt = bot_turn_prompt.format(
instructions=instructions, user_profile=self.user_profile
)
print(f"\n---\nInstructions\n{instructions}\n---\n")
# Generate a chatbot message based on the current instructions + conversation history
bot_response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "system", "content": prompt}] + self.messages,
)
bot_message = bot_response.choices[0].message.content
self.messages.append({"role": "assistant", "content": bot_message})
print(f"[Bot@{node_label}]: {bot_message}")
self.user_turn()
def user_turn(self):
node = self.current_node
classes = "\n".join(
[
f"- (Label: {cat.label}) {cat.description}"
for cat in node.response_categories
]
)
class_labels = [cat.label for cat in node.response_categories] + ["UNKNOWN"]
class ClassLabelType(BaseModel):
label: Literal[tuple(class_labels)]
instructions = self.current_node.instructions
prompt = user_turn_prompt.format(instructions=instructions, classes=classes)
user_input = input("[User]: ")
self.messages.append({"role": "user", "content": user_input})
classification_response = client.beta.chat.completions.parse(
model="gpt-4o",
response_format=ClassLabelType,
messages=[{"role": "system", "content": prompt}] + self.messages,
)
classification_result: ClassLabelType = classification_response.choices[0].message.parsed.label
if classification_result == "UNKNOWN":
print(f"[staying on node {node.node_label}]")
self.bot_turn(node.node_label)
else:
# Move to next node
next_turn_label = [
v.goto
for v in node.response_categories
if v.label == classification_result
][0]
print(f"[moving to node {next_turn_label}]")
self.bot_turn(next_turn_label)
def start(self):
return self.bot_turn(self.current_node)
Finally, I’ve roleplayed a chat with this bot myself, just to illustrate how it works.
runner = ChatGraphRunner(
generated_chat_model, user_profile="User prefers a light, breezy tone, with emojis."
)
runner.start()
--- Instructions Greet the user and acknowledge that they are seeking help for anxiety. Ask them to describe their current feeling or situation. --- [Bot@introduction]: Hey there! 😊 It sounds like you're looking for a bit of support with anxiety. Want to share a little about how you're feeling or what's going on right now? I'm here to help! [User]: I'm stressed at work [moving to node explore_causes] --- Instructions Ask the user about potential factors contributing to their anxiety. Encourage them to consider recent events, ongoing stressors, or specific triggers. --- [Bot@explore_causes]: Oh no, that sounds tough! 😟 Work can be such a big source of stress. Do you think there's something specific at work that's been weighing on you lately? Is it your workload, deadlines, or something else? [User]: Yeah, deadlines [moving to node discuss_thoughts] --- Instructions Ask the user to share their thoughts when they feel anxious. Suggest identifying any negative or automatic thoughts. --- [Bot@discuss_thoughts]: Ugh, tight deadlines can really make things stressful! 🗓️ When you're feeling anxious about these deadlines, it might help to pause and identify any negative or automatic thoughts you might be having. Recognizing them can be the first step in managing that stress. What kind of thoughts pop into your head when you're up against the clock? [User]: I'm going to be held responsible if we don't deliver this [moving to node challenge_thoughts] --- Instructions Guide the user on how to challenge negative thoughts by questioning their validity and looking for evidence. Ask if they've tried this before. --- [Bot@challenge_thoughts]: That sounds like a lot of pressure. 😧 One thing you can try is to question the validity of those thoughts. Ask yourself, is there concrete evidence that supports feeling solely responsible, or is it a shared team effort? Sometimes, being realistic about what's truly within our control helps ease some of that weight. Have you tried questioning these kinds of thoughts before? 🤔 [User]: Yeah. Still have them [moving to node explore_successes] --- Instructions Ask the user about any small successes or improvements they've noticed recently. Encourage them by acknowledging these positive changes. --- [Bot@explore_successes]: It's totally normal for those thoughts to stick around, even when you start questioning them. 🌸 Have you noticed any small improvements or successes in managing your stress recently? Even tiny wins, like finding a moment of calm in your day, can be worth celebrating. 🎉 Anything like that happening for you lately? [User]: Like what? [moving to node suggest_journaling] --- Instructions Encourage the user to keep a journal to track their feelings and thoughts, which might help in identifying patterns or triggers. Ask if they think this would be useful. --- [Bot@suggest_journaling]: Even small things can make a difference! 😊 For example, maybe you've found a few minutes to take a breather during a break, or you managed to prioritize your tasks in a way that made the day a bit more manageable. One thing that might really help is keeping a journal to track your feelings and thoughts. 📓✨ This can make it easier to spot patterns or triggers. Do you think having a journal could be useful for you? 🤔 [User]: I'm not sure [staying on node suggest_journaling] --- Instructions Encourage the user to keep a journal to track their feelings and thoughts, which might help in identifying patterns or triggers. Ask if they think this would be useful. --- [Bot@suggest_journaling]: That's okay! It's not everyone's cup of tea. 😊 You could start small by jotting down just a few words about how you're feeling each day, and see if you notice any patterns over time. It doesn't have to be anything too detailed. How do you usually like to unwind after a busy day at work? Maybe we can explore some other stress-busting ideas! 🌈
…at this point, I had had enough and ended the chat. It works! Clearly, there’s room for improvement, but I think this is a nice pattern, with a lot of potential for use in cases where you want finer-grained control than can be achieved with the standard approach of plugging your users in to the LLM firehose.
This code is available as a gist here. Like the rest of this website, it’s MIT licensed, but if you end up using it I’d love to hear from you.