Module 6: Prompt Composition and Chaining

2. Prompt Chaining: Output → Input

Overview

Prompt chaining is the fundamental pattern of prompt composition: the output of one prompt becomes the input of the next. This technique lets you build pipelines where each stage is a specialized prompt that transforms the information incrementally.

In this capsule you'll learn the three main chaining patterns: sequential (A→B→C), conditional (branching based on content), and parallel (multiple simultaneous prompts), along with robust error handling strategies.


Why Prompt Chaining

A single prompt has limitations:

❌ One prompt for a complex analysis:
   "Read this contract, identify the parties, extract risk clauses, 
    determine whether the deadlines are reasonable, suggest changes, and generate
    an executive summary in 3 languages."
   
   Result: A shallow answer that tries to do everything halfway.

✓ Specialized chaining:
   Prompt 1: Extract parties and structure → clean data
   Prompt 2: Identify risk clauses → deep analysis
   Prompt 3: Assess deadlines → focused evaluation
   Prompt 4: Suggest changes → based on the real analysis
   Prompt 5: Summary in 3 languages → translation of the final synthesis
   
   Result: A detailed analysis on every dimension.

Pattern 1: Sequential Chaining

Base Implementation

from openai import OpenAI
from typing import Optional, Callable, Any
import json

client = OpenAI()

def call_llm(
    prompt: str,
    temperature: float = 0,
    max_tokens: int = 500,
    json_mode: bool = False
) -> str:
    """Basic helper to call the LLM."""
    kwargs = {
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": prompt}],
        "temperature": temperature,
        "max_tokens": max_tokens
    }
    if json_mode:
        kwargs["response_format"] = {"type": "json_object"}
    
    return client.chat.completions.create(**kwargs).choices[0].message.content

def chain_sequential(
    stages: list[dict],
    initial_input: str,
    verbose: bool = True
) -> dict:
    """
    Runs a sequential chain of prompts.
    
    Args:
        stages: List of {name: str, prompt_template: str, temperature: float}
                The prompt_template uses {input} to receive the previous output
        initial_input: The input of the first stage
        verbose: If True, prints the process
    
    Returns:
        dict with every output keyed by stage name
    """
    outputs = {"_initial_input": initial_input}
    current_input = initial_input
    
    for stage in stages:
        name = stage["name"]
        template = stage["prompt_template"]
        temperature = stage.get("temperature", 0)
        json_mode = stage.get("json_mode", False)
        max_tokens = stage.get("max_tokens", 500)
        
        if verbose:
            print(f"[Chain] Running stage: {name}")
        
        # Format the template with the current input
        prompt = template.format(
            input=current_input,
            **{k: v for k, v in outputs.items() if not k.startswith("_")}
        )
        
        output = call_llm(prompt, temperature, max_tokens, json_mode)
        outputs[name] = output
        current_input = output  # The output becomes the input of the next stage
        
        if verbose:
            print(f"  Output ({len(output)} chars): {output[:100]}...")
    
    return outputs

# Example: Product review analysis pipeline
review_stages = [
    {
        "name": "extraction",
        "prompt_template": """Extract from this product review: 
1. Aspects mentioned (quality, price, shipping, etc.)
2. Opinion on each aspect (positive/negative/neutral)
3. Implicit rating (1-5)

Review: {input}

Respond in JSON: {{"aspects": [{{"name": str, "opinion": str, "rating": 1-5}}], "overall_rating": 1-5}}""",
        "json_mode": True,
        "temperature": 0
    },
    {
        "name": "analysis",
        "prompt_template": """Based on this analysis of a product review:
{input}

Identify:
1. Is this a genuine review or could it be fake? (authenticity signals)
2. Which aspects of the product need urgent improvement?
3. Which strengths should marketing highlight?

Answer concisely.""",
        "temperature": 0
    },
    {
        "name": "action",
        "prompt_template": """Based on this analysis:
{input}

Generate 3 concrete actions the product team should take.
Each action: who does it, what they do, in how much time.
Format: bullet points.""",
        "temperature": 0
    }
]

