AI Orchestration Deep Dive: From No-Agent to Multi-Agent and Beyond
Explores seven architectural patterns, from AI as a service (no agent) to multi-agents, and more.
The Use Case
A tennis court booking system with two functions:
- check_availability — Given date/time, return open slots
- book — Reserve the selected slot, return confirmation
All 7 patterns implement these same 2 functions. The difference: who decides which function to call and when.
Pattern A: Lambda → Bedrock (AI as Service, no agent)
Style: None — AI just generates/responds
Architecture
User → API Gateway → Lambda → Bedrock → Lambda → DB → User
You control everything. Bedrock is just a text utility—no decision-making. It performs discriminative tasks only: parsing, classifying, extracting. The reasoning happens in your code.
Pseudo Code
# Lambda handler - YOU control all logic
# Two functions: check_availability, book
def check_availability(date, time):
return db.query_available_slots(date, time)
def book(slot_id, user_id):
return db.reserve_slot(slot_id, user_id)
def handler(event):
user_input = event["body"]
session = get_session(event) # your state store
# Use Bedrock to parse natural language
prompt = f"Extract intent and params from: {user_input}"
parsed = bedrock.invoke_model(prompt)
# e.g., {intent: "check", date: "2025-12-04", time: "15:00"}
# YOU decide which function to call
if parsed["intent"] == "check":
slots = check_availability(parsed["date"], parsed["time"])
session["available_slots"] = slots
return f"Available slots: {slots}"
elif parsed["intent"] == "book":
result = book(parsed["slot_id"], session["user_id"])
return f"Booked! Confirmation: {result}"
else:
return "Please tell me if you want to check availability or book."
Key point: Bedrock parses text. Your code decides which function to call.
Handling Multi-Turn Conversations
What if booking requires multiple inputs: date, time, slot?
You manage the state:
User: "Book a court for tomorrow"
↓
Lambda
├──→ Bedrock parse → {date: "2025-12-04", time: ?, slot: ?}
├──→ Check: missing time, slot
↓
System: "What time would you like?"
User: "3pm"
↓
Lambda
├──→ Bedrock parse → {time: "15:00"}
├──→ Merge state → {date: "2025-12-04", time: "15:00", slot: ?}
├──→ DB: get available slots
↓
System: "Slot A and B are available. Which one?"
User: "Slot A"
↓
Lambda
├──→ Merge state → {date: "2025-12-04", time: "15:00", slot: "A"}
├──→ All fields complete → DB book
↓
System: "Booked! Court A, Dec 4 at 3pm"
You need to:
- Store conversation state (DynamoDB, session, etc.)
- Check what’s missing after each parse
- Prompt user for missing fields
- Merge new input into existing state
This is where Pattern A gets painful — you’re coding a state machine manually.
Pattern B/C/D handle this naturally. Agents track context and ask follow-ups automatically.
Pros
- Full control
- Predictable behavior
- Easy to debug
Cons
- Rigid — every flow must be coded
- No reasoning capability
- Multi-turn conversations require manual state management
When to Use
- Fixed, predictable workflows
- AI only needed for text generation/formatting
- You want full control over logic
- Single-turn or simple interactions
Pattern B: Bedrock Agent → Lambda (Agent Style)
Style: Agent — autonomous reasoning + action loop
Architecture
User → Bedrock Agent → [Decides] → Lambda (Action Group) → DB
↑___________ observes result ___________|
Bedrock Agent reasons about what to do, picks actions, executes, and loops until done.
Pseudo Code
# Agent definition (configured in Bedrock console/API)
agent_config = {
"instruction": "You help users book tennis courts.",
"action_groups": [
{
"name": "BookingActions",
"lambda_arn": "arn:aws:lambda:...:booking-handler",
"actions": [
{
"name": "check_availability",
"description": "Check available tennis court slots",
"parameters": {
"date": "string"
}
},
{
"name": "book_slot",
"description": "Book a specific slot",
"parameters": {
"slot_id": "string",
"user_id": "string"
}
}
]
}
]
}
# Lambda handles the actual DB work
def booking_handler(event):
action = event["actionGroup"]
params = event["parameters"]
if action == "check_availability":
return db.query_slots(params["date"])
elif action == "book_slot":
return db.book(params["slot_id"], params["user_id"])
# Invocation - agent handles the rest
response = bedrock_agent.invoke(
agent_id="xxx",
session_id="user-session",
input_text="Book me a court for tomorrow at 3pm"
)
# Agent autonomously: checks availability → picks slot → books → confirms
Pros
- Handles ambiguity (“tomorrow at 3pm” → agent figures it out)
- Multi-step reasoning built-in
- Less code for complex flows
Cons
- Less predictable
- Debugging is harder
- Vendor lock-in (AWS)
When to Use
- Multi-step tasks with user intent interpretation
- You want AWS-native solution
- Acceptable latency for agent reasoning
Pattern C: OpenAI Function Call → Lambda (Function Call Style)
Style: Function Call — AI suggests, YOU execute and control loop
Architecture
User → Your Code → OpenAI API → [suggests function] → Your Code → Lambda → DB
↑______________________ you decide next step __________________|
OpenAI suggests which function to call. You execute it and decide what happens next.
How the Loop Works
User: "Book a court for tomorrow at 3pm"
Loop 1:
┌─────────────────────────────────────────────────────────────┐
│ messages = [{role: "user", content: "Book a court..."}] │
│ ↓ │
│ OpenAI API (with tools defined) │
│ ↓ │
│ Response: tool_calls = [{name: "check_availability", │
│ args: {date: "2025-12-04"}}] │
│ ↓ │
│ Has tool_calls? YES → YOU execute invoke_lambda() │
│ ↓ │
│ Append to messages: │
│ - assistant msg (with tool_call) │
│ - tool result: [{slot_id: "A", time: "3pm"}, ...] │
│ ↓ │
│ Continue loop │
└─────────────────────────────────────────────────────────────┘
Loop 2:
┌─────────────────────────────────────────────────────────────┐
│ messages = [user msg, assistant tool_call, tool result] │
│ ↓ │
│ OpenAI API (sees availability result) │
│ ↓ │
│ Response: tool_calls = [{name: "book_slot", │
│ args: {slot_id: "A"}}] │
│ ↓ │
│ Has tool_calls? YES → YOU execute invoke_lambda() │
│ ↓ │
│ Append to messages: │
│ - assistant msg (with tool_call) │
│ - tool result: {confirmation: "Booked!"} │
│ ↓ │
│ Continue loop │
└─────────────────────────────────────────────────────────────┘
Loop 3:
┌─────────────────────────────────────────────────────────────┐
│ messages = [user, tool_call, result, tool_call, result] │
│ ↓ │
│ OpenAI API (sees booking confirmed) │
│ ↓ │
│ Response: tool_calls = None │
│ content = "Your court is booked for..." │
│ ↓ │
│ Has tool_calls? NO → return content → EXIT LOOP │
└─────────────────────────────────────────────────────────────┘
Who controls what:
| What | Who |
|---|---|
| Which function to call | AI suggests |
| Actually calling the function | You |
| Continue or stop loop | You |
| What to do with result | You |
Pseudo Code
# YOU control the loop
def handle_booking_request(user_input):
messages = [{"role": "user", "content": user_input}]
tools = [
{
"type": "function",
"function": {
"name": "check_availability",
"description": "Check available slots",
"parameters": {
"type": "object",
"properties": {
"date": {"type": "string"}
}
}
}
},
{
"type": "function",
"function": {
"name": "book_slot",
"description": "Book a slot",
"parameters": {
"type": "object",
"properties": {
"slot_id": {"type": "string"}
}
}
}
}
]
# Loop controlled by YOU
while True:
response = openai.chat.completions.create(
model="gpt-4",
messages=messages,
tools=tools
)
msg = response.choices[0].message
# No function call? Done.
if not msg.tool_calls:
return msg.content
# YOU execute the function
for tool_call in msg.tool_calls:
fn_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
# YOU call Lambda and decide what to do with result
result = invoke_lambda(fn_name, args)
messages.append(msg)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result)
})
# YOU decide: continue loop or break?
Pros
- Fine-grained control over execution
- Can add validation/logging between steps
- Portable — not locked to one vendor
Cons
- More code to write
- You manage the loop logic
When to Use
- Need control between AI decisions
- Want to add custom validation/logic per step
- Building portable, vendor-agnostic solution
Pattern D: OpenAI Agent → Lambda (Agent Style)
Style: Agent — SDK handles reasoning + execution autonomously
Architecture
User → OpenAI Agent SDK → [Reasons + Acts autonomously] → Lambda → DB
The SDK manages the loop. You define tools; it handles execution.
Pseudo Code
from openai import OpenAI
from openai.agents import Agent, Tool
client = OpenAI()
# Define tools
def check_availability(date: str) -> dict:
"""Check available tennis court slots"""
return invoke_lambda("check_availability", {"date": date})
def book_slot(slot_id: str, user_id: str) -> dict:
"""Book a specific slot"""
return invoke_lambda("book_slot", {"slot_id": slot_id, "user_id": user_id})
# Create agent with tools
agent = Agent(
name="BookingAgent",
instructions="You help users book tennis courts. Check availability first, then book.",
tools=[check_availability, book_slot]
)
# Run - SDK handles the loop autonomously
result = agent.run("Book me a court for tomorrow at 3pm")
print(result.final_output)
# Agent autonomously reasons, calls tools, loops until done
Pros
- Clean, minimal code
- SDK handles complexity
- Good balance of power and simplicity
Cons
- Less control than function call pattern
- Depends on SDK behavior
When to Use
- Want agent capabilities without managing loops
- Trust SDK to handle execution
- Rapid prototyping
Pattern E: OpenAI Workflow → Lambda (Workflow Style)
Style: Workflow — deterministic steps with AI reasoning within each step
Architecture
User → OpenAI Workflow → [Step 1] → Lambda → [Step 2] → Lambda → Result
↓ ↓
AI reasoning AI reasoning
(constrained) (constrained)
You define the sequence explicitly. AI handles reasoning within each step, but cannot change the step order.
Note: Lambda is used as the execution example throughout. You can substitute with inline functions, HTTP services, or any callable.
How It Differs from Agents
| Aspect | Agent (Pattern D) | Workflow (Pattern E) |
|---|---|---|
| Step order | AI decides | You define |
| Can skip steps | Yes | No |
| Can reorder | Yes | No |
| AI role | Full autonomy | Constrained per step |
Pseudo Code
from agents import Agent
availability_agent = Agent(
name="AvailabilityChecker",
instructions="Extract date/time and check availability.",
tools=[check_availability_lambda]
)
booking_agent = Agent(
name="BookingAgent",
instructions="Book the selected slot.",
tools=[book_slot_lambda]
)
@workflow
def booking_workflow(user_input: str):
# Step 1: Always check availability first (deterministic)
availability_result = availability_agent.run(user_input)
# Custom logic between steps
if not availability_result.slots:
return "No slots available."
# Step 2: Then book (deterministic order)
confirmation = booking_agent.run(
f"Book from: {availability_result.slots}"
)
return confirmation
Pros
- Predictable sequence — you define step order explicitly
- AI flexibility within steps — handles ambiguity per step
- Easier debugging — know exactly which step failed
- Custom logic between steps — validation, logging, branching
Cons
- Less flexible than full agents (can’t dynamically reorder)
- Requires upfront workflow design
- May be overkill for simple cases
When to Use
- Known step sequence, but need AI reasoning within each
- Want predictability of workflows + flexibility of agents
- Compliance or audit requirements need deterministic flow
- Need to add business logic between AI steps
Pattern F: OpenAI Manager → Bedrock Sub-Agents (Multi-Agent)
Style: Multi-Agent — hierarchical delegation
Architecture
User → Manager Agent (OpenAI) → [Decides which specialist]
↓
┌──────────┴──────────┐
↓ ↓
Availability Agent Booking Agent
(Bedrock) (Bedrock)
↓ ↓
Lambda Lambda
↓ ↓
DB DB
Manager routes to specialists. Each specialist handles its domain:
- Availability Agent — handles
check_availability - Booking Agent — handles
book_slot
Pseudo Code
from openai.agents import Agent
# Sub-agents (Bedrock-based specialists)
def invoke_availability_agent(task: str) -> str:
"""Delegate to availability specialist - checks open slots"""
response = bedrock_agent.invoke(
agent_id="availability-agent-id",
input_text=task
)
return response["output"]
def invoke_booking_agent(task: str) -> str:
"""Delegate to booking specialist - reserves slots"""
response = bedrock_agent.invoke(
agent_id="booking-agent-id",
input_text=task
)
return response["output"]
# Manager agent
manager = Agent(
name="ManagerAgent",
instructions="""
You route user requests to the right specialist:
- Checking availability → availability agent
- Making a reservation → booking agent
For a complete booking flow:
1. First route to availability agent to get open slots
2. Then route to booking agent to reserve the chosen slot
Synthesize responses before returning to user.
""",
tools=[invoke_availability_agent, invoke_booking_agent]
)
# Run
result = manager.run("Book me a court for tomorrow at 3pm")
# Manager: calls availability agent → gets slots → calls booking agent → confirms
Bedrock Sub-Agent Configurations
# Availability Agent (Bedrock)
availability_agent_config = {
"instruction": "You check tennis court availability. Return available slots.",
"action_groups": [
{
"name": "AvailabilityActions",
"lambda_arn": "arn:aws:lambda:...:availability-handler",
"actions": [
{
"name": "check_availability",
"description": "Check available slots for date/time",
"parameters": {
"date": "string",
"time": "string"
}
}
]
}
]
}
# Booking Agent (Bedrock)
booking_agent_config = {
"instruction": "You book tennis court slots. Confirm reservations.",
"action_groups": [
{
"name": "BookingActions",
"lambda_arn": "arn:aws:lambda:...:booking-handler",
"actions": [
{
"name": "book_slot",
"description": "Reserve a specific slot",
"parameters": {
"slot_id": "string",
"user_id": "string"
}
}
]
}
]
}
Pros
- Separation of concerns
- Each agent can be optimized for its domain
- Scales to complex systems
Cons
- Higher complexity
- Multiple points of failure
- Cost (multiple agent invocations)
When to Use
- Multiple distinct domains
- Need specialized agents per domain
- Building complex, enterprise-scale systems
Pattern G: OpenAI Manager → Lambda-Wrapped Agents (Multi-Agent + Isolation)
Style: Multi-Agent — hierarchical delegation with agent isolation
Architecture
User → OpenAI Manager → [Routes]
↓
┌───────────┴───────────┐
↓ ↓
Lambda A Lambda B
(Availability Agent) (Booking Agent)
↓ ↓
Agent logic Agent logic
(Bedrock) (Claude)
↓ ↓
DB DB
Manager routes to Lambdas. Each Lambda wraps its own agent.
Difference from Pattern E
| Aspect | E: Direct | F: Lambda-Wrapped |
|---|---|---|
| Manager calls | Bedrock Agent directly | Lambda |
| Agent runs in | Bedrock (managed) | Lambda (your infra) |
| Vendor mix | Same vendor per agent | Mix vendors freely |
| Custom logic | Before/after agent call | Full control per Lambda |
Pseudo Code
from openai.agents import Agent
# Lambda A: Availability Agent (uses Bedrock)
# deployed as separate Lambda function
def availability_lambda_handler(event):
task = event["task"]
# Pre-processing (custom logic)
task = sanitize_input(task)
# Agent logic (Bedrock)
response = bedrock_agent.invoke(
agent_id="availability-agent-id",
input_text=task
)
# Post-processing (custom logic)
return format_availability_response(response["output"])
# Lambda B: Booking Agent (uses Claude)
def booking_lambda_handler(event):
task = event["task"]
# Agent logic (Claude)
response = claude.messages.create(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": task}],
tools=[{
"name": "book_slot",
"description": "Reserve a tennis court slot",
"input_schema": {
"type": "object",
"properties": {
"slot_id": {"type": "string"},
"user_id": {"type": "string"}
}
}
}]
)
# Execute tool if called
if response.stop_reason == "tool_use":
tool_result = db.book(
response.content[1].input["slot_id"],
response.content[1].input["user_id"]
)
return {"confirmation": tool_result}
return response.content[0].text
# Manager agent — calls Lambdas, not agents directly
def invoke_availability_lambda(task: str) -> str:
"""Delegate to availability Lambda"""
return lambda_client.invoke("availability-lambda", {"task": task})
def invoke_booking_lambda(task: str) -> str:
"""Delegate to booking Lambda"""
return lambda_client.invoke("booking-lambda", {"task": task})
manager = Agent(
name="ManagerAgent",
instructions="""
Route user requests to the right specialist Lambda:
- Checking availability → availability Lambda
- Making a reservation → booking Lambda
For a complete booking:
1. First call availability Lambda to get open slots
2. Then call booking Lambda to reserve the chosen slot
Synthesize responses before returning to user.
""",
tools=[invoke_availability_lambda, invoke_booking_lambda]
)
# Run
result = manager.run("Book me a court for tomorrow at 3pm")
Pros
- Mix agent vendors (Bedrock for availability, Claude for booking)
- Full control over each agent’s environment
- Add custom pre/post processing per agent
- Better isolation and independent scaling
Cons
- Most complex to set up
- More infrastructure to manage
- Higher latency (Lambda cold starts + agent calls)
When to Use
- Need to mix AI vendors per domain
- Require custom logic before/after each agent
- Want independent scaling/deployment per agent
- Enterprise systems with strict isolation requirements
Side-by-Side Comparison
| Pattern | Style | Control | Complexity | Predictability | Cost |
|---|---|---|---|---|---|
| A | AI as Service(no agent) | Full | Low | High | Low |
| B | Agent | Low | Medium | Low | Medium |
| C | Function Call | High | Medium | Medium | Medium |
| D | Agent | Medium | Low | Low | Medium |
| E | Workflow | High | Medium | High | Medium |
| F | Multi-Agent | Low | High | Low | High |
| G | Multi-Agent | Medium | Highest | Low | High |
Decision Guide
The Spectrum
Control ←————————————————————————→ Autonomy
A C E D B F/G
| | | | | |
Manual Loop Workflow Agent Managed Multi-
Code Control Steps SDK Agent Agent
When to Use What
Choose Pattern A if:
- Your workflow is fixed and predictable
- You only need AI for text generation
- You want maximum control and debuggability
Choose Pattern B if:
- You’re AWS-native
- Need multi-step reasoning
- Want managed agent infrastructure
Choose Pattern C if:
- You need control between AI decisions
- Want to add custom validation per step
- Building vendor-agnostic solution
Choose Pattern D if:
- Want agent power with minimal code
- Trust SDK to manage execution
- Rapid development is priority
Choose Pattern E if:
- You have a known step sequence but need AI within each step
- Want predictability of workflows + flexibility of agents
- Need to add business logic between AI steps
- Compliance or audit requirements need deterministic flow
Choose Pattern F if:
- Multiple domains require specialists
- Building enterprise-scale system
- Can handle the complexity
Choose Pattern G if:
- Need to mix AI vendors (Bedrock + Claude + OpenAI)
- Require custom logic before/after each agent
- Want independent scaling per agent
- Strict isolation requirements
Deeper Insight
How AI’s Role Evolves
| Pattern | AI Task |
|---|---|
| A | Discriminative only — parse, classify, extract |
| B–G | Discriminative + Generative — reason, plan, respond |
In Pattern A, AI just converts messy input to structured data. You could theoretically replace the LLM with a simpler NLU tool.
In Patterns B–G, the AI must think:
- “What’s missing? I should ask.”
- “Two slots available. I should present options.”
- “Booking failed. I should explain and suggest alternatives.”
This shift from parsing to reasoning is why agent patterns feel more powerful — but also less predictable.
The Workflow Sweet Spot
Pattern E occupies a unique middle ground. It gives you:
- Deterministic step order (like A/C)
- AI reasoning per step (like D/B)
- Custom logic between steps (unique advantage)
When you need predictable sequences but still want AI flexibility within each step, Pattern E is your answer.
Conclusion
There’s no silver bullet. Start simple (A or C), add workflow structure when you need predictable sequences with AI flexibility (E), graduate to agents for full autonomy (B or D), and scale to multi-agent (F or G) only when complexity demands it.