Deterministic Orchestration with Step Functions
When to let AWS Step Functions drive a multi-step AI workload and when to let a model decide the path — and why most production systems do both.
There are two ways to run a sequence of AI steps. A model can decide what happens next on every turn, or you can write the sequence down and have AWS Step Functions execute it. These are not competing philosophies; they answer different questions, and knowing which question you are facing is the whole skill.
Encode the path when you already know it
If the steps always happen, always in the same order, that is a workflow. Writing it as a Step Functions state machine buys you things a model orchestrator cannot give you:
- Retries and catchers per state, with exponential backoff and jitter, instead of a model deciding whether to try again.
- Real parallelism with
Parallel, and large fan-out over S3 objects or a dataset withMapin distributed mode. - Timeouts and hard bounds, so a run cannot quietly cost ten times what you expected.
- Per-state IAM through the state machine's role, and a complete execution history you can open in the console and replay.
- Human approval via the
.waitForTaskTokenpattern, where the workflow pauses until something outside callsSendTaskSuccess.
Step Functions has an optimised integration for bedrock:InvokeModel, and can call anything else — including InvokeAgent and Lambda — through its AWS SDK integrations. So "deterministic orchestration" does not mean "no models"; it means the control flow is yours and only the content is generated.
Let a model orchestrate when the path depends on what it finds
Open-ended investigation, variable-length tool loops, requests where the second step only makes sense after you see the first result — those are agent territory. A state machine cannot express "keep digging until you have enough evidence" without you enumerating what "enough" means.
The hybrid is usually the right answer
Put the skeleton in Step Functions and the ambiguity inside one state. Ingest, validate, redact, then one agent task that reasons, then a deterministic write to DynamoDB and a notification. You keep auditability and bounded cost for ninety per cent of the workload and pay for model reasoning only where it earns its place.
Standard or Express
Standard workflows are durable, run for up to a year, have exactly-once execution semantics and record full execution history — that is what you want around agent invocations, which are slow and expensive to repeat. Express workflows are for very high-volume, short-lived runs, have at-least-once semantics and a five-minute limit. Putting a long agent loop in an Express workflow is a classic wrong answer: it can time out mid-run and it can execute a step twice.
The trade-off
A state machine is rigid. Changing the path means changing and redeploying the definition, and it cannot cope with a request nobody anticipated. A model orchestrator adapts to anything and reproduces nothing — same input, different path, different bill. Choose rigidity wherever the business would be upset by a surprise.
Example
{
"Comment": "Deterministic skeleton, model reasoning only where needed",
"StartAt": "RedactPII",
"States": {
"RedactPII": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke",
"Parameters": { "FunctionName": "redact-pii", "Payload.$": "$" },
"ResultSelector": { "text.$": "$.Payload.text" },
"ResultPath": "$.clean",
"Next": "GatherEvidence"
},
"GatherEvidence": {
"Type": "Parallel",
"Next": "Decide",
"Branches": [
{
"StartAt": "AskComplianceAgent",
"States": {
"AskComplianceAgent": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:bedrockagentruntime:invokeAgent",
"Parameters": {
"AgentId": "ABCDEFGHIJ",
"AgentAliasId": "LIVE01",
"SessionId.$": "$$.Execution.Name",
"InputText.$": "$.clean.text"
},
"TimeoutSeconds": 300,
"Retry": [
{
"ErrorEquals": ["Bedrock.ThrottlingException", "States.Timeout"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2,
"JitterStrategy": "FULL"
}
],
"Catch": [
{ "ErrorEquals": ["States.ALL"], "Next": "ComplianceUnavailable" }
],
"End": true
},
"ComplianceUnavailable": {
"Type": "Pass",
"Result": { "status": "unavailable" },
"End": true
}
}
},
{
"StartAt": "LookupAccount",
"States": {
"LookupAccount": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:getItem",
"Parameters": {
"TableName": "accounts",
"Key": { "accountId": { "S.$": "$.accountId" } }
},
"End": true
}
}
}
]
},
"Decide": {
"Type": "Choice",
"Choices": [
{
"Variable": "$[0].status",
"StringEquals": "unavailable",
"Next": "HumanReview"
}
],
"Default": "Persist"
},
"HumanReview": {
"Type": "Task",
"Resource": "arn:aws:states:::sqs:sendMessage.waitForTaskToken",
"Parameters": {
"QueueUrl": "https://sqs.eu-west-1.amazonaws.com/111122223333/review",
"MessageBody": { "payload.$": "$", "token.$": "$$.Task.Token" }
},
"Next": "Persist"
},
"Persist": {
"Type": "Task",
"Resource": "arn:aws:states:::dynamodb:putItem",
"Parameters": {
"TableName": "case-decisions",
"Item": {
"caseId": { "S.$": "$$.Execution.Name" },
"result": { "S.$": "States.JsonToString($)" }
}
},
"End": true
}
}
}When to use it
- Running a nightly claims pipeline where every claim goes through the same redact, analyse, score and store sequence.
- Fanning out over thousands of S3 documents with a distributed Map state, invoking a summarisation model per document.
- Pausing an automated remediation workflow for human sign-off before an agent's recommended change is applied.
Discussion