Designing Error Messages for LLMs

3 min read

Designing Error Messages for LLMs

Error messages consume context window space and shape how LLMs respond to failures. A stack trace dumps hundreds of lines into context. A structured error with an error code, context, and recovery action gives the LLM exactly what it needs to proceed.

TL;DR

Structure errors as JSON with error codes, context, and suggested actions. Use reference IDs to link errors without passing full details in context. Return recovery options instead of stack traces. Filter technical details that waste tokens on information the LLM can't act on.

Structure Errors as Data

Return structured errors that LLMs can process immediately:

python
# Bad: string dumps everything
def process_payment(user_id, amount):
    try:
        charge = stripe.charge(user_id, amount)
    except Exception as e:
        return f"Error: {str(e)}\n{traceback.format_exc()}"
 
# Good: structured, actionable
def process_payment(user_id, amount):
    try:
        charge = stripe.charge(user_id, amount)
    except stripe.CardError as e:
        return {
            "success": False,
            "error": "card_declined",
            "code": e.code,
            "context": {
                "user_id": user_id,
                "amount": amount,
                "decline_code": e.decline_code
            },
            "action": "request_different_payment_method",
            "retry": False
        }

The LLM sees error code, context, and next action. No parsing required.

Use Reference IDs for Details

Instead of passing full error details through context, return a reference ID. The LLM can request full details only if needed:

python
# Track errors externally
error_store = {}
 
def analyze_logs(filepath):
    errors = []
    with open(filepath) as f:
        for line in f:
            if "ERROR" in line:
                error_id = generate_id()
                error_store[error_id] = {
                    "line": line,
                    "timestamp": parse_timestamp(line),
                    "type": parse_error_type(line),
                    "full_context": line
                }
                errors.append(error_id)
 
    # Return summary with IDs, not full errors
    return {
        "total_errors": len(errors),
        "error_ids": errors[:10],  # First 10
        "by_type": count_by_type(errors)
    }
 
def get_error_details(error_id):
    # LLM calls this only if it needs specifics
    return error_store.get(error_id)

The LLM sees 10 error IDs instead of 10 full error messages. If it needs details on err_abc123, it calls get_error_details("err_abc123"). This pattern keeps context clean while maintaining connections across multiple tool calls.

Return Recovery Actions

Tell the LLM what to do next:

python
# Bad: just the error
return {"error": "database_connection_failed"}
 
# Good: error + recovery path
return {
    "error": "database_connection_failed",
    "context": {
        "host": db_host,
        "last_success": "2025-11-20T14:23:00Z"
    },
    "actions": [
        {
            "type": "retry",
            "wait_seconds": 5,
            "max_attempts": 3
        },
        {
            "type": "check_credentials",
            "verify": ["DB_USER", "DB_PASSWORD", "DB_HOST"]
        },
        {
            "type": "fallback",
            "use": "read_replica_connection"
        }
    ]
}

The LLM can execute recovery logic immediately. No guessing.

Filter Technical Details

Stack traces and debug output waste context on information the LLM can't act on:

python
# Bad: dumps stack trace
def read_config(path):
    try:
        return json.load(open(path))
    except Exception as e:
        return {
            "error": str(e),
            "trace": traceback.format_exc()  # 50+ lines
        }
 
# Good: actionable error only
def read_config(path):
    try:
        return json.load(open(path))
    except FileNotFoundError:
        return {
            "error": "config_not_found",
            "path": path,
            "action": "create_default_config",
            "template_path": "config.template.json"
        }
    except json.JSONDecodeError as e:
        return {
            "error": "invalid_json",
            "path": path,
            "line": e.lineno,
            "action": "fix_syntax_error"
        }

The LLM gets error type, location, and recovery path. Skip the stack trace.

When to Use This

Design LLM-optimized error messages when:

  • Building tools that LLMs call directly (MCP servers, function calling APIs)
  • Creating multi-step workflows where errors trigger recovery logic
  • Debugging sessions where errors accumulate across turns
  • Systems where the same error ID needs to connect related failures
  • Tools that process large datasets and need to report failures efficiently

References