review = "The product arrived in perfect condition and works exactly as described. The price is a bit high but the quality justifies it. Shipping took longer than expected (5 days instead of 2), but the seller was very attentive and kept me informed."

result = chain_sequential(review_stages, review, verbose=True)
print("\n=== FINAL OUTPUT (ACTIONS) ===")
print(result["action"])

Chain with Format Transformation

from pydantic import BaseModel
from typing import Union

class ReviewExtraction(BaseModel):
    aspects: list[dict]
    overall_rating: int

def chain_with_validation(
    review_text: str
) -> dict:
    """
    Chain with schema validation between stages using Pydantic.
    """
    # Stage 1: Structured extraction
    extraction_prompt = f"""Extract from this review:
- aspects mentioned along with the opinion on each
- overall implicit rating (1-5)

Review: {review_text}

Respond in JSON: {{"aspects": [{{"name": str, "opinion": "positive|negative|neutral", "rating": 1-5}}], "overall_rating": int}}"""
    
    output_json = call_llm(extraction_prompt, json_mode=True)
    
    # Validate with Pydantic
    try:
        extraction = ReviewExtraction(**json.loads(output_json))
    except Exception as e:
        print(f"Validation error in stage 1: {e}. Retrying...")
        output_json = call_llm(
            extraction_prompt + "\n\nIMPORTANT: The response MUST be valid JSON with 'aspects' and 'overall_rating'.",
            json_mode=True
        )
        extraction = ReviewExtraction(**json.loads(output_json))
    
    # Stage 2: Analysis based on the validated extraction
    aspects_str = json.dumps([a for a in extraction.aspects], ensure_ascii=False)
    analysis_prompt = f"""Analyze these aspects of a product review:
{aspects_str}
Overall rating: {extraction.overall_rating}/5

What are the 2 most critical points that need improvement?"""
    
    analysis = call_llm(analysis_prompt)
    
    return {
        "extraction": extraction.model_dump(),
        "analysis": analysis
    }

Pattern 2: Conditional Chaining

from enum import Enum

class DocumentType(Enum):
    CONTRACT = "contract"
    INVOICE = "invoice"
    EMAIL = "email"
    REPORT = "report"
    OTHER = "other"

def classify_document(text: str) -> DocumentType:
    """First step of the conditional chain: classify the document type."""
    prompt = f"""Classify this text into one of these categories:
CONTRACT, INVOICE, EMAIL, REPORT, OTHER

Text (first 500 chars):
{text[:500]}

Respond with ONE word only: CONTRACT, INVOICE, EMAIL, REPORT, or OTHER"""
    
    classification = call_llm(prompt).strip().upper()
    
    mapping = {
        "CONTRACT": DocumentType.CONTRACT,
        "INVOICE": DocumentType.INVOICE,
        "EMAIL": DocumentType.EMAIL,
        "REPORT": DocumentType.REPORT
    }
    return mapping.get(classification, DocumentType.OTHER)

# Specialized prompts per document type
SPECIALIZED_PROMPTS = {
    DocumentType.CONTRACT: """Analyze this legal contract. Identify:
1. Parties involved
2. Subject matter of the contract
3. Main obligations of each party
4. Termination clauses
5. Penalties, if any

Contract: {input}""",
    
    DocumentType.INVOICE: """Extract the following information from this invoice:
1. Issuer and recipient
2. Invoice number and date
3. Invoice line items (description, quantity, unit price, total)
4. Taxable base, VAT, total

Invoice: {input}

Respond in structured JSON.""",
    
    DocumentType.EMAIL: """Analyze this business email:
1. Sender and recipient
2. Main purpose
3. Action required (if any)
4. Tone and urgency
5. Suggested reply

Email: {input}""",
    
    DocumentType.REPORT: """Analyze this report:
1. Main topic
2. Key metrics or data
3. Main conclusions
4. Recommendations, if any

Report: {input}""",
    
    DocumentType.OTHER: """Summarize the following document:
- Type of content
- Main points
- Relevant information

Document: {input}"""
}

