0Pricing
AI Engineering Academy · Урок

Почему приложения на LLM сложно отлаживать

Разберитесь, почему традиционного журналирования недостаточно для приложений на LLM, какая информация нужна для диагностики сбоев в RAG и конвейерах агентов и как устроена модель данных трассировки.

«Почему приложения на LLM сложно отлаживать» — бесплатный урок AI Engineering Academy на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения AI Engineering Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс AI Engineering Academy содержит 4 уроков всего.

Части этого урока еще не переведены и отображаются на английском.

The Unique Challenges of LLM Debugging

Traditional software fails deterministically: given the same input, it always produces the same output, and a stack trace points directly to the line that failed. LLM applications break these assumptions. The same prompt can produce different outputs on different calls, failures are often silent (wrong answer instead of exception), and the cause may be buried in a prompt used 5 steps earlier in a chain. Standard logging and debugging tools are simply not designed for this.

Non-Determinism Makes Reproduction Hard

LLM outputs are non-deterministic by default. Even with temperature=0, the same prompt may produce slightly different outputs due to batch processing and numerical precision. This means bugs are intermittent: a prompt that fails 20% of the time will pass your test suite if you only run it once. Reproducing a specific failure requires logging the exact input, model parameters, and output at the time of failure — not just the input.

import json
import time

def logged_llm_call(client, messages, model, temperature, **kwargs):
    request_id = f'{int(time.time() * 1000)}-{id(messages)}'
    
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=temperature,
        **kwargs
    )
    
    # Log EVERYTHING needed to reproduce this exact call
    log_entry = {
        'request_id': request_id,
        'model': model,
        'temperature': temperature,
        'messages': messages,
        'response': response.choices[0].message.content,
        'finish_reason': response.choices[0].finish_reason,
        'usage': response.usage.model_dump(),
        'timestamp': time.time()
    }
    write_to_trace_store(log_entry)
    return response

Silent Failures: Wrong, Not Broken

The most insidious LLM failures are silent failures: the API call succeeds (HTTP 200, no exception), but the answer is wrong, hallucinated, incomplete, or off-topic. Your application happily processes the wrong answer and returns it to the user with no indication anything failed. Traditional monitoring that only watches for exceptions and error codes will never catch these — you need semantic monitoring of output quality.

# This succeeds with HTTP 200 but returns wrong information
response = client.chat.completions.create(
    model='gpt-4o',
    messages=[{'role': 'user', 'content': 'What is the boiling point of water at sea level?'}]
)

output = response.choices[0].message.content
# response.status_code: None (not relevant - always 200 if we got here)
# No exception thrown
# But if output is '90 degrees Celsius', it is WRONG and your app will serve bad data

# You need semantic validation:
def validate_boiling_point_answer(text: str) -> bool:
    return '100' in text  # Rough check - real validation is more sophisticated

Multi-Step Chains: Where Did It Go Wrong?

In a RAG pipeline or agent chain, a failure in the final response might trace back to a retrieval step that returned irrelevant chunks, which itself traces back to a chunking strategy that split a key sentence across two chunks, which traces back to an embedding model that did not handle technical jargon well. Without per-step tracing, you see only the wrong final answer with no way to pinpoint which step introduced the error.

# Without tracing: you see only the final wrong answer
def rag_pipeline_naive(query):
    chunks = retrieve(query)         # step 1 - might return bad chunks
    context = format_context(chunks) # step 2 - might truncate key info
    answer = generate(query, context) # step 3 - LLM gets bad context
    return answer  # WRONG - but why?

# With tracing: you can see each step's input and output
def rag_pipeline_traced(query):
    with trace_span('retrieve') as span:
        chunks = retrieve(query)
        span.set_attribute('num_chunks', len(chunks))
        span.set_attribute('top_chunk_score', chunks[0]['score'] if chunks else 0)
    
    with trace_span('format_context') as span:
        context = format_context(chunks)
        span.set_attribute('context_length', len(context))
    
    with trace_span('generate') as span:
        answer = generate(query, context)
        span.set_attribute('answer_length', len(answer))
    
    return answer  # Now you can diagnose: was retrieve the problem?

Token Count and Cost Surprises

Without instrumentation, token counts and costs are invisible until the monthly bill arrives. A system prompt that grew from 500 to 5000 tokens due to an oversight, a retrieval function returning 20 chunks instead of 5, or a loop that calls the LLM 100 times instead of 10 — all of these silently multiply your costs. Instrument every LLM call to log prompt tokens, completion tokens, and estimated cost so anomalies are visible in real time.

COST_PER_1K = {'gpt-4o': {'input': 0.005, 'output': 0.015},
               'gpt-4o-mini': {'input': 0.000150, 'output': 0.000600}}

def compute_cost(model: str, usage) -> float:
    pricing = COST_PER_1K.get(model, {'input': 0.005, 'output': 0.015})
    input_cost = (usage.prompt_tokens / 1000) * pricing['input']
    output_cost = (usage.completion_tokens / 1000) * pricing['output']
    return input_cost + output_cost

def instrumented_call(client, model, messages):
    response = client.chat.completions.create(model=model, messages=messages)
    cost = compute_cost(model, response.usage)
    
    # Alert if single call is unexpectedly expensive
    if cost > 0.10:  # more than 10 cents for one call
        print(f'WARNING: Expensive LLM call: ${cost:.4f} ({response.usage.prompt_tokens} prompt tokens)')
    
    metrics.record('llm_cost_usd', cost, tags={'model': model})
    metrics.record('llm_prompt_tokens', response.usage.prompt_tokens)
    return response

Latency: Which Step Is Slow?

