DEV Community

Manas Joshi
Manas Joshi

Posted on

Reliable LLM JSON Output: Few-Shot Prompting & Robust Parsing

Achieving Reliable Structured JSON Output from LLMs

As developers integrate Large Language Models (LLMs) into their applications, a common challenge emerges: consistently obtaining structured data (like JSON) rather than freeform text. While LLMs excel at generating natural language, coercing them into a precise, parsable format requires specific techniques. This post dives into how to reliably extract structured JSON from LLMs using few-shot prompting and robust programmatic parsing.

The Challenge with Unstructured LLM Responses

By default, LLMs are designed to generate human-like text. When asked to produce JSON, they might include conversational filler, return malformed JSON, or deviate from the specified schema. Relying solely on a textual instruction often leads to brittle integrations that break with minor model variations or unexpected outputs.

Consider a simple request to extract product details into a JSON object. A basic prompt might look like this:

from openai import OpenAI
import json

client = OpenAI()

def get_basic_summary(text_content: str) -> str:
    response = client.chat.completions.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "You are a helpful assistant designed to extract information into JSON."},
            {"role": "user", "content": f"""
            Extract the product name, price, and category from the following text into a JSON object.
            Text: "{text_content}"
            Expected JSON format: {{"product_name": "string", "price": float, "category": "string"}}
            """}
        ]
    )
    return response.choices[0].message.content

# Example usage
text_input_good = "The new 'Quantum Leap SSD' is available for $199.99 in the Storage category."
text_input_bad = "I saw a 'Smart Coffee Maker' today at $120.00. It's in the Kitchen Appliances."

print("Good example output:")
print(get_basic_summary(text_input_good))

print("\nBad example output (might vary):")
print(get_basic_summary(text_input_bad))
Enter fullscreen mode Exit fullscreen mode

This code defines get_basic_summary to send a user message to an LLM, requesting information extraction into JSON. The prompt includes a template for the desired JSON structure. However, without explicit examples, the LLM might prepend conversational text like "Here's the JSON:" or sometimes return malformed JSON, making programmatic parsing difficult and unreliable.

Guiding LLMs with Few-Shot Prompting

Few-shot prompting is a technique where you provide the LLM with a few input-output examples before giving it the actual task. This teaches the model the desired format and behavior more effectively than just describing it. For structured output, this means demonstrating the exact JSON structure you expect.

When using the OpenAI API's chat.completions.create endpoint, few-shot examples are provided by adding pairs of {"role": "user", "content": ...} and {"role": "assistant", "content": ...} messages to the messages array, prior to the final user prompt. This implicitly trains the model on the desired interaction pattern.

from openai import OpenAI
import json

client = OpenAI()

def get_few_shot_summary(text_content: str) -> str:
    response = client.chat.completions.create(
        model="gpt-3.5-turbo", # Or gpt-4, gpt-4o for better results
        messages=[
            {"role": "system", "content": "You are a helpful assistant designed to extract information into JSON. Always respond with only the JSON object."},
            # Few-shot example 1
            {"role": "user", "content": "Extract the product name, price, and category from the following text into a JSON object. Text: 'New 'Zenith X Headphones' are priced at $249.00 under Audio.'"},
            {"role": "assistant", "content": '{"product_name": "Zenith X Headphones", "price": 249.00, "category": "Audio"}'},
            # Few-shot example 2
            {"role": "user", "content": "Extract the product name, price, and category from the following text into a JSON object. Text: 'Grab the 'Ergo Mouse Pro' for $45.50 in Computer Accessories.'"},
            {"role": "assistant", "content": '{"product_name": "Ergo Mouse Pro", "price": 45.50, "category": "Computer Accessories"}'},
            # Actual request for which we want the output
            {"role": "user", "content": f"""
            Extract the product name, price, and category from the following text into a JSON object.
            Text: "{text_content}"
            """}
        ]
    )
    return response.choices[0].message.content

# Example usage
text_input_valid = "The 'Ultra HD Monitor' is now $399.99, found in Electronics."
print("Few-shot output (much cleaner):")
print(get_few_shot_summary(text_input_valid))
Enter fullscreen mode Exit fullscreen mode

This updated function get_few_shot_summary incorporates few-shot examples within the messages array. Each pair of user and assistant messages explicitly demonstrates the desired input-output format, specifically the clean JSON structure. The system message also reinforces the instruction to only respond with JSON, leading to much more consistent and parseable output from the LLM.

Robust Output Parsing and Validation

Even with effective few-shot prompting, LLMs can occasionally make mistakes. Therefore, it's crucial to implement robust parsing and validation on the application side. This involves using a JSON parser and then validating the parsed data against an expected schema. For Python, the built-in json module is essential, and you can define a simple class for schema validation.