def chain_conditional_document(text: str, verbose: bool = True) -> dict:
    """
    Conditional chain: classify the document and then apply
    the specialized analysis for that type.
    """
    # Stage 1: Classification (always runs)
    doc_type = classify_document(text)
    
    if verbose:
        print(f"[Conditional Chain] Detected type: {doc_type.value}")
    
    # Stage 2: Specialized analysis (conditional on the type)
    specialized_prompt = SPECIALIZED_PROMPTS[doc_type].format(input=text)
    analysis = call_llm(
        specialized_prompt,
        json_mode=(doc_type == DocumentType.INVOICE)
    )
    
    # Stage 3: Recommended actions (always runs, but with the type as context)
    actions_prompt = f"""Given this analysis of a {doc_type.value} document:
{analysis}

Which 2-3 concrete actions should the recipient of this document take?"""
    
    actions = call_llm(actions_prompt)
    
    return {
        "document_type": doc_type.value,
        "specialized_analysis": analysis,
        "recommended_actions": actions
    }

# Example:
documents = [
    "Dear customer, attached you will find invoice #2026-0342 for services rendered in February...",
    "By this contract, PARTY A undertakes to deliver the services described in Annex I...",
]

for doc in documents:
    result = chain_conditional_document(doc, verbose=True)
    print(f"  Analysis: {result['specialized_analysis'][:100]}...\n")

Pattern 3: Parallel Chaining

import asyncio
from openai import AsyncOpenAI

async def chain_parallel_analysis(
    article: str,
    requested_analyses: list[str] = None
) -> dict:
    """
    Runs multiple analyses in parallel on the same article.
    Reduces total latency significantly.
    
    Args:
        article: Text of the article to analyze
        requested_analyses: List of desired analyses. Default: all of them.
    
    Returns:
        dict with every completed analysis
    """
    client_async = AsyncOpenAI()
    
    if requested_analyses is None:
        requested_analyses = ["summary", "keywords", "sentiment", "entities"]
    
    PARALLEL_PROMPTS = {
        "summary": f"Summarize in 3 concise sentences: {article}",
        
        "keywords": f"""Extract 5-8 keywords from the following article.
Format: comma-separated list.
Article: {article}""",
        
        "sentiment": f"""Analyze the sentiment and tone of this article.
State: sentiment (POSITIVE/NEGATIVE/NEUTRAL), tone (formal/informal/technical), and emotional intensity (high/medium/low).
Article: {article}""",
        
        "entities": f"""Extract named entities from this article.
Categories: people, organizations, places, dates/periods, products.
Article: {article}
JSON format.""",
        
        "questions": f"""What are the 3 most important questions this article answers?
Article: {article}""",
        
        "audience": f"""Who is this article mainly written for?
Describe the target audience and why.
Article: {article}"""
    }
    
    async def run_analysis(name: str) -> tuple[str, str]:
        """Runs an individual analysis."""
        if name not in PARALLEL_PROMPTS:
            return name, f"Analysis '{name}' not available"
        
        response = await client_async.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": PARALLEL_PROMPTS[name]}],
            temperature=0,
            max_tokens=300
        )
        return name, response.choices[0].message.content
    
    # Run all of them in parallel
    import time
    t0 = time.time()
    
    tasks = [run_analysis(name) for name in requested_analyses]
    raw_results = await asyncio.gather(*tasks)
    
    total_time = time.time() - t0
    results = dict(raw_results)
    results["_metadata"] = {
        "total_time": total_time,
        "n_analyses": len(requested_analyses),
        "avg_time_if_sequential": total_time * len(requested_analyses)
    }
    
    return results

# To run in a normal script (not a notebook):
async def parallel_example():
    article = """Artificial intelligence is transforming the financial industry. 
    Machine learning algorithms make it possible to detect fraud in real time 
    with 99.5% accuracy, reducing losses by 60%..."""
    
    import time
    t0 = time.time()
    results = await chain_parallel_analysis(
        article,
        requested_analyses=["summary", "keywords", "sentiment", "entities"]
    )
    t_total = time.time() - t0
    
    print(f"Total time: {t_total:.2f}s (vs ~{t_total * 4:.1f}s if it were sequential)")
    for name, result in results.items():
        if not name.startswith("_"):
            print(f"\n{name.upper()}:\n{result[:150]}...")

