Building Durable AI Workflows with Structured Outputs in Go
Learn how to create robust, long-running AI workflows with structured outputs using Inferable and Go.
While there's been an explosion of AI libraries and frameworks, many lack the infrastructure required for building robust, long-running workflows with structured outputs. This is where Inferable comes in - a powerful platform for creating durable AI workflows with minimal boilerplate.
First thing first - Inferable is not a framwork or a library. We are an opinionated platform for building AI workflows. We provide a REST API for building AI-native workflows and a client library for Go to make it easier to wrangle the API.
In this post, we'll walk through creating a simple yet powerful AI workflow in Go that processes restaurant menu websites and extracts structured data.
Workflows with Structured Outputs
When working with LLMs, getting reliable structured data is a significant challenge. Free-form text responses are unpredictable, making it difficult to build applications that can reliably parse and use the output. Structured outputs solve this problem by constraining the model to produce data in a specific format that your application can easily work with.
But all LLM providers don't support structured outputs. For some models, you might have to resort to doing some "prompt engineering" like: "PLEASE RETURN THE OUTPUT IN THE FOLLOWING JSON FORMAT, AND ONLY RETURN THE JSON, NO OTHER TEXT". This is not reliable and is error prone.
Inferable makes this straightforward with built-in support for structured outputs across different language models, handling retries and validation automatically. We have a model-agnostic approach to structured outputs that works with any LLM using a minimal schema definition. (More on this in a future post.)
Prerequisites
Before we begin, make sure you have:
- Go installed (1.18 or newer)
- Basic familiarity with Go programming
- An Inferable API key (you can create an account to get one)
Setting Up the Project
Let's start by creating a new Go project and installing the necessary packages:
mkdir menu-extractor
cd menu-extractor
go mod init menu-extractor
go get github.com/inferablehq/inferable/sdk-go
Creating Our First Workflow
The goal of our workflow is simple: given a URL to a restaurant website, extract a structured representation of their menu items and opening hours.
Let's create a file called main.go
:
package main
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"time"
inferable "github.com/inferablehq/inferable/sdk-go"
)
// Define our input and output structures
type WorkflowInput struct {
ExecutionId string `json:"executionId"`
URL string `json:"url"`
}
type MenuItem struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Price float64 `json:"price"`
Category string `json:"category,omitempty"`
}
type OpeningHours struct {
Monday string `json:"monday,omitempty"`
Tuesday string `json:"tuesday,omitempty"`
Wednesday string `json:"wednesday,omitempty"`
Thursday string `json:"thursday,omitempty"`
Friday string `json:"friday,omitempty"`
Saturday string `json:"saturday,omitempty"`
Sunday string `json:"sunday,omitempty"`
}
type WorkflowOutput struct {
MenuItems []MenuItem `json:"menuItems"`
Hours OpeningHours `json:"hours"`
RestaurantName string `json:"restaurantName"`
}
func main() {
// Initialize the Inferable client
client, err := inferable.New(inferable.InferableOptions{
APISecret: os.Getenv("INFERABLE_API_SECRET"),
})
if err != nil {
fmt.Printf("Error initializing Inferable client: %v\n", err)
return
}
// Create a workflow
workflow := client.Workflows.Create(inferable.WorkflowConfig{
Name: "menu-extractor",
InputSchema: WorkflowInput{},
})
// Define workflow logic (version 1)
workflow.Version(1).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
// Log the start of the workflow
ctx.Log("info", map[string]interface{}{
"message": "Starting menu extraction",
"url": input.URL,
})
// Fetch the content of the webpage
webContent, err := fetchWebpage(input.URL)
if err != nil {
return nil, fmt.Errorf("error fetching webpage: %w", err)
}
// Use LLM to extract structured data
result, err := ctx.LLM.Structured(inferable.StructuredInput{
Input: webContent,
Schema: WorkflowOutput{},
})
if err != nil {
return nil, fmt.Errorf("error extracting structured data: %w", err)
}
// Return the extracted data
return result, nil
})
// Start listening for workflow executions
fmt.Println("Starting to listen for menu-extractor workflow executions...")
err = workflow.Listen()
if err != nil {
fmt.Printf("Error listening for workflow executions: %v\n", err)
return
}
// Trigger the workflow after a countdown
triggerWorkflowWithCountdown(client, 3)
// Keep the program running
select {}
}
// triggerWorkflowWithCountdown triggers the workflow after a countdown
func triggerWorkflowWithCountdown(client *inferable.Inferable, seconds int) {
// Add a countdown before triggering
fmt.Printf("Triggering workflow in:\n")
for i := seconds; i > 0; i-- {
fmt.Printf("%d...\n", i)
time.Sleep(1 * time.Second)
}
fmt.Println("Triggering workflow now!")
err := client.Workflows.Trigger("menu-extractor", "unique-execution-id", map[string]interface{}{
"url": "https://a.inferable.ai/menu.txt",
})
if err != nil {
fmt.Printf("Error triggering workflow: %v\n", err)
return
}
fmt.Println("Workflow triggered successfully")
}
// Helper function to fetch webpage content
func fetchWebpage(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
This code sets up a workflow that:
- Takes a URL as input
- Fetches the content of the webpage
- Uses an LLM to extract menu items and opening hours in a structured format
- Returns the structured data
- Includes a built-in trigger function with a countdown
How Structured Output Works
The magic happens in this part:
result, err := ctx.LLM.Structured(inferable.StructuredInput{
Input: webContent,
Schema: WorkflowOutput{},
})
Here, we're telling the LLM to:
- Parse the webpage content we fetched
- Return the data in the format defined by our
WorkflowOutput
struct
Inferable handles all the complex work of:
- Converting our Go struct to a JSON schema the LLM can understand
- Passing this schema to the LLM with appropriate prompting
- Validating that the LLM's response conforms to our schema
- Automatically retrying if the response doesn't match our schema
- Parsing the response back into our Go struct
Making Our Workflow More Robust
Let's enhance our workflow to make it more resilient. We'll add:
- Caching to avoid redundant web requests
- Better error handling
- Fallback strategies
Here's how we can modify our workflow definition:
workflow.Version(1).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
// Log the start of the workflow
ctx.Log("info", map[string]interface{}{
"message": "Starting menu extraction",
"url": input.URL,
})
// Avoid redundant web requests, in case the workflow is retried
webContent, err := ctx.Memo("fetch_"+input.URL, func() (interface{}, error) {
content, err := fetchWebpage(input.URL)
if err != nil {
return "", fmt.Errorf("error fetching webpage: %w", err)
}
return content, nil
})
if err != nil {
return nil, fmt.Errorf("error in memoized fetch: %w", err)
}
// Convert interface{} to string
contentStr, ok := webContent.(string)
if !ok {
return nil, fmt.Errorf("unexpected type in memo result")
}
// Use LLM to extract structured data
result, err := ctx.LLM.Structured(inferable.StructuredInput{
Input: contentStr,
Schema: WorkflowOutput{},
})
if err != nil {
// Log the error
ctx.Log("error", map[string]interface{}{
"message": "Error extracting structured data",
"error": err.Error(),
})
// Try a fallback strategy with simpler requirements
ctx.Log("info", map[string]interface{}{
"message": "Attempting fallback extraction with simpler schema",
})
result, err = ctx.LLM.Structured(inferable.StructuredInput{
Input: contentStr,
Schema: struct {
RestaurantName string `json:"restaurantName"`
MenuItems []MenuItem `json:"menuItems"`
}{},
})
if err != nil {
return nil, fmt.Errorf("all extraction attempts failed: %w", err)
}
}
// Log success
ctx.Log("info", map[string]interface{}{
"message": "Successfully extracted menu data",
})
// Return the extracted data
return result, nil
})
The changes include:
- Using
ctx.Memo
to cache web requests, ensuring we only fetch each URL once even if the workflow is retried - Adding more detailed error logging
- Implementing a fallback strategy with a simpler schema if the full extraction fails
Running the Workflow
To run our workflow:
-
Set your Inferable API key:
export INFERABLE_API_SECRET=your_api_key_here
-
Build and run the application:
go build ./menu-extractor
This starts a service that listens for workflow executions and automatically triggers the workflow with a countdown. The workflow will be triggered with the URL "https://a.inferable.ai/menu.txt".
Enhancing with an Agent
For more complex scenarios, we might want to use an agent that can take multiple actions to extract the information.
Most scenarios can be handled with the structured output approach, but agents are useful when you need to take additional actions, or the input is unbounded. For example, you might want to follow links on the page to extract more information, or translate the content to extract information in a different language.
Let's update our workflow to use an agent that can follow links on the page to extract more information:
workflow.Version(2).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
// Register tools for the agent to use
workflow.Tools.Register(inferable.WorkflowTool{
Name: "fetchWebpage",
InputSchema: struct {
URL string `json:"url"`
}{},
Func: func(input struct {
URL string `json:"url"`
}, ctxInput inferable.ContextInput) (struct {
Content string `json:"content"`
}, error) {
content, err := fetchWebpage(input.URL)
return struct {
Content string `json:"content"`
}{
Content: content,
}, err
},
})
// Use an agent to extract the menu information
result, interrupt, err := ctx.Agents.React(inferable.ReactAgentConfig{
Name: "menuExtractor",
Instructions: "You are a menu extraction agent. Your goal is to extract menu items and opening hours from restaurant websites. If there are other links on the page, you should extract them as well.",
Input: fmt.Sprintf("Extract menu and hours from: %s", input.URL),
Tools: []string{"fetchWebpage"},
Schema: WorkflowOutput{},
})
if err != nil {
return nil, fmt.Errorf("agent execution failed: %w", err)
}
// Handle any interruptions (like if human approval is needed)
if interrupt != nil {
return interrupt, nil
}
return result, nil
})
This approach gives the LLM more flexibility by allowing it to call tools (like fetchWebpage
) on its own as needed, rather than having us hardcode the sequence of operations.
Benefits of This Approach
By using Inferable for structured outputs and workflow management, we gain several advantages:
- Durability: Workflows can be paused, resumed, and retried automatically
- Structured data: We get properly typed Go structs instead of unpredictable text
- Separation of concerns: The workflow definition is clean and focused on business logic
- Observability: Built-in logging and monitoring
- Scalability: The workflow can run on any machine with access to the Inferable API