Building LLM Workflows in Wolfram Language

Timothee Verdier, WRI
Giulio Alessandrini, WRI

#WolframTechConf

ABSTRACT

This is a hands-on workshop exploring how to construct and customize LLM-based workflows in Wolfram Language. Starting from simple single-call applications, we'll work step by step toward full agentic pipelines that integrate multiple models, external tools and symbolic computation. Along the way, we'll see examples ranging from conversational data analysis and natural language model building to multistep orchestration with external APIs and hybrid symbolic–numeric research assistants.

OUTLINE

Setup

Use case: Chatbot

Step 1: LLMSynthesize for a Greeting Message

Step 2: Create a Function to Take User Input

Step 3: Customize Prompts with User Information

Step 4: Integrate RAG using SemanticSearch

Step 5: Connect Paths with LLMGraph

Setup

In order to follow along during the workshop you will need to have the LLM functionality enabled.

Option 1: LLMKit subscription

Option 2: bring your own key for an external API (Anthropic, OpenAI, GoogleGemini, …)

Option 3: local LLM via Ollama

check preferences menu: Wolfram > Settings... > AI Settings > Services tab

Setup

The easiest way to test the setup is to run an LLM-powered function.

LLMSynthesize[“hi”] test

LLMSynthesize["hi"]

If you select a service for which credentials are not stored yet,  you will get a prompt like this

Credentials prompt

Use case: Chatbot

In this workshop, we are going to build a small chatbot that will handle customer queries for a fantastical company.

Whimsical Wonders Ltd.: Offers enchanting and whimsical products like "Magic Bubble Blower" and "Rainbow Maker."

Step 1: LLMSynthesize for a Greeting Message

Create a simple greeting message for the user using of (TextRefLink[LLMSynthesize, paclet:ref/LLMSynthesize, https://reference.wolfram.com/language/ref/LLMSynthesize.html])

role="Generate a friendly greeting message for a customer service chatbot.";

LLMSynthesize[role]

Step 1: LLMSynthesize for a Greeting Message

Make it more relevant by adding some text to describe what our company is doing

description="Whimsical Wonders Ltd is a fantastical company dedicated to bringing magic and joy into everyday life. Specializing in enchanting products, we offer a delightful array of items such as the \"Magic Bubble Blower\" that fills the air with shimmering bubbles, and the \"Rainbow Maker,\" which turns any rainy day into a colorful spectacle. Our mission is to inspire imagination and spread smiles with every whimsical creation.";

LLMSynthesize[{description,role}]

Step 2: Create a Function to Take User Input

Static content creation is quite boring! We need to pay attention to what the customer is saying in order to help them.
(TextRefLink[LLMFunction, paclet:ref/LLMFunction, https://reference.wolfram.com/language/ref/LLMFunction.html]) can generate prompt dynamically based on its input

LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,role}]["my rainbow maker is utterly broken! it just makes rain!!! REFUND!!111!"]

Step 3: Customize Prompts with User Information

We often have access to external information that we want to add to the prompt on the fly.
The information can be packaged in a function and then (TextRefLink[LLMPromptGenerator, paclet:ref/LLMPromptGenerator, https://reference.wolfram.com/language/ref/LLMPromptGenerator.html]) will add it to the list of static prompts.

user:=<|
"Name"->$RegisteredUserName,
"Products"->{"Magic Bubble Blower","Rainbow Maker"},
"DateTime"->Now,
"Location"->$GeoLocationCity
|>

Clear[userprompt]
userprompt[user_][context_]:="Information about the current user in the chat session:\n\n"<>ExportString[TextString/@user,"JSON"]

LLMPromptGenerator[userprompt[user]][""]

Step 3: Customize Prompts with User Information

Now the greetings will be customized for the current user.

custominfo="Use as much of the customer information as possible to make the conversation more personal. Address the user by name and use the proper greeting given the time of the day";

LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,role,custominfo,LLMPromptGenerator[userprompt[user]]}]["my rainbow maker is utterly broken! it just makes rain!!! REFUND!!111!"]

Step 4: Integrate RAG using SemanticSearch

Relying only on the data stored in the model memory will not take us very far. If we have a curated knowledgebase, like an FAQ section, we can add it to the prompt to guide the generation.

faq=;

faqPrompt=StringJoin["If relevant, use these FAQ to help the user:\n\n",StringRiffle[faq,"\n\n"]]

Pane[faqPrompt,{400,100},BaseStyle->{"Label"}]

LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,role,custominfo,LLMPromptGenerator[userprompt[user]],faqPrompt}]["i want my money back!!111"]