# asyncio.run(parallel_example())  # Uncomment to run

Pattern 4: Fan-Out / Fan-In

async def chain_fan_out_fan_in(
    document: str,
    n_perspectives: int = 3
) -> dict:
    """
    Fan-Out: Analyzes the document from multiple perspectives in parallel.
    Fan-In: Synthesizes every analysis into one coherent output.
    
    Useful for multi-dimensional analysis where each perspective
    can reveal different aspects of the same content.
    """
    client_async = AsyncOpenAI()
    
    perspectives = [
        ("technical", "Analyze the technical aspects, accuracy and methodology"),
        ("business", "Analyze the implications for business and ROI"),
        ("end_user", "Analyze the impact on the end user"),
        ("ethical", "Analyze ethical and privacy considerations"),
        ("competitive", "Analyze it in the context of the competitive landscape")
    ][:n_perspectives]
    
    async def analyze_perspective(name: str, instruction: str) -> tuple[str, str]:
        response = await client_async.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": f"""Analyze this document from the {name} perspective:
{instruction}

Document: {document[:3000]}

Provide 3 key points from this perspective."""
            }],
            temperature=0,
            max_tokens=300
        )
        return name, response.choices[0].message.content
    
    # Fan-Out: multiple analyses in parallel
    tasks = [analyze_perspective(n, i) for n, i in perspectives]
    perspective_results = dict(await asyncio.gather(*tasks))
    
    # Fan-In: synthesize every analysis
    perspectives_str = "\n\n".join([
        f"{name.upper()} PERSPECTIVE:\n{analysis}"
        for name, analysis in perspective_results.items()
    ])
    
    synthesis = client_async  # Reuse the async client
    synthesis_resp = await synthesis.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"""You have {n_perspectives} different analyses of the same document:

{perspectives_str}

Synthesize a holistic 3-5 paragraph assessment that integrates every perspective.
Highlight: agreements between perspectives, tensions or contradictions, and an overall conclusion."""
        }],
        temperature=0,
        max_tokens=600
    )
    
    return {
        "perspectives": perspective_results,
        "synthesis": synthesis_resp.choices[0].message.content
    }

Robust Error Handling in Chains

from typing import Optional
import logging

logger = logging.getLogger(__name__)

class StageError(Exception):
    """Error specific to one stage of the chain."""
    def __init__(self, stage: str, message: str, input_data: str = ""):
        self.stage = stage
        self.message = message
        self.input_data = input_data
        super().__init__(f"Error in stage '{stage}': {message}")

def chain_with_error_handling(
    stages: list[dict],
    initial_input: str,
    max_retries: int = 2,
    fallback_output: Optional[dict] = None
) -> dict:
    """
    Sequential chain with robust error handling.
    
    Features:
    - Automatic retry per stage
    - Improved prompt on retry
    - Fallback when retries run out
    - Error logging
    """
    outputs = {"_initial_input": initial_input}
    current_input = initial_input
    
    for stage in stages:
        name = stage["name"]
        template = stage["prompt_template"]
        temperature = stage.get("temperature", 0)
        json_mode = stage.get("json_mode", False)
        validator = stage.get("validator", None)  # Optional validation function
        
        last_error = None
        
        for attempt in range(max_retries + 1):
            try:
                prompt = template.format(
                    input=current_input,
                    **{k: v for k, v in outputs.items() if not k.startswith("_")}
                )
                
                # On retries, make the prompt more explicit
                if attempt > 0:
                    prompt += f"\n\nIMPORTANT: Attempt {attempt+1}. Previous error: {last_error}. Please respond in the exact requested format."
                
                output = call_llm(prompt, temperature, 600, json_mode)
                
                # Optional validation
                if validator:
                    is_valid, validation_error = validator(output)
                    if not is_valid:
                        raise ValueError(f"Validation failed: {validation_error}")
                
                outputs[name] = output
                current_input = output
                break  # Success, exit the retry loop
                
            except Exception as e:
                last_error = str(e)
                logger.warning(f"Attempt {attempt+1} failed in stage '{name}': {e}")
                
                if attempt == max_retries:
                    # Retries exhausted
                    if fallback_output and name in fallback_output:
                        outputs[name] = fallback_output[name]
                        current_input = fallback_output[name]
                        outputs[f"_{name}_failed"] = True
                        logger.error(f"Using fallback for stage '{name}'")
                    else:
                        raise StageError(name, last_error, current_input)
    
    return outputs

# Example validator for JSON
def validate_json_with_fields(required_fields: list[str]):
    """Builds a validator that checks the JSON has the required fields."""
    def validate(output: str) -> tuple[bool, str]:
        try:
            data = json.loads(output)
            for field in required_fields:
                if field not in data:
                    return False, f"Field '{field}' is missing from the JSON"
            return True, ""
        except json.JSONDecodeError as e:
            return False, f"Invalid JSON: {e}"
    return validate

# Usage:
stages_with_validation = [
    {
        "name": "extraction",
        "prompt_template": "Extract the entities from: {input}\nJSON: {{\"persons\": [], \"organizations\": [], \"dates\": []}}",
        "json_mode": True,
        "temperature": 0,
        "validator": validate_json_with_fields(["persons", "organizations"])
    }
]

try:
    result = chain_with_error_handling(
        stages_with_validation,
        "Apple launched the iPhone 17 on March 15, 2026. Tim Cook presented the device.",
        max_retries=2
    )
    print(result)
except StageError as e:
    print(f"Unrecoverable error in stage '{e.stage}': {e.message}")

Real Use Cases

Case 1: Resume Processing Pipeline

def pipeline_cv(cv_text: str) -> dict:
    """Complete pipeline to analyze a resume."""
    stages = [
        {
            "name": "extraction",
            "prompt_template": """Extract from the following resume:
1. Name and contact details
2. Work experience (company, role, duration)
3. Education
4. Technical skills
5. Languages

Resume: {input}

Respond in structured JSON.""",
            "json_mode": True
        },
        {
            "name": "evaluation",
            "prompt_template": """Based on this resume extraction:
{input}

Evaluate:
1. Total years of experience
2. Estimated technical level (junior/mid/senior)
3. Main area of specialization
4. Strengths (3 points)
5. Areas for improvement (2 points)

Be objective and base everything on evidence from the resume."""
        },
        {
            "name": "interview_questions",
            "prompt_template": """Given this resume analysis:
{input}

Generate 5 interview questions specific to this candidate.
Include: 2 technical, 2 about experience, 1 about a difficult situation.
Each question in the format: "Question: [question] (Goal: [what it assesses])"
"""
        }
    ]
    
    return chain_with_error_handling(stages, cv_text)

# Test:
example_cv = """
Juan García
Backend Developer | juan@email.com | LinkedIn: /in/jgarcia
5+ years of experience in Python, FastAPI, PostgreSQL.
Working at TechCorp (2021-present) as Lead Backend Developer...
"""
result = pipeline_cv(example_cv)
print("EVALUATION:", result["evaluation"][:300])

Case 2: Parallel Competitor Analysis

async def analyze_competitors(company: str, competitors: list[str]) -> dict:
    """
    Analyzes multiple competitors in parallel and compares them with the main company.
    """
    client_async = AsyncOpenAI()
    
    async def analyze_competitor(competitor_name: str) -> tuple[str, str]:
        response = await client_async.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": f"""Briefly analyze {competitor_name} as a competitor of {company}:
1. Main strengths (2-3 points)
2. Weaknesses (2 points)
3. Differentiators vs {company}
(Use general knowledge; state when something is uncertain)"""
            }],
            temperature=0,
            max_tokens=250
        )
        return competitor_name, response.choices[0].message.content
    
    # Fan-out: analyze all of them in parallel
    tasks = [analyze_competitor(c) for c in competitors]
    individual_analyses = dict(await asyncio.gather(*tasks))
    
    # Fan-in: comparative synthesis
    comparisons = "\n\n".join([f"### {comp}:\n{analysis}" 
                                for comp, analysis in individual_analyses.items()])
    
    synthesis_resp = await client_async.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": f"""Competitor analysis for {company}:

{comparisons}

Generate:
1. Comparison table with strengths/weaknesses
2. Main competitive threats
3. Differentiation opportunities for {company}"""
        }],
        temperature=0,
        max_tokens=600
    )
    
    return {
        "individual_analyses": individual_analyses,
        "comparative_synthesis": synthesis_resp.choices[0].message.content
    }

# asyncio.run(analyze_competitors("FastAPI", ["Django", "Flask", "Express.js"]))

Troubleshooting

Problem 1: Truncated output between stages

Symptom: The output of stage 1 is cut off and stage 2 works with incomplete information.

Causes:

  • max_tokens set too low
  • The model generates more tokens than expected

Solution:

def chain_with_truncation_check(
    template: str,
    input_data: str,
    max_tokens: int = 500
) -> str:
    """Detects and handles truncated outputs."""
    output = call_llm(template.format(input=input_data), max_tokens=max_tokens)
    
    # Truncation signals
    truncation_signals = [
        output.endswith("..."),
        output.endswith(","),
        len(output.split()) > max_tokens * 0.9,
        output.count("{") != output.count("}"),  # Unbalanced JSON
    ]
    
    if any(truncation_signals):
        # Try again with more tokens, or ask it to complete
        continuation = call_llm(
            f"{output}\n\n[CONTINUE from where you were cut off]",
            max_tokens=max_tokens * 2
        )
        return output + continuation
    
    return output

Problem 2: Inconsistent format between stages

Symptom: Stage 2 expects JSON but stage 1 returns free text.

Solution: Use json_mode=True when you need JSON, and validate with Pydantic:

def stage_with_guaranteed_json(prompt: str) -> dict:
    """Guarantees the output is valid JSON."""
    max_attempts = 3
    for attempt in range(max_attempts):
        output = call_llm(
            prompt if attempt == 0 else f"{prompt}\n\nCRITICAL: Respond with valid JSON ONLY.",
            json_mode=True
        )
        try:
            return json.loads(output)
        except json.JSONDecodeError:
            if attempt == max_attempts - 1:
                return {"error": "Could not parse JSON", "raw": output}
    return {}

Problem 3: Error cascade

Symptom: An error in stage 2 makes stage 3 fail too, and so on down the chain.

Solution: Every stage must have an independent fallback:

def stage_with_fallback(
    template: str,
    input_data: str,
    fallback_message: str = "Analysis not available for this stage"
) -> str:
    """Individual stage with a fallback."""
    try:
        return call_llm(template.format(input=input_data))
    except Exception as e:
        print(f"⚠ Stage failed: {e}. Using fallback.")
        return fallback_message

Exercises

Exercise 1: Feedback analysis chain

Implement a 3-stage chain to analyze user feedback:

  1. Categorize the feedback (bug, feature request, complaint, compliment)
  2. Assess the priority (high/medium/low) based on category and urgency
  3. Generate an automatic reply appropriate to the type
See solution
def chain_user_feedback(feedback: str) -> dict:
    stages = [
        {
            "name": "categorization",
            "prompt_template": """Categorize this user feedback:
Categories: BUG, FEATURE_REQUEST, COMPLAINT, COMPLIMENT, QUESTION

Feedback: {input}

Respond in JSON: {{"category": str, "subcategory": str, "urgency": "high|medium|low", "sentiment": "positive|negative|neutral"}}""",
            "json_mode": True
        },
        {
            "name": "priority",
            "prompt_template": """Given this feedback analysis:
{input}

Determine:
1. Attention priority (1-5, where 5 is the most urgent)
2. Responsible team (engineering, product, support, marketing)
3. Suggested response time (immediate/24h/72h/week)

JSON: {{"priority": int, "team": str, "response_time": str, "reason": str}}""",
            "json_mode": True
        },
        {
            "name": "auto_reply",
            "prompt_template": """Original feedback: {_initial_input}
Analysis: {priority}

