Adding classification functionality "for free" to LLM pipelines with asyncio.gather

Adding classification functionality "for free" to LLM pipelines with asyncio.gather

We introduce a pattern for adding classification functionality to a RAG pipeline with zero latency increase by using asyncio.gather() to run lightweight LLM classification in parallel behind the I/O wait of KB search, along with an implementation example.
2026.07.12

This page has been translated by machine translation. View original

Introduction

When operating a RAG system, the need to "classify user inquiries" often arises to improve search accuracy. Identifying the target software, classifying the problem category, determining the escalation destination——with this information, you can rerank search results and inject context into prompts.

However, making an additional LLM call for classification increases latency. Many teams struggle with the question of whether classification is worth sacrificing user-perceived response speed.

In this article, I'll introduce a pattern using Python's asyncio.gather() to run lightweight LLM classification in parallel behind the I/O wait time of KB searches, adding classification functionality with zero latency increase. This is a technique actually adopted in the development of a service desk AI.

Prerequisites & Environment

  • Python 3.12 + FastAPI
  • Amazon Bedrock (Claude Haiku 4.5 / Claude Sonnet 4.5)
  • OpenSearch Serverless (vector search)
  • Anthropic Python SDK (anthropic[bedrock])

Pipeline Overview

First, let me show the overall structure of the RAG pipeline covered in this article.

zero-cost-parallel-classification-asyncio-gather-pipeline