Step 4: Integrate RAG using SemanticSearch

When the extra content become too much, is expensive and inefficient to add it all to the prompt verbatim,
You can encode the text in a vector embedding using (TextRefLink[CreateSemanticSearchIndex, paclet:ref/CreateSemanticSearchIndex, https://reference.wolfram.com/language/ref/CreateSemanticSearchIndex.html]) and user neighbor search to only add the relevant elements

DeleteObject@SemanticSearchIndices["FAQ*"]

indexFAQ=CreateSemanticSearchIndex[faq,"FAQ-Index"]

SemanticSearch[indexFAQ,"i want my money back!!111",MaxItems->3]

LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,role,custominfo,LLMPromptGenerator[userprompt[user]],LLMPromptGenerator[indexFAQ]}]["i want my money back!!111"]

Step 4.1  (bonus): Augment the RAG using LLMs

Sometimes the documents that you want to put in the index have a different tone than the sentences used to search for them. LLMs can be leveraged to create meaningful descriptors.

Clear[generateDescriptions]
generateDescriptions[qa_]:=StringTrim@StringSplit[LLMFunction["generate five short customer messages that will be addressed by this question/answer pair, separate them by newlines and avoid decorators or bullets:
``"][qa],"\n"..]

This is what it does on the first FAQ:

generateDescriptions[faq[[1]]]

Now we can run it on all questions:

faqdescription=generateDescriptions/@faq;

A precomputed result

faqdescription=;

The new index has more paths to each answer now

indexFAQaugmented=CreateSemanticSearchIndex[MapThread[Thread[#->#2]&,{faqdescription,faq}],"FAQ-Index-Augmented"]

LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,role,custominfo,LLMPromptGenerator[userprompt[user]],LLMPromptGenerator[indexFAQaugmented]}]["i want my money back!!111"]

From Simple to Complex

So far we've built:
✓ Dynamic prompt generation
✓ Personalized responses
✓ Smart knowledge retrieval

But what about complex conversations that need:
• Different handling for different intents
• Multiple decision points
• Quality control

This is where LLMGraph comes in!

Step 5: Connect Paths with LLMGraph

Managing complex conversations requires multiple logical steps. We can't rely on a single LLM call to handle:
• Routing different types of queries
• Applying different response strategies
• Quality control

(TextRefLink[LLMGraph, paclet:ref/LLMGraph, https://reference.wolfram.com/language/ref/LLMGraph.html]) lets us build multi-step workflows with explicit control flow.

Step 5: Connect Paths with LLMGraph

The first step is just a repackaging of out customized greeting from before

No input needed - generates greeting from user info alone

greetings=LLMFunction["",LLMEvaluator->"Prompts"->{description,role,custominfo,LLMPromptGenerator[userprompt[user]]}];

Step 5.1: Building Our Response Functions

First, let's create our specialized responders:
• The Conversationalist - handles friendly chat
• The Helper - answers informational queries  
• The Firefighter - handles complaints with extra care

conversationPrompt="you are a friendly conversation partner and a helpful assitant, answer the user being respectful and polite";
conversationalist=LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,conversationPrompt,custominfo,LLMPromptGenerator[userprompt[user]]}];

informationPrompt="assist the user with answer from our knowledgebase. if an answer is missing suggest to contact support using the button";
helper=LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,conversationPrompt,informationPrompt,custominfo,LLMPromptGenerator[userprompt[user]],LLMPromptGenerator[indexFAQ]}];

Let’s be extra polite if the user is angry!

waterPrompt="the user is particularly angry. make sure to reassure them and let them know we are here to help";
firefighter=LLMFunction["Customer message:\n``",LLMEvaluator->"Prompts"->{description,conversationPrompt,informationPrompt,waterPrompt,custominfo,LLMPromptGenerator[userprompt[user]],LLMPromptGenerator[indexFAQ]}];

Step 5.2: Building Our Response Functions

A quick test of each function to verify they work before we connect them.

conversationalist["hi there! what's up"]

helper["where is my order"]

firefighter["i want my money back!!111"]

Notice how each gives appropriate responses for its role!

Step 5.3: The Dispatcher - Routing User Intent

Then we need to build an intent judge. We can ask the LLM to provide a simple tag that will be easy to parse.

dispatcher=LLMFunction["Classify the intent of this message in one of:
- [information] : the customer wants to know something about the company, a product, an order
- [complaint] : the customer has an issue and they are angry about it
- [chat] : the customer just wants to have a friendly chat
Only answer with one of the [tag] and nothing else.

Customer message:\n``
"]

Let’s test it

dispatcher/@{"hi there! what's up","where is my order","i want my money back!!111"}

Step 5.4: Building Blocks Ready!

We now have:
• Greetings - welcomes the user
• Dispatcher - routes to the right handler  
• Conversationalist - friendly chat
• Helper - informational queries
• Firefighter - angry customers

Time to connect them into a workflow!

Step 5.4: Our First Graph - A Simple Pipeline

Let's start with just: Greet → Classify → Respond

We're building a dictionary where:
• Keys are node names
• Values describe what each node does and when it runs

simpleGraph=LLMGraph[<|
"Greetings"-><|"LLMFunction"->greetings,"Input"->{},"TestFunction"->Function[#Input===""]|>,
"Dispatcher"-><|"LLMFunction"->dispatcher,"Input"->{"Input"},"TestFunction"->Function[#Input=!=""]|>,
"Conversation"-><|"LLMFunction"->conversationalist,"TestFunction"->Function[StringQ[#Dispatcher]&&StringContainsQ[#Dispatcher,"chat"]],"Input"->{"Input"}|>
|>];

Each node has a name (dictionary key)
•  “LLMFunction" - what to run
• "Input" - which previous nodes to use
• "TestFunction" - when to activate this node
• Nodes run when their test passes

Notice how TestFunction uses #Dispatcher - it reads the output from that node!

Step 5.4: Our First Graph - A Simple Pipeline

This is what is going on

Information[simpleGraph,"Graph"]

•  Diamond – graph inputs
• Circles - internal nodes
• Squares - output nodes
• Dashed lines - test dependency

Step 5.4: Our First Graph - A Simple Pipeline

Let's test the greeting providing an empty string input

simpleGraph[<|"Input"->""|>]

When there is a single input node, you can omit the Association:

simpleGraph["hi there! what's up"]

Step 5.5: Time to Branch!

Our simple graph only handles chat. Let's add paths for:
💬  Chat (already working!)
ℹ️  Information requests → Helper
🔥  Complaints → Firefighter

We also need an "Answer" node to combine the outputs.

Step 5.5: Expanding to Multiple Paths

Now let's add the Helper and Firefighter paths:

multiPathGraph=LLMGraph[<|
"Greetings"-><|"LLMFunction"->greetings,"Input"->{},"TestFunction"->Function[#Input===""]|>,
"Dispatcher"->
<|"LLMFunction"->dispatcher,"Input"->{"Input"},"TestFunction"->Function[#Input=!=""]|>,
"Conversation"->
<|"LLMFunction"->conversationalist,"TestFunction"->Function[StringQ[#Dispatcher]&&StringContainsQ[#Dispatcher,"chat"]],"Input"->{"Input"}|>,
"Help"->
<|"LLMFunction"->helper,"TestFunction"->Function[StringQ[#Dispatcher]&&StringContainsQ[#Dispatcher,"information"]],"Input"->{"Input"}|>,
"Emergency"->
<|"LLMFunction"->firefighter,"TestFunction"->Function[StringQ[#Dispatcher]&&StringContainsQ[#Dispatcher,"complaint"]],"Input"->{"Input"}|>,
"Answer"->
Function[StringJoin[Replace[{#Greetings,#Conversation,#Help,#Emergency},Except[_String]->"",{1}]]]
|>
];

And this is how the multi-path graph looks like:

Information[multiPathGraph,"Graph"]

Step 5.5: Expanding to Multiple Paths

Let’s test it!

multiPathGraph["hi there! what's up"]
multiPathGraph["where is my order"]
multiPathGraph["i want my money back!!111"]

Step 5.6: Debugging Your Graphs

When building complex workflows, you need to see what's happening at each step.
The "LLMGraph" return format shows you everything:

resgraph=multiPathGraph["i want my money back!!111","LLMGraph"]

Information[resgraph,"Nodes"][All,{"Result"}]

This is invaluable for debugging!
• See which nodes fired
• Check intermediate results
• Identify where things went wrong

Common issues:
• TestFunction didn't match → node shows empty
• Wrong Input specification → node gets wrong data
• LLMFunction error → check the Result column

Step 5.7 (bonus): Adding a Safety Layer

Should we trust the LLM output directly?

In production, you might want:
• Human review for sensitive responses
• Fallback to specialists for complex issues
• Quality checks before responding

Let's add a "Judge" node to evaluate responses!

judge=LLMFunction["You are a supervisor in charge of evaluating the customer service responses.
You must respond with one of the following tag:
- [correct] : the answer is good and can be given to the user
- [specialist] : the answer is inappropriate or we don't know how to answer, we need a specialist to step in and provide a better one

Only answer with one of the [tag] and nothing else.

User input: `Input`
Generated answer: `Answer`
"];

judge[<|"Input"->"i want my money back!!111","Answer"->"I'm really sorry to hear that you're feeling this way. We offer a Joy Guarantee..."|>]

judge[<|"Input"->"i want my money back!!111","Answer"->"well that's your problem not mine"|>]

Step 5.7 (bonus): Adding a Safety Layer

Perfect! Now let's add this safety layer to our graph.

New flow: Response paths → Answer → Judge → Output (with potential human escalation)

waitingMessage="One of our fairy godmothers is being materialized to help you! Please be patient";

graph=LLMGraph[<|
"Greetings"-><|"LLMFunction"->greetings,"Input"->{},"TestFunction"->Function[#Input===None]|>,
"Dispatcher"->
<|"LLMFunction"->dispatcher,"Input"->{"Input"},"TestFunction"->Function[StringQ[#Input]]|>,
"Conversation"->
<|"LLMFunction"->conversationalist,"TestFunction"->Function@StringContainsQ[#Dispatcher,"chat"],"Input"->{"Input"}|>,
"Help"->
<|"LLMFunction"->helper,"TestFunction"->Function@StringContainsQ[#Dispatcher,"information"],"Input"->{"Input"}|>,
"Emergency"->
<|"LLMFunction"->firefighter,"TestFunction"->Function@StringContainsQ[#Dispatcher,"complaint"],"Input"->{"Input"}|>,
"Answer"->
Function[StringJoin[Replace[{#Greetings,#Conversation,#Help,#Emergency},Except[_String]->"",{1}]]],
"Judge"->
judge,
"Output"->
Function[If[StringContainsQ[#Judge,"[correct]"],#Answer,waitingMessage]]
|>
]

Here’s a picture of the nodes connectivity:

Information[graph,"Graph"]

Extra Challenges

Want More? Try These Extensions:
1. Add conversation history tracking
2. Add product search integration
3. Create a sentiment analysis node
4. Build a different use case (tech support, restaurant booking, etc.)

THANK YOU

What You've Learned

✓ LLM basics: Synthesize → Function → PromptGenerator
✓ RAG: Semantic search for knowledge retrieval
✓ Workflows: Multi-node graphs with conditional logic
✓ Production patterns: Testing, debugging, safety checks

Resources

• Documentation: https://reference.wolfram.com/language/guide/LLMFunctions.html
• Community: https://community.wolfram.com
• Your notebook: Contains all code + bonus material

Keep Building!

The extra challenges are in your notebook.
Share what you build: #WolframTechConf