Generate an empathetic, professional automatic reply for this feedback.
- If it's a BUG: Thank them for the report, say it will be investigated
- If it's a FEATURE_REQUEST: Thank them for the suggestion, say it will be considered
- If it's a COMPLAINT: Empathize, apologize if it applies, state the action
- If it's a COMPLIMENT: Thank them genuinely
Maximum 3 sentences."""
        }
    ]
    
    return chain_with_error_handling(stages, feedback, max_retries=1)

test_feedback = "I've spent 3 days trying to export my data to CSV and the button doesn't work. It's urgent because I have a presentation tomorrow and I need that data."
result = chain_user_feedback(test_feedback)
print("Reply:", result["auto_reply"])

Exercise 2: Chain with branching by complexity

Implement a chain that:

  1. Analyzes the complexity of a technical question (simple/complex)
  2. If it's simple: answer directly
  3. If it's complex: decompose into sub-questions, answer each one, synthesize
See solution
def adaptive_question_chain(question: str) -> dict:
    # Step 1: Assess complexity
    classification = call_llm(f"Is this question SIMPLE (direct answer) or COMPLEX (requires multiple steps)?\nQuestion: {question}\nRespond with only: SIMPLE or COMPLEX")
    
    if "SIMPLE" in classification.upper():
        answer = call_llm(f"Answer concisely: {question}")
        return {"complexity": "simple", "answer": answer}
    
    # Step 2 (only for complex ones): Decompose
    sub_questions_raw = call_llm(f"Break this complex question down into 2-4 simpler sub-questions:\n{question}\nFormat: '1. ...' '2. ...'")
    
    sub_questions = [l.lstrip('0123456789.-) ').strip() 
                     for l in sub_questions_raw.split('\n') 
                     if l.strip() and l.strip()[0].isdigit()]
    
    # Step 3: Answer each sub-question
    partial_answers = []
    for sq in sub_questions[:4]:
        resp = call_llm(f"Answer briefly: {sq}")
        partial_answers.append(f"Q: {sq}\nA: {resp}")
    
    # Step 4: Synthesize
    context = "\n\n".join(partial_answers)
    synthesis = call_llm(f"Original question: {question}\n\nPartial answers:\n{context}\n\nSynthesize a complete answer.")
    
    return {
        "complexity": "complex",
        "sub_questions": sub_questions,
        "partial_answers": partial_answers,
        "synthesis": synthesis
    }

Exercise 3: Parallel content analysis pipeline

Implement an async pipeline that analyzes an article in parallel for: summary, keywords, target audience, and topic classification. The output must be structured JSON.

See solution
async def article_analysis_pipeline(article: str) -> dict:
    client_async = AsyncOpenAI()
    
    ANALYSES = {
        "summary": f"Generate a 2-sentence executive summary: {article[:2000]}",
        "keywords": f"List the 5 main keywords, comma-separated: {article[:2000]}",
        "audience": f"Who is this article written for? (2-3 sentences): {article[:2000]}",
        "topic": f"Classify into ONE topic: technology, business, science, politics, culture, sports, other. One word only: {article[:500]}"
    }
    
    async def run(name: str, prompt: str) -> tuple[str, str]:
        r = await client_async.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0, max_tokens=200
        )
        return name, r.choices[0].message.content.strip()
    
    results = dict(await asyncio.gather(*[run(n, p) for n, p in ANALYSES.items()]))
    return results

# To run it:
# result = asyncio.run(article_analysis_pipeline("The text of the article..."))

Summary

  • Sequential chaining: A→B→C, the output of each stage feeds the next one. Simple but effective for linear processes.
  • Conditional chaining: Branching based on the content. Enables specialization per input type.
  • Parallel chaining: Multiple simultaneous analyses with asyncio. Cuts latency from N*T down to ~T.
  • Fan-out/fan-in: Multiple parallel analyses + synthesis. Ideal for multi-perspective analysis.
  • Error handling: Retry per stage, validation with Pydantic, fallback, logging. Never propagate errors without context.

Additional resources

  1. LangChain Expression Language (LCEL) - Framework for chaining
  2. Python asyncio - gather
  3. OpenAI AsyncOpenAI client
  4. Pydantic v2 Documentation
  5. Prompt Engineering Guide - Prompt Chaining
  6. OpenAI Cookbook - How to build chained prompts