The key is the dependency relationships between two Haiku calls (query generation + problem classification) and the OpenSearch search.

  • Query generation and problem classification are mutually independent (both take the user's raw text as input)
  • OpenSearch depends on the results of query generation
  • The final answer depends on all results

This dependency graph reveals where parallelization is possible.

What Happens with Sequential Execution

With naive sequential execution, it looks like this.

# Sequential execution (naive implementation)
kb_query = await generate_kb_query(messages)          # ~100ms
problem_ctx = await classify_problem(raw_query)       # ~100ms
chunks = await retrieve_from_kb(kb_query, top_k=5)    # ~500ms
# Total: ~700ms (overhead before answer generation)

Classification adds 100ms. It's "just 100ms," but in chat UIs sensitive to response time, it affects perceived performance.

Parallelizing with asyncio.gather

With asyncio.gather(), you can run independent async functions simultaneously.

First Turn: Parallelizing Query Generation and Classification

On the first turn, query generation and classification are executed simultaneously from the user's raw text.

async def _chat_stream(request: ChatRequest) -> AsyncGenerator[str]:
    is_first_turn = not any(m.role == "assistant" for m in request.messages)

    if is_first_turn:
        # 2 Haiku calls in parallel: query generation + problem classification
        kb_query, problem_ctx = await asyncio.gather(
            generate_kb_query(request.messages),
            classify_problem(request.messages[0].content),
        )
        # KB search depends on query generation results, so run sequentially here
        chunks = await retrieve_from_kb(kb_query, top_k=request.top_k)
        # ※ From here, generate streaming answer using chunks and problem_ctx (yield)

asyncio.gather() schedules the passed coroutines simultaneously and waits until all of them complete. The return value is a list in the same order as the arguments.

Second Turn Onward: Parallelizing KB Search and Classification

From the second turn onward, query generation requires conversation context (answers to clarifying questions), so query generation runs first. Instead, KB search and classification are parallelized.

    else:
        # Turn 2+: query generation needs conversation context, so run first
        kb_query = await generate_kb_query(request.messages)

        # Parallelize KB search (I/O bound) and classification (CPU bound)
        chunks, problem_ctx = await asyncio.gather(
            retrieve_from_kb(kb_query, top_k=request.top_k),
            classify_problem(kb_query),
        )

The key point is that the combination of what's parallelized changes depending on the turn. The principle is to accurately understand dependencies and only gather processes that are independent at that point in time.

Why It's "Free"

The reason this pattern is effective lies in the asymmetry of bottlenecks.

Process Model Typical Latency Nature
Query generation Haiku ~100ms LLM inference (lightweight)
Problem classification Haiku ~100ms LLM inference (lightweight)
KB search OpenSearch ~500ms Network I/O
Answer generation Sonnet ~2000ms LLM inference (heavyweight)

When running in parallel with asyncio.gather(), the slowest process determines the overall latency.

zero-cost-parallel-classification-asyncio-gather-latency

On the second turn, KB search and classification run in parallel after query generation (100ms). Since the classification latency is completely hidden behind the KB search, the total latency doesn't change whether or not classification is added. Both patterns complete in 600ms compared to 700ms with sequential execution.

Classification Function Implementation

Let's look at the implementation of the classification function classify_problem().

from pydantic import BaseModel, Field

class ProblemClassification(BaseModel):
    """Classification results for problem domain and category"""
    software: str | None = Field(
        None,
        description="Target software name (e.g., Exchange Online, SAP)"
    )
    category: str | None = Field(
        None,
        description=(
            "Problem category: Authentication/Login / Permissions/Access / "
            "Installation/Updates / Performance / Data/Files / "
            "Settings/Configuration / Other"
        ),
    )

The schema for classification results is defined with a Pydantic model. The description in Field is used directly as the tool use schema passed to the LLM.

async def classify_problem(query: str) -> ProblemClassification:
    """Classifies the problem domain and category.
    Executed in parallel with KB search via asyncio.gather, so zero latency increase.
    """
    tool_schema = ProblemClassification.model_json_schema()

    try:
        response = await _client.messages.create(
            model=settings.bedrock_haiku_model_id,  # Haiku 4.5
            messages=[{"role": "user", "content": query}],
            system=(
                "Analyze the service desk inquiry and identify "
                "the target software and problem category. "
                "Return null if unknown."
            ),
            max_tokens=100,
            temperature=0.0,
            tools=[{
                "name": "classify_problem",
                "description": "Classify the target software and problem category of an inquiry",
                "input_schema": tool_schema,
            }],
            tool_choice={"type": "tool", "name": "classify_problem"},
        )
        for block in response.content:
            if block.type == "tool_use" and block.name == "classify_problem":
                return ProblemClassification(**block.input)
        return ProblemClassification(software=None, category=None)
    except Exception:
        logger.warning("classify_problem failed silently", exc_info=True)
        return ProblemClassification(software=None, category=None)

There are three important design points.

1. Guaranteed Structured Output via Tool Use

By specifying tool_choice={"type": "tool", "name": "classify_problem"}, the LLM always returns JSON conforming to the specified schema. Unlike the approach of parsing with json.loads() and validating, there are no issues with markdown fence contamination or JSON truncation.

2. Failures Don't Stop the Main Flow

Classification is merely supplementary information that's "nice to have." If an exception occurs, the default values (software=None, category=None) are returned, and the main RAG flow continues as normal. By default, asyncio.gather() raises an exception for the entire gather if any coroutine raises one, but since this implementation catches exceptions internally, the gather will never be interrupted.

3. Haiku + Short max_tokens

Since classification only extracts 2 fields, max_tokens=100 is sufficient. Using a lightweight model (Haiku) × short output minimizes both latency and cost.

Utilizing Classification Results: Domain Context Injection

Classification alone is meaningless. Value is only created by injecting results into the main LLM's prompt.

def _build_domain_context(
    ticket_sw_map: dict[int, str],
    problem_ctx: ProblemClassification,
) -> str:
    """Builds domain context to inject into Sonnet's prompt"""
    # Prioritize software information extracted from KB chunks
    sw_counts = Counter(ticket_sw_map.values())
    kb_software = [sw for sw, _ in sw_counts.most_common(2)]
    systems = kb_software or (
        [problem_ctx.software] if problem_ctx.software else []
    )

    return (
        f"\n\n## Domain Context (System Automatic Analysis)\n"
        f"- Target System: {'、'.join(systems) or 'Unknown'}\n"
        f"- Problem Category: {problem_ctx.category or 'Unknown'}"
    )

Having this domain context injected into Sonnet's (the answer generation model's) prompt resulted in the following improvements.

  • Reduction of redundant clarifying questions: "Can't log in to Exchange Online" → Sonnet doesn't ask about the system, but asks about specific symptoms like error codes
  • Optimized questions based on category: For the "Authentication/Login" category it asks for "error codes," for "Permissions/Access" it asks "what operation fails"
  • Automatic escalation destination determination: Searches for the system owner from the software name and includes it in the response

Prioritizing Evidence from KB Chunks

