Skip to content

General Tips for Prompt Engineering

The overarching theme of using Instructor and Pydantic for function calling is to make the models as self-descriptive, modular, and flexible as possible, while maintaining data integrity and ease of use.

  • Modularity: Design self-contained components for reuse.
  • Self-Description: Use Pydantic's Field for clear field descriptions.
  • Optionality: Use Python's Optional type for nullable fields and set sensible defaults.
  • Standardization: Employ enumerations for fields with a fixed set of values; include a fallback option.
  • Dynamic Data: Use key-value pairs for arbitrary properties and limit list lengths.
  • Entity Relationships: Define explicit identifiers and relationship fields.
  • Contextual Logic: Optionally add a "chain of thought" field in reusable components for extra context.

Modular Chain of Thought

This approach to "chain of thought" improves data quality but can have modular components rather than global CoT.

from pydantic import BaseModel, Field


class Role(BaseModel):
    chain_of_thought: str = Field(
        ..., description="Think step by step to determine the correct title"
    )
    title: str


class UserDetail(BaseModel):
    age: int
    name: str
    role: Role

Utilize Optional Attributes

Use Python's Optional type and set a default value to prevent undesired defaults like empty strings.

from typing import Optional
from pydantic import BaseModel, Field


class UserDetail(BaseModel):
    age: int
    name: str
    role: Optional[str] = Field(default=None)

Handling Errors Within Function Calls

You can create a wrapper class to hold either the result of an operation or an error message. This allows you to remain within a function call even if an error occurs, facilitating better error handling without breaking the code flow.

from pydantic import BaseModel, Field
from typing import Optional


class UserDetail(BaseModel):
    age: int
    name: str
    role: Optional[str] = Field(default=None)


class MaybeUser(BaseModel):
    result: Optional[UserDetail] = Field(default=None)
    error: bool = Field(default=False)
    message: Optional[str]

    def __bool__(self):
        return self.result is not None

With the MaybeUser class, you can either receive a UserDetail object in result or get an error message in message.

Simplification with the Maybe Pattern

You can further simplify this using instructor to create the Maybe pattern dynamically from any BaseModel.

import instructor
from pydantic import BaseModel


class UserDetail(BaseModel):
    age: int
    name: str


MaybeUser = instructor.Maybe(UserDetail)

This allows you to quickly create a Maybe type for any class, streamlining the process.

Tips for Enumerations

To prevent data misalignment, use Enums for standardized fields. Always include an "Other" option as a fallback so the model can signal uncertainty.

from enum import Enum, auto
from pydantic import BaseModel, Field


class Role(Enum):
    PRINCIPAL = auto()
    TEACHER = auto()
    STUDENT = auto()
    OTHER = auto()


class UserDetail(BaseModel):
    age: int
    name: str
    role: Role = Field(
        description="Correctly assign one of the predefined roles to the user."
    )

If you're having a hard time with Enum and alternative is to use Literal

from typing import Literal
from pydantic import BaseModel


class UserDetail(BaseModel):
    age: int
    name: str
    role: Literal["PRINCIPAL", "TEACHER", "STUDENT", "OTHER"]

If you'd like to improve performance more you can reiterate the requirements in the field descriptions or in the docstrings.

Reiterate Long Instructions

For complex attributes, it helps to reiterate the instructions in the field's description.

from pydantic import BaseModel, Field


class Role(BaseModel):
    """
    Extract the role based on the following rules ...
    """

    instructions: str = Field(
        ...,
        description="Restate the instructions and rules to correctly determine the title.",
    )
    title: str


class UserDetail(BaseModel):
    age: int
    name: str
    role: Role

Handle Arbitrary Properties

When you need to extract undefined attributes, use a list of key-value pairs.

from typing import List
from pydantic import BaseModel, Field


class Property(BaseModel):
    key: str
    value: str


class UserDetail(BaseModel):
    age: int
    name: str
    properties: List[Property] = Field(
        ..., description="Extract any other properties that might be relevant."
    )

Limiting the Length of Lists

When dealing with lists of attributes, especially arbitrary properties, it's crucial to manage the length. You can use prompting and enumeration to limit the list length, ensuring a manageable set of properties.

from typing import List
from pydantic import BaseModel, Field


class Property(BaseModel):
    index: str = Field(..., description="Monotonically increasing ID")
    key: str
    value: str


class UserDetail(BaseModel):
    age: int
    name: str
    properties: List[Property] = Field(
        ...,
        description="Numbered list of arbitrary extracted properties, should be less than 6",
    )

Using Tuples for Simple Types

For simple types, tuples can be a more compact alternative to custom classes, especially when the properties don't require additional descriptions.

from typing import List, Tuple
from pydantic import BaseModel, Field


class UserDetail(BaseModel):
    age: int
    name: str
    properties: List[Tuple[int, str]] = Field(
        ...,
        description="Numbered list of arbitrary extracted properties, should be less than 6",
    )

Advanced Arbitrary Properties

For multiple users, aim to use consistent key names when extracting properties.

from typing import List
from pydantic import BaseModel


class UserDetail(BaseModel):
    id: int
    age: int
    name: str


class UserDetails(BaseModel):
    """
    Extract information for multiple users.
    Use consistent key names for properties across users.
    """

    users: List[UserDetail]

This refined guide should offer a cleaner and more organized approach to structure engineering in Python.

Defining Relationships Between Entities

In cases where relationships exist between entities, it's vital to define them explicitly in the model. The following example demonstrates how to define relationships between users by incorporating an id and a friends field:

from typing import List
from pydantic import BaseModel, Field


class UserDetail(BaseModel):
    id: int = Field(..., description="Unique identifier for each user.")
    age: int
    name: str
    friends: List[int] = Field(
        ...,
        description="Correct and complete list of friend IDs, representing relationships between users.",
    )


class UserRelationships(BaseModel):
    users: List[UserDetail] = Field(
        ...,
        description="Collection of users, correctly capturing the relationships among them.",
    )

Reusing Components with Different Contexts

You can reuse the same component for different contexts within a model. In this example, the TimeRange component is used for both work_time and leisure_time.

from pydantic import BaseModel, Field


class TimeRange(BaseModel):
    start_time: int = Field(..., description="The start time in hours.")
    end_time: int = Field(..., description="The end time in hours.")


class UserDetail(BaseModel):
    id: int = Field(..., description="Unique identifier for each user.")
    age: int
    name: str
    work_time: TimeRange = Field(
        ..., description="Time range during which the user is working."
    )
    leisure_time: TimeRange = Field(
        ..., description="Time range reserved for leisure activities."
    )

Sometimes, a component like TimeRange may require some context or additional logic to be used effectively. Employing a "chain of thought" field within the component can help in understanding or optimizing the time range allocations.

from pydantic import BaseModel, Field


class TimeRange(BaseModel):
    chain_of_thought: str = Field(
        ..., description="Step by step reasoning to get the correct time range"
    )
    start_time: int = Field(..., description="The start time in hours.")
    end_time: int = Field(..., description="The end time in hours.")