If PydanticAI agents are a hammer, and multi-agent workflows are a sledgehammer, then graphs are a nail gun:
sure, nail guns look cooler than hammers
but nail guns take a lot more setup than hammers
and nail guns don't make you a better builder, they make you a builder with a nail gun
Lastly, (and at the risk of torturing this metaphor), if you're a fan of medieval tools like mallets and untyped Python, you probably won't like nail guns or our approach to graphs. (But then again, if you're not a fan of type hints in Python, you've probably already bounced off PydanticAI to use one of the toy agent frameworks — good luck, and feel free to borrow my sledgehammer when you realize you need it)
In short, graphs are a powerful tool, but they're not the right tool for every job. Please consider other multi-agent approaches before proceeding.
If you're not confident a graph-based approach is a good idea, it might be unnecessary.
Graphs and finite state machines (FSMs) are a powerful abstraction to model, execute, control and visualize complex workflows.
Alongside PydanticAI, we've developed pydantic-graph — an async graph and state machine library for Python where nodes and edges are defined using type hints.
While this library is developed as part of PydanticAI; it has no dependency on pydantic-ai and can be considered as a pure graph-based state machine library. You may find it useful whether or not you're using PydanticAI or even building with GenAI.
pydantic-graph is designed for advanced users and makes heavy use of Python generics and type hints. It is not designed to be as beginner-friendly as PydanticAI.
Installation
pydantic-graph is a required dependency of pydantic-ai, and an optional dependency of pydantic-ai-slim, see installation instructions for more information. You can also install it directly:
pipinstallpydantic-graph
uvaddpydantic-graph
Graph Types
pydantic-graph is made up of a few key components:
GraphRunContext
GraphRunContext — The context for the graph run, similar to PydanticAI's RunContext. This holds the state of the graph and dependencies and is passed to nodes when they're run.
GraphRunContext is generic in the state type of the graph it's used in, StateT.
End
End — return value to indicate the graph run should end.
End is generic in the graph return type of the graph it's used in, RunEndT.
Nodes
Subclasses of BaseNode define nodes for execution in the graph.
Nodes, which are generally dataclasses, generally consist of:
fields containing any parameters required/optional when calling the node
the business logic to execute the node, in the run method
return annotations of the run method, which are read by pydantic-graph to determine the outgoing edges of the node
Nodes are generic in:
state, which must have the same type as the state of graphs they're included in, StateT has a default of None, so if you're not using state you can omit this generic parameter, see stateful graphs for more information
deps, which must have the same type as the deps of the graph they're included in, DepsT has a default of None, so if you're not using deps you can omit this generic parameter, see dependency injection for more information
graph return type — this only applies if the node returns End. RunEndT has a default of Never so this generic parameter can be omitted if the node doesn't return End, but must be included if it does.
Here's an example of a start or intermediate node in a graph — it can't end the run as it doesn't return End:
The "state" concept in pydantic-graph provides an optional way to access and mutate an object (often a dataclass or Pydantic model) as nodes run in a graph. If you think of Graphs as a production line, then your state is the engine being passed along the line and built up by each node as the graph is run.
In the future, we intend to extend pydantic-graph to provide state persistence with the state recorded after each node is run, see #695.
Here's an example of a graph which represents a vending machine where the user may insert coins and select a product to purchase.
vending_machine.py
from__future__importannotationsfromdataclassesimportdataclassfromrich.promptimportPromptfrompydantic_graphimportBaseNode,End,Graph,GraphRunContext@dataclassclassMachineState:user_balance:float=0.0product:str|None=None@dataclassclassInsertCoin(BaseNode[MachineState]):asyncdefrun(self,ctx:GraphRunContext[MachineState])->CoinsInserted:returnCoinsInserted(float(Prompt.ask('Insert coins')))@dataclassclassCoinsInserted(BaseNode[MachineState]):amount:floatasyncdefrun(self,ctx:GraphRunContext[MachineState])->SelectProduct|Purchase:ctx.state.user_balance+=self.amountifctx.state.productisnotNone:returnPurchase(ctx.state.product)else:returnSelectProduct()@dataclassclassSelectProduct(BaseNode[MachineState]):asyncdefrun(self,ctx:GraphRunContext[MachineState])->Purchase:returnPurchase(Prompt.ask('Select product'))PRODUCT_PRICES={'water':1.25,'soda':1.50,'crisps':1.75,'chocolate':2.00,}@dataclassclassPurchase(BaseNode[MachineState,None,None]):product:strasyncdefrun(self,ctx:GraphRunContext[MachineState])->End|InsertCoin|SelectProduct:ifprice:=PRODUCT_PRICES.get(self.product):ctx.state.product=self.productifctx.state.user_balance>=price:ctx.state.user_balance-=pricereturnEnd(None)else:diff=price-ctx.state.user_balanceprint(f'Not enough money for {self.product}, need {diff:0.2f} more')#> Not enough money for crisps, need 0.75 morereturnInsertCoin()else:print(f'No such product: {self.product}, try again')returnSelectProduct()vending_machine_graph=Graph(nodes=[InsertCoin,CoinsInserted,SelectProduct,Purchase])asyncdefmain():state=MachineState()awaitvending_machine_graph.run(InsertCoin(),state=state)print(f'purchase successful item={state.product} change={state.user_balance:0.2f}')#> purchase successful item=crisps change=0.25
(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add asyncio.run(main()) to run main)
A mermaid diagram for this graph can be generated with the following code:
See below for more information on generating diagrams.
GenAI Example
So far we haven't shown an example of a Graph that actually uses PydanticAI or GenAI at all.
In this example, one agent generates a welcome email to a user and the other agent provides feedback on the email.
This graph has a very simple structure:
genai_email_feedback.py
from__future__importannotationsas_annotationsfromdataclassesimportdataclass,fieldfrompydanticimportBaseModel,EmailStrfrompydantic_aiimportAgent,format_as_xmlfrompydantic_ai.messagesimportModelMessagefrompydantic_graphimportBaseNode,End,Graph,GraphRunContext@dataclassclassUser:name:stremail:EmailStrinterests:list[str]@dataclassclassEmail:subject:strbody:str@dataclassclassState:user:Userwrite_agent_messages:list[ModelMessage]=field(default_factory=list)email_writer_agent=Agent('google-vertex:gemini-1.5-pro',output_type=Email,system_prompt='Write a welcome email to our tech blog.',)@dataclassclassWriteEmail(BaseNode[State]):email_feedback:str|None=Noneasyncdefrun(self,ctx:GraphRunContext[State])->Feedback:ifself.email_feedback:prompt=(f'Rewrite the email for the user:\n'f'{format_as_xml(ctx.state.user)}\n'f'Feedback: {self.email_feedback}')else:prompt=(f'Write a welcome email for the user:\n'f'{format_as_xml(ctx.state.user)}')result=awaitemail_writer_agent.run(prompt,message_history=ctx.state.write_agent_messages,)ctx.state.write_agent_messages+=result.all_messages()returnFeedback(result.output)classEmailRequiresWrite(BaseModel):feedback:strclassEmailOk(BaseModel):passfeedback_agent=Agent[None,EmailRequiresWrite|EmailOk]('openai:gpt-4o',output_type=EmailRequiresWrite|EmailOk,# type: ignoresystem_prompt=('Review the email and provide feedback, email must reference the users specific interests.'),)@dataclassclassFeedback(BaseNode[State,None,Email]):email:Emailasyncdefrun(self,ctx:GraphRunContext[State],)->WriteEmail|End[Email]:prompt=format_as_xml({'user':ctx.state.user,'email':self.email})result=awaitfeedback_agent.run(prompt)ifisinstance(result.output,EmailRequiresWrite):returnWriteEmail(email_feedback=result.output.feedback)else:returnEnd(self.email)asyncdefmain():user=User(name='John Doe',email='john.joe@example.com',interests=['Haskel','Lisp','Fortran'],)state=State(user)feedback_graph=Graph(nodes=(WriteEmail,Feedback))result=awaitfeedback_graph.run(WriteEmail(),state=state)print(result.output)""" Email( subject='Welcome to our tech blog!', body='Hello John, Welcome to our tech blog! ...', ) """
(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add asyncio.run(main()) to run main)
Iterating Over a Graph
Using Graph.iter for async for iteration
Sometimes you want direct control or insight into each node as the graph executes. The easiest way to do that is with the Graph.iter method, which returns a context manager that yields a GraphRun object. The GraphRun is an async-iterable over the nodes of your graph, allowing you to record or modify them as they execute.
Alternatively, you can drive iteration manually with the GraphRun.next method, which allows you to pass in whichever node you want to run next. You can modify or selectively skip nodes this way.
Below is a contrived example that stops whenever the counter is at 2, ignoring any node runs beyond that:
count_down_next.py
frompydantic_graphimportEnd,FullStatePersistencefromcount_downimportCountDown,CountDownState,count_down_graphasyncdefmain():state=CountDownState(counter=5)persistence=FullStatePersistence()asyncwithcount_down_graph.iter(CountDown(),state=state,persistence=persistence)asrun:node=run.next_nodewhilenotisinstance(node,End):print('Node:',node)#> Node: CountDown()#> Node: CountDown()#> Node: CountDown()#> Node: CountDown()ifstate.counter==2:breaknode=awaitrun.next(node)print(run.result)#> Noneforstepinpersistence.history:print('History Step:',step.state,step.state)#> History Step: CountDownState(counter=5) CountDownState(counter=5)#> History Step: CountDownState(counter=4) CountDownState(counter=4)#> History Step: CountDownState(counter=3) CountDownState(counter=3)#> History Step: CountDownState(counter=2) CountDownState(counter=2)
State Persistence
One of the biggest benefits of finite state machine (FSM) graphs is how they simplify the handling of interrupted execution. This might happen for a variety of reasons:
the state machine logic might fundamentally need to be paused — e.g. the returns workflow for an e-commerce order needs to wait for the item to be posted to the returns center or because execution of the next node needs input from a user so needs to wait for a new http request,
the execution takes so long that the entire graph can't reliably be executed in a single continuous run — e.g. a deep research agent that might take hours to run,
you want to run multiple graph nodes in parallel in different processes / hardware instances (note: parallel node execution is not yet supported in pydantic-graph, see #704).
Trying to make a conventional control flow (i.e., boolean logic and nested function calls) implementation compatible with these usage scenarios generally results in brittle and over-complicated spaghetti code, with the logic required to interrupt and resume execution dominating the implementation.
To allow graph runs to be interrupted and resumed, pydantic-graph provides state persistence — a system for snapshotting the state of a graph run before and after each node is run, allowing a graph run to be resumed from any point in the graph.
pydantic-graph includes three state persistence implementations:
SimpleStatePersistence — Simple in memory state persistence that just hold the latest snapshot. If no state persistence implementation is provided when running a graph, this is used by default.
FullStatePersistence — In memory state persistence that hold a list of snapshots.
FileStatePersistence — File-based state persistence that saves snapshots to a JSON file.
In production applications, developers should implement their own state persistence by subclassing BaseStatePersistence abstract base class, which might persist runs in a relational database like PostgresQL.
At a high level the role of StatePersistence implementations is to store and retrieve NodeSnapshot and EndSnapshot objects.
As you can see in this code, run_node requires no external application state (apart from state persistence) to be run, meaning graphs can easily be executed by distributed execution and queueing systems.
(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add asyncio.run(main()) to run main)
Example: Human in the loop.
As noted above, state persistence allows graphs to be interrupted and resumed. One use case of this is to allow user input to continue.
In this example, an AI asks the user a question, the user provides an answer, the AI evaluates the answer and ends if the user got it right or asks another question if they got it wrong.
Instead of running the entire graph in a single process invocation, we run the graph by running the process repeatedly, optionally providing an answer to the question as a command line argument.
ai_q_and_a_graph.py — question_graph definition
ai_q_and_a_graph.py
from__future__importannotationsas_annotationsfromdataclassesimportdataclass,fieldfromgroqimportBaseModelfrompydantic_graphimport(BaseNode,End,Graph,GraphRunContext,)frompydantic_aiimportAgent,format_as_xmlfrompydantic_ai.messagesimportModelMessageask_agent=Agent('openai:gpt-4o',output_type=str,instrument=True)@dataclassclassQuestionState:question:str|None=Noneask_agent_messages:list[ModelMessage]=field(default_factory=list)evaluate_agent_messages:list[ModelMessage]=field(default_factory=list)@dataclassclassAsk(BaseNode[QuestionState]):asyncdefrun(self,ctx:GraphRunContext[QuestionState])->Answer:result=awaitask_agent.run('Ask a simple question with a single correct answer.',message_history=ctx.state.ask_agent_messages,)ctx.state.ask_agent_messages+=result.all_messages()ctx.state.question=result.outputreturnAnswer(result.output)@dataclassclassAnswer(BaseNode[QuestionState]):question:strasyncdefrun(self,ctx:GraphRunContext[QuestionState])->Evaluate:answer=input(f'{self.question}: ')returnEvaluate(answer)classEvaluationResult(BaseModel,use_attribute_docstrings=True):correct:bool"""Whether the answer is correct."""comment:str"""Comment on the answer, reprimand the user if the answer is wrong."""evaluate_agent=Agent('openai:gpt-4o',output_type=EvaluationResult,system_prompt='Given a question and answer, evaluate if the answer is correct.',)@dataclassclassEvaluate(BaseNode[QuestionState,None,str]):answer:strasyncdefrun(self,ctx:GraphRunContext[QuestionState],)->End[str]|Reprimand:assertctx.state.questionisnotNoneresult=awaitevaluate_agent.run(format_as_xml({'question':ctx.state.question,'answer':self.answer}),message_history=ctx.state.evaluate_agent_messages,)ctx.state.evaluate_agent_messages+=result.all_messages()ifresult.output.correct:returnEnd(result.output.comment)else:returnReprimand(result.output.comment)@dataclassclassReprimand(BaseNode[QuestionState]):comment:strasyncdefrun(self,ctx:GraphRunContext[QuestionState])->Ask:print(f'Comment: {self.comment}')ctx.state.question=NonereturnAsk()question_graph=Graph(nodes=(Ask,Answer,Evaluate,Reprimand),state_type=QuestionState)
(This example is complete, it can be run "as is" with Python 3.10+)
ai_q_and_a_run.py
importsysfrompathlibimportPathfrompydantic_graphimportEndfrompydantic_graph.persistence.fileimportFileStatePersistencefrompydantic_ai.messagesimportModelMessage# noqa: F401fromai_q_and_a_graphimportAsk,question_graph,Evaluate,QuestionState,Answerasyncdefmain():answer:str|None=sys.argv[1]iflen(sys.argv)>1elseNonepersistence=FileStatePersistence(Path('question_graph.json'))persistence.set_graph_types(question_graph)ifsnapshot:=awaitpersistence.load_next():state=snapshot.stateassertanswerisnotNonenode=Evaluate(answer)else:state=QuestionState()node=Ask()asyncwithquestion_graph.iter(node,state=state,persistence=persistence)asrun:whileTrue:node=awaitrun.next()ifisinstance(node,End):print('END:',node.data)history=awaitpersistence.load_all()print([e.nodeforeinhistory])breakelifisinstance(node,Answer):print(node.question)#> What is the capital of France?break# otherwise just continue
(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add asyncio.run(main()) to run main)
As with PydanticAI, pydantic-graph supports dependency injection via a generic parameter on Graph and BaseNode, and the GraphRunContext.deps field.
As an example of dependency injection, let's modify the DivisibleBy5 example above to use a ProcessPoolExecutor to run the compute load in a separate process (this is a contrived example, ProcessPoolExecutor wouldn't actually improve performance in this example):
deps_example.py
from__future__importannotationsimportasynciofromconcurrent.futuresimportProcessPoolExecutorfromdataclassesimportdataclassfrompydantic_graphimportBaseNode,End,Graph,GraphRunContext@dataclassclassGraphDeps:executor:ProcessPoolExecutor@dataclassclassDivisibleBy5(BaseNode[None,GraphDeps,int]):foo:intasyncdefrun(self,ctx:GraphRunContext[None,GraphDeps],)->Increment|End[int]:ifself.foo%5==0:returnEnd(self.foo)else:returnIncrement(self.foo)@dataclassclassIncrement(BaseNode[None,GraphDeps]):foo:intasyncdefrun(self,ctx:GraphRunContext[None,GraphDeps])->DivisibleBy5:loop=asyncio.get_running_loop()compute_result=awaitloop.run_in_executor(ctx.deps.executor,self.compute,)returnDivisibleBy5(compute_result)defcompute(self)->int:returnself.foo+1fives_graph=Graph(nodes=[DivisibleBy5,Increment])asyncdefmain():withProcessPoolExecutor()asexecutor:deps=GraphDeps(executor)result=awaitfives_graph.run(DivisibleBy5(3),deps=deps)print(result.output)#> 5# the full history is quite verbose (see below), so we'll just print the summaryprint([item.data_snapshot()foriteminresult.history])""" [ DivisibleBy5(foo=3), Increment(foo=3), DivisibleBy5(foo=4), Increment(foo=4), DivisibleBy5(foo=5), End(data=5), ] """
(This example is complete, it can be run "as is" with Python 3.10+ — you'll need to add asyncio.run(main()) to run main)
...fromtypingimportAnnotatedfrompydantic_graphimportBaseNode,End,Graph,GraphRunContext,Edge...@dataclassclassAsk(BaseNode[QuestionState]):"""Generate question using GPT-4o."""docstring_notes=Trueasyncdefrun(self,ctx:GraphRunContext[QuestionState])->Annotated[Answer,Edge(label='Ask the question')]:......@dataclassclassEvaluate(BaseNode[QuestionState]):answer:strasyncdefrun(self,ctx:GraphRunContext[QuestionState],)->Annotated[End[str],Edge(label='success')]|Reprimand:......question_graph.mermaid_save('image.png',highlighted_nodes=[Answer])
(This example is not complete and cannot be run directly)
This would generate an image that looks like this:
Setting Direction of the State Diagram
You can specify the direction of the state diagram using one of the following values:
'TB': Top to bottom, the diagram flows vertically from top to bottom.
'LR': Left to right, the diagram flows horizontally from left to right.
'RL': Right to left, the diagram flows horizontally from right to left.
'BT': Bottom to top, the diagram flows vertically from bottom to top.
Here is an example of how to do this using 'Left to Right' (LR) instead of the default 'Top to Bottom' (TB):