There's another implementation detail I care about. There are two sources for determining the software name, and they differ in reliability.

# Extract software names from KB chunks via regex (based on actual data)
ticket_sw_map = _extract_software_from_chunks(chunks)

# Haiku classification (inferred from query text only)
_ctx_sw = problem_ctx.software

# Prioritize KB evidence, fall back to Haiku classification if absent
_kb_sw = [
    sw for sw, _ in Counter(ticket_sw_map.values()).most_common()
    if sw not in _GENERIC_SW
]
dominant_sw = _kb_sw[0] if _kb_sw else _ctx_sw

Software names extracted from KB chunks are evidence based on actual ticket data. On the other hand, Haiku classification is inference from query text alone, carrying a risk of misclassification. Therefore, the fallback priority is: KB evidence → Haiku classification → None.

Caveats When Using asyncio.gather

Wrap Blocking Functions with to_thread

When using synchronous libraries like boto3 or opensearch-py for KB search, not wrapping them with asyncio.to_thread() will block the event loop.

async def retrieve_from_kb(query: str, top_k: int = 5) -> list[dict]:
    def _blocking() -> dict:
        client = get_opensearch_client()
        return client.search(index=index_name, body=search_body)

    # Run the synchronous function in a thread pool to avoid blocking the event loop
    response = await asyncio.to_thread(_blocking)
    return _parse_response(response)

If you forget this, even though you think you're parallelizing with asyncio.gather(), the blocking function will stop other coroutines and the parallelization effect will be lost.

Decide on an Error Handling Strategy

asyncio.gather() has a return_exceptions parameter.

# Default: if even one exception occurs, the entire gather raises an exception
results = await asyncio.gather(task_a(), task_b())

# return_exceptions=True: exceptions are included in results
results = await asyncio.gather(task_a(), task_b(), return_exceptions=True)

In this implementation, classify_problem() catches exceptions internally and returns default values. This makes the calling code simpler and type-safe. Using return_exceptions=True makes the return value a union type of Exception | T, requiring isinstance checks.

Supplement: Correspondence with JavaScript's Promise.all

asyncio.gather() is nearly the same concept as JavaScript's Promise.all(). Here's a correspondence table for implementing the same pattern in Node.js.

// Python: await asyncio.gather(task_a(), task_b())
const [kbQuery, problemCtx] = await Promise.all([
  generateKbQuery(messages),
  classifyProblem(rawQuery),
])
asyncio.gather() Promise.all()
Basic behavior Runs all coroutines concurrently, waits for all to complete Runs all Promises concurrently, waits for all to complete
Return value List in argument order Array in argument order
On error One failure raises exception for all (default) One failure rejects all
Collecting errors return_exceptions=True Use Promise.allSettled()
Concurrency model Single-thread event loop Single-thread event loop

The pattern in this article of "hiding lightweight LLM calls behind I/O-bound processing" can be applied as-is in Node.js. As long as the bottleneck is network I/O, the same principle applies regardless of language to obtain "free" processing time.

Summary

  • By using asyncio.gather() to hide lightweight LLM calls behind I/O-bound processing, classification functionality can be added with zero latency increase
  • The basis for parallelization decisions is the dependency graph: only gather processes that are mutually independent
  • Classification functions should be designed so that failures don't stop the main flow (because it's supplementary information)
  • Synchronous libraries must be wrapped with asyncio.to_thread(), otherwise parallelization won't work
  • Classification results are utilized by injecting context into prompts, leading to improved clarifying question quality and automatic escalation destination determination

This pattern is not limited to "classification" — it can be applied broadly to any lightweight preprocessing that can be done during the I/O wait time of the main process. Summary generation, language detection, tone analysis——as long as the bottleneck is I/O, asyncio.gather() can obtain "free" processing time.


国内企業 AI活用実態調査2026 配布中

クラスメソッドが独自に行なったAI診断調査をもとに、企業のAI活用の現在地を調査レポートとしてまとめました。企業規模別の活用度傾向に加え、規模を超えてAI活用を進める企業に共通する取り組みまで、自社の現在地を捉えるためのヒントにぜひ。

国内企業 AI活用実態調査2026

無料でダウンロードする

Share this article

AWSのお困り事はクラスメソッドへ