Users experience LLM latency as a single wait time, but it is actually the sum of many individual steps: vector database query, document retrieval, prompt assembly, API network call, token generation, and response parsing. Without per-step timing, you cannot tell whether a slow response is due to a slow retriever or a slow LLM call. Instrument each step with latency measurements to identify your real bottleneck.

import time
from contextlib import contextmanager

@contextmanager
def timed(name: str, metrics_client):
    start = time.monotonic()
    try:
        yield
    finally:
        elapsed_ms = (time.monotonic() - start) * 1000
        metrics_client.histogram(f'step_latency_ms', elapsed_ms, tags={'step': name})
        if elapsed_ms > 2000:  # flag steps taking more than 2 seconds
            print(f'SLOW STEP [{name}]: {elapsed_ms:.0f}ms')

# Usage
def rag_with_timing(query, metrics):
    with timed('embed_query', metrics):
        query_embedding = embed(query)
    
    with timed('vector_search', metrics):
        chunks = vector_db.search(query_embedding, top_k=5)
    
    with timed('llm_generate', metrics):
        answer = generate(query, chunks)
    
    return answer

What Information You Actually Need

To diagnose any LLM application failure, you need to capture and store: the complete input prompt (system + all messages), the model and parameters used (temperature, max_tokens), the complete output, token counts and estimated cost, latency per step, any tool calls and their results, and a session or request ID that ties all steps of one user request together. This is the minimum viable tracing dataset.

from dataclasses import dataclass, field
from typing import Optional
import time

@dataclass
class LLMTrace:
    request_id: str
    session_id: str
    step_name: str
    model: str
    temperature: float
    system_prompt: str
    user_messages: list[dict]
    response: str
    finish_reason: str
    prompt_tokens: int
    completion_tokens: int
    cost_usd: float
    latency_ms: float
    tool_calls: list[dict] = field(default_factory=list)
    error: Optional[str] = None
    timestamp: float = field(default_factory=time.time)

    def is_anomalous(self) -> bool:
        return (
            self.cost_usd > 0.10 or
            self.latency_ms > 10000 or
            self.finish_reason == 'length' or  # was cut off
            self.error is not None
        )

Trace Correlation with Request IDs

A single user request might trigger 10 LLM calls across different services. Without a correlation ID that flows through all of them, you cannot group these calls into a single trace. Inject a unique request ID at the entry point of every user request and pass it in every downstream LLM call, database query, and log message. This makes it possible to reconstruct the complete execution path for any specific user request.

import uuid
from contextvars import ContextVar

# Thread-safe request ID propagation using context variables
request_id_var: ContextVar[str] = ContextVar('request_id', default='unknown')

def handle_user_request(query: str):
    # Set request ID at the entry point
    req_id = str(uuid.uuid4())[:8]
    request_id_var.set(req_id)
    return rag_pipeline(query)

def get_current_request_id() -> str:
    return request_id_var.get()

# Every LLM call logs with the same request_id
def log_llm_call(model, prompt, response):
    logger.info('LLM call', extra={
        'request_id': get_current_request_id(),  # automatically correlates all calls
        'model': model,
        'prompt_length': len(prompt),
        'response_length': len(response)
    })

The LLM Observability Stack

The LLM observability stack has three layers: Logging captures structured records of every LLM call (LangSmith, Langfuse, custom logs). Metrics track aggregated numbers over time: request count, average latency, error rate, daily cost. Tracing records the causal chain of steps within a single request. Together these three pillars give you the visibility to diagnose failures, catch regressions, and optimize performance.

Alerting on Quality Degradation

Unlike traditional software where errors are binary (working/broken), LLM quality degrades gradually. A prompt change might reduce answer quality from 85% to 70% without any exceptions being thrown. Monitor quality by running automated evaluation (LLM-as-judge scoring) on a sample of production responses daily. Alert when the rolling average quality score drops below a threshold, before users start complaining.

Starting Your Observability Practice

Do not wait until you have a production incident to add observability. Start with three minimal steps: (1) log every LLM call with its full input, output, and token counts to a database or file, (2) assign a request ID to every user interaction and include it in all log entries, and (3) add per-step timing to your pipeline. These three things alone will make 80% of debugging tasks solvable in minutes rather than hours.

Quick Check

Test your understanding of why LLM apps are hard to debug from this lesson.

Lesson Recap

In this lesson you learned: non-determinism makes LLM bugs intermittent and hard to reproduce without capturing the full request context, silent failures (wrong answers from successful API calls) bypass traditional error monitoring and require semantic quality checks, and per-step tracing with request ID correlation is the minimum needed to diagnose failures in multi-step RAG and agent pipelines. Next up we implement tracing with LangSmith.

Часто задаваемые вопросы

Урок «Почему приложения на LLM сложно отлаживать» бесплатный?

Да — полный текст урока «Почему приложения на LLM сложно отлаживать» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс AI Engineering Academy, подпишись на CoddyKit PRO. Курс AI Engineering Academy содержит 4 уроков всего.

Чему я научусь в уроке «Почему приложения на LLM сложно отлаживать»?

Разберитесь, почему традиционного журналирования недостаточно для приложений на LLM, какая информация нужна для диагностики сбоев в RAG и конвейерах агентов и как устроена модель данных трассировки. Ты практикуешь AI Engineering Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать AI Engineering Academy?

Предыдущий опыт не требуется. AI Engineering Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.

Сколько времени занимает урок «Почему приложения на LLM сложно отлаживать»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке AI Engineering Academy?

Да. Каждый урок AI Engineering Academy включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Почему приложения на LLM сложно отлаживать
  2. Трассировка с LangSmith
  3. Langfuse для наблюдаемости, независимой от модели
  4. Оповещения о задержке, расходах и ухудшении качества
← Назад к AI Engineering Academy