from openai import OpenAI
import json

client = OpenAI()

# Assuming get_few_shot_summary is defined as above
# (omitted here for brevity, but it would be included in a full script)

class ProductData:
    def __init__(self, product_name: str, price: float, category: str):
        if not isinstance(product_name, str) or not product_name:
            raise ValueError("Product name must be a non-empty string.")
        if not isinstance(price, (int, float)) or price < 0:
            raise ValueError("Price must be a non-negative number.")
        if not isinstance(category, str) or not category:
            raise ValueError("Category must be a non-empty string.")

        self.product_name = product_name
        self.price = price
        self.category = category

    @classmethod
    def from_dict(cls, data: dict):
        # Basic validation on presence of keys before passing to __init__
        required_keys = ["product_name", "price", "category"]
        if not all(key in data for key in required_keys):
            missing_keys = [key for key in required_keys if key not in data]
            raise ValueError(f"Missing required fields: {', '.join(missing_keys)}")
        return cls(
            product_name=data["product_name"],
            price=data["price"],
            category=data["category"]
        )

    def __repr__(self):
        return f"ProductData(name='{self.product_name}', price={self.price}, category='{self.category}')"

def get_and_parse_product_data(text_content: str) -> ProductData | None:
    llm_output = get_few_shot_summary(text_content) # Call the LLM with few-shot prompting

    try:
        # Step 1: Parse the JSON string into a Python dictionary
        parsed_json = json.loads(llm_output)

        # Step 2: Validate against our custom schema/class
        product_instance = ProductData.from_dict(parsed_json)
        return product_instance
    except json.JSONDecodeError as e:
        print(f"Error parsing JSON from LLM: {e}")
        print(f"LLM Output was: {llm_output}")
        return None
    except ValueError as e:
        print(f"Validation error for product data: {e}")
        print(f"LLM Output was: {llm_output}")
        print(f"Parsed JSON (before validation error) was: {parsed_json if 'parsed_json' in locals() else 'N/A'}")
        return None
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        return None

# Example usage:
text_input = "The 'Gaming Headset Pro' is listed at $89.99 in the Peripherals section."
result = get_and_parse_product_data(text_input)
print(result)

text_input_malformed = "'Wireless Earbuds' for $59.99, category Audio. oops, this isn't json"
result_malformed = get_and_parse_product_data(text_input_malformed) # This will likely trigger JSONDecodeError
print(result_malformed)
Enter fullscreen mode Exit fullscreen mode

This code block demonstrates robust parsing and validation of LLM output. It first defines a ProductData class with type hints and validation logic to ensure the extracted data adheres to expected rules (e.g., price is a non-negative number, product_name is a non-empty string). The get_and_parse_product_data function calls the get_few_shot_summary function (which leverages few-shot prompting) to get the LLM's raw string output. Crucially, it wraps the json.loads call and ProductData.from_dict instantiation in a try-except block to catch json.JSONDecodeError for malformed JSON and ValueError for data that fails ProductData's validation, making the application resilient to imperfect LLM responses.

Common Mistakes and Gotchas

When working with structured LLM output, be aware of these pitfalls:

  • Ignoring parsing errors: Never assume the LLM will always return perfectly valid, schema-compliant JSON. Always wrap your parsing logic in try-except blocks to handle malformed outputs gracefully.
  • Too few or too many examples: While one or two few-shot examples often suffice for simple structures, providing too few can lead to ambiguity. Conversely, too many examples consume more tokens, increasing latency and cost, and can sometimes overconstrain the model or hit token limits.
  • Inconsistent example formats: Your few-shot examples must strictly adhere to the exact desired output format. Any deviation in spacing, key names, or data types within the examples can confuse the model.
  • Schema drift: If your application's data schema changes, remember to update both your few-shot examples and your parsing/validation logic (e.g., your ProductData class). Outdated schemas lead to validation failures.
  • Prompt Injection within Examples: Be cautious if user-supplied input is directly incorporated into the few-shot examples without sanitization. Malicious input could potentially alter the model's behavior in unexpected ways.

Conclusion

Achieving reliable structured JSON output from LLMs is not about magic, but about meticulous prompt engineering and robust application-side parsing. By consistently employing few-shot prompting to demonstrate your desired format and implementing strong validation, you can build much more stable and predictable LLM integrations. Embrace these techniques to transform your LLM from a free-text generator into a dependable data extraction engine.

Start experimenting with few-shot examples and see how much more control you gain over your LLM's output today!

Top comments (0)